From d98a9fff4df21f8295d6b5c38f9565ef78f980a2 Mon Sep 17 00:00:00 2001 From: yasman Date: Wed, 16 Sep 2026 16:08:44 +0700 Subject: [PATCH] feat: add diagnostics and harden shared ADB runtime --- .github/workflows/release.yml | 7 + README.md | 11 +- THIRD_PARTY_NOTICES.md | 3 + bin/README.md | 3 + package-lock.json | 4 +- package.json | 2 +- scripts/prepare-runtime.ps1 | 23 +- src/main/appPaths.ts | 47 +++- src/main/index.ts | 22 +- src/main/ipc.ts | 5 + src/main/services/adbService.test.ts | 30 ++- src/main/services/adbService.ts | 239 +++++++++++++++++- src/main/services/gnirehtetService.ts | 6 +- src/preload/index.ts | 1 + src/renderer/src/App.tsx | 4 +- .../src/components/views/Dashboard.tsx | 165 +++++++++++- src/renderer/src/hooks/index.ts | 33 +++ src/renderer/src/i18n.ts | 38 +++ src/shared/types.ts | 34 +++ 19 files changed, 634 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 576c92a..375095c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ on: permissions: contents: write + id-token: write + attestations: write jobs: release-windows: @@ -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: diff --git a/README.md b/README.md index 51b4a68..521fbbc 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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 @@ -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 @@ -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 -R man612/wirebound`. ## License and credits diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 94fb042..3dac6f6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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. \ No newline at end of file diff --git a/bin/README.md b/bin/README.md index 1195cb9..0db7cb3 100644 --- a/bin/README.md +++ b/bin/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 36ad237..ad6eecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wirebound", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wirebound", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "dependencies": { "@electron-toolkit/utils": "^4.0.0", diff --git a/package.json b/package.json index 61df4d4..11c1b1f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/prepare-runtime.ps1 b/scripts/prepare-runtime.ps1 index b09b902..ff5e871 100644 --- a/scripts/prepare-runtime.ps1 +++ b/scripts/prepare-runtime.ps1 @@ -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' @@ -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." diff --git a/src/main/appPaths.ts b/src/main/appPaths.ts index 911b665..61df131 100644 --- a/src/main/appPaths.ts +++ b/src/main/appPaths.ts @@ -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 { @@ -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 @@ -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'), diff --git a/src/main/index.ts b/src/main/index.ts index b2a7171..7fdd10c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { const { paths, adb, engine } = ensureServices() const settings = loadSettings() @@ -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) } @@ -122,14 +128,8 @@ async function shutdown(): Promise { 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() @@ -151,11 +151,11 @@ if (!hasSingleInstanceLock) { optimizer.watchWindowShortcuts(window) }) - bootstrap() + void bootstrap() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0 && !shutdownStarted) { - bootstrap() + void bootstrap() } }) }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e125002..45241a3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) diff --git a/src/main/services/adbService.test.ts b/src/main/services/adbService.test.ts index a22de9c..a85758b 100644 --- a/src/main/services/adbService.test.ts +++ b/src/main/services/adbService.test.ts @@ -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', () => { @@ -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') + }) +}) diff --git a/src/main/services/adbService.ts b/src/main/services/adbService.ts index 64fde17..82c7520 100644 --- a/src/main/services/adbService.ts +++ b/src/main/services/adbService.ts @@ -1,12 +1,21 @@ -import { execFile, type ExecFileException } from 'child_process' +import { execFile, spawn, type ExecFileException } from 'child_process' +import { createConnection } from 'net' import { existsSync } from 'fs' import type { RuntimePaths } from '../appPaths' -import type { ActionResult, AdbDevice, AdbSnapshot } from '../../shared/types' +import type { + ActionResult, + AdbDevice, + AdbSnapshot, + ConnectionStatus, + DiagnosticCheck, + DiagnosticDevice, + DiagnosticReport, + DiagnosticStatus +} from '../../shared/types' const GNIREHTET_PACKAGE = 'com.genymobile.gnirehtet' const SPEED_TEST_URL = 'https://fast.com' const DEVICE_DETAILS_TTL_MS = 30_000 -export const WIREBOUND_ADB_SERVER_PORT = '5038' interface DeviceDetails { name: string @@ -30,6 +39,25 @@ function readableError(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback } +function isAdbPortOpen(timeout = 300): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port: 5037 }) + let settled = false + + const finish = (open: boolean): void => { + if (settled) return + settled = true + socket.destroy() + resolve(open) + } + + socket.setTimeout(timeout) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + }) +} + export function parseAdbDevices(stdout: string): AdbDevice[] { return stdout .split(/\r?\n/) @@ -48,16 +76,33 @@ export function parseBatteryLevel(output: string): string | undefined { return output.match(/level:\s*(\d+)/i)?.[1] } +export function maskDeviceId(deviceId: string): string { + const value = deviceId.trim() + if (value.length <= 4) return '****' + return `${value.slice(0, 2)}...${value.slice(-2)}` +} + +export function classifyDeviceAccess(devices: AdbDevice[]): DiagnosticStatus { + if (devices.some((device) => device.status === 'device')) return 'pass' + if ( + devices.some((device) => device.status === 'unauthorized' || device.status === 'no permissions') + ) { + return 'error' + } + return 'warning' +} + export class AdbService { private readonly detailsCache = new Map() + private serverReadyPromise: Promise | null = null + private serverReadyAt = 0 public constructor(private readonly paths: RuntimePaths) {} private adbEnv(): NodeJS.ProcessEnv { return { ...process.env, - PATH: `${this.paths.adbDir};${process.env.PATH ?? ''}`, - ANDROID_ADB_SERVER_PORT: WIREBOUND_ADB_SERVER_PORT + PATH: `${this.paths.adbDir};${process.env.PATH ?? ''}` } } @@ -87,16 +132,58 @@ export class AdbService { return this.runAdb(args, timeout) } - public async releaseOwnedServer(): Promise { + public async ensureServerReady(): Promise { + if (Date.now() - this.serverReadyAt < 5000) return + if (this.serverReadyPromise) return this.serverReadyPromise + + this.serverReadyPromise = (async () => { + if (!existsSync(this.paths.adbExe)) { + throw new Error(`ADB runtime not found: ${this.paths.adbExe}`) + } + + if (!(await isAdbPortOpen())) { + const server = spawn(this.paths.adbExe, ['nodaemon', 'server'], { + env: this.adbEnv(), + detached: true, + stdio: 'ignore', + windowsHide: true + }) + server.on('error', (error) => { + console.warn('Wirebound: Detached ADB server process failed.', error) + }) + server.unref() + } + + let lastError: unknown + for (let attempt = 0; attempt < 20; attempt += 1) { + if (!(await isAdbPortOpen())) { + await new Promise((resolve) => setTimeout(resolve, 250)) + continue + } + + try { + await this.execAdb(['devices'], 3000) + this.serverReadyAt = Date.now() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } + + throw lastError instanceof Error ? lastError : new Error('ADB server did not become ready.') + })() + try { - await this.runAdb(['kill-server'], 3000) - } catch (error) { - console.warn('Wirebound: Failed to stop dedicated ADB server.', error) + await this.serverReadyPromise + } finally { + this.serverReadyPromise = null } } public async getSnapshot(): Promise { try { + await this.ensureServerReady() const devices = parseAdbDevices(await this.execAdb(['devices'])) const connectedIds = new Set(devices.map((device) => device.id)) @@ -125,12 +212,115 @@ export class AdbService { } } + public async getDiagnostics(engineStatus: ConnectionStatus): Promise { + const checks: DiagnosticCheck[] = [ + { + id: 'adbRuntime', + status: existsSync(this.paths.adbExe) ? 'pass' : 'error', + detail: existsSync(this.paths.adbExe) + ? 'ADB runtime is present.' + : 'ADB runtime is missing.' + }, + { + id: 'gnirehtetRuntime', + status: existsSync(this.paths.gnirehtetExe) ? 'pass' : 'error', + detail: existsSync(this.paths.gnirehtetExe) + ? 'Gnirehtet runtime is present.' + : 'Gnirehtet runtime is missing.' + } + ] + + if (!existsSync(this.paths.adbExe)) { + return { generatedAt: new Date().toISOString(), engineStatus, checks, devices: [] } + } + + try { + const versionOutput = await this.execAdb(['version'], 3000) + const versionLine = versionOutput + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith('Version ')) + checks.push({ + id: 'adbQuery', + status: 'pass', + detail: versionLine ?? 'ADB responded successfully.' + }) + } catch (error) { + checks.push({ + id: 'adbQuery', + status: 'error', + detail: readableError(error, 'ADB did not respond.') + }) + return { generatedAt: new Date().toISOString(), engineStatus, checks, devices: [] } + } + + const snapshot = await this.getSnapshot() + if (snapshot.error) { + checks.push({ id: 'deviceAccess', status: 'error', detail: snapshot.error }) + return { generatedAt: new Date().toISOString(), engineStatus, checks, devices: [] } + } + + checks.push({ + id: 'deviceAccess', + status: classifyDeviceAccess(snapshot.devices), + detail: + snapshot.devices.length === 0 + ? 'No Android device is visible to ADB.' + : `${snapshot.devices.filter((device) => device.status === 'device').length} authorized device(s), ${snapshot.devices.length} total.` + }) + + const devices = await Promise.all( + snapshot.devices.map((device) => this.getDiagnosticDevice(device)) + ) + const readyDevices = devices.filter((device) => device.status === 'device') + + checks.push({ + id: 'androidVersion', + status: readyDevices.length > 0 ? 'pass' : 'info', + detail: + readyDevices.length > 0 + ? readyDevices + .map( + (device) => + `${device.name}: Android ${device.androidVersion ?? '?'} (API ${device.apiLevel ?? '?'})` + ) + .join('; ') + : 'Android version can be read after a device is authorized.' + }) + + const activeClients = readyDevices.filter((device) => device.gnirehtetActive).length + checks.push({ + id: 'gnirehtetClient', + status: + engineStatus === 'connected' + ? activeClients > 0 + ? 'pass' + : 'error' + : engineStatus === 'connecting' + ? 'warning' + : engineStatus === 'error' + ? 'error' + : 'info', + detail: + engineStatus === 'connected' + ? `${activeClients} active Android Gnirehtet client(s).` + : engineStatus === 'connecting' + ? 'Desktop relay is running and waiting for the Android VPN client.' + : engineStatus === 'error' + ? 'The Wirebound engine is in an error state.' + : 'The Wirebound engine is stopped.' + }) + + return { generatedAt: new Date().toISOString(), engineStatus, checks, devices } + } + public async openSpeedTest(deviceId: string): Promise { if (!deviceId.trim()) { return { success: false, error: 'Device id is empty.' } } try { + await this.ensureServerReady() await this.execAdb( [ '-s', @@ -161,7 +351,7 @@ export class AdbService { throw new Error(snapshot.error) } - const activeDevices = snapshot.devices.filter((device) => device.status === 'device') + const activeDevices = await this.getActiveGnirehtetClients(snapshot.devices) await Promise.all(activeDevices.map((device) => this.stopClient(device.id))) return activeDevices.length } @@ -188,6 +378,35 @@ export class AdbService { return states.filter((state) => state.active).map((state) => state.device) } + private async getDiagnosticDevice(device: AdbDevice): Promise { + const diagnosticDevice: DiagnosticDevice = { + id: maskDeviceId(device.id), + name: device.name, + status: device.status + } + + if (device.status !== 'device') return diagnosticDevice + + const [androidVersion, apiLevel, packagePath, clientActive] = await Promise.allSettled([ + this.execAdb(['-s', device.id, 'shell', 'getprop', 'ro.build.version.release'], 3000), + this.execAdb(['-s', device.id, 'shell', 'getprop', 'ro.build.version.sdk'], 3000), + this.execAdb(['-s', device.id, 'shell', 'pm', 'path', GNIREHTET_PACKAGE], 4000), + this.isGnirehtetClientActive(device.id) + ]) + + if (androidVersion.status === 'fulfilled') { + diagnosticDevice.androidVersion = androidVersion.value.trim() || undefined + } + if (apiLevel.status === 'fulfilled') { + diagnosticDevice.apiLevel = apiLevel.value.trim() || undefined + } + diagnosticDevice.gnirehtetInstalled = + packagePath.status === 'fulfilled' && packagePath.value.includes('package:') + diagnosticDevice.gnirehtetActive = clientActive.status === 'fulfilled' && clientActive.value + + return diagnosticDevice + } + private async getDeviceDetails(deviceId: string): Promise { const cached = this.detailsCache.get(deviceId) diff --git a/src/main/services/gnirehtetService.ts b/src/main/services/gnirehtetService.ts index b4e0cbb..d66c521 100644 --- a/src/main/services/gnirehtetService.ts +++ b/src/main/services/gnirehtetService.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs' import { isIP } from 'net' import type { RuntimePaths } from '../appPaths' import type { ActionResult, AdbDevice, ConnectionStatus, LogEntry } from '../../shared/types' -import { WIREBOUND_ADB_SERVER_PORT, type AdbService } from './adbService' +import type { AdbService } from './adbService' type LogSender = (message: string, type?: LogEntry['type']) => void type StatusSender = (status: ConnectionStatus) => void @@ -59,12 +59,12 @@ export class GnirehtetService { this.sendLog(`Starting Wirebound engine (DNS: ${dns}, Port: ${port})`) try { + await this.adbService.ensureServerReady() const childProcess = spawn(this.paths.gnirehtetExe, ['autorun', '-d', dns, '-p', port], { cwd: this.paths.gnirehtetDir, env: { ...process.env, - PATH: `${this.paths.adbDir};${process.env.PATH ?? ''}`, - ANDROID_ADB_SERVER_PORT: WIREBOUND_ADB_SERVER_PORT + PATH: `${this.paths.adbDir};${process.env.PATH ?? ''}` }, windowsHide: true }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 99f61ad..c17b922 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ const api: GnirehtetAPI = { getSettings: () => ipcRenderer.invoke('settings:get'), saveSettings: (settings) => ipcRenderer.invoke('settings:set', settings), getAppVersion: () => ipcRenderer.invoke('app:version'), + getDiagnostics: () => ipcRenderer.invoke('app:diagnostics'), testSpeedOnDevice: (deviceId: string) => ipcRenderer.invoke('adb:testSpeed', deviceId), openExternal: (url: string) => ipcRenderer.invoke('app:open-external', url), windowControl: (action: 'minimize' | 'maximize' | 'close') => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7d9f975..94891fd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,7 +4,7 @@ import Sidebar from './components/layout/Sidebar' import OnboardingScreen from './components/OnboardingScreen' import Dashboard from './components/views/Dashboard' import Settings from './components/views/Settings' -import { useAppVersion, useDevices, useGnirehtet, useSettings } from './hooks' +import { useAppVersion, useDevices, useDiagnostics, useGnirehtet, useSettings } from './hooks' import type { Language } from './i18n' import { translations } from './i18n' @@ -13,6 +13,7 @@ function App(): React.JSX.Element { const { status, logs, isLoading, start, stop, clearLogs } = useGnirehtet() const { devices, error: adbError } = useDevices() const { settings, updateSettings, loaded } = useSettings() + const diagnostics = useDiagnostics() const version = useAppVersion() const t = translations[(settings?.language as Language) || 'en'] @@ -74,6 +75,7 @@ function App(): React.JSX.Element { adbError={adbError} logs={logs} onClearLogs={clearLogs} + diagnostics={diagnostics} t={t} /> )} diff --git a/src/renderer/src/components/views/Dashboard.tsx b/src/renderer/src/components/views/Dashboard.tsx index 06c9ded..2444c1a 100644 --- a/src/renderer/src/components/views/Dashboard.tsx +++ b/src/renderer/src/components/views/Dashboard.tsx @@ -1,18 +1,27 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { AlertTriangle, Battery, CheckCircle2, + ClipboardCopy, Laptop, Play, + RefreshCw, ShieldAlert, Square as StopSquare, + Stethoscope, Terminal, Usb, Zap } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import type { AdbDevice, ConnectionStatus, LogEntry } from '../../../../shared/types' +import type { + AdbDevice, + ConnectionStatus, + DiagnosticCheckId, + DiagnosticReport, + LogEntry +} from '../../../../shared/types' import type { Translation } from '../../i18n' interface DashboardProps { @@ -24,6 +33,12 @@ interface DashboardProps { adbError?: string logs: LogEntry[] onClearLogs: () => void + diagnostics: { + report?: DiagnosticReport + isRunning: boolean + error?: string + run: () => Promise + } t: Translation } @@ -118,6 +133,40 @@ function getDeviceSetupState( } } +function getDiagnosticLabel(id: DiagnosticCheckId, t: Translation): string { + if (id === 'adbRuntime') return t.diagAdbRuntime + if (id === 'gnirehtetRuntime') return t.diagGnirehtetRuntime + if (id === 'adbQuery') return t.diagAdbQuery + if (id === 'deviceAccess') return t.diagDeviceAccess + if (id === 'androidVersion') return t.diagAndroidVersion + return t.diagGnirehtetClient +} + +function formatDiagnosticReport(report: DiagnosticReport): string { + const lines = [ + 'Wirebound Diagnostic Report', + `Generated: ${report.generatedAt}`, + `Engine status: ${report.engineStatus}`, + '', + 'Checks:' + ] + + for (const check of report.checks) { + lines.push(`- [${check.status.toUpperCase()}] ${check.id}: ${check.detail ?? ''}`) + } + + if (report.devices.length > 0) { + lines.push('', 'Devices:') + for (const device of report.devices) { + lines.push( + `- ${device.name} (${device.id}) | ${device.status} | Android ${device.androidVersion ?? '?'} / API ${device.apiLevel ?? '?'} | Gnirehtet installed: ${device.gnirehtetInstalled ? 'yes' : 'no'} | client active: ${device.gnirehtetActive ? 'yes' : 'no'}` + ) + } + } + + return lines.join('\n') +} + export default function Dashboard({ status, isLoading, @@ -127,6 +176,7 @@ export default function Dashboard({ adbError, logs, onClearLogs, + diagnostics, t }: DashboardProps): React.JSX.Element { const isRunning = status === 'connected' @@ -135,11 +185,24 @@ export default function Dashboard({ const setupState = getDeviceSetupState(devices, status, adbError, t) const SetupIcon = setupState.icon const logEndRef = useRef(null) + const [reportCopied, setReportCopied] = useState(false) useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [logs]) + const handleCopyDiagnostics = async (): Promise => { + if (!diagnostics.report) return + + try { + await navigator.clipboard.writeText(formatDiagnosticReport(diagnostics.report)) + setReportCopied(true) + window.setTimeout(() => setReportCopied(false), 1500) + } catch (error) { + console.warn('Failed to copy diagnostic report.', error) + } + } + const handleOpenDesktopSpeedTest = (): void => { void window.api.openExternal('https://fast.com') } @@ -256,6 +319,104 @@ export default function Dashboard({ +
+
+
+ +
+ {t.diagnostics} +
+
+
+ + +
+
+ + {!diagnostics.report && !diagnostics.error && ( +

{t.diagnosticsIdle}

+ )} + {diagnostics.error && ( +

+ {diagnostics.error} +

+ )} + {diagnostics.report && ( + <> +
+ {diagnostics.report.checks.map((check) => ( +
+
+ + + {getDiagnosticLabel(check.id, t)} + +
+ {check.detail && ( +

+ {check.detail} +

+ )} +
+ ))} +
+ + {diagnostics.report.devices.length > 0 && ( +
+
+ {t.diagnosticDevices} +
+ {diagnostics.report.devices.map((device) => ( +
+ {device.name} ·{' '} + {device.id} · {getDeviceStatusLabel(device.status, t)} + {device.androidVersion && + ` · Android ${device.androidVersion} / API ${device.apiLevel ?? '?'}`} + {device.status === 'device' && + ` · ${t.gnirehtetInstalled}: ${device.gnirehtetInstalled ? t.yes : t.no} · ${t.gnirehtetActive}: ${device.gnirehtetActive ? t.yes : t.no}`} +
+ ))} +
+ )} + +

+ {t.diagnosticsGenerated}:{' '} + {new Date(diagnostics.report.generatedAt).toLocaleString()} +

+ + )} +
+
diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4f91f8b..785a544 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -5,6 +5,7 @@ import type { AdbDevice, AppSettings, ConnectionStatus, + DiagnosticReport, LogEntry } from '../../../shared/types' @@ -28,6 +29,13 @@ interface UseSettingsResult { loaded: boolean } +interface UseDiagnosticsResult { + report?: DiagnosticReport + isRunning: boolean + error?: string + run: () => Promise +} + const hasApi = (): boolean => typeof window !== 'undefined' && Boolean(window.api) function actionFailure(error: unknown, fallback: string): ActionResult { @@ -189,6 +197,31 @@ export function useSettings(): UseSettingsResult { return { settings, updateSettings, loaded } } +export function useDiagnostics(): UseDiagnosticsResult { + const [report, setReport] = useState() + const [isRunning, setIsRunning] = useState(false) + const [error, setError] = useState() + + const run = useCallback(async (): Promise => { + if (!hasApi()) { + setError('Electron API is not available.') + return + } + + setIsRunning(true) + setError(undefined) + try { + setReport(await window.api.getDiagnostics()) + } catch (runError) { + setError(runError instanceof Error ? runError.message : 'Diagnostics failed.') + } finally { + setIsRunning(false) + } + }, []) + + return { report, isRunning, error, run } +} + export function useAppVersion(): string { const [version, setVersion] = useState('') diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index d7b22ca..834ff4d 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -16,6 +16,25 @@ export const translations = { noDevice: 'No device detected', adbUnavailable: 'ADB is unavailable', devicesLabel: 'Android Devices', + diagnostics: 'Diagnostics', + runDiagnostics: 'Run Diagnostics', + rerunDiagnostics: 'Run Again', + copyReport: 'Copy Report', + reportCopied: 'Copied', + diagnosticsIdle: + 'Check the bundled runtime, ADB access, Android version, and Gnirehtet client state.', + diagnosticsGenerated: 'Generated', + diagnosticDevices: 'Device details', + diagAdbRuntime: 'ADB runtime', + diagGnirehtetRuntime: 'Gnirehtet runtime', + diagAdbQuery: 'ADB response', + diagDeviceAccess: 'Device access', + diagAndroidVersion: 'Android version', + diagGnirehtetClient: 'Gnirehtet client', + gnirehtetInstalled: 'Gnirehtet installed', + gnirehtetActive: 'VPN client active', + yes: 'Yes', + no: 'No', setupReadyTitle: 'Device ready', setupReadyDesc: 'Start the connection, then accept the VPN prompt on the Android device.', setupConnectedTitle: 'Relay and Android VPN are active', @@ -99,6 +118,25 @@ export const translations = { noDevice: 'Tidak ada perangkat terdeteksi', adbUnavailable: 'ADB tidak tersedia', devicesLabel: 'Perangkat Android', + diagnostics: 'Diagnostik', + runDiagnostics: 'Jalankan Diagnostik', + rerunDiagnostics: 'Jalankan Lagi', + copyReport: 'Salin Laporan', + reportCopied: 'Tersalin', + diagnosticsIdle: + 'Periksa runtime bawaan, akses ADB, versi Android, dan status klien Gnirehtet.', + diagnosticsGenerated: 'Dibuat', + diagnosticDevices: 'Detail perangkat', + diagAdbRuntime: 'Runtime ADB', + diagGnirehtetRuntime: 'Runtime Gnirehtet', + diagAdbQuery: 'Respons ADB', + diagDeviceAccess: 'Akses perangkat', + diagAndroidVersion: 'Versi Android', + diagGnirehtetClient: 'Klien Gnirehtet', + gnirehtetInstalled: 'Gnirehtet terpasang', + gnirehtetActive: 'Klien VPN aktif', + yes: 'Ya', + no: 'Tidak', setupReadyTitle: 'Perangkat siap', setupReadyDesc: 'Mulai koneksi, lalu terima prompt VPN Gnirehtet di perangkat Android.', setupConnectedTitle: 'Relay dan VPN Android aktif', diff --git a/src/shared/types.ts b/src/shared/types.ts index f2212ef..8ce056c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -33,6 +33,39 @@ export interface LogEntry { type: 'stdout' | 'stderr' | 'info' } +export type DiagnosticStatus = 'pass' | 'warning' | 'error' | 'info' + +export type DiagnosticCheckId = + | 'adbRuntime' + | 'gnirehtetRuntime' + | 'adbQuery' + | 'deviceAccess' + | 'androidVersion' + | 'gnirehtetClient' + +export interface DiagnosticCheck { + id: DiagnosticCheckId + status: DiagnosticStatus + detail?: string +} + +export interface DiagnosticDevice { + id: string + name: string + status: AdbDevice['status'] + androidVersion?: string + apiLevel?: string + gnirehtetInstalled?: boolean + gnirehtetActive?: boolean +} + +export interface DiagnosticReport { + generatedAt: string + engineStatus: ConnectionStatus + checks: DiagnosticCheck[] + devices: DiagnosticDevice[] +} + export interface GnirehtetAPI { startGnirehtet: (dns: string, port: string) => Promise stopGnirehtet: () => Promise @@ -41,6 +74,7 @@ export interface GnirehtetAPI { getSettings: () => Promise saveSettings: (settings: AppSettings) => Promise getAppVersion: () => Promise + getDiagnostics: () => Promise testSpeedOnDevice: (deviceId: string) => Promise openExternal: (url: string) => Promise windowControl: (action: 'minimize' | 'maximize' | 'close') => void