diff --git a/build/dsh-loader.gif b/build/dsh-loader.gif index 57aac117..7a97068e 100644 Binary files a/build/dsh-loader.gif and b/build/dsh-loader.gif differ diff --git a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.7.patch b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.7.patch index 6064b3c3..7365eb55 100644 --- a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.7.patch +++ b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.0-rc.7.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 9ca3639..16d58b0 100644 +index 9ca3639..9709649 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,20 @@ window.__ModuleLoader__.load({ @@ -107,7 +107,7 @@ index 9ca3639..16d58b0 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 +256,7 @@ window.__ModuleLoader__.load({ +@@ -205,11 +256,13 @@ window.__ModuleLoader__.load({ }) }), (0, react_jsx_runtime.jsxs)("div", { @@ -115,3 +115,9 @@ index 9ca3639..16d58b0 100644 className: SidebarRoot_module_css_default.footArea, children: [(0, react_jsx_runtime.jsx)("div", { className: SidebarRoot_module_css_default.footerActions, + children: renderSlot("sidebar.footer.action", { wide }) + }), (0, react_jsx_runtime.jsx)("div", { ++ "data-dsh-sidebar-settings": "", + className: SidebarRoot_module_css_default.settingsArea, + children: renderSlot("sidebar.settings", { wide }) + })] diff --git a/src/main/index.ts b/src/main/index.ts index 2e241123..a46a5213 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,7 +29,11 @@ import { resolveProfileRecoveryPlugins, uninstallPluginFromProfile } from './state/plugin-recovery' -import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation' +import { + desktopHarnessUrl, + isAbortedNavigationError, + shouldLoadHarnessUrl +} from './window-navigation' import { checkForUpdates, registerUpdateHandlers, @@ -368,12 +372,13 @@ function createWindow(): BrowserWindow { async function openHarness(url: string): Promise { const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow() + const rendererUrl = desktopHarnessUrl(url, process.platform) if (shouldLoadHarnessUrl(window.webContents.getURL(), url)) { const navigationVersion = ++mainWindowNavigationVersion rendererPluginFailureLogs = [] window.webContents.stop() try { - await window.loadURL(url) + await window.loadURL(rendererUrl) } catch (error) { if (navigationVersion !== mainWindowNavigationVersion) return if (isAbortedNavigationError(error)) return @@ -859,8 +864,12 @@ async function bootstrap(): Promise { dark: dshBrandLogoPath('dark') }, appIconPath: desktopIconPath(), - port: developmentBuild ? 43128 : 43127 + port: developmentBuild ? 43128 : 43127, + onReconnectRequested: () => { + void showMobilePairing().catch(showUnexpectedError) + } }) + void mobileBridge.start().catch(showUnexpectedError) ipcMain.handle('directory-picker:open', async (event) => { if ( !mainWindow || diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index d4eed6c4..95ee9206 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -4,7 +4,12 @@ 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' +import { + renderDesktopPairingPage, + renderMobilePage, + renderMobileReconnectPage, + renderPairingWaitPage +} from './lan-mobile-pages' const MAX_BODY_BYTES = 64 * 1024 const PAIRING_TTL_MS = 5 * 60 * 1000 @@ -25,6 +30,7 @@ export interface LanMobileBridgeOptions { appIconPath?: string port?: number now?: () => number + onReconnectRequested?: () => void } export interface LanMobileBridgeSnapshot { @@ -38,6 +44,7 @@ export interface LanMobileBridgeSnapshot { interface MobileSession { token: string + remoteAddress: string } interface PendingPairing { @@ -53,6 +60,7 @@ export class LanMobileBridge { private pairingToken?: string private pairingExpiresAt?: number private readonly sessions = new Map() + private readonly suspendedSessions = new Map() private readonly pendingPairings = new Map() private readonly now: () => number @@ -62,8 +70,9 @@ export class LanMobileBridge { async start(): Promise { if (this.server) { - this.rotatePairingToken() - this.pendingPairings.clear() + if (!this.pairingToken || !this.pairingExpiresAt || this.pairingExpiresAt < this.now()) { + this.rotatePairingToken() + } return this.snapshot() } this.rotatePairingToken() @@ -88,6 +97,7 @@ export class LanMobileBridge { this.pairingToken = undefined this.pairingExpiresAt = undefined this.sessions.clear() + this.suspendedSessions.clear() this.pendingPairings.clear() if (!server) return await new Promise((resolve) => server.close(() => resolve())) @@ -191,6 +201,7 @@ export class LanMobileBridge { if (request.method === 'POST' && url.pathname === '/desktop/disconnect') { if (!isLoopbackAddress(remoteAddress)) return this.text(response, 403, 'Desktop only.') + for (const [token, session] of this.sessions) this.suspendedSessions.set(token, session) this.sessions.clear() this.pendingPairings.clear() this.rotatePairingToken() @@ -206,8 +217,25 @@ export class LanMobileBridge { return this.json(response, 200, { ok: true }) } + if (request.method === 'GET' && url.pathname === '/disconnected') { + return this.html(response, renderMobileReconnectPage(this.locale())) + } + + if (request.method === 'GET' && url.pathname === '/reconnect') { + const pending = this.reconnectPairing(remoteAddress) + this.options.onReconnectRequested?.() + return this.html(response, renderPairingWaitPage(pending.id, this.locale())) + } + + if (request.method === 'POST' && url.pathname === '/pair/retry') { + this.verifySameOrigin(request) + const pending = this.reconnectPairing(remoteAddress) + this.options.onReconnectRequested?.() + return this.json(response, 200, { id: pending.id, expiresAt: pending.expiresAt }) + } + if (request.method === 'GET' && url.pathname === '/pair') { - if (this.authorized(request)) { + if (this.authorized(request, remoteAddress)) { response.statusCode = 302 response.setHeader('location', '/') response.end() @@ -239,8 +267,12 @@ export class LanMobileBridge { } 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 }) + for (const [savedToken, session] of this.suspendedSessions) { + if (session.remoteAddress !== pending.remoteAddress) continue + this.sessions.set(savedToken, session) + this.suspendedSessions.delete(savedToken) + } + this.sessions.set(token, { token, remoteAddress: pending.remoteAddress }) this.pendingPairings.delete(pending.id) this.pairingToken = undefined this.pairingExpiresAt = undefined @@ -248,7 +280,15 @@ export class LanMobileBridge { return this.json(response, 200, { approved: true }) } - if (!this.authorized(request)) return this.text(response, 401, 'Pair your phone again.') + if (!this.authorized(request, remoteAddress)) { + this.rememberMobileContext(request, remoteAddress) + if (!this.authorized(request, remoteAddress)) { + if (request.method === 'GET' && url.pathname === '/') { + return this.html(response, renderMobileReconnectPage(this.locale())) + } + return this.text(response, 401, 'Pair your phone again.') + } + } if (request.method === 'GET' && url.pathname === '/api/status') { return this.json(response, 200, { connected: true }) } @@ -280,11 +320,50 @@ export class LanMobileBridge { return left.length === right.length && timingSafeEqual(left, right) } - private authorized(request: IncomingMessage): boolean { + private reconnectPairing(remoteAddress: string): PendingPairing { + const current = [...this.pendingPairings.values()].find( + (item) => + item.remoteAddress === remoteAddress && + item.decision === undefined && + item.expiresAt >= this.now() + ) + if (current) return current + const pending = { + id: randomUUID(), + remoteAddress, + expiresAt: this.now() + PAIRING_TTL_MS + } + this.pendingPairings.set(pending.id, pending) + return pending + } + + private authorized(request: IncomingMessage, remoteAddress: string): boolean { + const token = this.mobileToken(request) + if (token && this.sessions.has(token)) return true + return [...this.sessions.values()].some((session) => session.remoteAddress === remoteAddress) + } + + private mobileToken(request: IncomingMessage): string | undefined { const cookie = request.headers.cookie ?? '' - const match = /(?:^|;\s*)dsh_mobile=([^;]+)/.exec(cookie) - if (!match) return false - return this.sessions.has(match[1]!) + return /(?:^|;\s*)dsh_mobile=([^;]+)/.exec(cookie)?.[1] + } + + private rememberMobileContext(request: IncomingMessage, remoteAddress: string): void { + const token = this.mobileToken(request) + if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return + const sameDeviceIsActive = [...this.sessions.values()].some( + (session) => session.remoteAddress === remoteAddress + ) + if (sameDeviceIsActive) { + this.sessions.set(token, { token, remoteAddress }) + this.suspendedSessions.delete(token) + return + } + if (!this.suspendedSessions.has(token) && this.suspendedSessions.size >= 16) { + const oldest = this.suspendedSessions.keys().next().value + if (oldest) this.suspendedSessions.delete(oldest) + } + this.suspendedSessions.set(token, { token, remoteAddress }) } private verifySameOrigin(request: IncomingMessage): void { diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 356e49a3..bd2c65f8 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -9,43 +9,45 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { - + + DSH Mobile
DSH Desktop
-
-
+

${zh ? '移动工作台' : 'Mobile workspace'}

${zh ? '继续对话' : 'Continue working'}

+
+
${zh ? '最近会话' : 'Recent sessions'}0
-
+
` } +export function renderMobileReconnectPage(locale: 'en' | 'zh'): string { + const zh = locale === 'zh' + const text = { + title: zh ? '重新连接 DSH' : 'Reconnect DSH', + heading: zh ? '连接已断开' : 'Connection lost', + action: zh ? '重新连接' : 'Reconnect', + guidance: zh + ? '请确保手机和电脑连接到同一 Wi-Fi。点击重新连接后,在电脑上的 DSH Desktop 中允许此手机。' + : 'Keep both devices on the same Wi-Fi, then reconnect and approve this phone in DSH Desktop.' + } + return `${text.title}
DSH Desktop

${text.heading}

${text.guidance}

${text.action}
` +} + export function renderDesktopPairingPage(options: { qrSvg: string pairingUrl: string @@ -124,13 +143,24 @@ export function renderDesktopPairingPage(options: { 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}
` + :root{color-scheme:light;--bg:#fff;--surface:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--success-bg:#f2f8f4;--success-ink:#277347;--success-muted:#557565;--request-accent:#c16f52;--request-border:#dfbcae;--request-bg:#fbf6f3}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#141416;--surface:#1d1d20;--panel:#202023;--ink:#f5f5f6;--muted:#95979d;--line:#303034;--success-bg:#17261d;--success-ink:#75c991;--success-muted:#8ab99a;--request-accent:#df9275;--request-border:#68483d;--request-bg:#291f1c}}*{box-sizing:border-box}html,body{min-height:100%;background:var(--bg)}body{margin:0;color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding: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}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}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:var(--success-bg);color:var(--success-ink)}.connection.show{display:flex}.connection-title{font-size:16px;font-weight:600}.connection-title:before{content:'✓';display:inline-grid;place-items:center;width:24px;height:24px;margin-right:9px;border-radius:50%;background:#35a867;color:white}.connection-hint{max-width:310px;margin-top:8px;color:var(--success-muted);font-size:13px}.connection-actions{display:flex;gap:8px;margin-top:18px}.connection-actions button{min-width:94px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}.connection-actions .done{background:var(--ink);color:var(--bg);border-color:var(--ink)}.phone-connected .pairing-content{display:none}.manage-connected .connection-hint,.manage-connected .done{display:none}.manage-connected .connection-actions{margin-top:16px}.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:var(--surface);color:var(--ink);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(--request-border);border-radius:12px;background:var(--request-bg);text-align:left}.request.show{display:block}.request-title{display:flex;align-items:center;gap:8px;font-weight:600}.request-title:before{content:'';width:8px;height:8px;border-radius:50%;background:var(--request-accent)}#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:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:var(--bg);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 { 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}
` + 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 ? '连接申请已被拒绝。' : 'The connection request was declined.', + expired: zh ? '本次连接申请已过期。' : 'This connection request expired.', + unavailable: zh ? '暂时无法连接桌面端,请先启动 DSH Desktop。' : 'Cannot reach the desktop. Start DSH Desktop and try again.', + retry: zh ? '再次发起申请' : 'Request approval again', + retrying: zh ? '正在重新发起申请…' : 'Requesting approval again…' + } + return `${text.title}

${text.heading}

${text.hint}

${text.waiting}
` } function escapeHtml(value: string): string { diff --git a/src/main/window-navigation.ts b/src/main/window-navigation.ts index 99c44a65..c74bddf3 100644 --- a/src/main/window-navigation.ts +++ b/src/main/window-navigation.ts @@ -8,6 +8,19 @@ export function shouldLoadHarnessUrl(currentUrl: string, targetUrl: string): boo } } +export function desktopHarnessUrl(url: string, platform: NodeJS.Platform): string { + if (platform !== 'win32') return url + + try { + const parsed = new URL(url) + parsed.searchParams.set('dsh-desktop-mode', 'advanced') + parsed.searchParams.set('dsh-desktop-platform', platform) + return parsed.toString() + } catch { + return url + } +} + export function isAbortedNavigationError(error: unknown): boolean { if (typeof error !== 'object' || error === null) return false diff --git a/src/preload/index.ts b/src/preload/index.ts index bb26bb3c..6ec61bef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -99,8 +99,8 @@ function mountMobileButton(): void { style.textContent = mobileButtonStyles document.head.appendChild(style) } - const footer = document.querySelector('[data-dsh-sidebar-footer]') - if (!footer) return + const settingsArea = document.querySelector('[data-dsh-sidebar-settings]') + if (!settingsArea) return let button = document.getElementById(MOBILE_BUTTON_ID) as HTMLButtonElement | null if (!button) { button = document.createElement('button') @@ -113,7 +113,7 @@ function mountMobileButton(): void { }) }) } - if (button.parentElement !== footer) footer.appendChild(button) + if (button.parentElement !== settingsArea) settingsArea.appendChild(button) renderMobileButton() } @@ -454,11 +454,12 @@ 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; } + [data-dsh-sidebar-settings] { position:relative; box-sizing:border-box; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] [data-dsh-sidebar-settings] { 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; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="false"] [data-dsh-sidebar-settings] { flex-direction:column; align-items:center; } + [data-dsh-sidebar-root][data-dsh-sidebar-wide="false"] #${MOBILE_BUTTON_ID} { flex:none; 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; } diff --git a/test/branding-patch.test.ts b/test/branding-patch.test.ts index 68b2eaf4..fd3b89cb 100644 --- a/test/branding-patch.test.ts +++ b/test/branding-patch.test.ts @@ -69,6 +69,10 @@ describe('DSH Desktop sidebar branding', () => { expect(patch).toContain('data-dsh-sidebar-root') expect(patch).toContain('data-dsh-sidebar-wide') expect(patch).toContain('data-dsh-sidebar-footer') + expect(patch).toContain('data-dsh-sidebar-settings') + expect(preload).toContain("document.querySelector('[data-dsh-sidebar-settings]')") + expect(preload).toContain('settingsArea.appendChild(button)') + expect(preload).not.toContain('footer.appendChild(button)') expect(preload).toContain("button.hidden = !wide && !phoneConnected") expect(preload).toContain("button.classList.toggle('is-connected', phoneConnected)") expect(preload).toContain("ipcRenderer.invoke('mobile:open-pairing')") diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index 491dda6d..a902fc9b 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -50,14 +50,78 @@ describe('LAN mobile bridge pairing surface', () => { expect(await response.text()).toContain('Connect your phone') }) - it('does not expose the mobile UI before pairing', async () => { + it('offers a reconnect page without exposing mobile APIs before approval', 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) + expect(response.status).toBe(200) + expect(await response.text()).toContain('Reconnect') + const blocked = await fetch(`http://127.0.0.1:${snapshot.port}/api/status`) + expect(blocked.status).toBe(401) + }) + + it('retries an expired approval inside the same Home Screen browser context', async () => { + let reconnectRequests = 0 + let now = Date.now() + const bridge = new LanMobileBridge({ + harnessUrl: () => 'http://127.0.0.1:9999', + now: () => now, + onReconnectRequested: () => { + reconnectRequests += 1 + } + }) + bridges.push(bridge) + const snapshot = await bridge.start() + const reconnect = await fetch(`http://127.0.0.1:${snapshot.port}/reconnect`) + const reconnectHtml = await reconnect.text() + let pairingId = /let id="([^"]+)"/.exec(reconnectHtml)?.[1] + expect(pairingId).toBeTruthy() + expect(reconnectHtml).toContain('Approve this phone') + expect(reconnectRequests).toBe(1) + + now += 5 * 60 * 1000 + 1 + const expired = await fetch( + `http://127.0.0.1:${snapshot.port}/pair/status?id=${pairingId}` + ) + expect(await expired.json()).toEqual({ expired: true }) + const retried = await fetch(`http://127.0.0.1:${snapshot.port}/pair/retry`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: `http://127.0.0.1:${snapshot.port}` + }, + body: '{}' + }) + const retriedPairing = (await retried.json()) as { id: string } + expect(retriedPairing.id).toBeTruthy() + expect(retriedPairing.id).not.toBe(pairingId) + expect(reconnectRequests).toBe(2) + pairingId = retriedPairing.id + + // Opening the desktop approval window starts the bridge again. That must + // not rotate away the pending request the phone is already polling. + const reopened = await bridge.start() + expect(reopened.port).toBe(snapshot.port) + const pending = await fetch(`http://127.0.0.1:${snapshot.port}/desktop/pending`) + expect(await pending.json()).toMatchObject({ id: pairingId }) + 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 approved = await fetch( + `http://127.0.0.1:${snapshot.port}/pair/status?id=${pairingId}` + ) + expect(await approved.clone().json()).toEqual({ approved: true }) + const cookie = approved.headers.get('set-cookie')!.split(';', 1)[0]! + const mobile = await fetch(`http://127.0.0.1:${snapshot.port}/`, { + headers: { cookie } + }) + expect(mobile.status).toBe(200) + expect(await mobile.text()).toContain('DSH Mobile') }) it('requires approval, then forwards only allowlisted RPC methods', async () => { @@ -85,7 +149,7 @@ describe('LAN mobile bridge pairing surface', () => { 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] + const pairingId = /let 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' }) @@ -137,6 +201,11 @@ describe('LAN mobile bridge pairing surface', () => { }) expect(mobileStatus.status).toBe(200) expect(await mobileStatus.json()).toEqual({ connected: true }) + const samePhoneHomeScreen = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status` + ) + expect(samePhoneHomeScreen.status).toBe(200) + expect(await samePhoneHomeScreen.json()).toEqual({ connected: true }) const blocked = await fetch(`http://127.0.0.1:${snapshot.port}/api/rpc`, { method: 'POST', @@ -156,5 +225,61 @@ describe('LAN mobile bridge pairing surface', () => { headers: { cookie } }) expect(disconnectedStatus.status).toBe(401) + const disconnectedHomeScreen = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status` + ) + expect(disconnectedHomeScreen.status).toBe(401) + const legacyHomeScreenCookie = `dsh_mobile=${'a'.repeat(43)}` + const unknownHomeScreenBeforeApproval = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status`, + { headers: { cookie: legacyHomeScreenCookie } } + ) + expect(unknownHomeScreenBeforeApproval.status).toBe(401) + + const reconnectSnapshot = bridge.snapshot() + const reconnectToken = new URL(reconnectSnapshot.pairingUrl!).searchParams.get('token') + const reconnectPage = await fetch( + `http://127.0.0.1:${snapshot.port}/pair?token=${reconnectToken}` + ) + const reconnectHtml = await reconnectPage.text() + const reconnectId = /let id="([^"]+)"/.exec(reconnectHtml)?.[1] + expect(reconnectId).toBeTruthy() + await fetch(`http://127.0.0.1:${snapshot.port}/desktop/decide`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: reconnectId, approved: true }) + }) + const reapproved = await fetch( + `http://127.0.0.1:${snapshot.port}/pair/status?id=${reconnectId}` + ) + expect(await reapproved.clone().json()).toEqual({ approved: true }) + const newCookie = reapproved.headers.get('set-cookie')!.split(';', 1)[0]! + + const restoredHomeScreen = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status`, + { headers: { cookie } } + ) + expect(restoredHomeScreen.status).toBe(200) + expect(await restoredHomeScreen.json()).toEqual({ connected: true }) + const newlyPairedSafari = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status`, + { headers: { cookie: newCookie } } + ) + expect(newlyPairedSafari.status).toBe(200) + const homeScreenWithoutSharedCookies = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status` + ) + expect(homeScreenWithoutSharedCookies.status).toBe(200) + const restoredLegacyHomeScreen = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status`, + { headers: { cookie: legacyHomeScreenCookie } } + ) + expect(restoredLegacyHomeScreen.status).toBe(200) + const lateHomeScreenCookie = `dsh_mobile=${'b'.repeat(43)}` + const restoredAfterSafariPaired = await fetch( + `http://127.0.0.1:${snapshot.port}/api/status`, + { headers: { cookie: lateHomeScreenCookie } } + ) + expect(restoredAfterSafariPaired.status).toBe(200) }) }) diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 50043dd2..be48aa2a 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { renderDesktopPairingPage, renderMobilePage, + renderMobileReconnectPage, renderPairingWaitPage } from '../src/main/mobile/lan-mobile-pages' @@ -17,8 +18,28 @@ describe('LAN mobile page', () => { const html = renderMobilePage({ locale: 'en' }) expect(html).toContain('--brand:#4d6bfe') expect(html).toContain('prefers-color-scheme:dark') + expect(html).toContain('content="#ffffff" media="(prefers-color-scheme:light)"') + expect(html).toContain('content="#141416" media="(prefers-color-scheme:dark)"') expect(html).toContain('/brand-logo/light') - expect(html).toContain('id="chatTitle"') + expect(html).not.toContain('id="chatTitle"') + expect(html).toContain('body.chat-open header{display:none}') + expect(html).toContain('body.chat-open .shell{padding:env(safe-area-inset-top) 14px 0}') + expect(html).toContain('.chat-toolbar{position:absolute;inset:0 0 auto;z-index:2;min-height:40px;padding:0;background:transparent') + expect(html).toContain('background:var(--card)!important;box-shadow:0 2px 8px') + expect(html).toContain('.chat-open .messages{padding-top:4px;padding-bottom:84px}') + expect(html).toContain('.composer{position:absolute;z-index:2;inset:auto 0 0;padding:10px 0 4px;background:transparent;pointer-events:none}') + expect(html).toContain('background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05);pointer-events:auto') + expect(html).toContain('@media(display-mode:standalone)') + expect(html).not.toContain("返回") + expect(html).not.toContain("Back") + expect(html).toContain("document.body.classList.add('chat-open')") + expect(html).toContain("document.body.classList.remove('chat-open')") + expect(html).toContain("history.replaceState({view:'sessions'},'')") + expect(html).toContain("history.pushState({view:'chat',sessionId:id") + expect(html).toContain("window.addEventListener('popstate'") + expect(html).toContain("if(history.state?.view==='chat')history.back()") + expect(html).toContain("function showSessionList()") + expect(html).toContain("function handleHistory(state)") expect(html).toContain('class=\"skeleton\"') expect(html).toContain('agentRunning?250:750') expect(html).toContain("t==='user/message'") @@ -46,6 +67,16 @@ describe('LAN mobile page', () => { expect(html).toContain('var(--app-height,100dvh)') expect(html).toContain('id=\"workspaceHint\"') expect(html).toContain('id=\"newSession\" class=\"new-session\" disabled') + expect(html).toContain('class=\"session-hero\"') + expect(html).toContain('class=\"workspace-panel\"') + expect(html).toContain('padding:calc(6px + env(safe-area-inset-top))') + expect(html).toContain('header{height:48px') + expect(html).toContain('.session-hero{display:flex;align-items:center') + expect(html).toContain('padding:7px 2px 11px') + expect(html).toContain('.session-actions select{flex:1;min-width:0;height:39px') + expect(html).toContain('id=\"sessionCount\" class=\"session-count\"') + expect(html).toContain('class=\"session-mark\"') + expect(html).toContain('class=\"row-copy\"') expect(html).toContain("workspaces[0].workspaceId") expect(html).toContain('function refreshAll()') expect(html).toContain('function relativeTime(value)') @@ -56,14 +87,37 @@ describe('LAN mobile page', () => { 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("location.replace('/disconnected')") expect(html).toContain('setInterval(checkConnection,1500)') expect(html).toContain("status.classList.add('error-state')") + expect(html).toContain("if(r.status===401)") + expect(html).toContain('e.disconnected=true') + expect(html).toContain("function showError(id,error)") + expect(html).toContain("showError('chatError',e)") + expect(html).not.toContain("$('chatError').textContent=e.message") expect(html).toContain('archivedSessionIds=value.archivedSessionIds||[]') expect(html).toContain('archived=new Set(archivedSessionIds)') expect(html).not.toContain('new Set(value.archivedSessionIds||[])') expect(html).toContain('!archived.has(s.sessionId)') }) + it('renders an adaptive reconnect action for the Home Screen app', () => { + const zh = renderMobileReconnectPage('zh') + const en = renderMobileReconnectPage('en') + expect(zh).toContain('连接已断开') + expect(zh).toContain('href="/reconnect">重新连接') + expect(zh).toContain('请确保手机和电脑连接到同一 Wi-Fi。点击重新连接后,在电脑上的 DSH Desktop 中允许此手机。') + expect(zh).not.toContain('class="approval"') + expect(zh).not.toContain('class="network"') + expect(zh).not.toContain('class="symbol"') + expect(en).toContain('Connection lost') + for (const html of [zh, en]) { + expect(html).toContain('prefers-color-scheme:dark') + expect(html).toContain('/brand-logo/light') + expect(html).toContain('/brand-logo/dark') + } + }) + it('uses DSH styling on both pairing surfaces', () => { const desktop = renderDesktopPairingPage({ qrSvg: '', @@ -74,7 +128,6 @@ describe('LAN mobile page', () => { }) const phone = renderPairingWaitPage('pairing-id', 'en') for (const html of [desktop, phone]) { - expect(html).toContain('--brand:#4d6bfe') expect(html).toContain('/brand-logo/light') for (const script of [...html.matchAll(/