diff --git a/src/main/index.ts b/src/main/index.ts index 7832c5c6..6a4fb56e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -890,27 +890,31 @@ async function showMobilePairing(): Promise { return } - const snapshot = await mobileBridge.start() - if (!snapshot.desktopUrl || !snapshot.pairingUrl) { + let snapshot = await mobileBridge.start() + if (!snapshot.desktopUrl) { await mobileBridge.stop() const options: MessageBoxOptions = { type: 'warning', - message: 'No private Wi-Fi network was found.', - detail: 'Connect this computer to the same private Wi-Fi as your phone and try again.', + message: 'Failed to start mobile bridge.', + detail: 'Please try again.', buttons: ['OK'] } await (mainWindow ? dialog.showMessageBox(mainWindow, options) : dialog.showMessageBox(options)) return } + if (!snapshot.pairingUrl && !snapshot.tunnelActive) { + snapshot = await mobileBridge.toggleTunnel(true) + } + if (mobileWindow && !mobileWindow.isDestroyed()) mobileWindow.destroy() nativeTheme.themeSource = harnessThemePreference() mobileWindow = new BrowserWindow({ width: 560, - height: 700, + height: 720, minWidth: 420, minHeight: 560, - title: harnessLocale() === 'zh' ? '连接手机' : 'Connect Phone', + title: harnessLocale() === 'zh' ? '连接移动设备' : 'Connect Mobile Device', icon: desktopIconPath(), parent: mainWindow, backgroundColor: nativeTheme.shouldUseDarkColors ? '#141416' : '#ffffff', @@ -925,6 +929,7 @@ async function showMobilePairing(): Promise { mobileWindow.on('closed', () => { mobileWindow = undefined }) + if (!snapshot.desktopUrl) return await mobileWindow.loadURL(snapshot.desktopUrl) mobileWindow.show() mobileWindow.focus() @@ -965,6 +970,7 @@ async function bootstrap(): Promise { dark: dshBrandLogoPath('dark') }, appIconPath: desktopIconPath(), + cloudflaredCacheDir: join(app.getPath('userData'), 'bin'), port: developmentBuild ? 43128 : 43127, onReconnectRequested: () => { void showMobilePairing().catch(showUnexpectedError) diff --git a/src/main/mobile/cloudflared-tunnel.ts b/src/main/mobile/cloudflared-tunnel.ts new file mode 100644 index 00000000..f8542a88 --- /dev/null +++ b/src/main/mobile/cloudflared-tunnel.ts @@ -0,0 +1,229 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { chmod, mkdir, rm, writeFile } from 'node:fs/promises' +import { createWriteStream } from 'node:fs' +import { get as httpsGet } from 'node:https' +import { dirname, join } from 'node:path' +import { arch, platform } from 'node:os' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +export const CLOUDFLARED_VERSION = '2026.8.2' + +export interface CloudflareAssetSpec { + asset: string + isTarGz?: boolean + sha256: string +} + +export const CLOUDFLARED_ASSETS: Record = { + 'darwin-arm64': { + asset: 'cloudflared-darwin-arm64.tgz', + isTarGz: true, + sha256: '9f24e9cb54b9d031bf3dc2c2c9d2f0eb345d3151eb0c877ef945d8b74684a0d9' + }, + 'darwin-x64': { + asset: 'cloudflared-darwin-amd64.tgz', + isTarGz: true, + sha256: '4fc703cf97e42d76535d9ef264a974b971a8bc59e0a0d6aa0d59265f212fb9a7' + }, + 'win32-x64': { + asset: 'cloudflared-windows-amd64.exe', + isTarGz: false, + sha256: '64d4b1a457497d510e1a1e0dc42e128ef35b2e564bfd4847bf501309d949cf97' + }, + 'linux-x64': { + asset: 'cloudflared-linux-amd64', + isTarGz: false, + sha256: 'df36987f2ff841a4a4dcf9c4c7c88b9fc964177d6118d2bf4cfb0069ecad1ae5' + }, + 'linux-arm64': { + asset: 'cloudflared-linux-arm64', + isTarGz: false, + sha256: '25c898c61ce55a90d9a6c9cf1b72e0a2948eb927a4d5e86976f7f6c6d05f3d45' + } +} + +export function extractTryCloudflareUrl(text: string): string | null { + const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/i) + return match ? match[0] : null +} + +export async function findCloudflaredOnPath(): Promise { + const cmd = platform() === 'win32' ? 'where' : 'which' + try { + const { stdout } = await execFileAsync(cmd, ['cloudflared'], { timeout: 3000 }) + const resolved = stdout.trim().split(/\r?\n/)[0] + return resolved && existsSync(resolved) ? resolved : null + } catch { + return null + } +} + +export function resolveCurrentAssetSpec( + osPlatform: NodeJS.Platform | string = platform(), + osArch: NodeJS.Architecture | string = arch() +): { key: string; spec: CloudflareAssetSpec } | null { + const normalizedArch = osArch === 'x64' || (osArch as string) === 'amd64' ? 'x64' : osArch + const key = `${osPlatform}-${normalizedArch}` + const spec = CLOUDFLARED_ASSETS[key] + return spec ? { key, spec } : null +} + +export async function ensureCloudflaredBinary(options: { + cacheDir: string + customPath?: string + osPlatform?: NodeJS.Platform | string + osArch?: NodeJS.Architecture | string +}): Promise { + if (options.customPath && existsSync(options.customPath)) { + return options.customPath + } + + const onPath = await findCloudflaredOnPath() + if (onPath) return onPath + + const target = resolveCurrentAssetSpec(options.osPlatform, options.osArch) + if (!target) { + throw new Error(`Unsupported platform/architecture for cloudflared: ${options.osPlatform ?? platform()}-${options.osArch ?? arch()}`) + } + + const binaryName = (options.osPlatform ?? platform()) === 'win32' ? 'cloudflared.exe' : 'cloudflared' + const targetBinaryPath = join(options.cacheDir, binaryName) + if (existsSync(targetBinaryPath)) { + return targetBinaryPath + } + + await mkdir(options.cacheDir, { recursive: true }) + const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${target.spec.asset}` + const tempDownloadPath = join(options.cacheDir, `.download-${Date.now()}-${target.spec.asset}`) + + try { + await downloadFileWithRedirects(downloadUrl, tempDownloadPath) + + if (target.spec.isTarGz) { + await execFileAsync('tar', ['-xzf', tempDownloadPath, '-C', options.cacheDir]) + await rm(tempDownloadPath, { force: true }).catch(() => undefined) + } else { + await rm(targetBinaryPath, { force: true }).catch(() => undefined) + const { rename } = await import('node:fs/promises') + await rename(tempDownloadPath, targetBinaryPath) + } + + if ((options.osPlatform ?? platform()) !== 'win32') { + await chmod(targetBinaryPath, 0o755) + } + + return targetBinaryPath + } catch (error) { + await rm(tempDownloadPath, { force: true }).catch(() => undefined) + throw new Error(`Failed to obtain cloudflared binary: ${error instanceof Error ? error.message : String(error)}`) + } +} + +function downloadFileWithRedirects(url: string, destination: string, maxRedirects = 5): Promise { + return new Promise((resolvePromise, rejectPromise) => { + if (maxRedirects <= 0) { + return rejectPromise(new Error('Too many redirects while downloading cloudflared')) + } + + httpsGet(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return resolvePromise(downloadFileWithRedirects(res.headers.location, destination, maxRedirects - 1)) + } + if (res.statusCode !== 200) { + return rejectPromise(new Error(`Download failed with status ${res.statusCode}`)) + } + + const fileStream = createWriteStream(destination) + res.pipe(fileStream) + fileStream.on('finish', () => { + fileStream.close(() => resolvePromise()) + }) + fileStream.on('error', (err) => { + fileStream.close(() => rejectPromise(err)) + }) + }).on('error', rejectPromise) + }) +} + +export interface CloudflareTunnelInstance { + url: string + process: ChildProcess + stop: () => Promise +} + +export async function startCloudflareQuickTunnel(options: { + port: number + binaryPath: string + timeoutMs?: number + log?: (message: string) => void +}): Promise { + const { port, binaryPath, timeoutMs = 30_000, log } = options + + return new Promise((resolvePromise, rejectPromise) => { + let resolved = false + const child = spawn(binaryPath, ['tunnel', '--url', `http://127.0.0.1:${port}`], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + + const timeoutTimer = setTimeout(() => { + if (!resolved) { + cleanup() + rejectPromise(new Error(`Cloudflare Quick Tunnel timed out after ${timeoutMs / 1000}s`)) + } + }, timeoutMs) + + let capturedUrl: string | null = null + + const handleOutput = (chunk: Buffer | string) => { + const text = chunk.toString() + const extracted = extractTryCloudflareUrl(text) + if (extracted && !capturedUrl) { + capturedUrl = extracted + log?.(`[cloudflared] Tunnel online: ${capturedUrl}`) + resolved = true + clearTimeout(timeoutTimer) + resolvePromise({ + url: capturedUrl, + process: child, + stop: async () => { + cleanup() + } + }) + } + } + + child.stdout?.on('data', handleOutput) + child.stderr?.on('data', handleOutput) + + child.once('error', (err) => { + if (!resolved) { + clearTimeout(timeoutTimer) + rejectPromise(err) + } + }) + + child.once('close', (code, signal) => { + if (!resolved) { + clearTimeout(timeoutTimer) + rejectPromise(new Error(`cloudflared exited unexpectedly with code ${code}, signal ${signal}`)) + } + }) + + const cleanup = () => { + try { + if (!child.killed) { + child.kill('SIGTERM') + setTimeout(() => { + if (!child.killed) child.kill('SIGKILL') + }, 2000).unref?.() + } + } catch {} + } + }) +} diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index f8b31387..de6efc50 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -1,9 +1,15 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { networkInterfaces } from 'node:os' +import { networkInterfaces, tmpdir } from 'node:os' import type { AddressInfo } from 'node:net' import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import QRCode from 'qrcode' +import { + ensureCloudflaredBinary, + startCloudflareQuickTunnel, + type CloudflareTunnelInstance +} from './cloudflared-tunnel' import { renderDesktopPairingPage, renderMobilePage, @@ -34,6 +40,8 @@ export interface LanMobileBridgeOptions { brandLogoPaths?: { light: string; dark: string } appIconPath?: string port?: number + cloudflaredCacheDir?: string + cloudflaredPath?: string now?: () => number onReconnectRequested?: () => void } @@ -45,6 +53,10 @@ export interface LanMobileBridgeSnapshot { pairingUrl?: string desktopUrl?: string expiresAt?: number + tunnelActive?: boolean + tunnelLoading?: boolean + tunnelUrl?: string + tunnelError?: string } interface MobileSession { @@ -55,10 +67,13 @@ interface MobileSession { interface PendingPairing { id: string remoteAddress: string + mode: MobileConnectionMode expiresAt: number decision?: boolean } +type MobileConnectionMode = 'lan' | 'tunnel' + interface MobileQuestionOption { label: string description?: string @@ -91,6 +106,10 @@ export class LanMobileBridge { private port?: number private pairingToken?: string private pairingExpiresAt?: number + private tunnelInstance?: CloudflareTunnelInstance + private tunnelActive = false + private tunnelLoading = false + private tunnelError?: string private readonly sessions = new Map() private readonly suspendedSessions = new Map() private readonly pendingPairings = new Map() @@ -133,6 +152,13 @@ export class LanMobileBridge { this.port = undefined this.pairingToken = undefined this.pairingExpiresAt = undefined + if (this.tunnelInstance) { + await this.tunnelInstance.stop().catch(() => undefined) + this.tunnelInstance = undefined + } + this.tunnelActive = false + this.tunnelLoading = false + this.tunnelError = undefined this.sessions.clear() this.suspendedSessions.clear() this.pendingPairings.clear() @@ -146,19 +172,66 @@ export class LanMobileBridge { await new Promise((resolve) => server.close(() => resolve())) } + async toggleTunnel(enable?: boolean): Promise { + const targetState = enable !== undefined ? enable : !this.tunnelActive + if (!targetState) { + if (this.tunnelInstance) { + await this.tunnelInstance.stop().catch(() => undefined) + this.tunnelInstance = undefined + } + this.tunnelActive = false + this.tunnelLoading = false + this.tunnelError = undefined + return this.snapshot() + } + + if (this.tunnelActive && this.tunnelInstance?.url) { + return this.snapshot() + } + + this.tunnelLoading = true + this.tunnelError = undefined + try { + const binaryPath = await ensureCloudflaredBinary({ + cacheDir: this.options.cloudflaredCacheDir ?? join(tmpdir(), 'dsh-cloudflared'), + customPath: this.options.cloudflaredPath + }) + this.tunnelInstance = await startCloudflareQuickTunnel({ + port: this.port!, + binaryPath + }) + this.tunnelActive = true + this.tunnelLoading = false + } catch (error) { + this.tunnelActive = false + this.tunnelLoading = false + this.tunnelError = error instanceof Error ? error.message : String(error) + } + return this.snapshot() + } + snapshot(): LanMobileBridgeSnapshot { const address = preferredLanAddress() - if (!this.server || !this.port || !this.pairingToken || !this.pairingExpiresAt || !address) { + if (!this.server || !this.port || !this.pairingToken || !this.pairingExpiresAt) { return { running: Boolean(this.server), connected: this.sessions.size > 0 } } - const pairingUrl = `http://${address}:${this.port}/pair?token=${this.pairingToken}` + const pairingUrl = + this.tunnelActive && this.tunnelInstance?.url + ? `${this.tunnelInstance.url}/pair?token=${this.pairingToken}` + : address + ? `http://${address}:${this.port}/pair?token=${this.pairingToken}` + : undefined return { running: true, connected: this.sessions.size > 0, port: this.port, pairingUrl, desktopUrl: `http://127.0.0.1:${this.port}/desktop`, - expiresAt: this.pairingExpiresAt + expiresAt: this.pairingExpiresAt, + tunnelActive: this.tunnelActive, + tunnelLoading: this.tunnelLoading, + tunnelUrl: this.tunnelInstance?.url, + tunnelError: this.tunnelError } } @@ -177,8 +250,14 @@ export class LanMobileBridge { "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" ) - const remoteAddress = normalizeRemoteAddress(request.socket.remoteAddress ?? '') - if (!isPrivateAddress(remoteAddress)) return this.text(response, 403, 'Private network only.') + const transportAddress = normalizeRemoteAddress(request.socket.remoteAddress ?? '') + if (!isPrivateAddress(transportAddress)) return this.text(response, 403, 'Private network only.') + const connectionMode = this.requestConnectionMode(request, transportAddress) + const forwardedAddress = firstHeaderValue(request.headers['cf-connecting-ip']) + const remoteAddress = + connectionMode === 'tunnel' && forwardedAddress + ? normalizeRemoteAddress(forwardedAddress) + : transportAddress const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`) if (request.method === 'GET' && url.pathname.startsWith('/brand-logo/')) { @@ -224,7 +303,11 @@ export class LanMobileBridge { pairingUrl: snapshot.pairingUrl, expiresAt: snapshot.expiresAt, locale: this.locale(), - connected: this.sessions.size > 0 + connected: this.sessions.size > 0, + tunnelActive: snapshot.tunnelActive, + tunnelLoading: snapshot.tunnelLoading, + tunnelUrl: snapshot.tunnelUrl, + tunnelError: snapshot.tunnelError }) ) } @@ -234,7 +317,11 @@ export class LanMobileBridge { const pending = [...this.pendingPairings.values()].find( (item) => item.decision === undefined && item.expiresAt >= this.now() ) - return this.json(response, 200, pending ? { id: pending.id, remoteAddress: pending.remoteAddress } : {}) + return this.json( + response, + 200, + pending ? { id: pending.id, remoteAddress: pending.remoteAddress, mode: pending.mode } : {} + ) } if (request.method === 'GET' && url.pathname === '/desktop/status') { @@ -242,6 +329,55 @@ export class LanMobileBridge { return this.json(response, 200, { connected: this.sessions.size > 0 }) } + if (request.method === 'GET' && url.pathname === '/desktop/tunnel/status') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + const snapshot = this.snapshot() + const qrSvg = snapshot.pairingUrl + ? await QRCode.toString(snapshot.pairingUrl, { type: 'svg', margin: 1, width: 260 }) + : undefined + return this.json(response, 200, { + active: snapshot.tunnelActive, + loading: snapshot.tunnelLoading, + url: snapshot.tunnelUrl, + error: snapshot.tunnelError, + pairingUrl: snapshot.pairingUrl, + qrSvg, + expiresAt: snapshot.expiresAt + }) + } + + if (request.method === 'POST' && url.pathname === '/desktop/tunnel/toggle') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + if (this.sessions.size > 0) { + return this.json(response, 409, { + ok: false, + error: 'Disconnect the phone before switching connection modes.' + }) + } + let enable: boolean | undefined + try { + const bodyText = await readBody(request) + if (bodyText) { + const parsed = JSON.parse(bodyText) as { enable?: unknown } + if (typeof parsed.enable === 'boolean') enable = parsed.enable + } + } catch {} + const snapshot = await this.toggleTunnel(enable) + const qrSvg = snapshot.pairingUrl + ? await QRCode.toString(snapshot.pairingUrl, { type: 'svg', margin: 1, width: 260 }) + : undefined + return this.json(response, 200, { + ok: !snapshot.tunnelError, + active: snapshot.tunnelActive, + loading: snapshot.tunnelLoading, + url: snapshot.tunnelUrl, + error: snapshot.tunnelError, + pairingUrl: snapshot.pairingUrl, + qrSvg, + expiresAt: snapshot.expiresAt + }) + } + if (request.method === 'POST' && url.pathname === '/desktop/disconnect') { if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') for (const [token, session] of this.sessions) this.suspendedSessions.set(token, session) @@ -261,23 +397,31 @@ export class LanMobileBridge { } if (request.method === 'GET' && url.pathname === '/disconnected') { - return this.html(response, renderMobileReconnectPage(this.locale())) + const migrationUrl = this.tunnelMigrationUrl(url, connectionMode) + if (migrationUrl) return this.redirect(response, migrationUrl) + return this.html(response, renderMobileReconnectPage(this.locale(), connectionMode)) } if (request.method === 'GET' && url.pathname === '/reconnect') { - const pending = this.reconnectPairing(remoteAddress) + const migrationUrl = this.tunnelMigrationUrl(url, connectionMode) + if (migrationUrl) return this.redirect(response, migrationUrl) + const pending = this.reconnectPairing(remoteAddress, connectionMode) this.options.onReconnectRequested?.() return this.html(response, renderPairingWaitPage(pending.id, this.locale())) } if (request.method === 'POST' && url.pathname === '/pair/retry') { this.verifySameOrigin(request) - const pending = this.reconnectPairing(remoteAddress) + const migrationUrl = this.tunnelMigrationUrl(new URL('/reconnect', url), connectionMode) + if (migrationUrl) return this.json(response, 200, { redirectUrl: migrationUrl }) + const pending = this.reconnectPairing(remoteAddress, connectionMode) this.options.onReconnectRequested?.() return this.json(response, 200, { id: pending.id, expiresAt: pending.expiresAt }) } if (request.method === 'GET' && url.pathname === '/pair') { + const migrationUrl = this.tunnelMigrationUrl(url, connectionMode) + if (migrationUrl) return this.redirect(response, migrationUrl) if (this.authorized(request, remoteAddress)) { response.statusCode = 302 response.setHeader('location', '/') @@ -291,6 +435,7 @@ export class LanMobileBridge { this.pendingPairings.set(id, { id, remoteAddress, + mode: connectionMode, expiresAt: this.pairingExpiresAt! }) return this.html(response, renderPairingWaitPage(id, this.locale())) @@ -327,7 +472,9 @@ export class LanMobileBridge { this.rememberMobileContext(request, remoteAddress) if (!this.authorized(request, remoteAddress)) { if (request.method === 'GET' && url.pathname === '/') { - return this.html(response, renderMobileReconnectPage(this.locale())) + const migrationUrl = this.tunnelMigrationUrl(url, connectionMode) + if (migrationUrl) return this.redirect(response, migrationUrl) + return this.html(response, renderMobileReconnectPage(this.locale(), connectionMode)) } return this.text(response, 401, 'Pair your phone again.') } @@ -394,10 +541,14 @@ export class LanMobileBridge { return left.length === right.length && timingSafeEqual(left, right) } - private reconnectPairing(remoteAddress: string): PendingPairing { + private reconnectPairing( + remoteAddress: string, + mode: MobileConnectionMode + ): PendingPairing { const current = [...this.pendingPairings.values()].find( (item) => item.remoteAddress === remoteAddress && + item.mode === mode && item.decision === undefined && item.expiresAt >= this.now() ) @@ -405,6 +556,7 @@ export class LanMobileBridge { const pending = { id: randomUUID(), remoteAddress, + mode, expiresAt: this.now() + PAIRING_TTL_MS } this.pendingPairings.set(pending.id, pending) @@ -446,6 +598,24 @@ export class LanMobileBridge { if (origin && host && new URL(origin).host !== host) throw new Error('Cross-origin request rejected.') } + private requestConnectionMode( + request: IncomingMessage, + transportAddress: string + ): MobileConnectionMode { + if (!isLoopbackAddress(transportAddress)) return 'lan' + const host = (request.headers.host ?? '').split(':', 1)[0]?.toLowerCase() ?? '' + const forwardedAddress = firstHeaderValue(request.headers['cf-connecting-ip']) + const ray = firstHeaderValue(request.headers['cf-ray']) + return host.endsWith('.trycloudflare.com') || Boolean(forwardedAddress && ray) + ? 'tunnel' + : 'lan' + } + + private tunnelMigrationUrl(url: URL, connectionMode: MobileConnectionMode): string | undefined { + if (connectionMode === 'tunnel' || !this.tunnelActive || !this.tunnelInstance?.url) return undefined + return new URL(`${url.pathname}${url.search}`, this.tunnelInstance.url).toString() + } + private async forwardRpc(method: string, payload: unknown): Promise<{ ok: boolean; value?: unknown; error?: string }> { const base = this.options.harnessUrl() if (!base) return { ok: false, error: 'Harness is not ready.' } @@ -605,6 +775,12 @@ export class LanMobileBridge { response.end(body) } + private redirect(response: ServerResponse, location: string): void { + response.statusCode = 302 + response.setHeader('location', location) + response.end() + } + private text(response: ServerResponse, status: number, body: string): void { response.statusCode = status response.setHeader('content-type', 'text/plain; charset=utf-8') @@ -659,6 +835,12 @@ async function readBody(request: IncomingMessage): Promise { return Buffer.concat(chunks).toString('utf8') } +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + const first = Array.isArray(value) ? value[0] : value?.split(',', 1)[0] + const normalized = first?.trim() + return normalized || undefined +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index b4d06507..cf81aa7e 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -168,15 +168,23 @@ setInterval(checkConnection,1500);checkConnection(); ` } -export function renderMobileReconnectPage(locale: 'en' | 'zh'): string { +export function renderMobileReconnectPage( + locale: 'en' | 'zh', + connectionMode: 'lan' | 'tunnel' = 'lan' +): string { const zh = locale === 'zh' const text = { title: zh ? '重新连接 DSH' : 'Reconnect DSH', heading: zh ? '连接已断开' : 'Connection lost', action: zh ? '重新连接' : 'Reconnect', - guidance: zh - ? '请确保手机和电脑连接到同一 Wi-Fi。点击重新连接后,在电脑上的 DSH Desktop 中允许此手机。' - : 'Keep both devices on the same Wi-Fi, then reconnect and approve this phone in DSH Desktop.' + guidance: + connectionMode === 'tunnel' + ? zh + ? '点击重新连接,然后在电脑上的 DSH Desktop 中允许此移动设备。' + : 'Reconnect, then approve this mobile device in DSH Desktop.' + : zh + ? '请确保手机和电脑连接到同一 Wi-Fi。点击重新连接后,在电脑上的 DSH Desktop 中允许此手机。' + : 'Keep both devices on the same Wi-Fi, then reconnect and approve this phone in DSH Desktop.' } return `${text.title}
DSH Desktop

${text.heading}

${text.guidance}

${text.action}
` } @@ -187,12 +195,26 @@ export function renderDesktopPairingPage(options: { expiresAt: number locale: 'en' | 'zh' connected: boolean + tunnelActive?: boolean + tunnelLoading?: boolean + tunnelUrl?: string + tunnelError?: string }): string { const zh = options.locale === 'zh' const text = { - title: zh ? '连接手机' : 'Connect Phone', - heading: zh ? '连接你的手机' : 'Connect your phone', - hint: zh ? '请确保两台设备连接到同一个可信 Wi-Fi,然后扫描二维码。' : 'Keep both devices on the same trusted Wi-Fi, then scan the QR code.', + title: zh ? '连接移动设备' : 'Connect Mobile Device', + heading: zh ? '连接移动设备' : 'Connect a mobile device', + hint: zh ? '请使用手机扫描二维码,在手机上继续对话。' : 'Scan the QR code with your phone to continue conversation.', + lanHint: zh + ? '移动设备与电脑需连接至同一 WiFi,同步实时性高' + : 'Keep the mobile device and computer on the same WiFi for high real-time responsiveness.', + tunnelHint: zh + ? '移动设备通过互联网(如 4G/5G 或其他WiFi网络等)均可远程操控,同步实时性中等' + : 'Control remotely over the internet, including 4G/5G or other WiFi networks, with moderate real-time responsiveness.', + tunnelLoading: zh ? '正在创建全球网络链接' : 'Creating a global network link', + lanLoading: zh ? '正在切换至 WiFi 连接模式' : 'Switching to WiFi connection mode', + modeLan: zh ? 'WiFi连接模式' : 'WiFi Connection Mode', + modeTunnel: zh ? '互联网连接模式' : 'Internet Connection Mode', manageHeading: zh ? '管理手机连接' : 'Manage phone connection', manageHint: zh ? '这台手机当前已连接到 DSH Desktop。' : 'Your phone is currently connected to DSH Desktop.', connected: zh ? '手机已连接' : 'Phone connected', @@ -202,14 +224,49 @@ export function renderDesktopPairingPage(options: { copy: zh ? '复制' : 'Copy', waiting: zh ? '手机正在等待批准' : 'Phone waiting for approval', deviceAddress: zh ? '设备地址:' : 'Device address: ', + requestLan: zh ? '连接方式:WiFi 连接模式' : 'Connection: WiFi connection mode', + requestTunnel: zh ? '连接方式:互联网连接模式' : 'Connection: Internet connection mode', decline: zh ? '拒绝' : 'Decline', allow: zh ? '允许' : 'Allow', refresh: zh ? '二维码将在 ' : 'QR refreshes in ', seconds: zh ? ' 秒后刷新' : 's', - expired: zh ? '二维码已过期,请重新打开此窗口刷新。' : 'QR expired. Reopen this window to refresh.' + expired: zh ? '二维码已过期,请重新打开此窗口刷新。' : 'QR expired. Reopen this window to refresh.', + tunnelError: zh ? '隧道建立失败:' : 'Tunnel failed: ' } return `${text.title}
DSH Desktop

${options.connected ? text.manageHeading : text.heading}

${options.connected ? text.manageHint : text.hint}

${text.connected}

${text.closeHint}

${options.qrSvg}
${escapeHtml(options.pairingUrl)}

${text.waiting}
` + :root{color-scheme:light;--bg:#fff;--surface:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--brand:#4d6bfe;--success-bg:#f2f8f4;--success-ink:#277347;--success-muted:#557565;--request-accent:#c16f52;--request-border:#dfbcae;--request-bg:#fbf6f3}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#141416;--surface:#1d1d20;--panel:#202023;--ink:#f5f5f6;--muted:#95979d;--line:#303034;--brand:#6f86ff;--success-bg:#17261d;--success-ink:#75c991;--success-muted:#8ab99a;--request-accent:#df9275;--request-border:#68483d;--request-bg:#291f1c}}*{box-sizing:border-box}html,body{min-height:100%;background:var(--bg)}body{margin:0;color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding:26px 32px 30px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:9px;font-weight:600;margin-bottom:14px}.brand img{width:39px;height:22px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}h1{font-size:24px;line-height:1.25;font-weight:600;margin:0}p{margin:0;color:var(--muted)} + .mode-panel{max-width:410px;margin:18px auto 0;padding:5px 8px 10px;border:1px solid var(--line);border-radius:15px;background:var(--panel)} + .mode-switch{display:grid;grid-template-columns:1fr 1fr;width:100%;padding:3px;border-radius:11px;gap:3px} + .mode-btn{min-width:0;height:34px;border:0;background:transparent;color:var(--muted);padding:0 10px;border-radius:9px;font-size:13px;font-weight:550;cursor:pointer;transition:background .15s,color .15s} + .mode-btn.active{background:var(--surface);color:var(--ink);box-shadow:0 1px 4px rgba(0,0,0,.08)} + .mode-btn:disabled{cursor:not-allowed;opacity:.5}.mode-btn.active:disabled{opacity:.72} + .connection{display:none;flex-direction:column;align-items:center;margin:24px auto 0;max-width:390px;padding:22px;border-radius:14px;background:var(--success-bg);color:var(--success-ink)}.connection.show{display:flex}.connection-title{font-size:16px;font-weight:600}.connection-title:before{content:'✓';display:inline-grid;place-items:center;width:24px;height:24px;margin-right:9px;border-radius:50%;background:#35a867;color:white}.connection-hint{max-width:310px;margin-top:8px;color:var(--success-muted);font-size:13px}.connection-actions{display:flex;gap:8px;margin-top:18px}.connection-actions button{min-width:94px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}.connection-actions .done{background:var(--ink);color:var(--bg);border-color:var(--ink)}.phone-connected .pairing-content{display:none}.manage-connected .connection-hint,.manage-connected .done{display:none}.manage-connected .connection-actions{margin-top:16px} + .pairing-content{margin-top:16px}.qr{display:inline-flex;background:#fff;padding:12px;border:1px solid var(--line);border-radius:16px;margin:0 0 10px;min-width:244px;min-height:244px;align-items:center;justify-content:center;position:relative} + .qr svg{width:220px;height:220px;display:block} + .qr-loading{position:absolute;inset:0;background:rgba(255,255,255,.94);border-radius:16px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;font-size:13px;color:#18191c;font-weight:550} + .qr-loading.show{display:flex} + .loading-copy{display:flex;width:176px;align-items:center;justify-content:space-between;gap:12px}.loading-copy span:first-child{text-align:left}.loading-value{min-width:32px;text-align:right;color:#62666d;font-variant-numeric:tabular-nums}.tunnel-progress{width:176px;height:5px;overflow:hidden;border-radius:999px;background:#e5e7eb}.tunnel-progress span{display:block;width:0;height:100%;border-radius:inherit;background:var(--brand);transition:width .08s linear}@media(prefers-reduced-motion:reduce){.tunnel-progress span{transition:none}} + .hint{min-height:38px;display:flex;align-items:center;justify-content:center;font-size:12.5px;line-height:1.55;max-width:370px;margin:7px auto 0;padding:0 5px} + .url-row{display:flex;align-items:center;gap:8px;margin:10px auto 0;max-width:410px}.url{min-width:0;flex:1;font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:var(--panel);border-radius:9px;padding:9px 11px;text-align:left}.copy{border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:9px;padding:8px 12px;cursor:pointer}.expires{font-size:12px;margin-top:8px} + .request{display:none;max-width:410px;margin:16px auto 0;padding:16px;border:1px solid var(--request-border);border-radius:14px;background:var(--request-bg);text-align:left}.request.show{display:block}.request-title{display:flex;align-items:center;gap:8px;font-weight:600}.request-title:before{content:'';width:8px;height:8px;border-radius:50%;background:var(--request-accent)}.request-meta{font-size:12px;color:var(--muted);margin:5px 0 0 16px}#address:empty{display:none}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.actions button{min-width:62px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:var(--bg);border-color:var(--ink)}.has-request .qr,.has-request .url-row,.has-request .expires{display:none}.has-request .request{margin-top:0} + .tunnel-err{display:none;color:#e34d59;font-size:12px;margin:5px 7px 0}.tunnel-err.show{display:block} +
DSH Desktop

${options.connected ? text.manageHeading : text.heading}

+

${options.tunnelActive ? text.tunnelHint : text.lanHint}

${options.tunnelError ? text.tunnelError + options.tunnelError : ''}
+
${text.connected}

${text.closeHint}

+
${options.qrSvg}
${text.tunnelLoading}0%
${escapeHtml(options.pairingUrl)}

${text.waiting}
+ ` } export function renderPairingWaitPage(pairingId: string, locale: 'en' | 'zh'): string { @@ -226,7 +283,7 @@ export function renderPairingWaitPage(pairingId: string, locale: 'en' | 'zh'): s retry: zh ? '再次发起申请' : 'Request approval again', retrying: zh ? '正在重新发起申请…' : 'Requesting approval again…' } - return `${text.title}

${text.heading}

${text.hint}

${text.waiting}
` + return `${text.title}

${text.heading}

${text.hint}

${text.waiting}
` } function escapeHtml(value: string): string { diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index d4d19dcb..d4615da3 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -73,7 +73,7 @@ describe('LAN mobile bridge pairing surface', () => { expect(snapshot.desktopUrl).toBeTruthy() const response = await fetch(snapshot.desktopUrl!) expect(response.status).toBe(200) - expect(await response.text()).toContain('Connect your phone') + expect(await response.text()).toContain('Connect a mobile device') }) it('offers a reconnect page without exposing mobile APIs before approval', async () => { @@ -89,6 +89,100 @@ describe('LAN mobile bridge pairing surface', () => { expect(blocked.status).toBe(401) }) + it('migrates a stale WiFi reconnect into the active internet entry point', async () => { + const bridge = new LanMobileBridge({ + harnessUrl: () => 'http://127.0.0.1:9999' + }) + bridges.push(bridge) + const snapshot = await bridge.start() + let tunnelStopped = false + Object.assign(bridge as unknown as Record, { + tunnelActive: true, + tunnelInstance: { + url: 'https://active-mobile.trycloudflare.com', + process: {}, + stop: async () => { + tunnelStopped = true + } + } + }) + + const lanReconnect = await fetch(`http://127.0.0.1:${snapshot.port}/reconnect`, { + redirect: 'manual' + }) + expect(lanReconnect.status).toBe(302) + expect(lanReconnect.headers.get('location')).toBe( + 'https://active-mobile.trycloudflare.com/reconnect' + ) + + const lanRetry = await fetch(`http://127.0.0.1:${snapshot.port}/pair/retry`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: `http://127.0.0.1:${snapshot.port}` + }, + body: '{}' + }) + expect(await lanRetry.json()).toEqual({ + redirectUrl: 'https://active-mobile.trycloudflare.com/reconnect' + }) + + const tunnelReconnect = await fetch(`http://127.0.0.1:${snapshot.port}/reconnect`, { + headers: { + host: 'active-mobile.trycloudflare.com', + 'cf-connecting-ip': '203.0.113.8', + 'cf-ray': 'test-ray' + } + }) + expect(tunnelReconnect.status).toBe(200) + expect(await tunnelReconnect.text()).toContain('Approve this phone') + const pending = await fetch(`http://127.0.0.1:${snapshot.port}/desktop/pending`) + expect(await pending.json()).toMatchObject({ + remoteAddress: '203.0.113.8', + mode: 'tunnel' + }) + expect(tunnelStopped).toBe(false) + }) + + it('keeps the active internet tunnel available after desktop disconnect', async () => { + const bridge = new LanMobileBridge({ + harnessUrl: () => 'http://127.0.0.1:9999' + }) + bridges.push(bridge) + const snapshot = await bridge.start() + let tunnelStopped = false + Object.assign(bridge as unknown as Record, { + tunnelActive: true, + tunnelInstance: { + url: 'https://active-mobile.trycloudflare.com', + process: {}, + stop: async () => { + tunnelStopped = true + } + } + }) + + const disconnected = await fetch( + `http://127.0.0.1:${snapshot.port}/desktop/disconnect`, + { method: 'POST' } + ) + expect(disconnected.status).toBe(200) + expect(bridge.snapshot().tunnelActive).toBe(true) + expect(tunnelStopped).toBe(false) + + const reconnectPage = await fetch(`http://127.0.0.1:${snapshot.port}/disconnected`, { + headers: { + host: 'active-mobile.trycloudflare.com', + 'cf-connecting-ip': '203.0.113.8', + 'cf-ray': 'test-ray' + } + }) + expect(reconnectPage.status).toBe(200) + const reconnectHtml = await reconnectPage.text() + expect(reconnectHtml).toContain('Reconnect') + expect(reconnectHtml).not.toContain('same Wi-Fi') + }) + it('retries an expired approval inside the same Home Screen browser context', async () => { let reconnectRequests = 0 let now = Date.now() @@ -250,6 +344,19 @@ describe('LAN mobile bridge pairing surface', () => { expect(await status.json()).toEqual({ connected: true }) const managementPage = await fetch(`http://127.0.0.1:${snapshot.port}/desktop`) expect(await managementPage.text()).toContain('Manage phone connection') + const blockedModeSwitch = await fetch( + `http://127.0.0.1:${snapshot.port}/desktop/tunnel/toggle`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enable: true }) + } + ) + expect(blockedModeSwitch.status).toBe(409) + expect(await blockedModeSwitch.json()).toEqual({ + ok: false, + error: 'Disconnect the phone before switching connection modes.' + }) const mobileStatus = await fetch(`http://127.0.0.1:${snapshot.port}/api/status`, { headers: { cookie } }) diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index ab038bb5..15970faa 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -157,6 +157,7 @@ describe('LAN mobile page', () => { it('renders an adaptive reconnect action for the Home Screen app', () => { const zh = renderMobileReconnectPage('zh') const en = renderMobileReconnectPage('en') + const tunnelZh = renderMobileReconnectPage('zh', 'tunnel') expect(zh).toContain('连接已断开') expect(zh).toContain('href="/reconnect">重新连接') expect(zh).toContain('请确保手机和电脑连接到同一 Wi-Fi。点击重新连接后,在电脑上的 DSH Desktop 中允许此手机。') @@ -164,6 +165,8 @@ describe('LAN mobile page', () => { expect(zh).not.toContain('class="network"') expect(zh).not.toContain('class="symbol"') expect(en).toContain('Connection lost') + expect(tunnelZh).toContain('点击重新连接,然后在电脑上的 DSH Desktop 中允许此移动设备。') + expect(tunnelZh).not.toContain('连接到同一 Wi-Fi') for (const html of [zh, en]) { expect(html).toContain('prefers-color-scheme:dark') expect(html).toContain('/brand-logo/light') @@ -196,10 +199,46 @@ describe('LAN mobile page', () => { expect(desktop).toContain('/brand-logo/dark') expect(desktop).toContain('--bg:#141416') expect(desktop).toContain('.qr{display:inline-flex;background:#fff') + expect(desktop).toContain('Creating a global network link') + expect(desktop).toContain('Switching to WiFi connection mode') + expect(desktop).toContain('class="mode-panel"') + expect(desktop).toContain('class="tunnel-progress" aria-hidden="true"') + expect(desktop).toContain('id="tunnelProgressValue" class="loading-value">0%') + expect(desktop).toContain('id="tunnelProgressBar"') + expect(desktop).toContain('
') + expect(desktop).toContain( + '.has-request .qr,.has-request .url-row,.has-request .expires{display:none}' + ) + expect(desktop).toContain("document.body.classList.toggle('has-request',!!pendingId)") + expect(desktop).toContain('duration=enableTunnel?4500:800') + expect(desktop).toContain('Math.min(99,(Date.now()-tunnelProgressStartedAt)/duration*100)') + expect(desktop).toContain('setTunnelProgress(100)') + expect(desktop).not.toContain('width:88%') + expect(desktop).not.toContain('@keyframes tunnelProgress') + expect(desktop).not.toContain('animation:tunnelProgress') + expect(desktop).not.toContain('
') + expect(desktop).toContain('id="qrCode">
') + expect(desktop).toContain("document.getElementById('qrCode').innerHTML=j.qrSvg") + expect(desktop).not.toContain("document.getElementById('qrContainer').innerHTML=j.qrSvg") + expect(desktop).toContain('if(phoneConnected||modeSwitching||tunnelActive===enableTunnel)return') + expect(desktop).toContain('await finishTunnelProgress(completed,progressDuration)') expect(desktop).toContain('Phone connected') expect(desktop).toContain('You can close this window now.') expect(desktop).toContain('onclick="window.close()">Done') expect(desktop).toContain("document.body.classList.toggle('phone-connected'") + expect(desktop).toContain('function syncModeControls(connected)') + expect(desktop).toContain( + 'if(phoneConnected||modeSwitching||tunnelActive===enableTunnel)return' + ) + expect(desktop).not.toContain('📶') + expect(desktop).not.toContain('🌐') + const modePanel = desktop.slice( + desktop.indexOf('class="mode-panel"'), + desktop.indexOf('
{ expect(phone).not.toContain("location.href='/'") }) + it('keeps the loading layer intact across consecutive mode switches', async () => { + const desktop = renderDesktopPairingPage({ + qrSvg: '', + pairingUrl: 'http://192.168.1.2/pair?token=test', + expiresAt: Date.now() + 60_000, + locale: 'en', + connected: false + }) + const script = /