From 9ae029903ed341dc63d52db1a7a1fbb3da6fc53b Mon Sep 17 00:00:00 2001 From: QinRui Date: Thu, 10 Sep 2026 12:27:25 +0800 Subject: [PATCH] fix(desktop): open local web links externally and stabilize macOS sidebar chrome --- ...k-ai+dsh-client-ui-layout+0.1.2-rc.1.patch | 2 +- ...-ai+dsh-client-ui-sidebar+0.1.2-rc.1.patch | 2 +- src/main/context-menu-template.ts | 4 +- src/main/index.ts | 20 +++------- src/main/safe-mode-overlay.ts | 2 +- src/main/security-policy.ts | 9 ++++- src/main/security.ts | 9 +++-- src/main/window-button-position.ts | 9 +++++ test/branding-patch.test.ts | 12 ++++-- test/context-menu.test.ts | 4 +- test/runtime.test.ts | 10 +++-- test/security.test.ts | 39 +++++++++++++++++++ test/window-button-position.test.ts | 10 +++++ 13 files changed, 98 insertions(+), 34 deletions(-) create mode 100644 src/main/window-button-position.ts create mode 100644 test/security.test.ts create mode 100644 test/window-button-position.test.ts diff --git a/patches/@deepseek-ai+dsh-client-ui-layout+0.1.2-rc.1.patch b/patches/@deepseek-ai+dsh-client-ui-layout+0.1.2-rc.1.patch index 1aec9fbba..b6551f8d1 100644 --- a/patches/@deepseek-ai+dsh-client-ui-layout+0.1.2-rc.1.patch +++ b/patches/@deepseek-ai+dsh-client-ui-layout+0.1.2-rc.1.patch @@ -6,7 +6,7 @@ index cdaea1a..131fc45 100644 * LG breakpoint); a manual toggle below it re-expands over the squeezed center * (stores.ts narrowExpanded). */ const SIDEBAR_AUTO_COLLAPSE = 1024; -+ const COLLAPSED_SIDEBAR_WIDTH = navigator.userAgent.includes("Macintosh") ? 80 : 56; ++ const COLLAPSED_SIDEBAR_WIDTH = navigator.userAgent.includes("Macintosh") ? 96 : 56; /** * Clamp a panel width into its contract range. * @param px - requested width. diff --git a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.2-rc.1.patch b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.2-rc.1.patch index 306338fe7..311060b7a 100644 --- a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.2-rc.1.patch +++ b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.2-rc.1.patch @@ -7,7 +7,7 @@ index 696591c..90c1520 100644 tag.dataset.plugin = "@deepseek-ai/dsh-client-ui-sidebar"; tag.dataset.pluginCss = tagId; - tag.textContent = css; -+ tag.textContent = css + "[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"true\"]{padding-top:32px}[data-dsh-sidebar-brand-identity]{gap:4px}" + (navigator.userAgent.includes("Macintosh") ? "[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"true\"]{padding-top:28px}[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"false\"]{padding:32px 22px 6px}" : ""); ++ tag.textContent = css + "[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"true\"]{padding-top:32px}[data-dsh-sidebar-brand-identity]{gap:4px}" + (navigator.userAgent.includes("Macintosh") ? "[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"true\"]{padding-top:28px}[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"false\"]{padding:28px 30px 6px}[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"false\"] [class*=\"_logoRow\"]{height:60px;margin-bottom:8px}[data-dsh-sidebar-root][data-dsh-sidebar-wide=\"false\"] [class*=\"_newSession\"]{height:38px}" : ""); document.head.appendChild(tag); } var SidebarRoot_module_css_default = { diff --git a/src/main/context-menu-template.ts b/src/main/context-menu-template.ts index def9c2747..3fcb6fa50 100644 --- a/src/main/context-menu-template.ts +++ b/src/main/context-menu-template.ts @@ -62,9 +62,7 @@ export function isExternalWebUrl(rawUrl: string): boolean { try { const url = new URL(rawUrl) return ( - (url.protocol === 'https:' || url.protocol === 'http:') && - url.hostname !== '127.0.0.1' && - url.hostname !== 'localhost' + url.protocol === 'https:' || url.protocol === 'http:' ) } catch { return false diff --git a/src/main/index.ts b/src/main/index.ts index 53233cbf2..9a3f56343 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,6 +58,7 @@ import { serializeGpuFallbackState } from './gpu-fallback' import { secureWindow } from './security' +import { MAC_WINDOW_BUTTON_POSITION } from './window-button-position' import { SafeModeOverlay } from './safe-mode-overlay' import { ensureLaunchRoot } from './state/launch-root' import { @@ -932,18 +933,8 @@ function createWindow(): BrowserWindow { }) if (process.platform === 'darwin') { window.setWindowButtonVisibility(true) - // Match the sidebar inset at the current zoom, with a 2px optical correction - // for the round native buttons relative to the logo's visible left edge. - const alignWindowButtons = (): void => { - if (window.isDestroyed()) return - window.setWindowButtonPosition({ - x: Math.round(16 * window.webContents.getZoomFactor()) - 2, - y: 9 - }) - } - alignWindowButtons() - window.webContents.on('did-finish-load', alignWindowButtons) - window.webContents.on('zoom-changed', () => setImmediate(alignWindowButtons)) + // Native window chrome stays fixed regardless of sidebar state or page zoom. + window.setWindowButtonPosition(MAC_WINDOW_BUTTON_POSITION) } else if (isWindows) { window.setMenuBarVisibility(false) } @@ -964,7 +955,8 @@ function createWindow(): BrowserWindow { appendRendererPluginFailureLog(details.message) }) installPluginRecoveryNavigation(window) - secureWindow(window) + // Resolve dynamically because Harness may restart on a different port. + secureWindow(window, () => runtime?.snapshot().url) installContextMenu(window, harnessLocale) installMainWindowRendererRecovery(window) window.on('closed', () => { @@ -2567,7 +2559,7 @@ async function showMobilePairing(): Promise { webSecurity: true } }) - secureWindow(mobileWindow) + secureWindow(mobileWindow, () => snapshot.desktopUrl) mobileWindow.on('closed', () => { mobileWindow = undefined }) diff --git a/src/main/safe-mode-overlay.ts b/src/main/safe-mode-overlay.ts index d03280496..2bfc410e5 100644 --- a/src/main/safe-mode-overlay.ts +++ b/src/main/safe-mode-overlay.ts @@ -24,7 +24,7 @@ export class SafeModeOverlay { this.webContents = this.view.webContents this.view.setBackgroundColor('#00000000') this.view.setVisible(false) - secureWindow(this.view) + secureWindow(this.view, () => undefined) parent.contentView.addChildView(this.view) parent.on('resize', this.syncBounds) parent.on('enter-full-screen', this.syncBounds) diff --git a/src/main/security-policy.ts b/src/main/security-policy.ts index 96186834a..95c3847ac 100644 --- a/src/main/security-policy.ts +++ b/src/main/security-policy.ts @@ -10,14 +10,19 @@ function isHarnessUrl(rawUrl: string): boolean { } } -export function isTrustedAppUrl(rawUrl: string): boolean { +export function isTrustedAppUrl(rawUrl: string, appUrl?: string): boolean { try { const parsed = new URL(rawUrl) if (parsed.protocol === 'file:' || parsed.protocol === 'dsh-recovery:') return true } catch { return false } - return isHarnessUrl(rawUrl) + if (!appUrl || !isHarnessUrl(appUrl)) return false + try { + return new URL(rawUrl).origin === new URL(appUrl).origin + } catch { + return false + } } export function canGrantWindowPermission( diff --git a/src/main/security.ts b/src/main/security.ts index d753eaa1b..8c3da8d6a 100644 --- a/src/main/security.ts +++ b/src/main/security.ts @@ -1,15 +1,18 @@ import { shell, type BrowserWindow } from 'electron' import { canGrantWindowPermission, isTrustedAppUrl } from './security-policy' -export function secureWindow(window: Pick): void { +export function secureWindow( + window: Pick, + appUrl: () => string | undefined +): void { window.webContents.setWindowOpenHandler(({ url }) => { - if (isTrustedAppUrl(url)) return { action: 'allow' } + if (isTrustedAppUrl(url, appUrl())) return { action: 'allow' } if (url.startsWith('https://') || url.startsWith('http://')) void shell.openExternal(url) return { action: 'deny' } }) window.webContents.on('will-navigate', (event, url) => { - if (isTrustedAppUrl(url)) return + if (isTrustedAppUrl(url, appUrl())) return event.preventDefault() if (url.startsWith('https://') || url.startsWith('http://')) void shell.openExternal(url) }) diff --git a/src/main/window-button-position.ts b/src/main/window-button-position.ts new file mode 100644 index 000000000..221558204 --- /dev/null +++ b/src/main/window-button-position.ts @@ -0,0 +1,9 @@ +// Native macOS controls span 60 DIP and do not scale with web content. +const TRAFFIC_LIGHT_WIDTH = 60 +const COLLAPSED_SIDEBAR_WIDTH = 96 + +// Center in the collapsed rail at Actual Size; never follow renderer geometry. +export const MAC_WINDOW_BUTTON_POSITION = Object.freeze({ + x: (COLLAPSED_SIDEBAR_WIDTH - TRAFFIC_LIGHT_WIDTH) / 2, + y: 9 +}) diff --git a/test/branding-patch.test.ts b/test/branding-patch.test.ts index 2a5035e83..48d1e1cf5 100644 --- a/test/branding-patch.test.ts +++ b/test/branding-patch.test.ts @@ -13,7 +13,9 @@ describe('DSH Desktop sidebar branding', () => { expect(main).toContain("document.body.hasAttribute('data-ds-dark-theme')") expect(main).toContain("window.setBackgroundColor(isDark ? '#141416' : '#ffffff')") expect(main).toContain('window.setWindowButtonVisibility(true)') - expect(main).toContain('x: Math.round(16 * window.webContents.getZoomFactor()) - 2') + expect(main).toContain('window.setWindowButtonPosition(MAC_WINDOW_BUTTON_POSITION)') + expect(main).not.toContain('desktop:window-button-anchor') + expect(main).not.toContain('alignWindowButtons') expect(main).toContain("titleBarStyle: 'hidden' as const") expect(main).not.toContain('dsh-desktop-titlebar-style') expect(main).not.toContain('--dsh-desktop-titlebar-height') @@ -64,20 +66,22 @@ describe('DSH Desktop sidebar branding', () => { expect(patch).toContain('[data-dsh-sidebar-brand-identity]{gap:4px}') expect(patch).toContain('navigator.userAgent.includes("Macintosh")') expect(patch).toContain('padding-top:28px') - expect(patch).toContain('padding:32px 22px 6px') + expect(patch).toContain('padding:28px 30px 6px') + expect(patch).toContain('{height:60px;margin-bottom:8px}') + expect(patch).toContain('{height:38px}') expect(installedSidebar).toContain('renderSlot("sidebar.brand.mark"') expect(installedSidebar).toContain('renderSlot("sidebar.brand.name"') expect(installedSidebar).not.toContain('DshDesktopBrand') expect(installedSidebar).not.toContain('brandWordmark') }) - it('uses an 80px macOS rail that clears the traffic lights', async () => { + it('uses a 96px macOS rail that clears the traffic lights', async () => { const patch = await readFile( patchPath('@deepseek-ai/dsh-client-ui-layout'), 'utf8' ) - expect(patch).toContain('navigator.userAgent.includes("Macintosh") ? 80 : 56') + expect(patch).toContain('navigator.userAgent.includes("Macintosh") ? 96 : 56') expect(patch).toContain('sidebar === 0 ? COLLAPSED_SIDEBAR_WIDTH') }) diff --git a/test/context-menu.test.ts b/test/context-menu.test.ts index ad76bb921..fb4b9fe27 100644 --- a/test/context-menu.test.ts +++ b/test/context-menu.test.ts @@ -118,8 +118,8 @@ describe('conversation context menu', () => { it('recognizes only HTTP and HTTPS as external web URLs', () => { expect(isExternalWebUrl('https://example.com')).toBe(true) expect(isExternalWebUrl('http://example.com')).toBe(true) - expect(isExternalWebUrl('http://127.0.0.1:43127/session')).toBe(false) - expect(isExternalWebUrl('http://localhost:43127/settings')).toBe(false) + expect(isExternalWebUrl('http://127.0.0.1:43127/session')).toBe(true) + expect(isExternalWebUrl('http://localhost:43127/settings')).toBe(true) expect(isExternalWebUrl('file:///tmp/report.html')).toBe(false) expect(isExternalWebUrl('not a URL')).toBe(false) }) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index ad9cb7635..4135bc81e 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -516,10 +516,14 @@ describe('offending plugin extraction', () => { }) describe('navigation trust boundary', () => { - it('only trusts the launcher and loopback HTTP pages', () => { + it('only trusts the launcher and the exact configured app origin', () => { expect(isTrustedAppUrl('file:///app/index.html')).toBe(true) - expect(isTrustedAppUrl('http://127.0.0.1:43127')).toBe(true) - expect(isTrustedAppUrl('http://localhost:43127')).toBe(true) + const appUrl = 'http://127.0.0.1:43127' + expect(isTrustedAppUrl(`${appUrl}/session/1`, appUrl)).toBe(true) + expect(isTrustedAppUrl('http://localhost:43127', appUrl)).toBe(false) + expect(isTrustedAppUrl('http://127.0.0.1:8080', appUrl)).toBe(false) + expect(isTrustedAppUrl(appUrl)).toBe(false) + expect(isTrustedAppUrl('http://localhost:43127', 'http://localhost:43127')).toBe(true) expect(isTrustedAppUrl('https://127.0.0.1:43127')).toBe(false) expect(isTrustedAppUrl('http://example.com')).toBe(false) expect(isTrustedAppUrl('javascript:alert(1)')).toBe(false) diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 000000000..18fc9f557 --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ shell: { openExternal: vi.fn().mockResolvedValue(undefined) } })) +import { shell } from 'electron' +import { secureWindow } from '../src/main/security' + +describe('desktop link routing', () => { + it('routes other origins externally and follows the current runtime port', () => { + const listeners = new Map void>() + let open!: (details: { url: string }) => { action: string } + let appUrl = 'http://127.0.0.1:43127' + const webContents = { + setWindowOpenHandler: vi.fn((handler) => { open = handler }), + on: vi.fn((event, handler) => { listeners.set(event, handler) }), + session: { + setPermissionCheckHandler: vi.fn(), + setPermissionRequestHandler: vi.fn() + } + } + secureWindow({ webContents } as unknown as Parameters[0], () => appUrl) + expect(open({ url: `${appUrl}/session` })).toEqual({ action: 'allow' }) + for (const url of ['http://127.0.0.1:8080/', 'http://localhost:8080/', 'https://example.com/']) { + expect(open({ url })).toEqual({ action: 'deny' }) + expect(shell.openExternal).toHaveBeenLastCalledWith(url) + const preventDefault = vi.fn() + listeners.get('will-navigate')!({ preventDefault }, url) + expect(preventDefault).toHaveBeenCalledOnce() + } + const preventDefault = vi.fn() + listeners.get('will-navigate')!({ preventDefault }, `${appUrl}/settings`) + expect(preventDefault).not.toHaveBeenCalled() + appUrl = 'http://127.0.0.1:43128' + expect(open({ url: 'http://127.0.0.1:43127/session' })).toEqual({ action: 'deny' }) + expect(open({ url: `${appUrl}/session` })).toEqual({ action: 'allow' }) + vi.mocked(shell.openExternal).mockClear() + expect(open({ url: 'javascript:alert(1)' })).toEqual({ action: 'deny' }) + expect(shell.openExternal).not.toHaveBeenCalled() + }) +}) diff --git a/test/window-button-position.test.ts b/test/window-button-position.test.ts new file mode 100644 index 000000000..d952d3875 --- /dev/null +++ b/test/window-button-position.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { MAC_WINDOW_BUTTON_POSITION } from '../src/main/window-button-position' + +describe('macOS traffic light alignment', () => { + it('uses an immutable native position centered in the Actual Size rail', () => { + expect(MAC_WINDOW_BUTTON_POSITION).toEqual({ x: 18, y: 9 }) + expect(MAC_WINDOW_BUTTON_POSITION.x + 60 / 2).toBe(96 / 2) + expect(Object.isFrozen(MAC_WINDOW_BUTTON_POSITION)).toBe(true) + }) +})