From ac0e52b230dac5425dddf12eb9f30d4dea63cb33 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 19:32:25 +0800 Subject: [PATCH 01/18] feat: add LAN mobile control bridge --- package-lock.json | 228 ++++++++++++++++++- package.json | 4 +- src/main/index.ts | 68 +++++- src/main/mobile/lan-mobile-bridge.ts | 313 +++++++++++++++++++++++++++ src/main/mobile/lan-mobile-pages.ts | 89 ++++++++ test/lan-mobile-bridge.test.ts | 122 +++++++++++ 6 files changed, 810 insertions(+), 14 deletions(-) create mode 100644 src/main/mobile/lan-mobile-bridge.ts create mode 100644 src/main/mobile/lan-mobile-pages.ts create mode 100644 test/lan-mobile-bridge.test.ts diff --git a/package-lock.json b/package-lock.json index 77230bb..d4c2210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,12 @@ "@deepseek-ai/dsh-timeout": "0.1.0-rc.6", "@deepseek-ai/dsh-workflow": "0.1.0-rc.6", "electron-updater": "^6.8.9", - "node": "24.9.0" + "node": "24.9.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@types/node": "24.10.1", + "@types/qrcode": "^1.5.6", "electron": "43.4.0", "electron-builder": "26.15.3", "electron-vite": "5.0.0", @@ -7228,6 +7230,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -7637,7 +7649,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7647,7 +7658,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8249,6 +8259,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -8426,7 +8445,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8439,7 +8457,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -8602,6 +8619,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8758,6 +8784,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -9102,7 +9134,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -9543,6 +9574,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", @@ -9710,7 +9754,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10229,7 +10272,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10578,6 +10620,18 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -12237,6 +12291,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -12250,6 +12331,15 @@ "node": ">=8" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -12311,6 +12401,15 @@ "node": ">=8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -12435,6 +12534,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -12614,6 +12722,89 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -12773,7 +12964,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12788,6 +12978,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -13067,6 +13263,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13392,7 +13594,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13421,7 +13622,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -14655,6 +14855,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 9c4d4a2..ed34192 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,12 @@ "@deepseek-ai/dsh-timeout": "0.1.0-rc.6", "@deepseek-ai/dsh-workflow": "0.1.0-rc.6", "electron-updater": "^6.8.9", - "node": "24.9.0" + "node": "24.9.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@types/node": "24.10.1", + "@types/qrcode": "^1.5.6", "electron": "43.4.0", "electron-builder": "26.15.3", "electron-vite": "5.0.0", diff --git a/src/main/index.ts b/src/main/index.ts index adaf3ae..38b5c4a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ import { type MessageBoxOptions } from 'electron' import { HarnessRuntime } from './runtime/harness-runtime' +import { LanMobileBridge } from './mobile/lan-mobile-bridge' import { secureWindow } from './security' import { ensureLaunchRoot } from './state/launch-root' import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation' @@ -23,7 +24,9 @@ import { import type { RuntimeSnapshot } from '../shared/contracts' let mainWindow: BrowserWindow | undefined +let mobileWindow: BrowserWindow | undefined let runtime: HarnessRuntime +let mobileBridge: LanMobileBridge let launchDirectory: string let quitting = false let failureDialogVisible = false @@ -243,7 +246,8 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { } function installMenu(): void { - const checkForUpdatesLabel = app.getLocale().toLowerCase().startsWith('zh') + const isChinese = app.getLocale().toLowerCase().startsWith('zh') + const checkForUpdatesLabel = isChinese ? '检查更新…' : 'Check for Updates…' const template: Electron.MenuItemConstructorOptions[] = [ @@ -271,6 +275,12 @@ function installMenu(): void { { label: 'Harness', submenu: [ + { + label: isChinese ? '连接手机…' : 'Connect Phone…', + accelerator: 'CmdOrCtrl+Shift+M', + click: () => void showMobilePairing().catch(showUnexpectedError) + }, + { type: 'separator' }, { label: 'Restart Harness', accelerator: 'CmdOrCtrl+Shift+R', @@ -325,6 +335,56 @@ function installMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } +async function showMobilePairing(): Promise { + if (runtime.snapshot().phase !== 'ready') { + const options: MessageBoxOptions = { + type: 'info', + message: 'Harness is still starting.', + detail: 'Wait until DSH Desktop is ready, then connect your phone again.', + buttons: ['OK'] + } + await (mainWindow ? dialog.showMessageBox(mainWindow, options) : dialog.showMessageBox(options)) + return + } + + const snapshot = await mobileBridge.start() + if (!snapshot.desktopUrl || !snapshot.pairingUrl) { + 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.', + buttons: ['OK'] + } + await (mainWindow ? dialog.showMessageBox(mainWindow, options) : dialog.showMessageBox(options)) + return + } + + if (mobileWindow && !mobileWindow.isDestroyed()) mobileWindow.destroy() + mobileWindow = new BrowserWindow({ + width: 560, + height: 700, + minWidth: 420, + minHeight: 560, + title: 'Connect Phone', + icon: desktopIconPath(), + parent: mainWindow, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true + } + }) + secureWindow(mobileWindow) + mobileWindow.on('closed', () => { + mobileWindow = undefined + }) + await mobileWindow.loadURL(snapshot.desktopUrl) + mobileWindow.show() + mobileWindow.focus() +} + async function bootstrap(): Promise { if (process.platform === 'darwin') app.dock?.setIcon(desktopIconPath()) launchDirectory = await ensureLaunchRoot(app.getPath('userData')) @@ -346,6 +406,10 @@ async function bootstrap(): Promise { } } }) + mobileBridge = new LanMobileBridge({ + harnessUrl: () => runtime.snapshot().url, + locale: app.getLocale().toLowerCase().startsWith('zh') ? 'zh' : 'en' + }) installMenu() await launchHarness() if (!developmentBuild) { @@ -390,6 +454,6 @@ if (!singleInstance) { event.preventDefault() quitting = true stopUpdateManager() - void runtime.stop().finally(() => app.quit()) + void Promise.all([runtime.stop(), mobileBridge?.stop()]).finally(() => app.quit()) }) } diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts new file mode 100644 index 0000000..311614b --- /dev/null +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -0,0 +1,313 @@ +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { networkInterfaces } from 'node:os' +import type { AddressInfo } from 'node:net' +import QRCode from 'qrcode' +import { renderDesktopPairingPage, renderMobilePage, renderPairingWaitPage } from './lan-mobile-pages' + +const MAX_BODY_BYTES = 64 * 1024 +const PAIRING_TTL_MS = 5 * 60 * 1000 +const SESSION_TTL_MS = 12 * 60 * 60 * 1000 + +const RPC_ALLOWLIST = new Set([ + 'workspace.list', + 'session.list', + 'session.history', + 'session.create', + 'session.prompt', + 'session.cancel' +]) + +export interface LanMobileBridgeOptions { + harnessUrl(): string | undefined + locale?: 'en' | 'zh' + now?: () => number +} + +export interface LanMobileBridgeSnapshot { + running: boolean + port?: number + pairingUrl?: string + desktopUrl?: string + expiresAt?: number +} + +interface MobileSession { + token: string + expiresAt: number +} + +interface PendingPairing { + id: string + remoteAddress: string + expiresAt: number + decision?: boolean +} + +export class LanMobileBridge { + private server?: ReturnType + private port?: number + private pairingToken?: string + private pairingExpiresAt?: number + private readonly sessions = new Map() + private readonly pendingPairings = new Map() + private readonly now: () => number + + constructor(private readonly options: LanMobileBridgeOptions) { + this.now = options.now ?? Date.now + } + + async start(): Promise { + await this.stop() + this.rotatePairingToken() + this.server = createServer((request, response) => { + void this.handle(request, response).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + this.json(response, 500, { ok: false, error: message }) + }) + }) + await new Promise((resolve, reject) => { + this.server?.once('error', reject) + this.server?.listen(0, '0.0.0.0', resolve) + }) + this.port = (this.server.address() as AddressInfo).port + return this.snapshot() + } + + async stop(): Promise { + const server = this.server + this.server = undefined + this.port = undefined + this.pairingToken = undefined + this.pairingExpiresAt = undefined + this.sessions.clear() + this.pendingPairings.clear() + if (!server) return + await new Promise((resolve) => server.close(() => resolve())) + } + + snapshot(): LanMobileBridgeSnapshot { + const address = preferredLanAddress() + if (!this.server || !this.port || !this.pairingToken || !this.pairingExpiresAt || !address) { + return { running: Boolean(this.server) } + } + const pairingUrl = `http://${address}:${this.port}/pair?token=${this.pairingToken}` + return { + running: true, + port: this.port, + pairingUrl, + desktopUrl: `http://127.0.0.1:${this.port}/desktop`, + expiresAt: this.pairingExpiresAt + } + } + + private rotatePairingToken(): void { + this.pairingToken = randomBytes(32).toString('base64url') + this.pairingExpiresAt = this.now() + PAIRING_TTL_MS + } + + private async handle(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader('cache-control', 'no-store') + response.setHeader('x-content-type-options', 'nosniff') + response.setHeader('x-frame-options', 'DENY') + response.setHeader('referrer-policy', 'no-referrer') + response.setHeader( + 'content-security-policy', + "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 url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`) + + if (request.method === 'GET' && url.pathname === '/desktop') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + const snapshot = this.snapshot() + if (!snapshot.pairingUrl || !snapshot.expiresAt) return this.text(response, 503, 'Bridge unavailable.') + const qrSvg = await QRCode.toString(snapshot.pairingUrl, { type: 'svg', margin: 1, width: 260 }) + return this.html( + response, + renderDesktopPairingPage({ + qrSvg, + pairingUrl: snapshot.pairingUrl, + expiresAt: snapshot.expiresAt + }) + ) + } + + if (request.method === 'GET' && url.pathname === '/desktop/pending') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + 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 } : {}) + } + + if (request.method === 'POST' && url.pathname === '/desktop/decide') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + const input = JSON.parse(await readBody(request)) as { id?: unknown; approved?: unknown } + const pending = typeof input.id === 'string' ? this.pendingPairings.get(input.id) : undefined + if (!pending || typeof input.approved !== 'boolean') return this.text(response, 404, 'Pairing request not found.') + pending.decision = input.approved + return this.json(response, 200, { ok: true }) + } + + if (request.method === 'GET' && url.pathname === '/pair') { + if (!this.validPairingToken(url.searchParams.get('token'))) { + return this.text(response, 401, 'This pairing link is invalid or expired.') + } + const id = randomUUID() + this.pendingPairings.set(id, { + id, + remoteAddress, + expiresAt: this.pairingExpiresAt! + }) + return this.html(response, renderPairingWaitPage(id)) + } + + if (request.method === 'GET' && url.pathname === '/pair/status') { + const id = url.searchParams.get('id') + const pending = id ? this.pendingPairings.get(id) : undefined + if (!pending) return this.json(response, 200, { expired: true }) + if (pending.expiresAt < this.now()) { + this.pendingPairings.delete(pending.id) + return this.json(response, 200, { expired: true }) + } + if (pending.decision === false) { + this.pendingPairings.delete(pending.id) + return this.json(response, 200, { denied: true }) + } + if (pending.decision !== true) return this.json(response, 200, { pending: true }) + const token = randomBytes(32).toString('base64url') + this.sessions.set(token, { token, expiresAt: this.now() + SESSION_TTL_MS }) + this.pendingPairings.delete(pending.id) + this.pairingToken = undefined + this.pairingExpiresAt = undefined + response.setHeader('set-cookie', `dsh_mobile=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200`) + return this.json(response, 200, { approved: true }) + } + + if (!this.authorized(request)) return this.text(response, 401, 'Pair your phone again.') + if (request.method === 'GET' && url.pathname === '/') { + return this.html(response, renderMobilePage({ locale: this.options.locale ?? 'en' })) + } + if (request.method === 'POST' && url.pathname === '/api/rpc') { + this.verifySameOrigin(request) + const input = JSON.parse(await readBody(request)) as { method?: unknown; payload?: unknown } + if (typeof input.method !== 'string' || !RPC_ALLOWLIST.has(input.method)) { + return this.json(response, 403, { ok: false, error: 'RPC method is not available on mobile.' }) + } + const result = await this.forwardRpc(input.method, input.payload ?? {}) + return this.json(response, result.ok ? 200 : 400, result) + } + this.text(response, 404, 'Not found.') + } + + private validPairingToken(candidate: string | null): boolean { + if (!candidate || !this.pairingToken || !this.pairingExpiresAt) return false + if (this.now() > this.pairingExpiresAt) return false + const left = Buffer.from(candidate) + const right = Buffer.from(this.pairingToken) + return left.length === right.length && timingSafeEqual(left, right) + } + + private authorized(request: IncomingMessage): boolean { + const cookie = request.headers.cookie ?? '' + const match = /(?:^|;\s*)dsh_mobile=([^;]+)/.exec(cookie) + if (!match) return false + const session = this.sessions.get(match[1]!) + if (!session || session.expiresAt < this.now()) { + if (session) this.sessions.delete(session.token) + return false + } + return true + } + + private verifySameOrigin(request: IncomingMessage): void { + const origin = request.headers.origin + const host = request.headers.host + if (origin && host && new URL(origin).host !== host) throw new Error('Cross-origin request rejected.') + } + + 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.' } + const rpcId = randomUUID() + const response = await fetch(new URL(`/api/${method}`, base), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), + signal: AbortSignal.timeout(30_000) + }) + if (!response.ok) return { ok: false, error: `Harness transport returned HTTP ${response.status}.` } + const envelope = (await response.json()) as { + rpcId?: unknown + result?: { ok?: unknown; value?: unknown; error?: { message?: unknown } } + } + if (envelope.rpcId !== rpcId) return { ok: false, error: 'Harness RPC response did not match the request.' } + if (envelope.result?.ok !== true) { + const message = envelope.result?.error?.message + return { ok: false, error: typeof message === 'string' ? message : 'Harness rejected the request.' } + } + return { ok: true, value: envelope.result.value } + } + + private html(response: ServerResponse, body: string): void { + response.statusCode = 200 + response.setHeader('content-type', 'text/html; charset=utf-8') + response.end(body) + } + + private text(response: ServerResponse, status: number, body: string): void { + response.statusCode = status + response.setHeader('content-type', 'text/plain; charset=utf-8') + response.end(body) + } + + private json(response: ServerResponse, status: number, body: unknown): void { + if (response.headersSent) { + response.end() + return + } + response.statusCode = status + response.setHeader('content-type', 'application/json; charset=utf-8') + response.end(JSON.stringify(body)) + } +} + +export function preferredLanAddress(): string | undefined { + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family === 'IPv4' && !entry.internal && isPrivateAddress(entry.address)) return entry.address + } + } + return undefined +} + +export function normalizeRemoteAddress(address: string): string { + return address.startsWith('::ffff:') ? address.slice(7) : address +} + +export function isLoopbackAddress(address: string): boolean { + return address === '::1' || address === '127.0.0.1' +} + +export function isPrivateAddress(address: string): boolean { + if (isLoopbackAddress(address)) return true + if (/^10\./.test(address) || /^192\.168\./.test(address)) return true + const match = /^172\.(\d+)\./.exec(address) + if (match && Number(match[1]) >= 16 && Number(match[1]) <= 31) return true + return /^f[cd][0-9a-f]{2}:/i.test(address) || /^fe8[0-9a-f]:/i.test(address) +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > MAX_BODY_BYTES) throw new Error('Request body is too large.') + chunks.push(buffer) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts new file mode 100644 index 0000000..57b33f8 --- /dev/null +++ b/src/main/mobile/lan-mobile-pages.ts @@ -0,0 +1,89 @@ +export interface MobilePageOptions { + locale: 'en' | 'zh' +} + +export function renderMobilePage({ locale }: MobilePageOptions): string { + const zh = locale === 'zh' + return ` + + + + + + DSH Mobile + + +
+
DSH Mobile
${zh ? '正在连接…' : 'Connecting…'}
+
+
+
+
+
+
+
+
+
+
+` +} + +export function renderDesktopPairingPage(options: { + qrSvg: string + pairingUrl: string + expiresAt: number +}): string { + return `Connect Phone

Connect your phone

Scan this QR code while your phone and DSH Desktop are on the same trusted Wi-Fi.

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

Keep this window open and approve the device below.

Phone waiting for approval
` +} + +export function renderPairingWaitPage(pairingId: string): string { + return `Pairing DSH

Approve on DSH Desktop

Your phone is waiting for permission.

` +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + const replacements: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } + return replacements[character]! + }) +} diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts new file mode 100644 index 0000000..179e08d --- /dev/null +++ b/test/lan-mobile-bridge.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { + isPrivateAddress, + LanMobileBridge, + normalizeRemoteAddress +} from '../src/main/mobile/lan-mobile-bridge' + +const bridges: LanMobileBridge[] = [] +const servers: ReturnType[] = [] + +afterEach(async () => { + await Promise.all(bridges.splice(0).map((bridge) => bridge.stop())) + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) +}) + +describe('LAN mobile bridge address policy', () => { + it('allows loopback and RFC1918 addresses', () => { + expect(isPrivateAddress('127.0.0.1')).toBe(true) + expect(isPrivateAddress('10.1.2.3')).toBe(true) + expect(isPrivateAddress('172.16.0.1')).toBe(true) + expect(isPrivateAddress('172.31.255.1')).toBe(true) + expect(isPrivateAddress('192.168.1.10')).toBe(true) + }) + + it('rejects public addresses and out-of-range 172 networks', () => { + expect(isPrivateAddress('8.8.8.8')).toBe(false) + expect(isPrivateAddress('172.15.0.1')).toBe(false) + expect(isPrivateAddress('172.32.0.1')).toBe(false) + }) + + it('normalizes IPv4-mapped IPv6 addresses', () => { + expect(normalizeRemoteAddress('::ffff:192.168.1.4')).toBe('192.168.1.4') + }) +}) + +describe('LAN mobile bridge pairing surface', () => { + it('serves the desktop pairing page only on loopback', async () => { + const bridge = new LanMobileBridge({ + harnessUrl: () => 'http://127.0.0.1:9999' + }) + bridges.push(bridge) + const snapshot = await bridge.start() + expect(snapshot.desktopUrl).toBeTruthy() + const response = await fetch(snapshot.desktopUrl!) + expect(response.status).toBe(200) + expect(await response.text()).toContain('Connect your phone') + }) + + it('does not expose the mobile UI before pairing', async () => { + const bridge = new LanMobileBridge({ + harnessUrl: () => 'http://127.0.0.1:9999' + }) + bridges.push(bridge) + const snapshot = await bridge.start() + const response = await fetch(`http://127.0.0.1:${snapshot.port}/`) + expect(response.status).toBe(401) + }) + + it('requires approval, then forwards only allowlisted RPC methods', async () => { + const harness = createServer(async (request, response) => { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { rpcId: string } + response.setHeader('content-type', 'application/json') + response.end( + JSON.stringify({ + type: 'server-response', + rpcId: envelope.rpcId, + result: { ok: true, value: { items: [], archivedSessionIds: [] } } + }) + ) + }) + servers.push(harness) + await new Promise((resolve) => harness.listen(0, '127.0.0.1', resolve)) + const harnessPort = (harness.address() as AddressInfo).port + const bridge = new LanMobileBridge({ + harnessUrl: () => `http://127.0.0.1:${harnessPort}` + }) + bridges.push(bridge) + const snapshot = await bridge.start() + const token = new URL(snapshot.pairingUrl!).searchParams.get('token') + const pairingPage = await fetch(`http://127.0.0.1:${snapshot.port}/pair?token=${token}`) + const pairingHtml = await pairingPage.text() + const pairingId = /const id="([^"]+)"/.exec(pairingHtml)?.[1] + expect(pairingId).toBeTruthy() + const pending = await fetch(`http://127.0.0.1:${snapshot.port}/desktop/pending`) + expect(await pending.json()).toMatchObject({ id: pairingId, remoteAddress: '127.0.0.1' }) + await fetch(`http://127.0.0.1:${snapshot.port}/desktop/decide`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: pairingId, approved: true }) + }) + const paired = await fetch( + `http://127.0.0.1:${snapshot.port}/pair/status?id=${pairingId}`, + { redirect: 'manual' } + ) + expect(await paired.clone().json()).toEqual({ approved: true }) + const cookie = paired.headers.get('set-cookie')!.split(';', 1)[0]! + + const forwarded = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ method: 'workspace.list', payload: {} }) + }) + expect(forwarded.status).toBe(200) + expect(await forwarded.json()).toEqual({ + ok: true, + value: { items: [], archivedSessionIds: [] } + }) + + const blocked = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ method: 'host.openPath', payload: { path: '/tmp/secret' } }) + }) + expect(blocked.status).toBe(403) + }) +}) From 37a2ad4c011910c55cfbfa96fae80676cd4d6ef0 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 19:37:03 +0800 Subject: [PATCH 02/18] fix: initialize mobile client and align DSH styling --- src/main/mobile/lan-mobile-pages.ts | 15 ++++++++------- test/lan-mobile-pages.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 test/lan-mobile-pages.test.ts diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 57b33f8..e38b665 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -12,15 +12,16 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { DSH Mobile
@@ -47,7 +48,7 @@ const L=${JSON.stringify({ let activeSession=null,poll=null,workspaces=[]; const $=id=>document.getElementById(id); async function rpc(method,payload={}){const r=await fetch('/api/rpc',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({method,payload})});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||L.failed);return j.value} -function textFrom(x){if(typeof x==='string')return x;if(Array.isArray(x))return x.map(textFrom).filter(Boolean).join('\n');if(!x||typeof x!=='object')return '';if(x.type==='text'&&typeof x.text==='string')return x.text;if(typeof x.message==='string')return x.message;if(typeof x.text==='string')return x.text;if(x.message)return textFrom(x.message);if(x.content)return textFrom(x.content);return ''} +function textFrom(x){if(typeof x==='string')return x;if(Array.isArray(x))return x.map(textFrom).filter(Boolean).join('\\n');if(!x||typeof x!=='object')return '';if(x.type==='text'&&typeof x.text==='string')return x.text;if(typeof x.message==='string')return x.message;if(typeof x.text==='string')return x.text;if(x.message)return textFrom(x.message);if(x.content)return textFrom(x.content);return ''} function eventMessage(entry){const e=entry.event||entry,d=e.data||{},message=d.message||{};const t=String(e.type||'').toLowerCase(),declaredRole=d.role||message.role;let role='';if(t.includes('user')||declaredRole==='user')role='user';else if(t.includes('assistant')||declaredRole==='assistant')role='assistant';else return null;const text=textFrom(d);return text?{role,text}:null} async function loadWorkspaces(){const value=await rpc('workspace.list');workspaces=value.items||[];$('workspace').innerHTML=''+workspaces.map(w=>'').join('')} async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):null;const items=(value.items||[]).filter(s=>!allowed||allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=items.length?items.map(s=>'').join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id))}catch(e){$('listError').textContent=e.message}} diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts new file mode 100644 index 0000000..157b3cc --- /dev/null +++ b/test/lan-mobile-pages.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { renderMobilePage } from '../src/main/mobile/lan-mobile-pages' + +describe('LAN mobile page', () => { + it('emits parseable browser JavaScript', () => { + const html = renderMobilePage({ locale: 'zh' }) + const scripts = [...html.matchAll(/` } diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 157b3cc..ccec5cd 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -13,5 +13,9 @@ describe('LAN mobile page', () => { const html = renderMobilePage({ locale: 'en' }) expect(html).toContain('--brand:#4d6bfe') expect(html).toContain('prefers-color-scheme:dark') + expect(html).toContain('/brand-logo/light') + expect(html).toContain('>Sessions') + expect(html).toContain('class=\"skeleton\"') + expect(html).toContain('},500)') }) }) From 6cfb9fc78b6b1a2e5cebd9dc3f50b95aa48074c1 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 19:53:47 +0800 Subject: [PATCH 04/18] fix: align mobile conversation surface --- src/main/mobile/lan-mobile-pages.ts | 15 ++++++++------- test/lan-mobile-pages.test.ts | 5 +++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 6088298..d052c04 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -18,10 +18,10 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { button,input,textarea,select{font:inherit}.shell{max-width:760px;height:100dvh;margin:auto;padding:calc(10px + env(safe-area-inset-top)) 14px calc(10px + env(safe-area-inset-bottom));display:flex;flex-direction:column} header{height:52px;display:flex;flex:none;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 4px}.brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px}.status:before{content:'';width:6px;height:6px;border-radius:50%;background:#35a867} .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.chat-toolbar{justify-content:space-between;border-bottom:1px solid var(--line);padding:8px 0}.back{border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px} - select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:48px;height:48px;padding:0}button.quiet{background:transparent} + select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} .list{display:grid;align-content:start;gap:4px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:8px;padding:10px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500}.row small{display:block;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:16px;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px}.message.assistant{align-self:stretch;padding:0 4px}.message .role{display:none} - .composer{flex:none;padding:10px 0 2px;background:var(--paper);border-top:1px solid var(--line)}.composer-inner{display:flex;gap:8px;align-items:flex-end}.composer textarea{flex:1;min-height:54px;max-height:150px;resize:none;border-radius:18px;padding:14px}.error{color:#e34d59;font-size:13px;margin:8px 0}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@media(prefers-reduced-motion:reduce){.skeleton{animation:none}} + .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@media(prefers-reduced-motion:reduce){.skeleton{animation:none}}
@@ -31,9 +31,9 @@ export function renderMobilePage({ locale }: MobilePageOptions): string {
-
+
-
+
` } diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index ccec5cd..23990e3 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -17,5 +17,10 @@ describe('LAN mobile page', () => { expect(html).toContain('>Sessions') expect(html).toContain('class=\"skeleton\"') expect(html).toContain('},500)') + expect(html).toContain("t==='user/message'") + expect(html).toContain("message.source?.kind!=='user'") + expect(html).toContain("t==='assistant/message'") + expect(html).not.toContain('id=\"stop\"') + expect(html).toContain('id=\"cancel\"') }) }) From d0d5db2c35c7ab97a8680c6f934a65a05f2267ad Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 19:57:28 +0800 Subject: [PATCH 05/18] feat: stream assistant text on mobile --- src/main/mobile/lan-mobile-pages.ts | 17 +++++++++-------- test/lan-mobile-pages.test.ts | 6 +++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index d052c04..12cf064 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -8,7 +8,7 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { - + DSH Mobile
@@ -45,19 +45,20 @@ const L=${JSON.stringify({ failed: zh ? '请求失败' : 'Request failed', chooseWorkspace: zh ? '请先选择一个工作区。' : 'Choose a workspace first.' })}; -let activeSession=null,poll=null,workspaces=[],historyBusy=false,lastHistoryKey=''; +let activeSession=null,poll=null,workspaces=[],historyBusy=false,lastHistoryKey='',agentRunning=false; const $=id=>document.getElementById(id); async function rpc(method,payload={}){const r=await fetch('/api/rpc',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({method,payload})});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||L.failed);return j.value} -function textFrom(x){if(typeof x==='string')return x;if(Array.isArray(x))return x.map(textFrom).filter(Boolean).join('\\n');if(!x||typeof x!=='object')return '';if(x.type==='text'&&typeof x.text==='string')return x.text;if(typeof x.message==='string')return x.message;if(typeof x.text==='string')return x.text;if(x.message)return textFrom(x.message);if(x.content)return textFrom(x.content);return ''} -function eventMessage(entry){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='user/message'){const message=d.message||d;if(message.source?.kind!=='user')return null;const text=textFrom(message.content);return text?{role:'user',text}:null}if(t==='assistant/message'){const message=d.message||{};const text=textFrom(message.content);return text?{role:'assistant',text}:null}return null} -function updateRunning(events){let running=false;for(const entry of events){const t=String((entry.event||entry).type||'').toLowerCase();if(t==='turn/start')running=true;else if(t==='turn/end')running=false}$('send').hidden=running;$('cancel').hidden=!running;$('prompt').disabled=running} +function bodyText(content){return Array.isArray(content)?content.filter(block=>block?.type==='text'&&typeof block.text==='string').map(block=>block.text).join('\\n'):''} +function eventMessage(entry){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='user/message'){const message=d.message||d;if(message.source?.kind!=='user')return null;const text=bodyText(message.content);return text?{role:'user',text}:null}if(t==='assistant/message'){const message=d.message||{};const text=bodyText(message.content);return text?{role:'assistant',text}:null}return null} +function visibleMessages(events){const out=[],partials=new Map();for(const entry of events){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='assistant/chunk'&&d.chunk?.type==='text-delta'){const key=String(d.turn)+':'+String(d.step);let index=partials.get(key);if(index===undefined){index=out.length;partials.set(key,index);out.push({role:'assistant',text:''})}out[index].text+=d.chunk.text||'';continue}const message=eventMessage(entry);if(!message)continue;if(t==='assistant/message'){const key=String(d.turn)+':'+String(d.step),index=partials.get(key);if(index!==undefined){out[index]=message;partials.delete(key);continue}}out.push(message)}return out.filter(message=>message.text)} +function updateRunning(events){let running=false;for(const entry of events){const t=String((entry.event||entry).type||'').toLowerCase();if(t==='turn/start')running=true;else if(t==='turn/end')running=false}agentRunning=running;$('send').hidden=running;$('cancel').hidden=!running;$('prompt').disabled=running} async function loadWorkspaces(){const value=await rpc('workspace.list');workspaces=value.items||[];$('workspace').innerHTML=''+workspaces.map(w=>'').join('')} async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):null;const items=(value.items||[]).filter(s=>!allowed||allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=items.length?items.map(s=>'').join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id))}catch(e){$('listError').textContent=e.message}} async function createSession(){const workspaceId=$('workspace').value;if(!workspaceId){$('listError').textContent=L.chooseWorkspace;return}try{const created=await rpc('session.create',{workspaceId});await openSession(created.sessionId)}catch(e){$('listError').textContent=e.message}} function titleFor(s){const p=s.projections&&s.projections.values||{};return p.title||p.sessionTitle||p['session.title']||L.untitled} async function openSession(id){clearTimeout(poll);activeSession=id;lastHistoryKey='';$('sessionsView').classList.remove('active');$('chatView').classList.add('active');$('messages').innerHTML='
';await loadHistory(true);schedulePoll()} -function schedulePoll(){clearTimeout(poll);if(activeSession)poll=setTimeout(async()=>{await loadHistory(false);schedulePoll()},500)} -async function loadHistory(initial){if(!activeSession||historyBusy)return;historyBusy=true;try{const value=await rpc('session.history',{sessionId:activeSession,maxMessages:100});const events=value.events||[],messages=events.map(eventMessage).filter(Boolean),key=events.length+':'+(events.at(-1)?.event?.seq??'');updateRunning(events);if(key!==lastHistoryKey){const box=$('messages'),nearBottom=box.scrollHeight-box.scrollTop-box.clientHeight<90;box.innerHTML=messages.map(m=>'
'+esc(m.role)+'
'+esc(m.text)+'
').join('')||'
'+L.noSessions+'
';lastHistoryKey=key;if(initial||nearBottom)box.scrollTop=box.scrollHeight}$('chatError').textContent=''}catch(e){$('chatError').textContent=e.message}finally{historyBusy=false}} +function schedulePoll(){clearTimeout(poll);if(activeSession)poll=setTimeout(async()=>{await loadHistory(false);schedulePoll()},agentRunning?250:750)} +async function loadHistory(initial){if(!activeSession||historyBusy)return;historyBusy=true;try{const value=await rpc('session.history',{sessionId:activeSession,maxMessages:100});const events=value.events||[],messages=visibleMessages(events),key=events.length+':'+(events.at(-1)?.event?.seq??'');updateRunning(events);if(key!==lastHistoryKey){const box=$('messages'),nearBottom=box.scrollHeight-box.scrollTop-box.clientHeight<90;box.innerHTML=messages.map(m=>'
'+esc(m.role)+'
'+esc(m.text)+'
').join('')||'
'+L.noSessions+'
';lastHistoryKey=key;if(initial||nearBottom)box.scrollTop=box.scrollHeight}$('chatError').textContent=''}catch(e){$('chatError').textContent=e.message}finally{historyBusy=false}} async function send(){const text=$('prompt').value.trim();if(!text||!activeSession)return;$('send').disabled=true;$('prompt').value='';const box=$('messages');box.insertAdjacentHTML('beforeend','
'+esc(text)+'
');box.scrollTop=box.scrollHeight;try{await rpc('session.prompt',{sessionId:activeSession,mode:'steer',content:[{type:'text',text}],clientTimeZone:Intl.DateTimeFormat().resolvedOptions().timeZone});lastHistoryKey='';await loadHistory(false)}catch(e){$('chatError').textContent=e.message;$('prompt').value=text}finally{$('send').disabled=false}} function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} $('workspace').onchange=loadSessions;$('newSession').onclick=createSession;$('refresh').onclick=loadSessions;$('send').onclick=send;$('prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}};$('back').onclick=()=>{clearTimeout(poll);poll=null;activeSession=null;$('chatView').classList.remove('active');$('sessionsView').classList.add('active');loadSessions()};$('cancel').onclick=async()=>{if(activeSession){await rpc('session.cancel',{sessionId:activeSession});lastHistoryKey='';await loadHistory(false)}}; diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 23990e3..0278b95 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -16,11 +16,15 @@ describe('LAN mobile page', () => { expect(html).toContain('/brand-logo/light') expect(html).toContain('>Sessions') expect(html).toContain('class=\"skeleton\"') - expect(html).toContain('},500)') + expect(html).toContain('agentRunning?250:750') expect(html).toContain("t==='user/message'") expect(html).toContain("message.source?.kind!=='user'") expect(html).toContain("t==='assistant/message'") expect(html).not.toContain('id=\"stop\"') expect(html).toContain('id=\"cancel\"') + expect(html).toContain("d.chunk?.type==='text-delta'") + expect(html).toContain("block?.type==='text'") + expect(html).toContain('font-size:16px') + expect(html).toContain('maximum-scale=1') }) }) From 64ef2c6874b2649816d0302493227c674f30a779 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 20:00:29 +0800 Subject: [PATCH 06/18] feat: mirror DSH conversation details on mobile --- src/main/mobile/lan-mobile-pages.ts | 21 ++++++++++++++++----- test/lan-mobile-pages.test.ts | 8 ++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 12cf064..db4f4cb 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -21,6 +21,7 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} .list{display:grid;align-content:start;gap:4px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:8px;padding:10px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500}.row small{display:block;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:16px;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px}.message.assistant{align-self:stretch;padding:0 4px}.message .role{display:none} + .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.thinking,.tool{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:8px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:9px 11px;color:var(--muted);font-size:13px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:10px 11px;color:var(--muted);font-size:13px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@media(prefers-reduced-motion:reduce){.skeleton{animation:none}} @@ -43,14 +44,24 @@ const L=${JSON.stringify({ untitled: zh ? '未命名会话' : 'Untitled session', online: zh ? '局域网已连接' : 'Connected on local network', failed: zh ? '请求失败' : 'Request failed', - chooseWorkspace: zh ? '请先选择一个工作区。' : 'Choose a workspace first.' + chooseWorkspace: zh ? '请先选择一个工作区。' : 'Choose a workspace first.', + running: zh ? '运行中' : 'Running', + done: zh ? '完成' : 'Done', + toolFailed: zh ? '失败' : 'Failed', + input: zh ? '输入' : 'Input', + output: zh ? '输出' : 'Output' })}; let activeSession=null,poll=null,workspaces=[],historyBusy=false,lastHistoryKey='',agentRunning=false; const $=id=>document.getElementById(id); async function rpc(method,payload={}){const r=await fetch('/api/rpc',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({method,payload})});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||L.failed);return j.value} -function bodyText(content){return Array.isArray(content)?content.filter(block=>block?.type==='text'&&typeof block.text==='string').map(block=>block.text).join('\\n'):''} -function eventMessage(entry){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='user/message'){const message=d.message||d;if(message.source?.kind!=='user')return null;const text=bodyText(message.content);return text?{role:'user',text}:null}if(t==='assistant/message'){const message=d.message||{};const text=bodyText(message.content);return text?{role:'assistant',text}:null}return null} -function visibleMessages(events){const out=[],partials=new Map();for(const entry of events){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='assistant/chunk'&&d.chunk?.type==='text-delta'){const key=String(d.turn)+':'+String(d.step);let index=partials.get(key);if(index===undefined){index=out.length;partials.set(key,index);out.push({role:'assistant',text:''})}out[index].text+=d.chunk.text||'';continue}const message=eventMessage(entry);if(!message)continue;if(t==='assistant/message'){const key=String(d.turn)+':'+String(d.step),index=partials.get(key);if(index!==undefined){out[index]=message;partials.delete(key);continue}}out.push(message)}return out.filter(message=>message.text)} +function stepKey(d){return String(d.turn)+':'+String(d.step)} +function contentBlocks(content){return(Array.isArray(content)?content:[]).flatMap(block=>{if(block?.type==='text'&&block.text)return[{kind:'text',text:block.text}];if(block?.type==='reasoning'&&block.text)return[{kind:'reasoning',text:block.text}];if(block?.type==='tool-call')return[{kind:'tool',id:block.id,name:block.name,args:block.arguments,status:'running'}];return[]})} +function visibleMessages(events){const out=[],steps=new Map();function ensureStep(d){const key=stepKey(d);if(!steps.has(key)){steps.set(key,{role:'assistant',blocks:[],streaming:true});out.push(steps.get(key))}return steps.get(key)}for(const entry of events){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='user/message'){const message=d.message||d;if(message.source?.kind==='user'){const blocks=contentBlocks(message.content).filter(block=>block.kind==='text');if(blocks.length)out.push({role:'user',blocks})}continue}if(t==='assistant/chunk'){const chunk=d.chunk||{};if(chunk.type!=='text-delta'&&chunk.type!=='reasoning-delta')continue;const node=ensureStep(d),kind=chunk.type==='text-delta'?'text':'reasoning';node.blocks[chunk.index]??={kind,text:''};node.blocks[chunk.index].text+=chunk.text||'';continue}if(t==='assistant/message'){const node=ensureStep(d);node.blocks=contentBlocks(d.message?.content);node.streaming=false;continue}if(t==='tool/call'){const node=ensureStep(d);if(!node.blocks.some(block=>block?.kind==='tool'&&block.id===d.callId))node.blocks.push({kind:'tool',id:d.callId,name:d.name,args:d.arguments,status:'running'});continue}if(t==='tool/result'){const node=ensureStep(d),result=(d.message?.content||[]).find(block=>block?.type==='tool-result'),id=result?.toolCallId;let tool=node.blocks.find(block=>block?.kind==='tool'&&block.id===id);if(!tool){tool={kind:'tool',id,name:id||'tool',args:'',status:'running'};node.blocks.push(tool)}tool.status=d.error||result?.isError?'error':'done';tool.result=(result?.content||[]).filter(block=>block?.type==='text').map(block=>block.text).join('\\n');node.streaming=false}}return out.filter(node=>node.blocks?.some(Boolean))} +function inlineMarkdown(text){return esc(text).replace(/\\x60([^\\x60]+)\\x60/g,'$1').replace(/\\*\\*([^*]+)\\*\\*/g,'$1').replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^ )]+)\\)/g,'$1')} +function markdown(text){const lines=String(text||'').split('\\n');let html='',code=false,list=false;for(const line of lines){if(line.trim().startsWith(String.fromCharCode(96,96,96))){if(list){html+='';list=false}html+=code?'':'
';code=!code;continue}if(code){html+=esc(line)+'\\n';continue}const heading=line.match(/^(#{1,3})\\s+(.+)$/);if(heading){if(list){html+='';list=false}const level=heading[1].length;html+=''+inlineMarkdown(heading[2])+'';continue}const bullet=line.match(/^[-*]\\s+(.+)$/);if(bullet){if(!list){html+='
    ';list=true}html+='
  • '+inlineMarkdown(bullet[1])+'
  • ';continue}if(list){html+='
';list=false}if(line.trim())html+='

'+inlineMarkdown(line)+'

'}if(list)html+='';if(code)html+='
';return html} +function pretty(value){if(!value)return'';try{return JSON.stringify(JSON.parse(value),null,2)}catch{return String(value)}} +function renderBlock(block,streaming){if(!block)return'';if(block.kind==='text')return'
'+markdown(block.text)+'
';if(block.kind==='reasoning')return'
Think
'+markdown(block.text)+'
';if(block.kind==='tool'){const label=block.status==='running'?L.running:block.status==='error'?L.toolFailed:L.done;return'
'+esc(block.name||'tool')+''+label+'
'+(block.args?''+L.input+'
'+esc(pretty(block.args))+'
':'')+(block.result?''+L.output+'
'+esc(block.result)+'
':'')+'
'}return''} +function renderMessage(message){const blocks=message.blocks.filter(Boolean);return'
'+blocks.map((block,index)=>renderBlock(block,message.streaming&&index===blocks.length-1&&block.kind==='text')).join('')+'
'} function updateRunning(events){let running=false;for(const entry of events){const t=String((entry.event||entry).type||'').toLowerCase();if(t==='turn/start')running=true;else if(t==='turn/end')running=false}agentRunning=running;$('send').hidden=running;$('cancel').hidden=!running;$('prompt').disabled=running} async function loadWorkspaces(){const value=await rpc('workspace.list');workspaces=value.items||[];$('workspace').innerHTML=''+workspaces.map(w=>'').join('')} async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):null;const items=(value.items||[]).filter(s=>!allowed||allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=items.length?items.map(s=>'').join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id))}catch(e){$('listError').textContent=e.message}} @@ -58,7 +69,7 @@ async function createSession(){const workspaceId=$('workspace').value;if(!worksp function titleFor(s){const p=s.projections&&s.projections.values||{};return p.title||p.sessionTitle||p['session.title']||L.untitled} async function openSession(id){clearTimeout(poll);activeSession=id;lastHistoryKey='';$('sessionsView').classList.remove('active');$('chatView').classList.add('active');$('messages').innerHTML='
';await loadHistory(true);schedulePoll()} function schedulePoll(){clearTimeout(poll);if(activeSession)poll=setTimeout(async()=>{await loadHistory(false);schedulePoll()},agentRunning?250:750)} -async function loadHistory(initial){if(!activeSession||historyBusy)return;historyBusy=true;try{const value=await rpc('session.history',{sessionId:activeSession,maxMessages:100});const events=value.events||[],messages=visibleMessages(events),key=events.length+':'+(events.at(-1)?.event?.seq??'');updateRunning(events);if(key!==lastHistoryKey){const box=$('messages'),nearBottom=box.scrollHeight-box.scrollTop-box.clientHeight<90;box.innerHTML=messages.map(m=>'
'+esc(m.role)+'
'+esc(m.text)+'
').join('')||'
'+L.noSessions+'
';lastHistoryKey=key;if(initial||nearBottom)box.scrollTop=box.scrollHeight}$('chatError').textContent=''}catch(e){$('chatError').textContent=e.message}finally{historyBusy=false}} +async function loadHistory(initial){if(!activeSession||historyBusy)return;historyBusy=true;try{const value=await rpc('session.history',{sessionId:activeSession,maxMessages:100});const events=value.events||[],messages=visibleMessages(events),key=events.length+':'+(events.at(-1)?.event?.seq??'');updateRunning(events);if(key!==lastHistoryKey){const box=$('messages'),nearBottom=box.scrollHeight-box.scrollTop-box.clientHeight<90;box.innerHTML=messages.map(renderMessage).join('')||'
'+L.noSessions+'
';lastHistoryKey=key;if(initial||nearBottom)box.scrollTop=box.scrollHeight}$('chatError').textContent=''}catch(e){$('chatError').textContent=e.message}finally{historyBusy=false}} async function send(){const text=$('prompt').value.trim();if(!text||!activeSession)return;$('send').disabled=true;$('prompt').value='';const box=$('messages');box.insertAdjacentHTML('beforeend','
'+esc(text)+'
');box.scrollTop=box.scrollHeight;try{await rpc('session.prompt',{sessionId:activeSession,mode:'steer',content:[{type:'text',text}],clientTimeZone:Intl.DateTimeFormat().resolvedOptions().timeZone});lastHistoryKey='';await loadHistory(false)}catch(e){$('chatError').textContent=e.message;$('prompt').value=text}finally{$('send').disabled=false}} function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} $('workspace').onchange=loadSessions;$('newSession').onclick=createSession;$('refresh').onclick=loadSessions;$('send').onclick=send;$('prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}};$('back').onclick=()=>{clearTimeout(poll);poll=null;activeSession=null;$('chatView').classList.remove('active');$('sessionsView').classList.add('active');loadSessions()};$('cancel').onclick=async()=>{if(activeSession){await rpc('session.cancel',{sessionId:activeSession});lastHistoryKey='';await loadHistory(false)}}; diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 0278b95..381e976 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -18,13 +18,17 @@ describe('LAN mobile page', () => { expect(html).toContain('class=\"skeleton\"') expect(html).toContain('agentRunning?250:750') expect(html).toContain("t==='user/message'") - expect(html).toContain("message.source?.kind!=='user'") + expect(html).toContain("message.source?.kind==='user'") expect(html).toContain("t==='assistant/message'") expect(html).not.toContain('id=\"stop\"') expect(html).toContain('id=\"cancel\"') - expect(html).toContain("d.chunk?.type==='text-delta'") + expect(html).toContain("chunk.type==='text-delta'") expect(html).toContain("block?.type==='text'") expect(html).toContain('font-size:16px') expect(html).toContain('maximum-scale=1') + expect(html).toContain("chunk.type!=='reasoning-delta'") + expect(html).toContain('class=\"thinking\"') + expect(html).toContain('class=\"tool\"') + expect(html).toContain('function markdown(text)') }) }) From f41981de72a064a9c5a5090b4db0eb6010a7ffb1 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 20:07:26 +0800 Subject: [PATCH 07/18] fix: refine mobile layout and pairing surfaces --- src/main/mobile/lan-mobile-pages.ts | 23 +++++++++++++---------- test/lan-mobile-pages.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index db4f4cb..8de206f 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -15,11 +15,11 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { :root{color-scheme:light;--paper:#fff;--sidebar:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--card:#fff;--hover:#f2f3f5;--brand:#4d6bfe} @media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#141416;--sidebar:#19191b;--ink:#f5f5f6;--muted:#95979d;--line:#303034;--card:#1d1d20;--hover:#29292d;--brand:#6f86ff}} *{box-sizing:border-box}html,body{height:100%;overflow:hidden}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} - button,input,textarea,select{font:inherit}.shell{max-width:760px;height:100dvh;margin:auto;padding:calc(10px + env(safe-area-inset-top)) 14px calc(10px + env(safe-area-inset-bottom));display:flex;flex-direction:column} + button,input,textarea,select{font:inherit}.shell{max-width:760px;height:var(--app-height,100dvh);margin:auto;padding:calc(10px + env(safe-area-inset-top)) 14px calc(8px + env(safe-area-inset-bottom));display:flex;flex-direction:column} header{height:52px;display:flex;flex:none;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 4px}.brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px}.status:before{content:'';width:6px;height:6px;border-radius:50%;background:#35a867} - .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.chat-toolbar{justify-content:space-between;border-bottom:1px solid var(--line);padding:8px 0}.back{border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px} + .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:8px;padding:14px 0 10px}.session-actions select{flex:1;min-width:0;height:42px}.icon-button{display:grid;place-items:center;width:42px;height:42px;padding:0}.new-session{width:100%;height:42px;font-weight:500;margin-bottom:14px}.section-title{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:13px;padding:0 10px 7px}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} - .list{display:grid;align-content:start;gap:4px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:8px;padding:10px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500}.row small{display:block;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .list{display:grid;align-content:start;gap:3px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500}.row small{display:block;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px} .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:16px;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px}.message.assistant{align-self:stretch;padding:0 4px}.message .role{display:none} .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.thinking,.tool{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:8px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:9px 11px;color:var(--muted);font-size:13px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:10px 11px;color:var(--muted);font-size:13px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@media(prefers-reduced-motion:reduce){.skeleton{animation:none}} @@ -28,11 +28,12 @@ export function renderMobilePage({ locale }: MobilePageOptions): string {
DSH Desktop
${zh ? '正在连接…' : 'Connecting…'}
-
+
+
${zh ? '会话' : 'Sessions'}
-
+
@@ -64,15 +65,17 @@ function renderBlock(block,streaming){if(!block)return'';if(block.kind==='text') function renderMessage(message){const blocks=message.blocks.filter(Boolean);return'
'+blocks.map((block,index)=>renderBlock(block,message.streaming&&index===blocks.length-1&&block.kind==='text')).join('')+'
'} function updateRunning(events){let running=false;for(const entry of events){const t=String((entry.event||entry).type||'').toLowerCase();if(t==='turn/start')running=true;else if(t==='turn/end')running=false}agentRunning=running;$('send').hidden=running;$('cancel').hidden=!running;$('prompt').disabled=running} async function loadWorkspaces(){const value=await rpc('workspace.list');workspaces=value.items||[];$('workspace').innerHTML=''+workspaces.map(w=>'').join('')} -async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):null;const items=(value.items||[]).filter(s=>!allowed||allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=items.length?items.map(s=>'').join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id))}catch(e){$('listError').textContent=e.message}} +async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):null;const items=(value.items||[]).filter(s=>!allowed||allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=items.length?items.map(s=>{const title=titleFor(s);return''}).join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id,b.dataset.title))}catch(e){$('listError').textContent=e.message}} async function createSession(){const workspaceId=$('workspace').value;if(!workspaceId){$('listError').textContent=L.chooseWorkspace;return}try{const created=await rpc('session.create',{workspaceId});await openSession(created.sessionId)}catch(e){$('listError').textContent=e.message}} function titleFor(s){const p=s.projections&&s.projections.values||{};return p.title||p.sessionTitle||p['session.title']||L.untitled} -async function openSession(id){clearTimeout(poll);activeSession=id;lastHistoryKey='';$('sessionsView').classList.remove('active');$('chatView').classList.add('active');$('messages').innerHTML='
';await loadHistory(true);schedulePoll()} +async function openSession(id,title){clearTimeout(poll);activeSession=id;lastHistoryKey='';$('chatTitle').textContent=title||L.untitled;$('sessionsView').classList.remove('active');$('chatView').classList.add('active');$('messages').innerHTML='
';await loadHistory(true);schedulePoll()} function schedulePoll(){clearTimeout(poll);if(activeSession)poll=setTimeout(async()=>{await loadHistory(false);schedulePoll()},agentRunning?250:750)} async function loadHistory(initial){if(!activeSession||historyBusy)return;historyBusy=true;try{const value=await rpc('session.history',{sessionId:activeSession,maxMessages:100});const events=value.events||[],messages=visibleMessages(events),key=events.length+':'+(events.at(-1)?.event?.seq??'');updateRunning(events);if(key!==lastHistoryKey){const box=$('messages'),nearBottom=box.scrollHeight-box.scrollTop-box.clientHeight<90;box.innerHTML=messages.map(renderMessage).join('')||'
'+L.noSessions+'
';lastHistoryKey=key;if(initial||nearBottom)box.scrollTop=box.scrollHeight}$('chatError').textContent=''}catch(e){$('chatError').textContent=e.message}finally{historyBusy=false}} async function send(){const text=$('prompt').value.trim();if(!text||!activeSession)return;$('send').disabled=true;$('prompt').value='';const box=$('messages');box.insertAdjacentHTML('beforeend','
'+esc(text)+'
');box.scrollTop=box.scrollHeight;try{await rpc('session.prompt',{sessionId:activeSession,mode:'steer',content:[{type:'text',text}],clientTimeZone:Intl.DateTimeFormat().resolvedOptions().timeZone});lastHistoryKey='';await loadHistory(false)}catch(e){$('chatError').textContent=e.message;$('prompt').value=text}finally{$('send').disabled=false}} function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} -$('workspace').onchange=loadSessions;$('newSession').onclick=createSession;$('refresh').onclick=loadSessions;$('send').onclick=send;$('prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}};$('back').onclick=()=>{clearTimeout(poll);poll=null;activeSession=null;$('chatView').classList.remove('active');$('sessionsView').classList.add('active');loadSessions()};$('cancel').onclick=async()=>{if(activeSession){await rpc('session.cancel',{sessionId:activeSession});lastHistoryKey='';await loadHistory(false)}}; +function syncViewport(){const viewport=window.visualViewport;document.documentElement.style.setProperty('--app-height',(viewport?.height||window.innerHeight)+'px');if(document.activeElement===$('prompt'))requestAnimationFrame(()=>$('prompt').scrollIntoView({block:'end'}))} +window.visualViewport?.addEventListener('resize',syncViewport);window.visualViewport?.addEventListener('scroll',syncViewport);window.addEventListener('resize',syncViewport);syncViewport(); +$('workspace').onchange=loadSessions;$('newSession').onclick=createSession;$('refresh').onclick=loadSessions;$('send').onclick=send;$('prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}};$('prompt').onfocus=syncViewport;$('back').onclick=()=>{clearTimeout(poll);poll=null;activeSession=null;$('chatView').classList.remove('active');$('sessionsView').classList.add('active');loadSessions()};$('cancel').onclick=async()=>{if(activeSession){await rpc('session.cancel',{sessionId:activeSession});lastHistoryKey='';await loadHistory(false)}}; (async()=>{try{await loadWorkspaces();await loadSessions();$('status').textContent=L.online}catch(e){$('status').textContent=e.message}})(); ` } @@ -83,11 +86,11 @@ export function renderDesktopPairingPage(options: { expiresAt: number }): string { return `Connect Phone

Connect your phone

Scan this QR code while your phone and DSH Desktop are on the same trusted Wi-Fi.

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

Keep this window open and approve the device below.

Phone waiting for approval
` + :root{color-scheme:light;--bg:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--brand:#4d6bfe}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding:28px 32px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:9px;font-weight:600;margin-bottom:22px}.brand img{width:39px;height:22px;object-fit:contain}h1{font-size:26px;line-height:1.25;font-weight:600;margin:0 0 8px}p{margin:0;color:var(--muted)}.qr{display:inline-flex;background:#fff;padding:14px;border:1px solid var(--line);border-radius:16px;margin:22px 0 14px}.qr svg{width:220px;height:220px}.hint{font-size:13px}.url-row{display:flex;align-items:center;gap:8px;margin:14px auto 0;max-width:390px}.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:#fff;border-radius:9px;padding:8px 12px;cursor:pointer}.expires{font-size:12px;margin-top:10px}.request{display:none;margin-top:16px;padding:14px;border:1px solid var(--brand);border-radius:12px;background:#f5f7ff;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(--brand)}#address{font-size:12px;color:var(--muted);margin:5px 0 0 16px}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}.actions button{border:1px solid var(--line);border-radius:9px;background:white;padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:white;border-color:var(--ink)}
DSH Desktop

Connect your phone

Keep both devices on the same trusted Wi-Fi, then scan the QR code.

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

Phone waiting for approval
` } export function renderPairingWaitPage(pairingId: string): string { - return `Pairing DSH

Approve on DSH Desktop

Your phone is waiting for permission.

` + return `Pairing DSH

Approve this phone

Confirm the connection request in DSH Desktop.

Waiting for approval…
` } function escapeHtml(value: string): string { diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 381e976..edd52b8 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { renderMobilePage } from '../src/main/mobile/lan-mobile-pages' +import { + renderDesktopPairingPage, + renderMobilePage, + renderPairingWaitPage +} from '../src/main/mobile/lan-mobile-pages' describe('LAN mobile page', () => { it('emits parseable browser JavaScript', () => { @@ -14,7 +18,7 @@ describe('LAN mobile page', () => { expect(html).toContain('--brand:#4d6bfe') expect(html).toContain('prefers-color-scheme:dark') expect(html).toContain('/brand-logo/light') - expect(html).toContain('>Sessions') + expect(html).toContain('id="chatTitle"') expect(html).toContain('class=\"skeleton\"') expect(html).toContain('agentRunning?250:750') expect(html).toContain("t==='user/message'") @@ -30,5 +34,24 @@ describe('LAN mobile page', () => { expect(html).toContain('class=\"thinking\"') expect(html).toContain('class=\"tool\"') expect(html).toContain('function markdown(text)') + expect(html).toContain('visualViewport') + expect(html).toContain('var(--app-height,100dvh)') + }) + + it('uses DSH styling on both pairing surfaces', () => { + const desktop = renderDesktopPairingPage({ + qrSvg: '', + pairingUrl: 'http://192.168.1.2/pair?token=test', + expiresAt: Date.now() + 60_000 + }) + const phone = renderPairingWaitPage('pairing-id') + for (const html of [desktop, phone]) { + expect(html).toContain('--brand:#4d6bfe') + expect(html).toContain('/brand-logo/light') + for (const script of [...html.matchAll(/` } diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index edd52b8..e77251a 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -32,10 +32,17 @@ describe('LAN mobile page', () => { expect(html).toContain('maximum-scale=1') expect(html).toContain("chunk.type!=='reasoning-delta'") expect(html).toContain('class=\"thinking\"') + expect(html).toContain("streamKey=kind+':'+String(chunk.index??0)") + expect(html).toContain("(streaming?' open':'')") + expect(html).toContain('key=JSON.stringify(messages)') expect(html).toContain('class=\"tool\"') expect(html).toContain('function markdown(text)') expect(html).toContain('visualViewport') expect(html).toContain('var(--app-height,100dvh)') + expect(html).toContain('id=\"workspaceHint\"') + expect(html).toContain('id=\"newSession\" class=\"new-session\" disabled') + expect(html).toContain('@keyframes connectedPulse') + expect(html).not.toContain('Connected on local network') }) it('uses DSH styling on both pairing surfaces', () => { From 3d376bd8a9e9f3c3a58bf1df33c59739b8d570f2 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 20:28:26 +0800 Subject: [PATCH 09/18] fix: render mobile markdown tables and tighten steps --- src/main/mobile/lan-mobile-pages.ts | 7 ++++--- test/lan-mobile-pages.test.ts | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index c089621..0fd3d47 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -20,8 +20,8 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:8px;padding:14px 0 6px}.session-actions select{flex:1;min-width:0;height:42px}.icon-button{display:grid;place-items:center;width:42px;height:42px;padding:0}.session-create{display:flex;align-items:center;justify-content:space-between;min-height:40px;margin:0 0 14px;padding:0 2px 0 10px}.workspace-hint{color:var(--muted);font-size:12px;line-height:1.4}.new-session{height:34px;flex:none;font-weight:500;padding:6px 11px;border-radius:9px}.new-session:disabled{cursor:default;opacity:.45}.section-title{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:13px;padding:0 10px 7px}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} .list{display:grid;align-content:start;gap:3px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500}.row small{display:block;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px} - .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:16px;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px}.message.assistant{align-self:stretch;padding:0 4px}.message .role{display:none} - .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.thinking,.tool{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:8px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:9px 11px;color:var(--muted);font-size:13px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:10px 11px;color:var(--muted);font-size:13px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} + .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:14px;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px}.message.assistant{align-self:stretch;padding:0 4px}.message.assistant+.message.assistant{margin-top:-8px}.message .role{display:none} + .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:3px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:9px 11px;color:var(--muted);font-size:13px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:10px 11px;color:var(--muted);font-size:13px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@media(prefers-reduced-motion:reduce){.skeleton{animation:none}} @@ -58,7 +58,8 @@ function stepKey(d){return String(d.turn)+':'+String(d.step)} function contentBlocks(content){return(Array.isArray(content)?content:[]).flatMap(block=>{if(block?.type==='text'&&block.text)return[{kind:'text',text:block.text}];if(block?.type==='reasoning'&&block.text)return[{kind:'reasoning',text:block.text}];if(block?.type==='tool-call')return[{kind:'tool',id:block.id,name:block.name,args:block.arguments,status:'running'}];return[]})} function visibleMessages(events){const out=[],steps=new Map();function ensureStep(d){const key=stepKey(d);if(!steps.has(key)){steps.set(key,{role:'assistant',blocks:[],streaming:true});out.push(steps.get(key))}return steps.get(key)}for(const entry of events){const e=entry.event||entry,d=e.data||{},t=String(e.type||'').toLowerCase();if(t==='user/message'){const message=d.message||d;if(message.source?.kind==='user'){const blocks=contentBlocks(message.content).filter(block=>block.kind==='text');if(blocks.length)out.push({role:'user',blocks})}continue}if(t==='assistant/chunk'){const chunk=d.chunk||{};if(chunk.type!=='text-delta'&&chunk.type!=='reasoning-delta')continue;const node=ensureStep(d),kind=chunk.type==='text-delta'?'text':'reasoning',streamKey=kind+':'+String(chunk.index??0);let block=node.blocks.find(item=>item?._streamKey===streamKey);if(!block){block={kind,text:'',_streamKey:streamKey};node.blocks.push(block)}block.text+=chunk.text||chunk.delta||'';continue}if(t==='assistant/message'){const node=ensureStep(d);node.blocks=contentBlocks(d.message?.content);node.streaming=false;continue}if(t==='tool/call'){const node=ensureStep(d);if(!node.blocks.some(block=>block?.kind==='tool'&&block.id===d.callId))node.blocks.push({kind:'tool',id:d.callId,name:d.name,args:d.arguments,status:'running'});continue}if(t==='tool/result'){const node=ensureStep(d),result=(d.message?.content||[]).find(block=>block?.type==='tool-result'),id=result?.toolCallId;let tool=node.blocks.find(block=>block?.kind==='tool'&&block.id===id);if(!tool){tool={kind:'tool',id,name:id||'tool',args:'',status:'running'};node.blocks.push(tool)}tool.status=d.error||result?.isError?'error':'done';tool.result=(result?.content||[]).filter(block=>block?.type==='text').map(block=>block.text).join('\\n');node.streaming=false}}return out.filter(node=>node.blocks?.some(Boolean))} function inlineMarkdown(text){return esc(text).replace(/\\x60([^\\x60]+)\\x60/g,'$1').replace(/\\*\\*([^*]+)\\*\\*/g,'$1').replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^ )]+)\\)/g,'$1')} -function markdown(text){const lines=String(text||'').split('\\n');let html='',code=false,list=false;for(const line of lines){if(line.trim().startsWith(String.fromCharCode(96,96,96))){if(list){html+='';list=false}html+=code?'':'
';code=!code;continue}if(code){html+=esc(line)+'\\n';continue}const heading=line.match(/^(#{1,3})\\s+(.+)$/);if(heading){if(list){html+='';list=false}const level=heading[1].length;html+=''+inlineMarkdown(heading[2])+'';continue}const bullet=line.match(/^[-*]\\s+(.+)$/);if(bullet){if(!list){html+='
    ';list=true}html+='
  • '+inlineMarkdown(bullet[1])+'
  • ';continue}if(list){html+='
';list=false}if(line.trim())html+='

'+inlineMarkdown(line)+'

'}if(list)html+='';if(code)html+='
';return html} +function tableCells(line){let value=line.trim();if(value.startsWith('|'))value=value.slice(1);if(value.endsWith('|'))value=value.slice(0,-1);return value.split('|').map(cell=>cell.trim())} +function markdown(text){const lines=String(text||'').split('\\n');let html='',code=false,list=false;for(let i=0;i';list=false}html+=code?'':'
';code=!code;continue}if(code){html+=esc(line)+'\\n';continue}const next=lines[i+1]||'',separator=next.trim().replace(/^\\||\\|$/g,'').split('|');if(line.includes('|')&&separator.length>0&&separator.every(cell=>/^\\s*:?-{3,}:?\\s*$/.test(cell))){if(list){html+='';list=false}const headers=tableCells(line);html+='
'+headers.map(cell=>'').join('')+'';i+=2;while(i'+tableCells(lines[i]).map(cell=>'').join('')+'';i++}i--;html+='
'+inlineMarkdown(cell)+'
'+inlineMarkdown(cell)+'
';continue}const heading=line.match(/^(#{1,3})\\s+(.+)$/);if(heading){if(list){html+='';list=false}const level=heading[1].length;html+=''+inlineMarkdown(heading[2])+'';continue}const bullet=line.match(/^[-*]\\s+(.+)$/);if(bullet){if(!list){html+='
    ';list=true}html+='
  • '+inlineMarkdown(bullet[1])+'
  • ';continue}if(list){html+='
';list=false}if(line.trim())html+='

'+inlineMarkdown(line)+'

'}if(list)html+='';if(code)html+='
';return html} function pretty(value){if(!value)return'';try{return JSON.stringify(JSON.parse(value),null,2)}catch{return String(value)}} function renderBlock(block,streaming){if(!block)return'';if(block.kind==='text')return'
'+markdown(block.text)+'
';if(block.kind==='reasoning')return'
Think'+(streaming?' · '+L.running:'')+'
'+markdown(block.text)+'
';if(block.kind==='tool'){const label=block.status==='running'?L.running:block.status==='error'?L.toolFailed:L.done;return'
'+esc(block.name||'tool')+''+label+'
'+(block.args?''+L.input+'
'+esc(pretty(block.args))+'
':'')+(block.result?''+L.output+'
'+esc(block.result)+'
':'')+'
'}return''} function renderMessage(message){const blocks=message.blocks.filter(Boolean);return'
'+blocks.map((block,index)=>renderBlock(block,message.streaming&&index===blocks.length-1&&(block.kind==='text'||block.kind==='reasoning'))).join('')+'
'} diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index e77251a..06e034d 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -37,6 +37,9 @@ describe('LAN mobile page', () => { expect(html).toContain('key=JSON.stringify(messages)') expect(html).toContain('class=\"tool\"') expect(html).toContain('function markdown(text)') + expect(html).toContain('function tableCells(line)') + expect(html).toContain('class=\"table-wrap\"') + expect(html).toContain('.message.assistant+.message.assistant{margin-top:-8px}') expect(html).toContain('visualViewport') expect(html).toContain('var(--app-height,100dvh)') expect(html).toContain('id=\"workspaceHint\"') From 585e23d2522129bc18b7bc0c7d9923712379a6f6 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 20:45:21 +0800 Subject: [PATCH 10/18] fix: polish mobile workspace and tool interactions --- src/main/mobile/lan-mobile-pages.ts | 28 ++++++++++++++++------------ test/lan-mobile-pages.test.ts | 6 +++++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 0fd3d47..3844828 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -17,19 +17,20 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { *{box-sizing:border-box}html,body{height:100%;overflow:hidden}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input,textarea,select{font:inherit}.shell{max-width:760px;height:var(--app-height,100dvh);margin:auto;padding:calc(10px + env(safe-area-inset-top)) 14px calc(8px + env(safe-area-inset-bottom));display:flex;flex-direction:column} header{height:52px;display:flex;flex:none;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 4px}.brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{width:8px;height:8px;border-radius:50%;background:#35a867;box-shadow:0 0 0 0 rgba(53,168,103,.42);animation:connectedPulse 1.8s ease-out infinite}.status.connecting{background:var(--muted);animation:none}.status.error-state{background:#e34d59;animation:none}@keyframes connectedPulse{70%{box-shadow:0 0 0 7px rgba(53,168,103,0)}100%{box-shadow:0 0 0 0 rgba(53,168,103,0)}} - .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:8px;padding:14px 0 6px}.session-actions select{flex:1;min-width:0;height:42px}.icon-button{display:grid;place-items:center;width:42px;height:42px;padding:0}.session-create{display:flex;align-items:center;justify-content:space-between;min-height:40px;margin:0 0 14px;padding:0 2px 0 10px}.workspace-hint{color:var(--muted);font-size:12px;line-height:1.4}.new-session{height:34px;flex:none;font-weight:500;padding:6px 11px;border-radius:9px}.new-session:disabled{cursor:default;opacity:.45}.section-title{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:13px;padding:0 10px 7px}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} + .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:7px;padding:14px 0 8px}.session-actions select{flex:1;min-width:0;height:40px}.icon-button{display:grid;place-items:center;width:40px;height:40px;padding:0}.icon-button.refreshing svg{animation:spin .65s linear infinite}.new-session{height:40px;flex:none;font-weight:500;padding:7px 11px;border-radius:10px}.new-session:disabled{cursor:default;opacity:.45}.workspace-hint{min-height:28px;color:var(--muted);font-size:12px;line-height:1.4;padding:2px 10px 10px}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} - .list{display:grid;align-content:start;gap:3px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500}.row small{display:block;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px} - .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:14px;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px}.message.assistant{align-self:stretch;padding:0 4px}.message.assistant+.message.assistant{margin-top:-8px}.message .role{display:none} - .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:3px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:9px 11px;color:var(--muted);font-size:13px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:10px 11px;color:var(--muted);font-size:13px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} - .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@media(prefers-reduced-motion:reduce){.skeleton{animation:none}} + .list{display:grid;align-content:start;gap:3px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:9px;padding:12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500} + .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:0;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px;margin:8px 0 14px}.message.assistant{align-self:stretch;padding:0 4px;margin:0}.message .role{display:none} + .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{border:1px solid var(--line);border-radius:8px;background:var(--card);margin:2px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:7px 10px;color:var(--muted);font-size:12px;line-height:20px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:9px 10px;color:var(--muted);font-size:12px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} + .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.toast{position:fixed;left:50%;top:calc(64px + env(safe-area-inset-top));z-index:5;transform:translate(-50%,-8px);background:var(--ink);color:var(--paper);border-radius:999px;padding:6px 11px;font-size:12px;opacity:0;pointer-events:none;transition:.18s ease}.toast.show{opacity:1;transform:translate(-50%,0)}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.skeleton,.status,.icon-button.refreshing svg{animation:none}}
+
DSH Desktop
-
-
${zh ? '选择工作区后即可新建会话' : 'Select a workspace to start a session'}
${zh ? '会话' : 'Sessions'}
+
+
@@ -40,7 +41,6 @@ export function renderMobilePage({ locale }: MobilePageOptions): string {
` } diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 06e034d..c5320ed 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -39,11 +39,15 @@ describe('LAN mobile page', () => { expect(html).toContain('function markdown(text)') expect(html).toContain('function tableCells(line)') expect(html).toContain('class=\"table-wrap\"') - expect(html).toContain('.message.assistant+.message.assistant{margin-top:-8px}') + expect(html).toContain('flex-direction:column;gap:0') expect(html).toContain('visualViewport') expect(html).toContain('var(--app-height,100dvh)') expect(html).toContain('id=\"workspaceHint\"') expect(html).toContain('id=\"newSession\" class=\"new-session\" disabled') + expect(html).toContain("workspaces[0].workspaceId") + expect(html).toContain('function refreshAll()') + expect(html).toContain('showToast(L.refreshed)') + expect(html).not.toContain("esc(s.cwd||s.sessionId)") expect(html).toContain('@keyframes connectedPulse') expect(html).not.toContain('Connected on local network') }) From f5258d852e83ca41c3e7813bde5c36294df05026 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 20:51:57 +0800 Subject: [PATCH 11/18] fix: compact mobile session list with activity times --- src/main/mobile/lan-mobile-pages.ts | 13 +++++++++---- test/lan-mobile-pages.test.ts | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 3844828..5b668bb 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -17,9 +17,9 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { *{box-sizing:border-box}html,body{height:100%;overflow:hidden}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} button,input,textarea,select{font:inherit}.shell{max-width:760px;height:var(--app-height,100dvh);margin:auto;padding:calc(10px + env(safe-area-inset-top)) 14px calc(8px + env(safe-area-inset-bottom));display:flex;flex-direction:column} header{height:52px;display:flex;flex:none;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 4px}.brand{font-size:15px;font-weight:600;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{width:8px;height:8px;border-radius:50%;background:#35a867;box-shadow:0 0 0 0 rgba(53,168,103,.42);animation:connectedPulse 1.8s ease-out infinite}.status.connecting{background:var(--muted);animation:none}.status.error-state{background:#e34d59;animation:none}@keyframes connectedPulse{70%{box-shadow:0 0 0 7px rgba(53,168,103,0)}100%{box-shadow:0 0 0 0 rgba(53,168,103,0)}} - .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:7px;padding:14px 0 8px}.session-actions select{flex:1;min-width:0;height:40px}.icon-button{display:grid;place-items:center;width:40px;height:40px;padding:0}.icon-button.refreshing svg{animation:spin .65s linear infinite}.new-session{height:40px;flex:none;font-weight:500;padding:7px 11px;border-radius:10px}.new-session:disabled{cursor:default;opacity:.45}.workspace-hint{min-height:28px;color:var(--muted);font-size:12px;line-height:1.4;padding:2px 10px 10px}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} + .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}.session-actions{display:flex;gap:7px;padding:12px 0 6px}.session-actions select{flex:1;min-width:0;height:40px}.icon-button{display:grid;place-items:center;width:40px;height:40px;padding:0}.icon-button.refreshing svg{animation:spin .65s linear infinite}.new-session{height:40px;flex:none;font-weight:500;padding:7px 11px;border-radius:10px}.new-session:disabled{cursor:default;opacity:.45}.workspace-hint{color:var(--muted);font-size:12px;line-height:1.4;padding:4px 10px 10px}.workspace-hint[hidden]{display:none}.chat-toolbar{border-bottom:1px solid var(--line);padding:8px 0;min-height:50px}.back{flex:none;border:none!important;padding-left:4px!important;font-weight:500}.back svg{vertical-align:-2px;margin-right:4px}.chat-title{min-width:0;flex:1;text-align:center;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:52px} select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} - .list{display:grid;align-content:start;gap:3px;overflow-y:auto;padding-bottom:12px}.row{width:100%;text-align:left;background:transparent;border:0;border-radius:9px;padding:12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;font-weight:500} + .list{display:grid;align-content:start;gap:2px;overflow-y:auto;padding:2px 0 12px}.row{width:100%;display:flex;align-items:center;gap:12px;text-align:left;background:transparent;border:0;border-radius:9px;padding:11px 12px}.row:hover,.row:active{background:var(--hover)}.row strong{display:block;min-width:0;flex:1;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row time{flex:none;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums} .empty{padding:56px 14px;text-align:center;color:var(--muted)}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:0;padding:18px 4px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px;margin:8px 0 14px}.message.assistant{align-self:stretch;padding:0 4px;margin:0}.message .role{display:none} .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{border:1px solid var(--line);border-radius:8px;background:var(--card);margin:2px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:7px 10px;color:var(--muted);font-size:12px;line-height:20px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:9px 10px;color:var(--muted);font-size:12px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} .composer{flex:none;padding:10px 0 2px;background:var(--paper)}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05)}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.toast{position:fixed;left:50%;top:calc(64px + env(safe-area-inset-top));z-index:5;transform:translate(-50%,-8px);background:var(--ink);color:var(--paper);border-radius:999px;padding:6px 11px;font-size:12px;opacity:0;pointer-events:none;transition:.18s ease}.toast.show{opacity:1;transform:translate(-50%,0)}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.skeleton,.status,.icon-button.refreshing svg{animation:none}} @@ -52,6 +52,10 @@ const L=${JSON.stringify({ output: zh ? '输出' : 'Output' ,noWorkspaces: zh ? '暂无工作区,请先在 DSH Desktop 中创建第一个工作区。' : 'No workspaces yet. Create your first workspace in DSH Desktop.' ,refreshed: zh ? '已刷新' : 'Updated' + ,justNow: zh ? '刚刚' : 'Now' + ,minute: zh ? '分钟' : 'm' + ,hour: zh ? '小时' : 'h' + ,day: zh ? '天' : 'd' })}; let activeSession=null,poll=null,workspaces=[],historyBusy=false,lastHistoryKey='',agentRunning=false; const $=id=>document.getElementById(id); @@ -67,8 +71,9 @@ function renderBlock(block,streaming){if(!block)return'';if(block.kind==='text') function renderMessage(message){const blocks=message.blocks.filter(Boolean);return'
'+blocks.map((block,index)=>renderBlock(block,message.streaming&&index===blocks.length-1&&(block.kind==='text'||block.kind==='reasoning'))).join('')+'
'} function updateRunning(events){let running=false;for(const entry of events){const t=String((entry.event||entry).type||'').toLowerCase();if(t==='turn/start')running=true;else if(t==='turn/end')running=false}agentRunning=running;$('send').hidden=running;$('cancel').hidden=!running;$('prompt').disabled=running} async function loadWorkspaces(){const previous=$('workspace').value,value=await rpc('workspace.list');workspaces=value.items||[];if(workspaces.length){$('workspace').innerHTML=workspaces.map(w=>'').join('');$('workspace').value=workspaces.some(w=>w.workspaceId===previous)?previous:workspaces[0].workspaceId}else $('workspace').innerHTML=''} -function syncWorkspaceUi(){const selected=!!$('workspace').value;$('newSession').disabled=!selected;$('workspaceHint').textContent=selected?'':L.noWorkspaces;if(selected)$('listError').textContent=''} -async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):new Set(),items=(value.items||[]).filter(s=>allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=!wid?'':items.length?items.map(s=>{const title=titleFor(s);return''}).join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id,b.dataset.title))}catch(e){$('listError').textContent=e.message}} +function syncWorkspaceUi(){const selected=!!$('workspace').value;$('newSession').disabled=!selected;$('workspaceHint').hidden=selected;$('workspaceHint').textContent=selected?'':L.noWorkspaces;if(selected)$('listError').textContent=''} +function relativeTime(value){const parsed=typeof value==='number'?value:Date.parse(value),elapsed=Math.max(0,Date.now()-(Number.isFinite(parsed)?parsed:Date.now())),minutes=Math.floor(elapsed/60000);if(minutes<1)return L.justNow;if(minutes<60)return minutes+L.minute;const hours=Math.floor(minutes/60);if(hours<24)return hours+L.hour;return Math.floor(hours/24)+L.day} +async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):new Set(),items=(value.items||[]).filter(s=>allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=!wid?'':items.length?items.map(s=>{const title=titleFor(s);return''}).join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id,b.dataset.title))}catch(e){$('listError').textContent=e.message}} async function createSession(){const workspaceId=$('workspace').value;if(!workspaceId){$('listError').textContent=L.chooseWorkspace;return}try{const created=await rpc('session.create',{workspaceId});await openSession(created.sessionId)}catch(e){$('listError').textContent=e.message}} function showToast(message){$('toast').textContent=message;$('toast').classList.add('show');clearTimeout(showToast.timer);showToast.timer=setTimeout(()=>$('toast').classList.remove('show'),1000)} async function refreshAll(){const button=$('refresh');button.disabled=true;button.classList.add('refreshing');try{await loadWorkspaces();syncWorkspaceUi();await loadSessions();showToast(L.refreshed)}catch(e){$('listError').textContent=e.message}finally{setTimeout(()=>{button.disabled=false;button.classList.remove('refreshing')},350)}} diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index c5320ed..9cf3395 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -46,6 +46,9 @@ describe('LAN mobile page', () => { expect(html).toContain('id=\"newSession\" class=\"new-session\" disabled') expect(html).toContain("workspaces[0].workspaceId") expect(html).toContain('function refreshAll()') + expect(html).toContain('function relativeTime(value)') + expect(html).toContain("") + expect(html).toContain("$('workspaceHint').hidden=selected") expect(html).toContain('showToast(L.refreshed)') expect(html).not.toContain("esc(s.cwd||s.sessionId)") expect(html).toContain('@keyframes connectedPulse') From c7baec49824f2b734a1e522815b82a9db577a893 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 21:23:25 +0800 Subject: [PATCH 12/18] feat: persist single-device mobile bridge access --- src/main/index.ts | 4 ++- src/main/mobile/lan-mobile-bridge.ts | 52 +++++++++++++++++++++------- src/main/mobile/lan-mobile-pages.ts | 6 +++- test/lan-mobile-bridge.test.ts | 20 +++++++++++ test/lan-mobile-pages.test.ts | 4 +++ 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 790faff..3063fbf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -423,7 +423,9 @@ async function bootstrap(): Promise { brandLogoPaths: { light: dshBrandLogoPath('light'), dark: dshBrandLogoPath('dark') - } + }, + appIconPath: desktopIconPath(), + port: developmentBuild ? 43128 : 43127 }) installMenu() await launchHarness() diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index 7bb3548..ce35517 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -8,7 +8,6 @@ import { renderDesktopPairingPage, renderMobilePage, renderPairingWaitPage } fro const MAX_BODY_BYTES = 64 * 1024 const PAIRING_TTL_MS = 5 * 60 * 1000 -const SESSION_TTL_MS = 12 * 60 * 60 * 1000 const RPC_ALLOWLIST = new Set([ 'workspace.list', @@ -23,6 +22,8 @@ export interface LanMobileBridgeOptions { harnessUrl(): string | undefined locale?: 'en' | 'zh' brandLogoPaths?: { light: string; dark: string } + appIconPath?: string + port?: number now?: () => number } @@ -36,7 +37,6 @@ export interface LanMobileBridgeSnapshot { interface MobileSession { token: string - expiresAt: number } interface PendingPairing { @@ -60,7 +60,11 @@ export class LanMobileBridge { } async start(): Promise { - await this.stop() + if (this.server) { + this.rotatePairingToken() + this.pendingPairings.clear() + return this.snapshot() + } this.rotatePairingToken() this.server = createServer((request, response) => { void this.handle(request, response).catch((error: unknown) => { @@ -70,7 +74,7 @@ export class LanMobileBridge { }) await new Promise((resolve, reject) => { this.server?.once('error', reject) - this.server?.listen(0, '0.0.0.0', resolve) + this.server?.listen(this.options.port ?? 0, '0.0.0.0', resolve) }) this.port = (this.server.address() as AddressInfo).port return this.snapshot() @@ -138,6 +142,21 @@ export class LanMobileBridge { return } + if (request.method === 'GET' && url.pathname === '/app-icon') { + const path = this.options.appIconPath + if (!path) return this.text(response, 404, 'App icon not found.') + try { + const body = await readFile(path) + response.statusCode = 200 + response.setHeader('content-type', 'image/png') + response.setHeader('cache-control', 'public, max-age=86400') + response.end(body) + } catch { + this.text(response, 404, 'App icon not found.') + } + return + } + if (request.method === 'GET' && url.pathname === '/desktop') { if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') const snapshot = this.snapshot() @@ -161,6 +180,19 @@ export class LanMobileBridge { return this.json(response, 200, pending ? { id: pending.id, remoteAddress: pending.remoteAddress } : {}) } + if (request.method === 'GET' && url.pathname === '/desktop/status') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + return this.json(response, 200, { connected: this.sessions.size > 0 }) + } + + if (request.method === 'POST' && url.pathname === '/desktop/disconnect') { + if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + this.sessions.clear() + this.pendingPairings.clear() + this.rotatePairingToken() + return this.json(response, 200, { ok: true }) + } + if (request.method === 'POST' && url.pathname === '/desktop/decide') { if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') const input = JSON.parse(await readBody(request)) as { id?: unknown; approved?: unknown } @@ -197,11 +229,12 @@ export class LanMobileBridge { } if (pending.decision !== true) return this.json(response, 200, { pending: true }) const token = randomBytes(32).toString('base64url') - this.sessions.set(token, { token, expiresAt: this.now() + SESSION_TTL_MS }) + this.sessions.clear() + this.sessions.set(token, { token }) this.pendingPairings.delete(pending.id) this.pairingToken = undefined this.pairingExpiresAt = undefined - response.setHeader('set-cookie', `dsh_mobile=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200`) + response.setHeader('set-cookie', `dsh_mobile=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`) return this.json(response, 200, { approved: true }) } @@ -233,12 +266,7 @@ export class LanMobileBridge { const cookie = request.headers.cookie ?? '' const match = /(?:^|;\s*)dsh_mobile=([^;]+)/.exec(cookie) if (!match) return false - const session = this.sessions.get(match[1]!) - if (!session || session.expiresAt < this.now()) { - if (session) this.sessions.delete(session.token) - return false - } - return true + return this.sessions.has(match[1]!) } private verifySameOrigin(request: IncomingMessage): void { diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 5b668bb..d334125 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -10,6 +10,10 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { + + + + DSH Mobile
DSH Desktop

Connect your phone

Keep both devices on the same trusted Wi-Fi, then scan the QR code.

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

Phone waiting for approval
` + :root{color-scheme:light;--bg:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--brand:#4d6bfe}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding:28px 32px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:9px;font-weight:600;margin-bottom:22px}.brand img{width:39px;height:22px;object-fit:contain}h1{font-size:26px;line-height:1.25;font-weight:600;margin:0 0 8px}p{margin:0;color:var(--muted)}.connection{display:none;align-items:center;justify-content:space-between;margin:18px auto 0;max-width:390px;padding:10px 12px;border-radius:10px;background:#f2f8f4;color:#277347;text-align:left}.connection.show{display:flex}.connection span:before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:#35a867;margin-right:8px}.disconnect{border:0;background:transparent;color:var(--ink);padding:4px 6px;cursor:pointer}.qr{display:inline-flex;background:#fff;padding:14px;border:1px solid var(--line);border-radius:16px;margin:22px 0 14px}.qr svg{width:220px;height:220px}.hint{font-size:13px}.url-row{display:flex;align-items:center;gap:8px;margin:14px auto 0;max-width:390px}.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:#fff;border-radius:9px;padding:8px 12px;cursor:pointer}.expires{font-size:12px;margin-top:10px}.request{display:none;margin-top:16px;padding:14px;border:1px solid var(--brand);border-radius:12px;background:#f5f7ff;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(--brand)}#address{font-size:12px;color:var(--muted);margin:5px 0 0 16px}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}.actions button{border:1px solid var(--line);border-radius:9px;background:white;padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:white;border-color:var(--ink)}
DSH Desktop

Connect your phone

Keep both devices on the same trusted Wi-Fi, then scan the QR code.

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

Phone waiting for approval
` } export function renderPairingWaitPage(pairingId: string): string { diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index 179e08d..a0a830e 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -112,11 +112,31 @@ describe('LAN mobile bridge pairing surface', () => { value: { items: [], archivedSessionIds: [] } }) + const sameBridge = await bridge.start() + expect(sameBridge.port).toBe(snapshot.port) + const stillAuthorized = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ method: 'workspace.list', payload: {} }) + }) + expect(stillAuthorized.status).toBe(200) + + const status = await fetch(`http://127.0.0.1:${snapshot.port}/desktop/status`) + expect(await status.json()).toEqual({ connected: true }) + const blocked = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ method: 'host.openPath', payload: { path: '/tmp/secret' } }) }) expect(blocked.status).toBe(403) + + await fetch(`http://127.0.0.1:${snapshot.port}/desktop/disconnect`, { method: 'POST' }) + const disconnected = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ method: 'workspace.list', payload: {} }) + }) + expect(disconnected.status).toBe(401) }) }) diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 9cf3395..bd3e349 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -30,6 +30,8 @@ describe('LAN mobile page', () => { expect(html).toContain("block?.type==='text'") expect(html).toContain('font-size:16px') expect(html).toContain('maximum-scale=1') + expect(html).toContain('rel="apple-touch-icon" href="/app-icon"') + expect(html).toContain('apple-mobile-web-app-capable') expect(html).toContain("chunk.type!=='reasoning-delta'") expect(html).toContain('class=\"thinking\"') expect(html).toContain("streamKey=kind+':'+String(chunk.index??0)") @@ -70,5 +72,7 @@ describe('LAN mobile page', () => { )) expect(() => new Function(script)).not.toThrow() } + expect(desktop).toContain('/desktop/disconnect') + expect(desktop).toContain('Phone connected') }) }) From 129c92608198051d5bcd7741e49eff191e7a1b42 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 21:35:15 +0800 Subject: [PATCH 13/18] feat: add sidebar phone connection entry --- ...-ai+dsh-client-ui-sidebar+0.1.0-rc.6.patch | 23 ++++- src/main/index.ts | 3 + src/main/mobile/lan-mobile-bridge.ts | 4 +- src/preload/index.ts | 86 ++++++++++++++++++- test/branding-patch.test.ts | 18 ++++ 5 files changed, 128 insertions(+), 6 deletions(-) diff --git a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.6.patch index 1d0f69c..a472893 100644 --- a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.6.patch +++ b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.6.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js -index 629e313..27a52a1 100644 +index 629e313..39c4519 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js @@ -23,16 +23,19 @@ window.__ModuleLoader__.load({ @@ -76,7 +76,16 @@ index 629e313..27a52a1 100644 //#region lib/types/client/SidebarRoot.js /** * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: -@@ -157,7 +205,7 @@ window.__ModuleLoader__.load({ +@@ -138,6 +186,8 @@ window.__ModuleLoader__.load({ + }, [pointerInside]); + return (0, react_jsx_runtime.jsxs)("div", { + ref: column, ++ "data-dsh-sidebar-root": "", ++ "data-dsh-sidebar-wide": wide ? "true" : "false", + className: clsx(SidebarRoot_module_css_default.root, !wide && SidebarRoot_module_css_default.collapsed, !wide && everWide.current && SidebarRoot_module_css_default.railIn, collapsed && wide && SidebarRoot_module_css_default.fading, !pointerInside && SidebarRoot_module_css_default.quietBars), + style: wide ? { width: collapsed ? lastWideWidth.current : width } : void 0, + onPointerEnter: () => { +@@ -157,7 +207,7 @@ window.__ModuleLoader__.load({ onClick: () => { startSession(); }, @@ -85,7 +94,7 @@ index 629e313..27a52a1 100644 }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, { label: collapsed ? t("toggle.open") : t("toggle.collapse"), delayMs: 500, -@@ -168,9 +216,9 @@ window.__ModuleLoader__.load({ +@@ -168,9 +218,9 @@ window.__ModuleLoader__.load({ onClick: () => { toggleSidebar(); }, @@ -97,3 +106,11 @@ index 629e313..27a52a1 100644 }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPanelLeftOutline16, { className: SidebarRoot_module_css_default.panelIcon, size: wide ? 16 : 18 +@@ -205,6 +255,7 @@ window.__ModuleLoader__.load({ + }) + }), + (0, react_jsx_runtime.jsxs)("div", { ++ "data-dsh-sidebar-footer": "", + className: SidebarRoot_module_css_default.footArea, + children: [(0, react_jsx_runtime.jsx)("div", { + className: SidebarRoot_module_css_default.footerActions, diff --git a/src/main/index.ts b/src/main/index.ts index 3063fbf..678c037 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, dialog, + ipcMain, Menu, nativeTheme, shell, @@ -427,6 +428,8 @@ async function bootstrap(): Promise { appIconPath: desktopIconPath(), port: developmentBuild ? 43128 : 43127 }) + ipcMain.handle('mobile:open-pairing', () => showMobilePairing()) + ipcMain.handle('mobile:status', () => ({ connected: mobileBridge.snapshot().connected })) installMenu() await launchHarness() if (!developmentBuild) { diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index ce35517..16d798c 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -29,6 +29,7 @@ export interface LanMobileBridgeOptions { export interface LanMobileBridgeSnapshot { running: boolean + connected: boolean port?: number pairingUrl?: string desktopUrl?: string @@ -95,11 +96,12 @@ export class LanMobileBridge { snapshot(): LanMobileBridgeSnapshot { const address = preferredLanAddress() if (!this.server || !this.port || !this.pairingToken || !this.pairingExpiresAt || !address) { - return { running: Boolean(this.server) } + return { running: Boolean(this.server), connected: this.sessions.size > 0 } } const pairingUrl = `http://${address}:${this.port}/pair?token=${this.pairingToken}` return { running: true, + connected: this.sessions.size > 0, port: this.port, pairingUrl, desktopUrl: `http://127.0.0.1:${this.port}/desktop`, diff --git a/src/preload/index.ts b/src/preload/index.ts index 217cd26..bbd896f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import { } from './update-view' const ROOT_ID = 'dsh-desktop-update-root' +const MOBILE_BUTTON_ID = 'dsh-desktop-mobile-button' const locale: UpdateLocale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en' let host: HTMLDivElement | undefined @@ -17,6 +18,72 @@ let dismissedVersion: string | null = null let dismissedTransientPhase: UpdateStatus['phase'] | null = null let installing = false let receivedStatusEvent = false +let phoneConnected = false +let mobileStatusTimer: number | undefined +const mobileButtonObserver = new MutationObserver(mountMobileButton) + +function mountMobileButton(): void { + let style = document.getElementById(`${MOBILE_BUTTON_ID}-style`) + if (!style) { + style = document.createElement('style') + style.id = `${MOBILE_BUTTON_ID}-style` + style.textContent = mobileButtonStyles + document.head.appendChild(style) + } + const footer = document.querySelector('[data-dsh-sidebar-footer]') + if (!footer) return + let button = document.getElementById(MOBILE_BUTTON_ID) as HTMLButtonElement | null + if (!button) { + button = document.createElement('button') + button.id = MOBILE_BUTTON_ID + button.type = 'button' + button.innerHTML = `${phoneIcon}` + button.addEventListener('click', () => { + void ipcRenderer.invoke('mobile:open-pairing').catch((error: unknown) => { + console.error('[mobile] unable to open pairing window', error) + }) + }) + } + if (button.parentElement !== footer) footer.appendChild(button) + renderMobileButton() +} + +function renderMobileButton(): void { + const button = document.getElementById(MOBILE_BUTTON_ID) as HTMLButtonElement | null + const root = document.querySelector('[data-dsh-sidebar-root]') + if (!button || !root) return + const wide = root.dataset.dshSidebarWide === 'true' + button.hidden = !wide && !phoneConnected + button.classList.toggle('is-connected', phoneConnected) + const label = phoneConnected + ? locale === 'zh' ? '管理手机连接' : 'Manage phone connection' + : locale === 'zh' ? '连接手机' : 'Connect phone' + button.setAttribute('aria-label', label) + button.title = label +} + +async function refreshMobileStatus(): Promise { + try { + const status = (await ipcRenderer.invoke('mobile:status')) as { connected?: boolean } + phoneConnected = status.connected === true + mountMobileButton() + } catch (error) { + console.warn('[mobile] unable to read connection status', error) + } +} + +function initializeUi(): void { + mount() + mountMobileButton() + mobileButtonObserver.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-dsh-sidebar-wide'] + }) + void refreshMobileStatus() + mobileStatusTimer ??= window.setInterval(() => void refreshMobileStatus(), 1000) +} function mount(): void { if (document.getElementById(ROOT_ID)) return @@ -279,6 +346,21 @@ const styles = ` } ` +const phoneIcon = `` + +const mobileButtonStyles = ` + [data-dsh-sidebar-footer] { position: relative; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] [data-dsh-sidebar-footer] > [class*="settingsArea"] { padding-right: 38px; } + #${MOBILE_BUTTON_ID} { appearance:none; position:relative; width:32px; height:32px; color:var(--dsw-alias-label-secondary,#73777f); background:transparent; border:0; border-radius:9px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] #${MOBILE_BUTTON_ID} { position:absolute; right:0; bottom:0; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="false"] #${MOBILE_BUTTON_ID} { margin-top:5px; } + #${MOBILE_BUTTON_ID}:hover { color:var(--dsw-alias-label-primary,#202124); background:var(--dsw-alias-interactive-bg-hover,rgba(32,33,36,.08)); } + #${MOBILE_BUTTON_ID}:focus-visible { outline:2px solid #4d6bfe; outline-offset:1px; } + #${MOBILE_BUTTON_ID}[hidden] { display:none; } + #${MOBILE_BUTTON_ID} > span { position:absolute; top:4px; right:4px; width:7px; height:7px; border:1.5px solid var(--dsw-specific-sidebar-fill,#fff); border-radius:50%; background:#4da66d; opacity:0; } + #${MOBILE_BUTTON_ID}.is-connected > span { opacity:1; } +` + ipcRenderer.on('updates:status-changed', (_event, status: UpdateStatus) => { receivedStatusEvent = true applyStatus(status) @@ -292,7 +374,7 @@ void ipcRenderer .catch((error: unknown) => console.warn('[updater] unable to read update status', error)) if (document.readyState === 'loading') { - window.addEventListener('DOMContentLoaded', mount, { once: true }) + window.addEventListener('DOMContentLoaded', initializeUi, { once: true }) } else { - mount() + initializeUi() } diff --git a/test/branding-patch.test.ts b/test/branding-patch.test.ts index 9dc2858..244c3aa 100644 --- a/test/branding-patch.test.ts +++ b/test/branding-patch.test.ts @@ -58,6 +58,24 @@ describe('DSH Desktop sidebar branding', () => { expect(patch).toContain('sidebar === 0 ? COLLAPSED_SIDEBAR_WIDTH') }) + it('provides a sidebar phone entry that follows expanded and connected state', async () => { + const patch = await readFile( + path.join(projectRoot, 'patches', '@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.6.patch'), + 'utf8' + ) + const preload = await readFile(path.join(projectRoot, 'src', 'preload', 'index.ts'), 'utf8') + const main = await readFile(path.join(projectRoot, 'src', 'main', 'index.ts'), 'utf8') + + expect(patch).toContain('data-dsh-sidebar-root') + expect(patch).toContain('data-dsh-sidebar-wide') + expect(patch).toContain('data-dsh-sidebar-footer') + expect(preload).toContain("button.hidden = !wide && !phoneConnected") + expect(preload).toContain("button.classList.toggle('is-connected', phoneConnected)") + expect(preload).toContain("ipcRenderer.invoke('mobile:open-pairing')") + expect(main).toContain("ipcMain.handle('mobile:open-pairing'") + expect(main).toContain("ipcMain.handle('mobile:status'") + }) + it('installs the source logo into the Harness static frontend', async () => { const packageJson = JSON.parse( await readFile(path.join(projectRoot, 'package.json'), 'utf8') From 9ccfe0cc26da31ccca9c2cf1828e1f497cea4fd4 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 21:53:48 +0800 Subject: [PATCH 14/18] fix: sync mobile locale and connection state --- src/main/index.ts | 16 ++++++++++++-- src/main/mobile/lan-mobile-bridge.ts | 17 +++++++++++---- src/main/mobile/lan-mobile-pages.ts | 31 +++++++++++++++++++++++----- src/preload/index.ts | 2 +- test/lan-mobile-bridge.test.ts | 9 ++++++++ test/lan-mobile-pages.test.ts | 22 ++++++++++++++++++-- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 678c037..cca6980 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process' import { join } from 'node:path' import { readFileSync } from 'node:fs' +import { parse } from 'yaml' import { app, BrowserWindow, @@ -146,6 +147,17 @@ function dshBrandLogoPath(variant: 'light' | 'dark'): string { ) } +function harnessLocale(): 'en' | 'zh' { + try { + const settings = parse( + readFileSync(join(app.getPath('userData'), 'harness', 'settings.yaml'), 'utf8') + ) as { locale?: { preference?: unknown } } + return settings.locale?.preference === 'zh' ? 'zh' : 'en' + } catch { + return app.getLocale().toLowerCase().startsWith('zh') ? 'zh' : 'en' + } +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1380, @@ -378,7 +390,7 @@ async function showMobilePairing(): Promise { height: 700, minWidth: 420, minHeight: 560, - title: 'Connect Phone', + title: harnessLocale() === 'zh' ? '连接手机' : 'Connect Phone', icon: desktopIconPath(), parent: mainWindow, webPreferences: { @@ -420,7 +432,7 @@ async function bootstrap(): Promise { }) mobileBridge = new LanMobileBridge({ harnessUrl: () => runtime.snapshot().url, - locale: app.getLocale().toLowerCase().startsWith('zh') ? 'zh' : 'en', + locale: harnessLocale, brandLogoPaths: { light: dshBrandLogoPath('light'), dark: dshBrandLogoPath('dark') diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index 16d798c..2c48322 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -20,7 +20,7 @@ const RPC_ALLOWLIST = new Set([ export interface LanMobileBridgeOptions { harnessUrl(): string | undefined - locale?: 'en' | 'zh' + locale?: 'en' | 'zh' | (() => 'en' | 'zh') brandLogoPaths?: { light: string; dark: string } appIconPath?: string port?: number @@ -169,7 +169,8 @@ export class LanMobileBridge { renderDesktopPairingPage({ qrSvg, pairingUrl: snapshot.pairingUrl, - expiresAt: snapshot.expiresAt + expiresAt: snapshot.expiresAt, + locale: this.locale() }) ) } @@ -214,7 +215,7 @@ export class LanMobileBridge { remoteAddress, expiresAt: this.pairingExpiresAt! }) - return this.html(response, renderPairingWaitPage(id)) + return this.html(response, renderPairingWaitPage(id, this.locale())) } if (request.method === 'GET' && url.pathname === '/pair/status') { @@ -241,8 +242,11 @@ export class LanMobileBridge { } if (!this.authorized(request)) return this.text(response, 401, 'Pair your phone again.') + if (request.method === 'GET' && url.pathname === '/api/status') { + return this.json(response, 200, { connected: true }) + } if (request.method === 'GET' && url.pathname === '/') { - return this.html(response, renderMobilePage({ locale: this.options.locale ?? 'en' })) + return this.html(response, renderMobilePage({ locale: this.locale() })) } if (request.method === 'POST' && url.pathname === '/api/rpc') { this.verifySameOrigin(request) @@ -256,6 +260,11 @@ export class LanMobileBridge { this.text(response, 404, 'Not found.') } + private locale(): 'en' | 'zh' { + const value = this.options.locale + return typeof value === 'function' ? value() : value ?? 'en' + } + private validPairingToken(candidate: string | null): boolean { if (!candidate || !this.pairingToken || !this.pairingExpiresAt) return false if (this.now() > this.pairingExpiresAt) return false diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index d334125..7ec55c8 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -90,7 +90,9 @@ function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'&l function syncViewport(){const viewport=window.visualViewport;document.documentElement.style.setProperty('--app-height',(viewport?.height||window.innerHeight)+'px');if(document.activeElement===$('prompt'))requestAnimationFrame(()=>$('prompt').scrollIntoView({block:'end'}))} window.visualViewport?.addEventListener('resize',syncViewport);window.visualViewport?.addEventListener('scroll',syncViewport);window.addEventListener('resize',syncViewport);syncViewport(); $('workspace').onchange=()=>{syncWorkspaceUi();loadSessions()};$('newSession').onclick=createSession;$('refresh').onclick=refreshAll;$('send').onclick=send;$('prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}};$('prompt').onfocus=syncViewport;$('back').onclick=()=>{clearTimeout(poll);poll=null;activeSession=null;$('chatView').classList.remove('active');$('sessionsView').classList.add('active');loadSessions()};$('cancel').onclick=async()=>{if(activeSession){await rpc('session.cancel',{sessionId:activeSession});lastHistoryKey='';await loadHistory(false)}}; -(async()=>{try{await loadWorkspaces();syncWorkspaceUi();await loadSessions();$('status').classList.remove('connecting');$('status').setAttribute('aria-label','Connected')}catch(e){$('status').classList.remove('connecting');$('status').classList.add('error-state');$('status').setAttribute('aria-label',e.message)}})(); +async function checkConnection(){const status=$('status');try{const response=await fetch('/api/status',{cache:'no-store'});status.classList.remove('connecting','error-state');if(response.ok){status.setAttribute('aria-label',${JSON.stringify(zh ? '已连接' : 'Connected')})}else{status.classList.add('error-state');status.setAttribute('aria-label',${JSON.stringify(zh ? '已断开' : 'Disconnected')})}}catch{status.classList.remove('connecting');status.classList.add('error-state');status.setAttribute('aria-label',${JSON.stringify(zh ? '已断开' : 'Disconnected')})}} +setInterval(checkConnection,1500);checkConnection(); +(async()=>{try{await loadWorkspaces();syncWorkspaceUi();await loadSessions()}catch(e){$('listError').textContent=e.message}})(); ` } @@ -98,13 +100,32 @@ export function renderDesktopPairingPage(options: { qrSvg: string pairingUrl: string expiresAt: number + locale: 'en' | 'zh' }): string { - return `Connect Phone
DSH Desktop

Connect your phone

Keep both devices on the same trusted Wi-Fi, then scan the QR code.

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

Phone waiting for approval
` + 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.', + connected: zh ? '手机已连接' : 'Phone connected', + disconnect: zh ? '断开连接' : 'Disconnect', + copy: zh ? '复制' : 'Copy', + waiting: zh ? '手机正在等待批准' : 'Phone waiting for approval', + deviceAddress: zh ? '设备地址:' : 'Device address: ', + decline: zh ? '拒绝' : 'Decline', + allow: zh ? '允许' : 'Allow', + refresh: zh ? '二维码将在 ' : 'QR refreshes in ', + seconds: zh ? ' 秒后刷新' : 's', + expired: zh ? '二维码已过期,请重新打开此窗口刷新。' : 'QR expired. Reopen this window to refresh.' + } + return `${text.title}
DSH Desktop

${text.heading}

${text.hint}

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

${text.waiting}
` } -export function renderPairingWaitPage(pairingId: string): string { - return `Pairing DSH

Approve this phone

Confirm the connection request in DSH Desktop.

Waiting for approval…
` +export function renderPairingWaitPage(pairingId: string, locale: 'en' | 'zh'): string { + const zh = locale === 'zh' + const text = { title: zh ? '连接 DSH' : 'Pairing DSH', heading: zh ? '批准此手机' : 'Approve this phone', hint: zh ? '请在 DSH Desktop 中确认连接请求。' : 'Confirm the connection request in DSH Desktop.', waiting: zh ? '正在等待批准…' : 'Waiting for approval…', connected: zh ? '连接成功,正在打开 DSH…' : 'Connected. Opening DSH…', declined: zh ? '连接已被拒绝。' : 'Connection declined.', expired: zh ? '二维码已过期,请扫描新的二维码。' : 'QR code expired. Scan a new one.' } + return `${text.title}

${text.heading}

${text.hint}

${text.waiting}
` } function escapeHtml(value: string): string { diff --git a/src/preload/index.ts b/src/preload/index.ts index bbd896f..2471854 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -352,7 +352,7 @@ const mobileButtonStyles = ` [data-dsh-sidebar-footer] { position: relative; } [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] [data-dsh-sidebar-footer] > [class*="settingsArea"] { padding-right: 38px; } #${MOBILE_BUTTON_ID} { appearance:none; position:relative; width:32px; height:32px; color:var(--dsw-alias-label-secondary,#73777f); background:transparent; border:0; border-radius:9px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; } - [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] #${MOBILE_BUTTON_ID} { position:absolute; right:0; bottom:0; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] #${MOBILE_BUTTON_ID} { position:absolute; right:0; top:50%; transform:translateY(-50%); } [data-dsh-sidebar-root][data-dsh-sidebar-wide="false"] #${MOBILE_BUTTON_ID} { margin-top:5px; } #${MOBILE_BUTTON_ID}:hover { color:var(--dsw-alias-label-primary,#202124); background:var(--dsw-alias-interactive-bg-hover,rgba(32,33,36,.08)); } #${MOBILE_BUTTON_ID}:focus-visible { outline:2px solid #4d6bfe; outline-offset:1px; } diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index a0a830e..de45173 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -123,6 +123,11 @@ describe('LAN mobile bridge pairing surface', () => { const status = await fetch(`http://127.0.0.1:${snapshot.port}/desktop/status`) expect(await status.json()).toEqual({ connected: true }) + const mobileStatus = await fetch(`http://127.0.0.1:${snapshot.port}/api/status`, { + headers: { cookie } + }) + expect(mobileStatus.status).toBe(200) + expect(await mobileStatus.json()).toEqual({ connected: true }) const blocked = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { method: 'POST', @@ -138,5 +143,9 @@ describe('LAN mobile bridge pairing surface', () => { body: JSON.stringify({ method: 'workspace.list', payload: {} }) }) expect(disconnected.status).toBe(401) + const disconnectedStatus = await fetch(`http://127.0.0.1:${snapshot.port}/api/status`, { + headers: { cookie } + }) + expect(disconnectedStatus.status).toBe(401) }) }) diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index bd3e349..563d995 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -55,15 +55,19 @@ describe('LAN mobile page', () => { expect(html).not.toContain("esc(s.cwd||s.sessionId)") expect(html).toContain('@keyframes connectedPulse') expect(html).not.toContain('Connected on local network') + expect(html).toContain("fetch('/api/status',{cache:'no-store'})") + expect(html).toContain('setInterval(checkConnection,1500)') + expect(html).toContain("status.classList.add('error-state')") }) it('uses DSH styling on both pairing surfaces', () => { const desktop = renderDesktopPairingPage({ qrSvg: '', pairingUrl: 'http://192.168.1.2/pair?token=test', - expiresAt: Date.now() + 60_000 + expiresAt: Date.now() + 60_000, + locale: 'en' }) - const phone = renderPairingWaitPage('pairing-id') + const phone = renderPairingWaitPage('pairing-id', 'en') for (const html of [desktop, phone]) { expect(html).toContain('--brand:#4d6bfe') expect(html).toContain('/brand-logo/light') @@ -75,4 +79,18 @@ describe('LAN mobile page', () => { expect(desktop).toContain('/desktop/disconnect') expect(desktop).toContain('Phone connected') }) + + it('localizes both pairing surfaces from the desktop preference', () => { + const desktop = renderDesktopPairingPage({ + qrSvg: '', + pairingUrl: 'http://192.168.1.2/pair?token=test', + expiresAt: Date.now() + 60_000, + locale: 'zh' + }) + const phone = renderPairingWaitPage('pairing-id', 'zh') + expect(desktop).toContain('') + expect(desktop).toContain('连接你的手机') + expect(desktop).toContain('断开连接') + expect(phone).toContain('请在 DSH Desktop 中确认连接请求。') + }) }) From ccd71ab7f7ca5bba7fb2baf6b19e8485e25e827e Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 22:06:58 +0800 Subject: [PATCH 15/18] fix: reuse authorized mobile pairing links --- src/main/mobile/lan-mobile-bridge.ts | 6 ++++++ test/lan-mobile-bridge.test.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index 2c48322..1f976f1 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -206,6 +206,12 @@ export class LanMobileBridge { } if (request.method === 'GET' && url.pathname === '/pair') { + if (this.authorized(request)) { + response.statusCode = 302 + response.setHeader('location', '/') + response.end() + return + } if (!this.validPairingToken(url.searchParams.get('token'))) { return this.text(response, 401, 'This pairing link is invalid or expired.') } diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index de45173..7c6e98c 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -101,6 +101,13 @@ describe('LAN mobile bridge pairing surface', () => { expect(await paired.clone().json()).toEqual({ approved: true }) const cookie = paired.headers.get('set-cookie')!.split(';', 1)[0]! + const rescanned = await fetch( + `http://127.0.0.1:${snapshot.port}/pair?token=${token}`, + { headers: { cookie }, redirect: 'manual' } + ) + expect(rescanned.status).toBe(302) + expect(rescanned.headers.get('location')).toBe('/') + const forwarded = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { method: 'POST', headers: { cookie, 'content-type': 'application/json' }, From 7c9dde07559e6062e96e72c1754c44d905a214e6 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 22:12:07 +0800 Subject: [PATCH 16/18] feat: show completed mobile connection state --- src/main/mobile/lan-mobile-pages.ts | 4 +++- test/lan-mobile-pages.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 7ec55c8..93d72fd 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -108,6 +108,8 @@ export function renderDesktopPairingPage(options: { heading: zh ? '连接你的手机' : 'Connect your phone', hint: zh ? '请确保两台设备连接到同一个可信 Wi-Fi,然后扫描二维码。' : 'Keep both devices on the same trusted Wi-Fi, then scan the QR code.', connected: zh ? '手机已连接' : 'Phone connected', + closeHint: zh ? '连接会在后台保持,现在可以关闭此窗口。' : 'The connection stays active in the background. You can close this window now.', + done: zh ? '完成' : 'Done', disconnect: zh ? '断开连接' : 'Disconnect', copy: zh ? '复制' : 'Copy', waiting: zh ? '手机正在等待批准' : 'Phone waiting for approval', @@ -119,7 +121,7 @@ export function renderDesktopPairingPage(options: { expired: zh ? '二维码已过期,请重新打开此窗口刷新。' : 'QR expired. Reopen this window to refresh.' } return `${text.title}
DSH Desktop

${text.heading}

${text.hint}

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

${text.waiting}
` + :root{color-scheme:light;--bg:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--brand:#4d6bfe}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding:28px 32px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:9px;font-weight:600;margin-bottom:22px}.brand img{width:39px;height:22px;object-fit:contain}h1{font-size:26px;line-height:1.25;font-weight:600;margin:0 0 8px}p{margin:0;color:var(--muted)}.connection{display:none;flex-direction:column;align-items:center;margin:28px auto 0;max-width:390px;padding:22px;border-radius:14px;background:#f2f8f4;color:#277347}.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:#557565;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:white;color:var(--ink);padding:8px 14px;cursor:pointer}.connection-actions .done{background:var(--ink);color:white;border-color:var(--ink)}.phone-connected .pairing-content{display:none}.qr{display:inline-flex;background:#fff;padding:14px;border:1px solid var(--line);border-radius:16px;margin:22px 0 14px}.qr svg{width:220px;height:220px}.hint{font-size:13px}.url-row{display:flex;align-items:center;gap:8px;margin:14px auto 0;max-width:390px}.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:#fff;border-radius:9px;padding:8px 12px;cursor:pointer}.expires{font-size:12px;margin-top:10px}.request{display:none;margin-top:16px;padding:14px;border:1px solid var(--brand);border-radius:12px;background:#f5f7ff;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(--brand)}#address{font-size:12px;color:var(--muted);margin:5px 0 0 16px}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}.actions button{border:1px solid var(--line);border-radius:9px;background:white;padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:white;border-color:var(--ink)}
DSH Desktop

${text.heading}

${text.hint}

${text.connected}

${text.closeHint}

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

${text.waiting}
` } export function renderPairingWaitPage(pairingId: string, locale: 'en' | 'zh'): string { diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 563d995..0af3eb5 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -78,6 +78,9 @@ describe('LAN mobile page', () => { } expect(desktop).toContain('/desktop/disconnect') 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'") }) it('localizes both pairing surfaces from the desktop preference', () => { @@ -91,6 +94,8 @@ describe('LAN mobile page', () => { expect(desktop).toContain('') expect(desktop).toContain('连接你的手机') expect(desktop).toContain('断开连接') + expect(desktop).toContain('现在可以关闭此窗口。') + expect(desktop).toContain('onclick="window.close()">完成') expect(phone).toContain('请在 DSH Desktop 中确认连接请求。') }) }) From 6d23933fca451f213b13ee7ff4101f045441c312 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 22:16:57 +0800 Subject: [PATCH 17/18] feat: add mobile connection management state --- src/main/mobile/lan-mobile-bridge.ts | 3 ++- src/main/mobile/lan-mobile-pages.ts | 5 ++++- test/lan-mobile-bridge.test.ts | 2 ++ test/lan-mobile-pages.test.ts | 20 ++++++++++++++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index 1f976f1..d4eed6c 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -170,7 +170,8 @@ export class LanMobileBridge { qrSvg, pairingUrl: snapshot.pairingUrl, expiresAt: snapshot.expiresAt, - locale: this.locale() + locale: this.locale(), + connected: this.sessions.size > 0 }) ) } diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 93d72fd..6a0df67 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -101,12 +101,15 @@ export function renderDesktopPairingPage(options: { pairingUrl: string expiresAt: number locale: 'en' | 'zh' + connected: boolean }): 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.', + manageHeading: zh ? '管理手机连接' : 'Manage phone connection', + manageHint: zh ? '这台手机当前已连接到 DSH Desktop。' : 'Your phone is currently connected to DSH Desktop.', connected: zh ? '手机已连接' : 'Phone connected', closeHint: zh ? '连接会在后台保持,现在可以关闭此窗口。' : 'The connection stays active in the background. You can close this window now.', done: zh ? '完成' : 'Done', @@ -121,7 +124,7 @@ export function renderDesktopPairingPage(options: { expired: zh ? '二维码已过期,请重新打开此窗口刷新。' : 'QR expired. Reopen this window to refresh.' } return `${text.title}
DSH Desktop

${text.heading}

${text.hint}

${text.connected}

${text.closeHint}

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

${text.waiting}
` + :root{color-scheme:light;--bg:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--brand:#4d6bfe}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding:28px 32px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:9px;font-weight:600;margin-bottom:22px}.brand img{width:39px;height:22px;object-fit:contain}h1{font-size:26px;line-height:1.25;font-weight:600;margin:0 0 8px}p{margin:0;color:var(--muted)}.connection{display:none;flex-direction:column;align-items:center;margin:28px auto 0;max-width:390px;padding:22px;border-radius:14px;background:#f2f8f4;color:#277347}.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:#557565;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:white;color:var(--ink);padding:8px 14px;cursor:pointer}.connection-actions .done{background:var(--ink);color:white;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}.qr{display:inline-flex;background:#fff;padding:14px;border:1px solid var(--line);border-radius:16px;margin:22px 0 14px}.qr svg{width:220px;height:220px}.hint{font-size:13px}.url-row{display:flex;align-items:center;gap:8px;margin:14px auto 0;max-width:390px}.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:#fff;border-radius:9px;padding:8px 12px;cursor:pointer}.expires{font-size:12px;margin-top:10px}.request{display:none;margin-top:16px;padding:14px;border:1px solid var(--brand);border-radius:12px;background:#f5f7ff;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(--brand)}#address{font-size:12px;color:var(--muted);margin:5px 0 0 16px}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}.actions button{border:1px solid var(--line);border-radius:9px;background:white;padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:white;border-color:var(--ink)}
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}
` } export function renderPairingWaitPage(pairingId: string, locale: 'en' | 'zh'): string { diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index 7c6e98c..491dda6 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -130,6 +130,8 @@ describe('LAN mobile bridge pairing surface', () => { const status = await fetch(`http://127.0.0.1:${snapshot.port}/desktop/status`) 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 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 0af3eb5..22d08b3 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -65,7 +65,8 @@ describe('LAN mobile page', () => { qrSvg: '', pairingUrl: 'http://192.168.1.2/pair?token=test', expiresAt: Date.now() + 60_000, - locale: 'en' + locale: 'en', + connected: false }) const phone = renderPairingWaitPage('pairing-id', 'en') for (const html of [desktop, phone]) { @@ -88,7 +89,8 @@ describe('LAN mobile page', () => { qrSvg: '', pairingUrl: 'http://192.168.1.2/pair?token=test', expiresAt: Date.now() + 60_000, - locale: 'zh' + locale: 'zh', + connected: false }) const phone = renderPairingWaitPage('pairing-id', 'zh') expect(desktop).toContain('') @@ -98,4 +100,18 @@ describe('LAN mobile page', () => { expect(desktop).toContain('onclick="window.close()">完成') expect(phone).toContain('请在 DSH Desktop 中确认连接请求。') }) + + it('renders a compact management state when a phone is already connected', () => { + const desktop = renderDesktopPairingPage({ + qrSvg: '', + pairingUrl: 'http://192.168.1.2/pair?token=test', + expiresAt: Date.now() + 60_000, + locale: 'en', + connected: true + }) + expect(desktop).toContain('class="phone-connected manage-connected"') + expect(desktop).toContain('Manage phone connection') + expect(desktop).toContain('Your phone is currently connected to DSH Desktop.') + expect(desktop).toContain('.manage-connected .connection-hint,.manage-connected .done{display:none}') + }) }) From 2493b228b5555b3bef644264bb3bf5bada2257ea Mon Sep 17 00:00:00 2001 From: QinRui Date: Sun, 16 Aug 2026 22:21:50 +0800 Subject: [PATCH 18/18] ci: generalize Windows prerelease metadata --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 470b367..d0df91d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -338,8 +338,8 @@ jobs: --repo $env:GITHUB_REPOSITORY ` --target $env:GITHUB_SHA ` --prerelease ` - --title 'DSH Desktop Windows Harness Test' ` - --notes "Windows x64 development build for validating the Harness startup fix from PR #39. This build uses an isolated app identity and user data directory, and has passed the packaged Windows Harness smoke test." ` + --title 'DSH Desktop Windows Development Test' ` + --notes "Windows x64 development build for validating commit $env:GITHUB_SHA. This build uses an isolated app identity and user data directory, and has passed the packaged Windows Harness smoke test." ` 'dist-dev/dsh-desktop-dev-windows-x64-setup.exe#DSH Desktop Dev Windows x64 Setup' - uses: actions/upload-artifact@v4 if: startsWith(github.ref, 'refs/tags/v')