Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:

permissions:
contents: write
id-token: write
attestations: write

jobs:
release-windows:
Expand Down Expand Up @@ -54,6 +56,11 @@ jobs:
}
$lines | Set-Content 'dist\SHA256SUMS.txt'

- name: Attest Windows installer
uses: actions/attest@v4.2.2
with:
subject-path: 'dist/*.exe'

- name: Publish installer to GitHub Release
shell: pwsh
env:
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Wirebound
# Wirebound

![Platform: Windows](https://img.shields.io/badge/Platform-Windows-0078D6?style=for-the-badge&logo=windows)
![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge)
Expand Down Expand Up @@ -30,6 +30,7 @@ A `Connected` state means the desktop relay is running and a Gnirehtet client is
- **DNS and relay settings** — Google, Cloudflare, custom IPv4 DNS, and configurable relay port.
- **Live engine logs** — Gnirehtet output stays visible for troubleshooting.
- **Device speed-test shortcut** — Opens Fast.com on an authorized Android device to verify real connectivity.
- **Privacy-safe diagnostics** — Checks runtime files, ADB response, device authorization, Android/API version, and Gnirehtet client state; copied reports mask device serials.
- **Light and dark themes** — With English and Indonesian localization.
- **Graceful cleanup** — Stopping or closing Wirebound also attempts to stop Gnirehtet clients on attached devices.

Expand All @@ -50,9 +51,9 @@ Release builds currently pin:
- **Gnirehtet:** 2.5.1, Rust Windows build
- **Android SDK Platform Tools:** 37.0.1

Runtime archives are downloaded by `scripts/prepare-runtime.ps1` and verified with SHA-256 before extraction. Generated runtime files are ignored by Git, so the repository does not rely on manually copied binaries.
Runtime archives are downloaded by `scripts/prepare-runtime.ps1` and verified with SHA-256 before extraction. Generated runtime files are ignored by Git, so the repository does not rely on manually copied binaries. The packaged Platform Tools subset contains only ADB, its required Windows DLLs, and Google's notice/version metadata; unrelated tools such as Fastboot and sqlite3 are not shipped.

Wirebound uses a dedicated local ADB server on port **5038**. The normal ADB server on port 5037 is left alone, so closing Wirebound does not intentionally stop an ADB server used by Android Studio or other tools.
Wirebound uses the standard local ADB server on port **5037** so it can share the same USB transport with Android Studio and other ADB clients. To keep an ADB daemon from locking files inside the installed application, Wirebound runs its ADB client from a content-addressed cache under `%LOCALAPPDATA%\Wirebound\runtime` and does not kill the shared ADB server when Wirebound exits.

## Usage

Expand All @@ -65,7 +66,7 @@ Wirebound uses a dedicated local ADB server on port **5038**. The normal ADB ser
7. Accept the Gnirehtet VPN permission prompt on Android.
8. When Wirebound reports `Connected`, use **Speed Test** if you want to verify end-to-end internet access.

If Wirebound reports `Unauthorized`, `Offline`, `No access`, or `ADB unavailable`, resolve that state before troubleshooting the relay itself.
If Wirebound reports `Unauthorized`, `Offline`, `No access`, or `ADB unavailable`, resolve that state before troubleshooting the relay itself. The **Run Diagnostics** action provides a copyable support report without exposing the full device serial.

## Development

Expand Down Expand Up @@ -106,7 +107,7 @@ Wirebound is intentionally Windows-only today. The runtime paths, ADB distributi

CI runs on Windows and verifies linting, TypeScript, unit tests, production build, and an unpacked package smoke test. Tags matching `v*` trigger the Windows release workflow.

Release artifacts should be treated as the canonical user distribution. Source checkouts fetch pinned runtime dependencies during development/build rather than storing executable binaries in Git.
Release artifacts should be treated as the canonical user distribution. Source checkouts fetch pinned runtime dependencies during development/build rather than storing executable binaries in Git. Release installers also receive a GitHub artifact attestation, which can be verified with `gh attestation verify <installer.exe> -R man612/wirebound`.

## License and credits

Expand Down
3 changes: 3 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ Android Debug Bridge (ADB) is distributed as part of Android SDK Platform Tools
- Official release information: https://developer.android.com/tools/releases/platform-tools
- Bundled version: **37.0.1**, Windows
- Archive SHA-256: `45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99`
- Redistributed subset: `adb.exe`, `AdbWinApi.dll`, `AdbWinUsbApi.dll`, `NOTICE.txt`, and `source.properties`

Wirebound uses ADB to discover authorized Android devices, inspect device state, launch the speed-test URL, detect the Gnirehtet client, and stop that client during cleanup.

Wirebound packages only `adb.exe`, `AdbWinApi.dll`, `AdbWinUsbApi.dll`, Google's `NOTICE.txt`, and `source.properties` from Platform Tools. At runtime the executable ADB files are copied into a content-addressed `%LOCALAPPDATA%\Wirebound\runtime` cache so Wirebound can share the standard ADB server on port 5037 without keeping packaged application files locked.

The hashes above are also enforced by `scripts/prepare-runtime.ps1`. If an upstream artifact changes, the build fails until the pinned version and checksum are intentionally reviewed and updated.

Refer to the respective upstream projects and distribution terms for complete copyright and license information.
3 changes: 3 additions & 0 deletions bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ Run `npm run runtime:prepare` on Windows to download the pinned runtime versions
- Gnirehtet Rust for Windows 2.5.1

The preparation script verifies SHA-256 checksums before extracting either archive. Release and CI workflows use the same script so local and packaged builds use the same runtime inputs.

Only the runtime files Wirebound actually needs are copied into the package: `adb.exe`, its two Windows ADB DLLs, Google's notice/version metadata, plus `gnirehtet.exe` and `gnirehtet.apk`. Tools such as Fastboot, sqlite3, mke2fs, and etc1tool are intentionally excluded.
At runtime, Wirebound copies the three executable ADB files into a content-addressed cache under `%LOCALAPPDATA%\Wirebound\runtime`. This lets Wirebound share the standard ADB server on port 5037 without an ADB daemon locking packaged application files during updates.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wirebound",
"version": "1.1.0",
"version": "1.1.1",
"description": "Desktop GUI for Gnirehtet reverse tethering",
"main": "./out/main/index.js",
"author": "man612",
Expand Down
23 changes: 18 additions & 5 deletions scripts/prepare-runtime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ $PlatformToolsVersion = '37.0.1'
$PlatformToolsSha256 = '45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99'
$GnirehtetVersion = '2.5.1'
$GnirehtetSha256 = '7f5b1063e7895182aa60def1437e50363c3758144088dcd079037bb7c3c46a1c'
$RuntimeLayout = 'minimal-v1'

$Root = Split-Path -Parent $PSScriptRoot
$Bin = Join-Path $Root 'bin'
$Marker = Join-Path $Bin '.runtime-versions'
$ExpectedMarker = "platform-tools=$PlatformToolsVersion`ngnirehtet=$GnirehtetVersion`n"
$ExpectedMarker = "platform-tools=$PlatformToolsVersion`ngnirehtet=$GnirehtetVersion`nlayout=$RuntimeLayout`n"
$AdbExe = Join-Path $Bin 'platform-tools\adb.exe'
$GnirehtetExe = Join-Path $Bin 'gnirehtet-rust-win64\gnirehtet.exe'

Expand Down Expand Up @@ -56,10 +57,22 @@ try {
Expand-Archive -Path $PlatformZip -DestinationPath $PlatformExtract -Force
Expand-Archive -Path $GnirehtetZip -DestinationPath $GnirehtetExtract -Force

Remove-Item (Join-Path $Bin 'platform-tools') -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item (Join-Path $Bin 'gnirehtet-rust-win64') -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $PlatformExtract 'platform-tools') (Join-Path $Bin 'platform-tools') -Recurse
Copy-Item (Join-Path $GnirehtetExtract 'gnirehtet-rust-win64') (Join-Path $Bin 'gnirehtet-rust-win64') -Recurse
$PlatformSource = Join-Path $PlatformExtract 'platform-tools'
$PlatformDestination = Join-Path $Bin 'platform-tools'
$GnirehtetSource = Join-Path $GnirehtetExtract 'gnirehtet-rust-win64'
$GnirehtetDestination = Join-Path $Bin 'gnirehtet-rust-win64'

if (Test-Path $PlatformDestination) { Remove-Item $PlatformDestination -Recurse -Force }
if (Test-Path $GnirehtetDestination) { Remove-Item $GnirehtetDestination -Recurse -Force }
New-Item -ItemType Directory -Force $PlatformDestination | Out-Null
New-Item -ItemType Directory -Force $GnirehtetDestination | Out-Null

@('adb.exe', 'AdbWinApi.dll', 'AdbWinUsbApi.dll', 'NOTICE.txt', 'source.properties') | ForEach-Object {
Copy-Item (Join-Path $PlatformSource $_) (Join-Path $PlatformDestination $_)
}
@('gnirehtet.exe', 'gnirehtet.apk') | ForEach-Object {
Copy-Item (Join-Path $GnirehtetSource $_) (Join-Path $GnirehtetDestination $_)
}
[System.IO.File]::WriteAllText($Marker, $ExpectedMarker, [System.Text.UTF8Encoding]::new($false))

Write-Host "Prepared Android Platform Tools $PlatformToolsVersion and Gnirehtet $GnirehtetVersion."
Expand Down
47 changes: 45 additions & 2 deletions src/main/appPaths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from 'crypto'
import { app } from 'electron'
import { existsSync } from 'fs'
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
import { join } from 'path'

export interface RuntimePaths {
Expand All @@ -11,10 +12,51 @@ export interface RuntimePaths {
icon: string
}

const ADB_RUNTIME_FILES = ['adb.exe', 'AdbWinApi.dll', 'AdbWinUsbApi.dll'] as const

function firstExisting(candidates: string[], fallback: string): string {
return candidates.find((candidate) => existsSync(candidate)) ?? fallback
}

function hashFile(file: string): string {
return createHash('sha256').update(readFileSync(file)).digest('hex')
}

function hashFiles(directory: string): string {
const hash = createHash('sha256')

for (const filename of ADB_RUNTIME_FILES) {
const file = join(directory, filename)
if (!existsSync(file)) throw new Error(`Missing ADB runtime file: ${file}`)
hash.update(filename)
hash.update(readFileSync(file))
}

return hash.digest('hex').slice(0, 16)
}

function prepareAdbCache(sourceDir: string): string {
try {
const fingerprint = hashFiles(sourceDir)
const localRoot = process.env.LOCALAPPDATA ?? app.getPath('userData')
const cacheDir = join(localRoot, 'Wirebound', 'runtime', `platform-tools-${fingerprint}`)
mkdirSync(cacheDir, { recursive: true })

for (const filename of ADB_RUNTIME_FILES) {
const source = join(sourceDir, filename)
const destination = join(cacheDir, filename)
if (!existsSync(destination) || hashFile(destination) !== hashFile(source)) {
copyFileSync(source, destination)
}
}

return cacheDir
} catch (error) {
console.warn('Wirebound: Failed to prepare the local ADB cache; using bundled runtime.', error)
return sourceDir
}
}

export function getRuntimePaths(): RuntimePaths {
const devRoot = process.cwd()
const packagedRoot = process.resourcesPath
Expand All @@ -26,10 +68,11 @@ export function getRuntimePaths(): RuntimePaths {
rootCandidates.map((candidate) => join(candidate, 'bin', 'gnirehtet-rust-win64')),
join(root, 'bin', 'gnirehtet-rust-win64')
)
const adbDir = firstExisting(
const bundledAdbDir = firstExisting(
rootCandidates.map((candidate) => join(candidate, 'bin', 'platform-tools')),
join(root, 'bin', 'platform-tools')
)
const adbDir = prepareAdbCache(bundledAdbDir)
const icon = firstExisting(
rootCandidates.flatMap((candidate) => [
join(candidate, 'icon.png'),
Expand Down
22 changes: 11 additions & 11 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function ensureServices(): { paths: RuntimePaths; adb: AdbService; engine: Gnire
return { paths: runtimePaths, adb: adbService, engine: gnirehtetService }
}

function bootstrap(): void {
async function bootstrap(): Promise<void> {
const { paths, adb, engine } = ensureServices()
const settings = loadSettings()

Expand All @@ -99,6 +99,12 @@ function bootstrap(): void {
handlersRegistered = true
}

try {
await adb.ensureServerReady()
} catch (error) {
console.warn('Wirebound: ADB server was not ready during bootstrap.', error)
}

if (!devicePoller) {
startDevicePolling(adb, engine)
}
Expand All @@ -122,14 +128,8 @@ async function shutdown(): Promise<void> {
console.warn('Wirebound: Engine shutdown cleanup failed.', error)
}

try {
await adbService?.releaseOwnedServer()
} catch (error) {
console.warn('Wirebound: ADB shutdown cleanup failed.', error)
} finally {
shutdownComplete = true
app.quit()
}
shutdownComplete = true
app.quit()
}

const hasSingleInstanceLock = app.requestSingleInstanceLock()
Expand All @@ -151,11 +151,11 @@ if (!hasSingleInstanceLock) {
optimizer.watchWindowShortcuts(window)
})

bootstrap()
void bootstrap()

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0 && !shutdownStarted) {
bootstrap()
void bootstrap()
}
})
})
Expand Down
5 changes: 5 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export function registerIpcHandlers({
return app.getVersion()
})

ipcMain.handle('app:diagnostics', (event) => {
trusted(event)
return adbService.getDiagnostics(gnirehtetService.getStatus())
})

ipcMain.handle('app:open-external', (event, url: unknown) => {
trusted(event)
return openExternal(url)
Expand Down
30 changes: 29 additions & 1 deletion src/main/services/adbService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { parseAdbDevices, parseBatteryLevel } from './adbService'
import {
classifyDeviceAccess,
maskDeviceId,
parseAdbDevices,
parseBatteryLevel
} from './adbService'

describe('parseAdbDevices', () => {
it('parses supported ADB device states and ignores headers', () => {
Expand Down Expand Up @@ -28,3 +33,26 @@ describe('parseBatteryLevel', () => {
expect(parseBatteryLevel('status: unknown')).toBeUndefined()
})
})

describe('diagnostic helpers', () => {
it('masks device identifiers before they enter support reports', () => {
expect(maskDeviceId('ZP222226P2')).toBe('ZP...P2')
expect(maskDeviceId('ABC')).toBe('****')
})

it('prioritizes an authorized device as a passing access state', () => {
expect(
classifyDeviceAccess([
{ id: 'A', name: 'Android Device', status: 'unauthorized' },
{ id: 'B', name: 'Android Device', status: 'device' }
])
).toBe('pass')
})

it('reports blocked authorization as an error and no devices as a warning', () => {
expect(
classifyDeviceAccess([{ id: 'A', name: 'Android Device', status: 'unauthorized' }])
).toBe('error')
expect(classifyDeviceAccess([])).toBe('warning')
})
})
Loading