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') 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/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 adaf3ae..cca6980 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,16 +1,19 @@ import { spawn } from 'node:child_process' import { join } from 'node:path' import { readFileSync } from 'node:fs' +import { parse } from 'yaml' import { app, BrowserWindow, dialog, + ipcMain, Menu, nativeTheme, shell, 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 +26,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 @@ -131,6 +136,28 @@ function desktopIconPath(): string { : join(app.getAppPath(), 'build', 'app-icon.png') } +function dshBrandLogoPath(variant: 'light' | 'dark'): string { + return join( + app.getAppPath(), + 'node_modules', + '@deepseek-ai', + 'dsh-web-frontend', + 'dist', + `dsh-desktop-logo-${variant}.png` + ) +} + +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, @@ -243,7 +270,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 +299,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 +359,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: harnessLocale() === 'zh' ? '连接手机' : '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 +430,18 @@ async function bootstrap(): Promise { } } }) + mobileBridge = new LanMobileBridge({ + harnessUrl: () => runtime.snapshot().url, + locale: harnessLocale, + brandLogoPaths: { + light: dshBrandLogoPath('light'), + dark: dshBrandLogoPath('dark') + }, + 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) { @@ -390,6 +486,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..d4eed6c --- /dev/null +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -0,0 +1,377 @@ +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 { readFile } from 'node:fs/promises' +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 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' | (() => 'en' | 'zh') + brandLogoPaths?: { light: string; dark: string } + appIconPath?: string + port?: number + now?: () => number +} + +export interface LanMobileBridgeSnapshot { + running: boolean + connected: boolean + port?: number + pairingUrl?: string + desktopUrl?: string + expiresAt?: number +} + +interface MobileSession { + token: string +} + +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 { + 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) => { + 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(this.options.port ?? 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), 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`, + 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.startsWith('/brand-logo/')) { + const variant = url.pathname === '/brand-logo/dark' ? 'dark' : 'light' + const path = this.options.brandLogoPaths?.[variant] + if (!path) return this.text(response, 404, 'Brand asset not found.') + try { + const body = await readFile(path) + response.statusCode = 200 + response.setHeader('content-type', 'image/png') + response.setHeader('cache-control', 'public, max-age=3600') + response.end(body) + } catch { + this.text(response, 404, 'Brand asset not found.') + } + 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() + 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, + locale: this.locale(), + connected: this.sessions.size > 0 + }) + ) + } + + 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 === '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 } + 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.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.') + } + const id = randomUUID() + this.pendingPairings.set(id, { + id, + remoteAddress, + expiresAt: this.pairingExpiresAt! + }) + return this.html(response, renderPairingWaitPage(id, this.locale())) + } + + 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.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=31536000`) + 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 === '/api/status') { + return this.json(response, 200, { connected: true }) + } + if (request.method === 'GET' && url.pathname === '/') { + return this.html(response, renderMobilePage({ locale: this.locale() })) + } + 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 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 + 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 + return this.sessions.has(match[1]!) + } + + 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..6a0df67 --- /dev/null +++ b/src/main/mobile/lan-mobile-pages.ts @@ -0,0 +1,147 @@ +export interface MobilePageOptions { + locale: 'en' | 'zh' +} + +export function renderMobilePage({ locale }: MobilePageOptions): string { + const zh = locale === 'zh' + return ` + + + + + + + + + + DSH Mobile + + +
+
+
DSH Desktop
+
+
+
+
+
+
+
+
+
+
+
+` +} + +export function renderDesktopPairingPage(options: { + qrSvg: string + 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', + 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

${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 { + 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 { + return value.replace(/[&<>"']/g, (character) => { + const replacements: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } + return replacements[character]! + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 217cd26..2471854 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; 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; } + #${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') diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts new file mode 100644 index 0000000..491dda6 --- /dev/null +++ b/test/lan-mobile-bridge.test.ts @@ -0,0 +1,160 @@ +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 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' }, + body: JSON.stringify({ method: 'workspace.list', payload: {} }) + }) + expect(forwarded.status).toBe(200) + expect(await forwarded.json()).toEqual({ + ok: true, + 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 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 } + }) + 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', + 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) + 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 new file mode 100644 index 0000000..22d08b3 --- /dev/null +++ b/test/lan-mobile-pages.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { + renderDesktopPairingPage, + renderMobilePage, + renderPairingWaitPage +} 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(/