+
setActiveView('code')}
diff --git a/desktop/src/components/shared/UpdateChecker.test.tsx b/desktop/src/components/shared/UpdateChecker.test.tsx
index 88d26084..9c4acf3b 100644
--- a/desktop/src/components/shared/UpdateChecker.test.tsx
+++ b/desktop/src/components/shared/UpdateChecker.test.tsx
@@ -10,11 +10,16 @@ import { useUpdateStore } from '../../stores/updateStore'
describe('UpdateChecker', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
- Reflect.deleteProperty(window, 'desktopHost')
- Object.defineProperty(window, '__TAURI__', {
- value: {},
- configurable: true,
- })
+ Reflect.deleteProperty(window, '__TAURI__')
+ window.desktopHost = {
+ ...browserHost,
+ kind: 'electron',
+ isDesktop: true,
+ capabilities: {
+ ...browserHost.capabilities,
+ updates: true,
+ },
+ }
useUpdateStore.setState({
status: 'available',
@@ -48,7 +53,6 @@ describe('UpdateChecker', () => {
})
it('renders the update prompt in Electron desktop runtime', () => {
- Reflect.deleteProperty(window, '__TAURI__')
window.desktopHost = {
...browserHost,
kind: 'electron',
diff --git a/desktop/src/lib/desktopHost/contract.test.ts b/desktop/src/lib/desktopHost/contract.test.ts
index 3df9516c..e394ed56 100644
--- a/desktop/src/lib/desktopHost/contract.test.ts
+++ b/desktop/src/lib/desktopHost/contract.test.ts
@@ -31,7 +31,7 @@ describe('desktop host contract', () => {
})
it('detects the browser fallback when native host globals are absent', () => {
- expect(createDesktopHost({ electronHost: null, hasTauri: false })).toBe(browserHost)
+ expect(createDesktopHost({ electronHost: null })).toBe(browserHost)
})
it('prefers an injected Electron preload host over browser fallback', () => {
@@ -41,20 +41,15 @@ describe('desktop host contract', () => {
isDesktop: true,
}
- expect(createDesktopHost({ electronHost, hasTauri: false })).toBe(electronHost)
+ expect(createDesktopHost({ electronHost })).toBe(electronHost)
})
- it('detects Tauri and Electron runtime globals without importing native modules', () => {
+ it('detects Electron runtime globals without importing native modules', () => {
const originalDesktopHost = window.desktopHost
- const originalTauri = window.__TAURI_INTERNALS__
try {
Reflect.deleteProperty(window, 'desktopHost')
- Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
- expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null, hasTauri: false })
-
- window.__TAURI_INTERNALS__ = {}
- expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null, hasTauri: true })
+ expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null })
const electronHost = {
...browserHost,
@@ -62,19 +57,13 @@ describe('desktop host contract', () => {
isDesktop: true,
}
window.desktopHost = electronHost
- expect(detectDesktopHostEnvironment()).toEqual({ electronHost, hasTauri: true })
+ expect(detectDesktopHostEnvironment()).toEqual({ electronHost })
} finally {
if (typeof originalDesktopHost === 'undefined') {
Reflect.deleteProperty(window, 'desktopHost')
} else {
window.desktopHost = originalDesktopHost
}
-
- if (typeof originalTauri === 'undefined') {
- Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
- } else {
- window.__TAURI_INTERNALS__ = originalTauri
- }
}
})
diff --git a/desktop/src/lib/desktopHost/index.ts b/desktop/src/lib/desktopHost/index.ts
index e94a5d64..23bae682 100644
--- a/desktop/src/lib/desktopHost/index.ts
+++ b/desktop/src/lib/desktopHost/index.ts
@@ -1,21 +1,17 @@
import { browserHost } from './browserHost'
-import { tauriHost } from './tauriHost'
import type { DesktopHost } from './types'
export type DesktopHostEnvironment = {
electronHost: DesktopHost | null
- hasTauri: boolean
- tauriHost?: DesktopHost | null
}
export function detectDesktopHostEnvironment(): DesktopHostEnvironment {
if (typeof window === 'undefined') {
- return { electronHost: null, hasTauri: false }
+ return { electronHost: null }
}
return {
electronHost: window.desktopHost ?? null,
- hasTauri: '__TAURI_INTERNALS__' in window || '__TAURI__' in window,
}
}
@@ -23,15 +19,11 @@ export function createDesktopHost(
environment: DesktopHostEnvironment = detectDesktopHostEnvironment(),
): DesktopHost {
if (environment.electronHost) return environment.electronHost
- if (environment.hasTauri && environment.tauriHost) return environment.tauriHost
return browserHost
}
export function getDesktopHost(): DesktopHost {
- return createDesktopHost({
- ...detectDesktopHostEnvironment(),
- tauriHost,
- })
+ return createDesktopHost(detectDesktopHostEnvironment())
}
export const desktopHost = getDesktopHost()
diff --git a/desktop/src/lib/desktopHost/tauriHost.ts b/desktop/src/lib/desktopHost/tauriHost.ts
deleted file mode 100644
index 56016d0d..00000000
--- a/desktop/src/lib/desktopHost/tauriHost.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-import { browserHost } from './browserHost'
-import type {
- DesktopHost,
- DesktopHostUnlisten,
- TerminalExitEvent,
- TerminalOutputEvent,
-} from './types'
-
-const tauriCapabilities: DesktopHost['capabilities'] = {
- appMode: true,
- dialogs: true,
- notifications: true,
- previewWebview: true,
- shell: true,
- terminal: true,
- updates: true,
- windowControls: true,
- zoom: true,
-}
-
-async function invoke(command: string, args?: Record): Promise {
- const api = await import('@tauri-apps/api/core')
- return typeof args === 'undefined'
- ? api.invoke(command)
- : api.invoke(command, args)
-}
-
-async function listen(
- eventName: string,
- handler: (payload: T) => void,
-): Promise {
- const events = await import('@tauri-apps/api/event')
- return events.listen(eventName, (event) => handler(event.payload))
-}
-
-export const tauriHost: DesktopHost = {
- ...browserHost,
- kind: 'tauri',
- isDesktop: true,
- capabilities: tauriCapabilities,
- runtime: {
- getServerUrl() {
- return invoke('get_server_url')
- },
- },
- app: {
- async getVersion() {
- const { getVersion } = await import('@tauri-apps/api/app')
- return getVersion()
- },
- },
- commands: {
- invoke,
- },
- events: {
- listen,
- },
- webview: {
- async onDragDropEvent(handler) {
- const { getCurrentWebview } = await import('@tauri-apps/api/webview')
- return getCurrentWebview().onDragDropEvent(handler)
- },
- },
- shell: {
- async open(target) {
- const { open } = await import('@tauri-apps/plugin-shell')
- await open(target)
- },
- async openPath(path) {
- const { open } = await import('@tauri-apps/plugin-shell')
- await open(path)
- },
- },
- dialogs: {
- async open(options) {
- const { open } = await import('@tauri-apps/plugin-dialog')
- return open(options)
- },
- async save(options) {
- const { save } = await import('@tauri-apps/plugin-dialog')
- return save(options)
- },
- },
- notifications: {
- async permissionState() {
- const { isPermissionGranted } = await import('@tauri-apps/plugin-notification')
- return await isPermissionGranted() ? 'granted' : 'default'
- },
- async requestPermission() {
- const {
- isPermissionGranted,
- requestPermission,
- } = await import('@tauri-apps/plugin-notification')
- if (await isPermissionGranted()) return 'granted'
- const permission = await requestPermission()
- return permission === 'granted' || permission === 'denied' || permission === 'default'
- ? permission
- : 'default'
- },
- async send(options) {
- const { sendNotification } = await import('@tauri-apps/plugin-notification')
- sendNotification(options)
- },
- async onAction(handler) {
- const notification = await import('@tauri-apps/plugin-notification') as {
- onAction?: (cb: (payload: unknown) => void) => Promise
- }
- if (!notification.onAction) return () => {}
- const listener = await notification.onAction(handler)
- return () => {
- if (typeof listener === 'function') {
- listener()
- return
- }
- const unregister = listener && typeof listener === 'object'
- ? (listener as { unregister?: () => Promise | void }).unregister
- : undefined
- if (typeof unregister === 'function') void unregister.call(listener)
- }
- },
- async ackAction() {
- return false
- },
- },
- window: {
- async minimize() {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- await getCurrentWindow().minimize()
- },
- async toggleMaximize() {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- await getCurrentWindow().toggleMaximize()
- },
- async close() {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- await getCurrentWindow().close()
- },
- async startDragging() {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- await getCurrentWindow().startDragging()
- },
- async requestAttention() {
- const { getCurrentWindow, UserAttentionType } = await import('@tauri-apps/api/window')
- await getCurrentWindow().requestUserAttention(UserAttentionType.Critical)
- },
- async focus() {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- const win = getCurrentWindow()
- await (win as unknown as { show?: () => Promise | void }).show?.()
- await (win as unknown as { setFocus?: () => Promise | void }).setFocus?.()
- },
- async isMaximized() {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- return getCurrentWindow().isMaximized()
- },
- async onResized(handler) {
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
- return getCurrentWindow().onResized(handler)
- },
- onNativeMenuNavigate(handler) {
- return listen('native-menu-navigate', handler)
- },
- },
- updates: {
- async check(options) {
- const updater = await import('@tauri-apps/plugin-updater')
- return updater.check(options)
- },
- prepareInstall() {
- return invoke('prepare_for_update_install')
- },
- cancelInstall() {
- return invoke('cancel_update_install')
- },
- async relaunch() {
- const { relaunch } = await import('@tauri-apps/plugin-process')
- await relaunch()
- },
- },
- terminal: {
- spawn(input) {
- return invoke('terminal_spawn', input)
- },
- write(sessionId, data) {
- return invoke('terminal_write', { sessionId, data })
- },
- resize(sessionId, cols, rows) {
- return invoke('terminal_resize', { sessionId, cols, rows })
- },
- kill(sessionId) {
- return invoke('terminal_kill', { sessionId })
- },
- onOutput(handler) {
- return listen('terminal-output', handler)
- },
- onExit(handler) {
- return listen('terminal-exit', handler)
- },
- getBashPath() {
- return invoke('get_terminal_bash_path')
- },
- setBashPath(path) {
- return invoke('set_terminal_bash_path', { path })
- },
- },
- preview: {
- open(url, bounds) {
- return invoke('preview_open', { url, bounds })
- },
- navigate(url) {
- return invoke('preview_navigate', { url })
- },
- setBounds(bounds) {
- return invoke('preview_set_bounds', { bounds })
- },
- setVisible(visible) {
- return invoke('preview_set_visible', { visible })
- },
- close() {
- return invoke('preview_close')
- },
- message(payload) {
- return invoke('preview_message', { raw: JSON.stringify(payload) })
- },
- onEvent(handler) {
- return listen('preview://event', handler)
- },
- },
- zoom: {
- set(level) {
- return invoke('set_app_zoom', { zoomFactor: level })
- },
- },
- adapters: {
- restartSidecar() {
- return invoke('restart_adapters_sidecar')
- },
- },
- appMode: {
- get() {
- return invoke('get_app_mode')
- },
- set(config) {
- return invoke('set_app_mode', config)
- },
- detectPortableDir() {
- return invoke('detect_portable_dir')
- },
- prepareRestart() {
- return invoke('prepare_for_app_mode_restart')
- },
- restart() {
- return tauriHost.updates.relaunch()
- },
- },
-}
diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts
index 240239f3..9597b32a 100644
--- a/desktop/src/lib/desktopHost/types.ts
+++ b/desktop/src/lib/desktopHost/types.ts
@@ -3,7 +3,7 @@ import type {
AppModeConfig as SettingsAppModeConfig,
} from '../../types/settings'
-export type DesktopHostKind = 'browser' | 'tauri' | 'electron'
+export type DesktopHostKind = 'browser' | 'electron'
export type DesktopHostCapability =
| 'appMode'
@@ -228,7 +228,5 @@ export type DesktopHost = {
declare global {
interface Window {
desktopHost?: DesktopHost
- __TAURI__?: unknown
- __TAURI_INTERNALS__?: unknown
}
}
diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts
index a8371536..f9f17017 100644
--- a/desktop/src/lib/desktopNotifications.test.ts
+++ b/desktop/src/lib/desktopNotifications.test.ts
@@ -42,9 +42,61 @@ import {
resetDesktopNotificationsForTests,
setNativeNotificationSenderForTests,
} from './desktopNotifications'
+import { browserHost } from './desktopHost/browserHost'
import { useSettingsStore } from '../stores/settingsStore'
describe('desktopNotifications', () => {
+ const installElectronNotificationHost = () => {
+ window.desktopHost = {
+ ...browserHost,
+ kind: 'electron',
+ isDesktop: true,
+ capabilities: {
+ ...browserHost.capabilities,
+ notifications: true,
+ shell: true,
+ },
+ commands: {
+ ...browserHost.commands,
+ invoke: (command, args) => args === undefined
+ ? coreApiMock.invoke(command)
+ : coreApiMock.invoke(command, args),
+ },
+ events: {
+ ...browserHost.events,
+ listen: (eventName, handler) => eventApiMock.listen(eventName, handler),
+ },
+ shell: {
+ ...browserHost.shell,
+ open: shellApiMock.open,
+ },
+ notifications: {
+ ...browserHost.notifications,
+ permissionState: async () => {
+ const granted = await notificationPluginMock.isPermissionGranted()
+ return granted ? 'granted' : 'denied'
+ },
+ requestPermission: notificationPluginMock.requestPermission,
+ send: notificationPluginMock.sendNotification,
+ onAction: async (handler) => {
+ const unlisten = await notificationPluginMock.onAction(handler)
+ if (typeof unlisten === 'function') return unlisten
+ if (unlisten && typeof unlisten === 'object' && 'unregister' in unlisten) {
+ return () => {
+ void (unlisten as { unregister: () => unknown }).unregister()
+ }
+ }
+ return () => {}
+ },
+ },
+ window: {
+ ...browserHost.window,
+ requestAttention: requestUserAttentionMock,
+ focus: vi.fn().mockResolvedValue(undefined),
+ },
+ }
+ }
+
beforeEach(() => {
vi.useRealTimers()
resetDesktopNotificationsForTests()
@@ -62,14 +114,12 @@ describe('desktopNotifications', () => {
configurable: true,
value: 'Linux x86_64',
})
- Object.defineProperty(window, '__TAURI_INTERNALS__', {
- configurable: true,
- value: {},
- })
- Reflect.deleteProperty(window, 'desktopHost')
+ installElectronNotificationHost()
+ Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
+ Reflect.deleteProperty(window, '__TAURI__')
})
- it('sends through the Tauri plugin when native notification permission is already granted', async () => {
+ it('sends through the desktop notification host when permission is already granted', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
notifyDesktop({
@@ -87,7 +137,7 @@ describe('desktopNotifications', () => {
})
})
- it('passes notification targets through the Tauri plugin payload', async () => {
+ it('passes notification targets through the desktop notification payload', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
@@ -185,7 +235,7 @@ describe('desktopNotifications', () => {
warnSpy.mockRestore()
})
- it('does not fall back to the Tauri plugin when the macOS bridge fails', async () => {
+ it('does not fall back to the generic notification bridge when the macOS bridge fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
@@ -336,7 +386,7 @@ describe('desktopNotifications', () => {
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
})
- it('does not use the Tauri plugin permission fallback on macOS bridge errors', async () => {
+ it('does not use the generic notification permission fallback on macOS bridge errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
@@ -432,7 +482,7 @@ describe('desktopNotifications', () => {
await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(windowApiMock.requestUserAttention).toHaveBeenCalledTimes(1))
- expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith(windowApiMock.UserAttentionType.Critical)
+ expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith()
})
it('throttles bursts within the same cooldown scope', async () => {
diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts
index d9fda06f..0f57a53f 100644
--- a/desktop/src/lib/desktopRuntime.ts
+++ b/desktop/src/lib/desktopRuntime.ts
@@ -6,7 +6,7 @@ import {
setAuthToken,
setBaseUrl,
} from '../api/client'
-import { detectDesktopHostEnvironment, getDesktopHost } from './desktopHost'
+import { getDesktopHost } from './desktopHost'
export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url'
export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token'
@@ -34,11 +34,6 @@ export class H5ConnectionRequiredError extends Error {
}
}
-export function isTauriRuntime() {
- if (typeof window === 'undefined') return false
- return detectDesktopHostEnvironment().hasTauri
-}
-
function getDetectedDesktopHost() {
return getDesktopHost()
}
diff --git a/desktop/src/lib/previewBridge.test.ts b/desktop/src/lib/previewBridge.test.ts
index 968b3672..a41b786b 100644
--- a/desktop/src/lib/previewBridge.test.ts
+++ b/desktop/src/lib/previewBridge.test.ts
@@ -3,11 +3,34 @@ import type { WebviewBounds } from '../components/browser/computeWebviewBounds'
import { browserHost } from './desktopHost/browserHost'
const invoke = vi.fn()
-vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a: unknown[]) => invoke(...a) }))
-vi.mock('./desktopRuntime', () => ({ isTauriRuntime: () => true }))
+
+function installElectronPreviewHost() {
+ const open = vi.fn().mockResolvedValue(undefined)
+ const setBounds = vi.fn().mockResolvedValue(undefined)
+ const message = vi.fn().mockResolvedValue(undefined)
+
+ window.desktopHost = {
+ ...browserHost,
+ kind: 'electron',
+ isDesktop: true,
+ capabilities: {
+ ...browserHost.capabilities,
+ previewWebview: true,
+ },
+ preview: {
+ ...browserHost.preview,
+ open,
+ setBounds,
+ message,
+ },
+ }
+
+ return { open, setBounds, message }
+}
beforeEach(() => {
- window.__TAURI_INTERNALS__ = {}
+ Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
+ Reflect.deleteProperty(window, '__TAURI__')
})
afterEach(() => {
@@ -18,28 +41,35 @@ afterEach(() => {
})
describe('previewBridge', () => {
- it('openPreview forwards url + bounds to preview_open', async () => {
+ it('openPreview forwards url + bounds to the Electron preview host', async () => {
+ const { open } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
const bounds: WebviewBounds = { x: 1, y: 2, width: 3, height: 4 }
await previewBridge.open('http://localhost/a', bounds)
- expect(invoke).toHaveBeenCalledWith('preview_open', { url: 'http://localhost/a', bounds })
+ expect(open).toHaveBeenCalledWith('http://localhost/a', bounds)
+ expect(invoke).not.toHaveBeenCalled()
})
- it('setBounds forwards to preview_set_bounds', async () => {
+ it('setBounds forwards to the Electron preview host', async () => {
+ const { setBounds } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
- await previewBridge.setBounds({ x: 0, y: 0, width: 10, height: 10 })
- expect(invoke).toHaveBeenCalledWith('preview_set_bounds', { bounds: { x: 0, y: 0, width: 10, height: 10 } })
+ const bounds: WebviewBounds = { x: 0, y: 0, width: 10, height: 10 }
+ await previewBridge.setBounds(bounds)
+ expect(setBounds).toHaveBeenCalledWith(bounds)
+ expect(invoke).not.toHaveBeenCalled()
})
- it('message forwards structured host messages to preview_message', async () => {
+ it('message forwards structured host messages to the Electron preview host', async () => {
+ const { message } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
- await previewBridge.message({ v: 1, type: 'capture', kind: 'full' })
- expect(invoke).toHaveBeenCalledWith('preview_message', { raw: '{"v":1,"type":"capture","kind":"full"}' })
+ const payload = { v: 1, type: 'capture', kind: 'full' } as const
+ await previewBridge.message(payload)
+ expect(message).toHaveBeenCalledWith(payload)
+ expect(invoke).not.toHaveBeenCalled()
})
- it('is a no-op outside the Tauri runtime', async () => {
+ it('is a no-op outside the desktop runtime', async () => {
vi.resetModules()
- vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false }))
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
const { previewBridge } = await import('./previewBridge')
await previewBridge.open('http://localhost/a', { x: 0, y: 0, width: 1, height: 1 })
@@ -48,7 +78,6 @@ describe('previewBridge', () => {
it('routes preview commands through an injected desktop host', async () => {
vi.resetModules()
- vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false }))
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
const open = vi.fn().mockResolvedValue(undefined)
const setBounds = vi.fn().mockResolvedValue(undefined)
diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx
index 6f7215aa..560144d7 100644
--- a/desktop/src/pages/AdapterSettings.tsx
+++ b/desktop/src/pages/AdapterSettings.tsx
@@ -34,7 +34,7 @@ export function AdapterSettings() {
const [activeIm, setActiveIm] = useState('feishu')
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
- // 桌面端用 Tauri env var 注入动态端口。
+ // 桌面端用 sidecar env var 注入动态端口。
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// Telegram
@@ -468,7 +468,7 @@ export function AdapterSettings() {
- {/* Server URL —— 之前是个手填字段,但桌面端 Tauri 启动 adapter sidecar
+ {/* Server URL —— 之前是个手填字段,但桌面端启动 adapter sidecar
时已经把 server 的动态端口通过 ADAPTER_SERVER_URL env var 注进去了,
loadConfig() 里 env 优先级高于这里的 file value,所以这个字段在桌面
运行时完全不会被读到。用户也根本不知道该填什么端口(每次启动随机)。
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 6dec93ef..4a3ca87b 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -35,6 +35,7 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider'
import { useUpdateStore } from '../stores/updateStore'
+import { getBaseUrl } from '../api/client'
import { formatBytes } from '../lib/formatBytes'
import { isDesktopRuntime } from '../lib/desktopRuntime'
import { getDesktopHost } from '../lib/desktopHost'
@@ -691,6 +692,7 @@ function updateSettingsJsonProviderConnection(
apiKey: string,
preset: ProviderPreset,
baseUrl: string,
+ proxyBaseUrl: string,
): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record
}
@@ -700,7 +702,7 @@ function updateSettingsJsonProviderConnection(
const env = { ...existingEnv }
delete env.ANTHROPIC_API_KEY
delete env.ANTHROPIC_AUTH_TOKEN
- env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl
+ env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? proxyBaseUrl : baseUrl
Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset))
parsed.env = env
return JSON.stringify(parsed, null, 2)
@@ -709,6 +711,10 @@ function updateSettingsJsonProviderConnection(
}
}
+function getProviderProxyBaseUrl(): string {
+ return `${getBaseUrl().replace(/\/$/, '')}/proxy`
+}
+
function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
return {
id: provider?.presetId ?? 'custom',
@@ -776,6 +782,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const [settingsJson, setSettingsJson] = useState('')
const [settingsJsonError, setSettingsJsonError] = useState(null)
const jsonPastedRef = useRef(false)
+ const providerProxyBaseUrl = useMemo(() => getProviderProxyBaseUrl(), [])
// Load current settings.json and merge provider env vars
useEffect(() => {
@@ -802,7 +809,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
...(Object.keys(modelContextWindows).length > 0
? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) }
: {}),
- ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
+ ANTHROPIC_BASE_URL: needsProxy ? providerProxyBaseUrl : baseUrl,
...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
ANTHROPIC_MODEL: normalizedModels.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: normalizedModels.haiku,
@@ -816,7 +823,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedPreset.id])
+ }, [selectedPreset.id, providerProxyBaseUrl])
const handlePresetChange = (preset: ProviderPreset) => {
setSelectedPreset(preset)
@@ -912,19 +919,19 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
}
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value)
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value))
+ setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value, providerProxyBaseUrl))
}
const handleApiKeyChange = (value: string) => {
setApiKey(value)
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl))
+ setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl, providerProxyBaseUrl))
}
const handleApiFormatChange = (value: ApiFormat) => {
setApiFormat(value)
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl))
+ setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl))
}
const handleAuthStrategyChange = (value: ProviderAuthStrategy) => {
setAuthStrategy(value)
- setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl))
+ setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl))
}
const handleModelChange = (slot: ModelSlot, value: string) => {
const nextModels = { ...models, [slot]: value }
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts
index e976dc7c..b7fde807 100644
--- a/desktop/src/stores/settingsStore.test.ts
+++ b/desktop/src/stores/settingsStore.test.ts
@@ -333,6 +333,22 @@ describe('settingsStore network persistence', () => {
})
describe('settingsStore app mode', () => {
+ const installElectronAppModeHost = (appMode: Partial) => {
+ window.desktopHost = {
+ ...browserHost,
+ kind: 'electron',
+ isDesktop: true,
+ capabilities: {
+ ...browserHost.capabilities,
+ appMode: true,
+ },
+ appMode: {
+ ...browserHost.appMode,
+ ...appMode,
+ },
+ }
+ }
+
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
@@ -341,21 +357,19 @@ describe('settingsStore app mode', () => {
Reflect.deleteProperty(window, '__TAURI__')
})
- it('hydrates app mode from the native desktop command', async () => {
- const invoke = vi.fn().mockResolvedValue({
+ it('hydrates app mode from the Electron desktop host', async () => {
+ const getAppMode = vi.fn().mockResolvedValue({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
- vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
- const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
- tauriWindow.__TAURI_INTERNALS__ = {}
+ installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAppMode()
- expect(invoke).toHaveBeenCalledWith('get_app_mode')
+ expect(getAppMode).toHaveBeenCalledTimes(1)
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
@@ -369,15 +383,7 @@ describe('settingsStore app mode', () => {
portableDir: 'D:\\cc-haha\\data',
defaultPortableDir: 'D:\\cc-haha\\data',
})
- window.desktopHost = {
- ...browserHost,
- kind: 'electron',
- isDesktop: true,
- appMode: {
- ...browserHost.appMode,
- get: getAppMode,
- },
- }
+ installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
@@ -391,11 +397,9 @@ describe('settingsStore app mode', () => {
})
})
- it('persists app mode through the native desktop command and marks restart required', async () => {
- const invoke = vi.fn().mockResolvedValue(undefined)
- vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
- const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
- tauriWindow.__TAURI_INTERNALS__ = {}
+ it('persists app mode through the Electron desktop host and marks restart required', async () => {
+ const setAppMode = vi.fn().mockResolvedValue(undefined)
+ installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@@ -409,7 +413,7 @@ describe('settingsStore app mode', () => {
await useSettingsStore.getState().setAppMode('portable')
- expect(invoke).toHaveBeenCalledWith('set_app_mode', {
+ expect(setAppMode).toHaveBeenCalledWith({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
@@ -425,15 +429,7 @@ describe('settingsStore app mode', () => {
it('persists app mode through an injected desktop host', async () => {
const setAppMode = vi.fn().mockResolvedValue(undefined)
- window.desktopHost = {
- ...browserHost,
- kind: 'electron',
- isDesktop: true,
- appMode: {
- ...browserHost.appMode,
- set: setAppMode,
- },
- }
+ installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@@ -455,10 +451,8 @@ describe('settingsStore app mode', () => {
})
it('persists a user-selected portable directory', async () => {
- const invoke = vi.fn().mockResolvedValue(undefined)
- vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
- const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
- tauriWindow.__TAURI_INTERNALS__ = {}
+ const setAppMode = vi.fn().mockResolvedValue(undefined)
+ installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@@ -472,7 +466,7 @@ describe('settingsStore app mode', () => {
await useSettingsStore.getState().setAppMode('portable', 'D:\\portable-data')
- expect(invoke).toHaveBeenCalledWith('set_app_mode', {
+ expect(setAppMode).toHaveBeenCalledWith({
mode: 'portable',
portableDir: 'D:\\portable-data',
})
@@ -485,10 +479,8 @@ describe('settingsStore app mode', () => {
})
it('switches app mode back to the system data source', async () => {
- const invoke = vi.fn().mockResolvedValue(undefined)
- vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
- const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
- tauriWindow.__TAURI_INTERNALS__ = {}
+ const setAppMode = vi.fn().mockResolvedValue(undefined)
+ installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@@ -504,7 +496,7 @@ describe('settingsStore app mode', () => {
await useSettingsStore.getState().setAppMode('default', null)
- expect(invoke).toHaveBeenCalledWith('set_app_mode', {
+ expect(setAppMode).toHaveBeenCalledWith({
mode: 'default',
portableDir: null,
})
diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts
index bef14016..466acd03 100644
--- a/desktop/src/stores/updateStore.test.ts
+++ b/desktop/src/stores/updateStore.test.ts
@@ -17,17 +17,33 @@ vi.mock('@tauri-apps/api/core', () => ({
invoke,
}))
+function installElectronUpdateHost() {
+ window.desktopHost = {
+ ...browserHost,
+ kind: 'electron',
+ isDesktop: true,
+ capabilities: {
+ ...browserHost.capabilities,
+ updates: true,
+ },
+ updates: {
+ ...browserHost.updates,
+ check,
+ prepareInstall: () => invoke('prepare_for_update_install'),
+ cancelInstall: () => invoke('cancel_update_install'),
+ relaunch,
+ },
+ }
+}
+
describe('updateStore', () => {
beforeEach(() => {
check.mockReset()
relaunch.mockReset()
invoke.mockReset()
window.localStorage.clear()
- Object.defineProperty(window, '__TAURI_INTERNALS__', {
- configurable: true,
- value: {},
- })
- Reflect.deleteProperty(window, 'desktopHost')
+ installElectronUpdateHost()
+ Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})
diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css
index 7bb092db..d8877974 100644
--- a/desktop/src/theme/globals.css
+++ b/desktop/src/theme/globals.css
@@ -958,8 +958,8 @@ html, body, #root {
color: var(--color-selection-fg);
}
-/* Tauri drag region */
-[data-tauri-drag-region] {
+/* Desktop drag region */
+[data-desktop-drag-region] {
-webkit-app-region: drag;
}
button, input, textarea, select, a, [role="button"] {
diff --git a/docs/desktop/02-architecture.md b/docs/desktop/02-architecture.md
index 4b7b7626..744a4b20 100644
--- a/docs/desktop/02-architecture.md
+++ b/docs/desktop/02-architecture.md
@@ -28,7 +28,7 @@
| electron-builder | - | macOS/Windows/Linux 打包与 release artifact 生成 |
| electron-updater | - | 自动更新检查、下载与安装 |
| node-pty | - | Electron main 中的终端 PTY runtime |
-| Tauri 2 | 2 | 迁移期 legacy host 与 sidecar 资源目录,React 层通过 `desktopHost` adapter 隔离 |
+| `desktop/src-tauri` 资源目录 | - | 历史资源位置,暂存 sidecar 二进制、图标和 preview agent;不再作为桌面 runtime |
### 服务端
@@ -144,7 +144,7 @@ Server 为每个 Session spawn 一个 CLI 子进程,通过 stdin/stdout JSON
- `cli` — 启动 CLI 子进程
- `adapters` — 启动 IM 适配器(解析 `--feishu`/`--telegram` 参数,检查凭据后按需加载)
-编译产物仍放置在 `desktop/src-tauri/binaries/`,作为迁移期稳定资源目录;Electron Builder 通过 `files` 和 `asarUnpack` 把它们打入 `app.asar.unpacked`。
+编译产物仍放置在 `desktop/src-tauri/binaries/`,作为历史稳定资源目录;Electron Builder 通过 `files` 和 `asarUnpack` 把它们打入 `app.asar.unpacked`。
---
@@ -360,10 +360,10 @@ desktop/
│ ├── config/ # providerPresets, spinnerVerbs
│ └── lib/ # desktopRuntime, cronDescribe, parseRunOutput
├── src-tauri/
-│ ├── binaries/ # Electron/Tauri 共用 sidecar 二进制
+│ ├── binaries/ # Electron 打包使用的 sidecar 二进制
│ ├── resources/preview-agent.js # 预览页面注入脚本
-│ ├── src/main.rs # 迁移期 Tauri legacy 入口
-│ ├── src/lib.rs # 迁移期 Tauri legacy host
+│ ├── src/main.rs # 历史 Tauri 入口,非当前 runtime
+│ ├── src/lib.rs # 历史 Tauri host,非当前 runtime
│ ├── Cargo.toml
│ └── tauri.conf.json
├── electron/
diff --git a/docs/desktop/06-h5-access.md b/docs/desktop/06-h5-access.md
index 46425205..7388d824 100644
--- a/docs/desktop/06-h5-access.md
+++ b/docs/desktop/06-h5-access.md
@@ -67,7 +67,7 @@ H5 的第一版优先保证聊天主流程:
- `@` 文件菜单会适配手机宽度。
- Workspace 面板和底部 Terminal 面板在手机宽度下不作为主流程显示。
-桌面端和 Tauri 应用仍保留原来的布局和交互。
+Electron 桌面端仍保留原来的布局和交互。
## 安全注意
diff --git a/docs/desktop/09-electron-migration-validation-checklist.md b/docs/desktop/09-electron-migration-validation-checklist.md
index d81df535..8838eb36 100644
--- a/docs/desktop/09-electron-migration-validation-checklist.md
+++ b/docs/desktop/09-electron-migration-validation-checklist.md
@@ -43,7 +43,7 @@
- [x] `bun run test:package-smoke --platform macos`
- [x] `cd desktop && bun run test -- --run scripts/dev-launcher.test.ts && bun run check:electron`:新增 Electron dev launcher 代理绕过回归,最新 Electron checks 为 17 files / 79 tests passed。
- [x] `bun run test:package-smoke --platform macos`:结构检查仍通过,并明确提示该命令不做 Gatekeeper launch approval。
-- [x] `cd desktop && bun run test -- src/lib/desktopRuntime.test.ts src/components/shared/UpdateChecker.test.tsx src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/layout/AppShell.test.tsx src/components/controls/PermissionModeSelector.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx`:8 files / 75 tests passed;覆盖 Electron/Tauri 通用 desktop runtime 判断、更新提示、native attachment picker 和移动布局分支。
+- [x] `cd desktop && bun run test -- src/lib/desktopRuntime.test.ts src/components/shared/UpdateChecker.test.tsx src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/layout/AppShell.test.tsx src/components/controls/PermissionModeSelector.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx`:8 files / 75 tests passed;覆盖 Electron desktop runtime 判断、更新提示、native attachment picker 和移动布局分支。
- [x] `cd desktop && bun run check:electron`:16 files / 77 tests passed;覆盖 Electron updater proxy contract、IPC payload validation、main/preload/preview-preload bundle。
- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts`:7 tests passed;发布型 macOS 包缺失 resources/app-update.yml 会失败,纯 `--dir` 开发包不强制该文件。
- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/quality-contract.test.ts`:12 tests passed;覆盖 host platform 到 package-smoke platform 的映射,并锁定 `check:native` 必须包含 `electron:package:dir` 与 `test:package-smoke:current`。
diff --git a/docs/desktop/index.md b/docs/desktop/index.md
index 0d2bcd39..76337dfd 100644
--- a/docs/desktop/index.md
+++ b/docs/desktop/index.md
@@ -60,7 +60,7 @@
2. 关键源码位置:
- `desktop/src/` — React 前端
- `desktop/electron/` — Electron main/preload/系统能力 host
- - `desktop/src-tauri/` — 迁移期保留的 Tauri/Rust legacy host 与 sidecar 资源目录
+ - `desktop/src-tauri/` — 历史资源目录,当前仅作为 sidecar、图标和 preview agent 的 Electron 打包输入
- `desktop/sidecars/` — Sidecar 入口
- `src/server/` — Express API 服务端
- `adapters/` — IM 适配器
diff --git a/docs/index.md b/docs/index.md
index db49f622..ca9eb836 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -46,6 +46,6 @@ features:
link: /features/computer-use
- icon: "\U0001F5A5"
title: 桌面端
- details: 基于 Tauri 2 + React 的图形化客户端,多标签、多会话、IM 适配器接入,支持 macOS 和 Windows
+ details: 基于 Electron + React 的图形化客户端,多标签、多会话、IM 适配器接入,支持 macOS、Windows 和 Linux
link: /desktop/
---
diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts
index 0ae5668f..9ebe8d76 100644
--- a/scripts/pr/release-workflow.test.ts
+++ b/scripts/pr/release-workflow.test.ts
@@ -2,8 +2,18 @@ import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
describe('release desktop workflow', () => {
+ function readReleaseWorkflow() {
+ return readFileSync('.github/workflows/release-desktop.yml', 'utf8')
+ }
+
+ function extractJob(workflow: string, jobName: string) {
+ return workflow.match(
+ new RegExp(`${jobName}:[\\s\\S]*?(?:\\n {2}[a-zA-Z0-9_-]+:|$)`),
+ )?.[0]
+ }
+
test('build job waits for a PR-quality preflight before packaging', () => {
- const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
+ const workflow = readReleaseWorkflow()
expect(workflow).toContain('quality-preflight:')
expect(workflow).toContain('run: bun run verify')
@@ -38,24 +48,22 @@ describe('release desktop workflow', () => {
})
test('release workflow requires macOS Gatekeeper launch approval before upload', () => {
- const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
+ const workflow = readReleaseWorkflow()
const gatekeeperStep = workflow.match(
/- name: Verify macOS launch policy[\s\S]*?(?:\n\s{6}- name:|$)/,
)?.[0]
expect(gatekeeperStep).toContain("if: matrix.smoke_platform == 'macos'")
expect(gatekeeperStep).toContain('bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper')
- expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload artifacts'))
+ expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
test('release workflow fails globally before matrix fan-out when signing or notarization secrets are missing', () => {
- const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
+ const workflow = readReleaseWorkflow()
const signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
)?.[0]
- const buildJob = workflow.match(
- /build:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
- )?.[0]
+ const buildJob = extractJob(workflow, 'build')
expect(signingJob).toContain('Validate release signing and notarization secrets')
for (const secret of [
@@ -73,33 +81,149 @@ describe('release desktop workflow', () => {
expect(buildJob).toContain('- quality-preflight')
expect(buildJob).toContain('- signing-preflight')
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:'))
- expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload artifacts'))
+ expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
test('release workflow avoids same-name updater metadata uploads from matrix builds', () => {
- const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
+ const workflow = readReleaseWorkflow()
const namespaceStep = workflow.match(
/- name: Namespace update metadata assets[\s\S]*?(?:\n\s{6}- name:|$)/,
)?.[0]
expect(namespaceStep).toContain('for file in latest*.yml')
expect(namespaceStep).toContain('"${file%.yml}-${{ matrix.label }}.yml"')
- expect(workflow.indexOf('Namespace update metadata assets')).toBeLessThan(workflow.indexOf('Upload artifacts'))
+ expect(workflow.indexOf('Namespace update metadata assets')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
- test('release workflow republishes standard updater metadata after all matrix builds pass', () => {
- const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
- const publishJob = workflow.match(
- /publish-update-metadata:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
- )?.[0]
+ test('release workflow uploads only Actions artifacts from matrix builds', () => {
+ const workflow = readReleaseWorkflow()
+ const buildJob = extractJob(workflow, 'build')
+
+ expect(buildJob).toContain('Validate matrix release asset set')
+ for (const label of ['macOS-ARM64', 'macOS-x64', 'Linux-x64', 'Linux-ARM64', 'Windows-x64']) {
+ expect(buildJob).toContain(`${label})`)
+ }
+ expect(buildJob).toContain('Upload release artifacts for final publish')
+ expect(buildJob).toContain('actions/upload-artifact@v4')
+ expect(buildJob).toContain('name: desktop-release-artifacts-${{ matrix.label }}')
+ expect(buildJob).not.toContain('softprops/action-gh-release@v2')
+ expect(buildJob).not.toContain('Load release notes')
+ })
+
+ test('release workflow publishes all release assets only after all matrix builds pass', () => {
+ const workflow = readReleaseWorkflow()
+ const publishJob = extractJob(workflow, 'publish-release')
expect(workflow).toContain('name: desktop-update-metadata-${{ matrix.label }}')
+ expect(workflow).toContain('name: desktop-release-artifacts-${{ matrix.label }}')
expect(publishJob).toContain('needs: build')
expect(publishJob).toContain('actions/download-artifact@v4')
+ expect(publishJob).toContain('pattern: desktop-release-artifacts-*')
expect(publishJob).toContain('pattern: desktop-update-metadata-*')
+ expect(publishJob).toContain('Validate complete release asset set')
expect(publishJob).toContain('bun run scripts/release-update-metadata.ts --metadata-dir artifacts/update-metadata --out-dir artifacts/update-metadata-standard')
- expect(publishJob).toContain('files: artifacts/update-metadata-standard/*.yml')
- expect(workflow.indexOf('publish-update-metadata:')).toBeGreaterThan(workflow.indexOf('build:'))
+ expect(publishJob).toContain('Validate standard update metadata set')
+ expect(publishJob).toContain('softprops/action-gh-release@v2')
+ expect(publishJob).toContain('artifacts/release-assets/**/*.dmg')
+ expect(publishJob).toContain('artifacts/release-assets/**/*.exe')
+ expect(publishJob).toContain('artifacts/release-assets/**/*.AppImage')
+ expect(publishJob).toContain('artifacts/update-metadata-standard/*.yml')
+ expect(publishJob).toContain('fail_on_unmatched_files: true')
+ expect(publishJob).toContain('Load release notes')
+ expect(workflow.indexOf('publish-release:')).toBeGreaterThan(workflow.indexOf('build:'))
+ })
+
+ test('release matrix asset basenames remain unique when final artifacts are flattened', () => {
+ const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
+ version: string
+ build: {
+ artifactName: string
+ }
+ }
+ const version = desktopPackage.version
+ expect(desktopPackage.build.artifactName).toBe('Claude-Code-Haha-${version}-${os}-${arch}.${ext}')
+
+ const expectedReleaseAssets = [
+ `Claude-Code-Haha-${version}-mac-arm64.dmg`,
+ `Claude-Code-Haha-${version}-mac-arm64.dmg.blockmap`,
+ `Claude-Code-Haha-${version}-mac-arm64.zip`,
+ `Claude-Code-Haha-${version}-mac-arm64.zip.blockmap`,
+ `Claude-Code-Haha-${version}-mac-x64.dmg`,
+ `Claude-Code-Haha-${version}-mac-x64.dmg.blockmap`,
+ `Claude-Code-Haha-${version}-mac-x64.zip`,
+ `Claude-Code-Haha-${version}-mac-x64.zip.blockmap`,
+ `Claude-Code-Haha-${version}-linux-x64.AppImage`,
+ `Claude-Code-Haha-${version}-linux-x64.AppImage.blockmap`,
+ `Claude-Code-Haha-${version}-linux-x64.deb`,
+ `Claude-Code-Haha-${version}-linux-arm64.AppImage`,
+ `Claude-Code-Haha-${version}-linux-arm64.AppImage.blockmap`,
+ `Claude-Code-Haha-${version}-linux-arm64.deb`,
+ `Claude-Code-Haha-${version}-win-x64.exe`,
+ `Claude-Code-Haha-${version}-win-x64.exe.blockmap`,
+ ]
+ const namespacedMetadata = [
+ 'latest-mac-macOS-ARM64.yml',
+ 'latest-mac-macOS-x64.yml',
+ 'latest-linux-Linux-x64.yml',
+ 'latest-linux-Linux-ARM64.yml',
+ 'latest-Windows-x64.yml',
+ ]
+ const standardMetadata = [
+ 'latest-mac.yml',
+ 'latest-linux.yml',
+ 'latest-linux-arm64.yml',
+ 'latest.yml',
+ ]
+ const flattenedNames = [
+ ...expectedReleaseAssets,
+ ...namespacedMetadata,
+ ...standardMetadata,
+ ]
+
+ expect(new Set(flattenedNames).size).toBe(flattenedNames.length)
+ expect(expectedReleaseAssets.filter((name) => name.endsWith('.dmg')).length).toBe(2)
+ expect(expectedReleaseAssets.filter((name) => name.endsWith('.zip')).length).toBe(2)
+ expect(expectedReleaseAssets.filter((name) => name.endsWith('.AppImage')).length).toBe(2)
+ expect(expectedReleaseAssets.filter((name) => name.endsWith('.deb')).length).toBe(2)
+ expect(expectedReleaseAssets.filter((name) => name.endsWith('.exe')).length).toBe(1)
+ for (const platform of ['mac', 'linux', 'win']) {
+ expect(expectedReleaseAssets.some((name) => name.includes(`-${platform}-`))).toBe(true)
+ }
+ expect(standardMetadata).toEqual([
+ 'latest-mac.yml',
+ 'latest-linux.yml',
+ 'latest-linux-arm64.yml',
+ 'latest.yml',
+ ])
+ })
+
+ test('release workflow validates exact expected release assets and update metadata before publishing', () => {
+ const workflow = readReleaseWorkflow()
+ const buildJob = extractJob(workflow, 'build')
+ const publishJob = extractJob(workflow, 'publish-release')
+ const expectedFiles = [
+ 'Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg',
+ 'Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip',
+ 'Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg',
+ 'Claude-Code-Haha-${APP_VERSION}-mac-x64.zip',
+ 'Claude-Code-Haha-${APP_VERSION}-linux-x64.AppImage',
+ 'Claude-Code-Haha-${APP_VERSION}-linux-x64.deb',
+ 'Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage',
+ 'Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb',
+ 'Claude-Code-Haha-${APP_VERSION}-win-x64.exe',
+ ]
+
+ for (const file of expectedFiles) {
+ expect(buildJob).toContain(file)
+ expect(publishJob).toContain(file)
+ }
+ for (const metadata of ['latest-mac.yml', 'latest-linux.yml', 'latest-linux-arm64.yml', 'latest.yml']) {
+ expect(publishJob).toContain(`artifacts/update-metadata-standard/$file`)
+ expect(publishJob).toContain(metadata)
+ }
+ expect(buildJob).toContain('Missing release assets for %s')
+ expect(publishJob).toContain('Missing complete release assets')
+ expect(publishJob).toContain('Missing standard update metadata')
})
test('Electron Builder publish config does not rely on git remote autodetection', () => {
diff --git a/src/server/__tests__/diagnostics-service.test.ts b/src/server/__tests__/diagnostics-service.test.ts
index cf675aab..6063a23d 100644
--- a/src/server/__tests__/diagnostics-service.test.ts
+++ b/src/server/__tests__/diagnostics-service.test.ts
@@ -72,6 +72,7 @@ describe('DiagnosticsService', () => {
details: {
apiKey: 'sk-secret',
url: 'https://api.example.com?api_key=secret-value',
+ proxyUrl: 'https://proxy-user:p%40ss@example.com:8443/api',
nested: { message: `home=${os.homedir()}` },
},
})
@@ -79,7 +80,10 @@ describe('DiagnosticsService', () => {
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
expect(raw).toContain('cli_start_failed')
expect(raw).toContain('[REDACTED]')
+ expect(raw).toContain('https://[REDACTED]@example.com:8443/api')
expect(raw).not.toContain('sk-secret')
+ expect(raw).not.toContain('proxy-user')
+ expect(raw).not.toContain('p%40ss')
expect(raw).not.toContain(os.homedir())
const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8')
diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts
index 7f060069..03b74a7a 100644
--- a/src/server/__tests__/h5-access-auth.test.ts
+++ b/src/server/__tests__/h5-access-auth.test.ts
@@ -109,6 +109,16 @@ function spoofedLoopbackHeaders(port: string): Record {
}
}
+function localFileUrl(base: string, absPath: string): string {
+ const normalized = absPath.replace(/\\/g, '/')
+ const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`
+ const encoded = rooted
+ .split('/')
+ .map((segment) => encodeURIComponent(segment))
+ .join('/')
+ return `${base}/local-file${encoded}`
+}
+
async function enableH5Access(options: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
@@ -241,7 +251,7 @@ describe('remote H5 auth and CORS integration', () => {
await expect(assetResponse.text()).resolves.toContain('window.__h5')
})
- test('finds Tauri packaged H5 resources under Resources/_up_/dist', async () => {
+ test('finds legacy packaged H5 resources under Resources/_up_/dist', async () => {
const appRoot = path.join(tmpDir, 'Fake.app', 'Contents', 'MacOS')
const mappedDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', '_up_', 'dist')
delete process.env.CLAUDE_H5_DIST_DIR
@@ -279,31 +289,29 @@ describe('remote H5 auth and CORS integration', () => {
})
})
- test('allows localhost WebUI origin without H5 token for browser development', async () => {
+ test('blocks localhost browser capability requests while H5 access is disabled', async () => {
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: 'http://127.0.0.1:5179',
},
})
- expect(response.status).toBe(200)
- expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:5179')
+ expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
- status: 'ok',
+ error: 'Forbidden',
})
})
- test('allows the Tauri desktop WebView origin to control the local sidecar without H5 token', async () => {
+ test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: 'http://tauri.localhost',
},
})
- expect(response.status).toBe(200)
- expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://tauri.localhost')
+ expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
- status: 'ok',
+ error: 'Forbidden',
})
})
@@ -334,6 +342,39 @@ describe('remote H5 auth and CORS integration', () => {
expect(wsResponse.status).toBe(403)
})
+ test('blocks remote browser local-file and preview-fs requests while H5 access is disabled', async () => {
+ const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(tmpDir, 'dist', 'index.html')), {
+ headers: {
+ Origin: PHONE_ORIGIN,
+ },
+ })
+ expect(localFileResponse.status).toBe(403)
+
+ const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
+ headers: {
+ Origin: PHONE_ORIGIN,
+ },
+ })
+ expect(previewResponse.status).toBe(403)
+ })
+
+ test('blocks loopback browser local-file and preview-fs requests while H5 access is disabled', async () => {
+ const loopbackBrowserOrigin = 'http://localhost:5173'
+ const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(tmpDir, 'dist', 'index.html')), {
+ headers: {
+ Origin: loopbackBrowserOrigin,
+ },
+ })
+ expect(localFileResponse.status).toBe(403)
+
+ const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
+ headers: {
+ Origin: loopbackBrowserOrigin,
+ },
+ })
+ expect(previewResponse.status).toBe(403)
+ })
+
test('blocks remote browser SDK requests while H5 access is disabled', async () => {
const response = await fetch(`${baseUrl}/sdk/h5-auth-test`, {
headers: makeUpgradeHeaders(PHONE_ORIGIN),
@@ -680,6 +721,75 @@ describe('remote H5 auth and CORS integration', () => {
}
})
+ test('requires H5 token for remote browser local-file and preview-fs requests when H5 access is enabled', async () => {
+ const token = await enableH5Access({
+ allowedOrigins: [PHONE_ORIGIN],
+ })
+ const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
+
+ const missingLocalFileToken = await fetch(localFile, {
+ headers: {
+ Origin: PHONE_ORIGIN,
+ },
+ })
+ expect(missingLocalFileToken.status).toBe(401)
+
+ const wrongLocalFileToken = await fetch(localFile, {
+ headers: {
+ Origin: PHONE_ORIGIN,
+ Authorization: 'Bearer wrong-token',
+ },
+ })
+ expect(wrongLocalFileToken.status).toBe(401)
+
+ const validLocalFileToken = await fetch(localFile, {
+ headers: {
+ Origin: PHONE_ORIGIN,
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ expect(validLocalFileToken.status).toBe(200)
+ await expect(validLocalFileToken.text()).resolves.toContain('"name"')
+
+ const missingPreviewToken = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
+ headers: {
+ Origin: PHONE_ORIGIN,
+ },
+ })
+ expect(missingPreviewToken.status).toBe(401)
+ })
+
+ test('requires H5 token for loopback browser local-file and preview-fs requests when H5 access is enabled', async () => {
+ const loopbackBrowserOrigin = 'http://localhost:5173'
+ const token = await enableH5Access({
+ allowedOrigins: [loopbackBrowserOrigin],
+ })
+ const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
+
+ const missingLocalFileToken = await fetch(localFile, {
+ headers: {
+ Origin: loopbackBrowserOrigin,
+ },
+ })
+ expect(missingLocalFileToken.status).toBe(401)
+
+ const validLocalFileToken = await fetch(localFile, {
+ headers: {
+ Origin: loopbackBrowserOrigin,
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ expect(validLocalFileToken.status).toBe(200)
+ await expect(validLocalFileToken.text()).resolves.toContain('"name"')
+
+ const missingPreviewToken = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
+ headers: {
+ Origin: loopbackBrowserOrigin,
+ },
+ })
+ expect(missingPreviewToken.status).toBe(401)
+ })
+
test('does not allow the server API key to replace the H5 token for remote browser requests', async () => {
process.env.ANTHROPIC_API_KEY = 'test-server-key'
await enableH5Access({
@@ -750,7 +860,7 @@ describe('remote H5 auth and CORS integration', () => {
})
})
- test('keeps Tauri loopback REST requests tokenless when H5 access is enabled', async () => {
+ test('does not keep retired Tauri loopback REST requests tokenless when H5 access is enabled', async () => {
await enableH5Access()
const response = await fetch(`${baseUrl}/api/status`, {
@@ -759,7 +869,7 @@ describe('remote H5 auth and CORS integration', () => {
},
})
- expect(response.status).toBe(200)
+ expect(response.status).toBe(403)
})
test('keeps local loopback websocket and SDK requests tokenless when H5 access is enabled', async () => {
@@ -788,6 +898,15 @@ describe('remote H5 auth and CORS integration', () => {
}
})
+ test('keeps local loopback local-file navigations tokenless when H5 access is enabled', async () => {
+ await enableH5Access()
+
+ const response = await fetch(localFileUrl(baseUrl, path.join(process.cwd(), 'package.json')))
+
+ expect(response.status).toBe(200)
+ await expect(response.text()).resolves.toContain('"name"')
+ })
+
test('blocks adapter requests from non-local browser origins when H5 access is enabled', async () => {
await enableH5Access()
diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts
index 68187a5d..807dbfc6 100644
--- a/src/server/__tests__/h5-access-policy.test.ts
+++ b/src/server/__tests__/h5-access-policy.test.ts
@@ -21,8 +21,8 @@ describe('h5AccessPolicy', () => {
expect(isLoopbackHost('192.168.0.20')).toBe(false)
})
- test('keeps desktop WebView requests to loopback tokenless', () => {
- for (const origin of ['file://', 'http://tauri.localhost']) {
+ test('keeps Electron desktop WebView requests to loopback tokenless', () => {
+ for (const origin of ['file://']) {
const request = req('http://127.0.0.1:3456/api/status', {
headers: { Origin: origin },
})
@@ -31,6 +31,16 @@ describe('h5AccessPolicy', () => {
}
})
+ test('does not keep retired Tauri origins trusted after Electron replacement', () => {
+ for (const origin of ['http://tauri.localhost', 'https://tauri.localhost', 'tauri://localhost']) {
+ const request = req('http://127.0.0.1:3456/api/status', {
+ headers: { Origin: origin },
+ })
+ expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
+ expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
+ }
+ })
+
test('keeps local internal SDK websocket routes tokenless', () => {
const request = req('http://127.0.0.1:3456/sdk/session-1')
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('internal-sdk')
@@ -49,9 +59,32 @@ describe('h5AccessPolicy', () => {
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false)
})
- test('does not trust loopback adapter requests from non-local browser origins', () => {
+ test('does not trust loopback browser origins for H5 capability routes', () => {
+ for (const pathname of [
+ '/api/status',
+ '/proxy/openai/v1/chat/completions',
+ '/ws/session-1',
+ '/local-file/Users/alice/report.html',
+ '/preview-fs/session-1/index.html',
+ ]) {
+ const request = req(`http://127.0.0.1:3456${pathname}`, {
+ headers: { Origin: 'http://localhost:5173' },
+ })
+ expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
+ expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
+ expect(shouldBlockDisabledH5Access({
+ request,
+ url: new URL(request.url),
+ h5Enabled: false,
+ explicitAuthRequired: false,
+ context: localContext,
+ })).toBe(true)
+ }
+ })
+
+ test('does not trust adapter requests from browser origins', () => {
const request = req('http://127.0.0.1:3456/api/adapters', {
- headers: { Origin: 'https://blocked.example.com' },
+ headers: { Origin: 'http://localhost:5173' },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
@@ -72,7 +105,7 @@ describe('h5AccessPolicy', () => {
})
test('keeps local desktop chat websocket routes tokenless', () => {
- for (const init of [{}, { headers: { Origin: 'file://' } }, { headers: { Origin: 'http://tauri.localhost' } }]) {
+ for (const init of [{}, { headers: { Origin: 'file://' } }]) {
const request = req('http://127.0.0.1:3456/ws/session-1', init)
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false)
@@ -85,6 +118,8 @@ describe('h5AccessPolicy', () => {
'/api/mcp',
'/api/plugins',
'/api/agents',
+ '/local-file/Users/alice/report.html',
+ '/preview-fs/session-1/index.html',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
]) {
@@ -102,6 +137,8 @@ describe('h5AccessPolicy', () => {
'/api/mcp',
'/api/plugins',
'/api/agents',
+ '/local-file/Users/alice/report.html',
+ '/preview-fs/session-1/index.html',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
'/sdk/session-1',
@@ -119,8 +156,27 @@ describe('h5AccessPolicy', () => {
}
})
- test('keeps local capability routes and static bootstrap routes available while H5 access is disabled', () => {
- for (const pathname of ['/api/status', '/proxy/openai/v1/chat/completions', '/ws/session-1', '/sdk/session-1']) {
+ test('keeps local non-filesystem capability routes and static bootstrap routes available while H5 access is disabled', () => {
+ for (const pathname of [
+ '/api/status',
+ '/proxy/openai/v1/chat/completions',
+ '/ws/session-1',
+ '/sdk/session-1',
+ ]) {
+ const request = req(`http://127.0.0.1:3456${pathname}`)
+ expect(shouldBlockDisabledH5Access({
+ request,
+ url: new URL(request.url),
+ h5Enabled: false,
+ explicitAuthRequired: false,
+ context: localContext,
+ })).toBe(false)
+ }
+
+ for (const pathname of [
+ '/local-file/Users/alice/report.html',
+ '/preview-fs/session-1/index.html',
+ ]) {
const request = req(`http://127.0.0.1:3456${pathname}`)
expect(shouldBlockDisabledH5Access({
request,
diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts
index 68a731b6..6ddc9a08 100644
--- a/src/server/h5AccessPolicy.ts
+++ b/src/server/h5AccessPolicy.ts
@@ -4,12 +4,7 @@ export type H5RequestContext = {
}
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
-const LOCAL_ORIGINS = new Set([
- 'file://',
- 'http://tauri.localhost',
- 'https://tauri.localhost',
- 'tauri://localhost',
-])
+const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
export function normalizeHostname(hostname: string): string {
return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
@@ -23,15 +18,14 @@ export function isLoopbackHost(hostname: string): boolean {
return LOCAL_HOSTS.has(normalized)
}
-function isLocalOrigin(origin: string | null): boolean {
+function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean {
if (!origin) return true
- if (LOCAL_ORIGINS.has(origin)) return true
+ return LOCAL_DESKTOP_ORIGINS.has(origin)
+}
- try {
- return isLoopbackHost(new URL(origin).hostname)
- } catch {
- return false
- }
+function isFilesystemCapabilityPath(pathname: string): boolean {
+ return pathname.startsWith('/local-file/') ||
+ pathname.startsWith('/preview-fs/')
}
export function classifyH5Request(
@@ -39,9 +33,18 @@ export function classifyH5Request(
url: URL,
context: H5RequestContext,
): H5RequestKind {
+ const origin = request.headers.get('Origin')
+ if (isFilesystemCapabilityPath(url.pathname)) {
+ const localFilesystemTrusted = Boolean(context.clientAddress) &&
+ isLoopbackHost(context.clientAddress!) &&
+ isLocalDesktopOrNavigationOrigin(origin)
+
+ return localFilesystemTrusted ? 'local-trusted' : 'h5-browser'
+ }
+
const localTrusted = Boolean(context.clientAddress) &&
isLoopbackHost(context.clientAddress!) &&
- isLocalOrigin(request.headers.get('Origin'))
+ isLocalDesktopOrNavigationOrigin(origin)
if (url.pathname.startsWith('/sdk/') && localTrusted) {
return 'internal-sdk'
@@ -102,6 +105,7 @@ export function shouldBlockDisabledH5Access({
function isH5ProtectedCapabilityPath(pathname: string): boolean {
return pathname.startsWith('/api/') ||
+ isFilesystemCapabilityPath(pathname) ||
pathname.startsWith('/proxy/') ||
pathname.startsWith('/ws/') ||
pathname.startsWith('/sdk/')
@@ -109,6 +113,7 @@ function isH5ProtectedCapabilityPath(pathname: string): boolean {
function isH5BrowserCapabilityPath(pathname: string): boolean {
return pathname.startsWith('/api/') ||
+ isFilesystemCapabilityPath(pathname) ||
pathname.startsWith('/proxy/') ||
pathname.startsWith('/ws/')
}
diff --git a/src/server/middleware/cors.test.ts b/src/server/middleware/cors.test.ts
index 4f76cce1..dcf42b7e 100644
--- a/src/server/middleware/cors.test.ts
+++ b/src/server/middleware/cors.test.ts
@@ -7,10 +7,8 @@ describe('corsHeaders', () => {
expect(corsHeaders('http://localhost:3000')['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
})
- it('allows tauri webview origins used in production builds', () => {
- expect(corsHeaders('http://tauri.localhost')['Access-Control-Allow-Origin']).toBe('http://tauri.localhost')
- expect(corsHeaders('https://tauri.localhost')['Access-Control-Allow-Origin']).toBe('https://tauri.localhost')
- expect(corsHeaders('tauri://localhost')['Access-Control-Allow-Origin']).toBe('tauri://localhost')
+ it('echoes explicit origins for open H5 responses', () => {
+ expect(corsHeaders('https://example.com')['Access-Control-Allow-Origin']).toBe('https://example.com')
})
it('allows arbitrary origins while H5 access is open', () => {
@@ -74,7 +72,7 @@ describe('resolveCors', () => {
})
it('keeps trusted local desktop origins allowed when H5 token mode is active', async () => {
- for (const origin of ['file://', 'http://tauri.localhost', 'http://127.0.0.1:5179']) {
+ for (const origin of ['file://']) {
const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,
@@ -86,6 +84,32 @@ describe('resolveCors', () => {
}
})
+ it('does not keep loopback browser origins allowed when H5 token mode is active', async () => {
+ for (const origin of ['http://localhost:5173', 'http://127.0.0.1:5179']) {
+ const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
+ h5Enabled: true,
+ isOriginAllowed: async () => false,
+ })
+
+ expect(result.allowed).toBe(false)
+ expect(result.rejected).toBe(true)
+ expect(result.headers['Access-Control-Allow-Origin']).toBeUndefined()
+ }
+ })
+
+ it('does not keep retired Tauri origins allowed when H5 token mode is active', async () => {
+ for (const origin of ['http://tauri.localhost', 'https://tauri.localhost', 'tauri://localhost']) {
+ const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
+ h5Enabled: true,
+ isOriginAllowed: async () => false,
+ })
+
+ expect(result.allowed).toBe(false)
+ expect(result.rejected).toBe(true)
+ expect(result.headers['Access-Control-Allow-Origin']).toBeUndefined()
+ }
+ })
+
it('does not trust non-local same-origin requests unless explicitly configured', async () => {
const result = await resolveCors('http://192.168.0.20:3456', 'http://192.168.0.20:3456', {
h5Enabled: true,
diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts
index e82333b8..f7093bd0 100644
--- a/src/server/middleware/cors.ts
+++ b/src/server/middleware/cors.ts
@@ -2,8 +2,6 @@
* CORS middleware for desktop and temporary open H5 access.
*/
-import { isLoopbackHost } from '../h5AccessPolicy.js'
-
export function corsHeaders(origin?: string | null): Record {
const allowedOrigin = origin || 'http://localhost:3000'
return {
@@ -35,27 +33,14 @@ export type CorsResolutionOptions = {
isOriginAllowed?: (origin: string) => Promise
}
-const LOCAL_ORIGINS = new Set([
- 'file://',
- 'http://tauri.localhost',
- 'https://tauri.localhost',
- 'tauri://localhost',
-])
+const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
function isLocalOrigin(origin?: string | null): boolean {
if (!origin) {
return true
}
- if (LOCAL_ORIGINS.has(origin)) {
- return true
- }
-
- try {
- return isLoopbackHost(new URL(origin).hostname)
- } catch {
- return false
- }
+ return LOCAL_DESKTOP_ORIGINS.has(origin)
}
export async function resolveCors(
diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts
index f3c3c356..e02b19e9 100644
--- a/src/server/services/diagnosticsService.ts
+++ b/src/server/services/diagnosticsService.ts
@@ -313,6 +313,7 @@ export class DiagnosticsService {
sanitizeString(value: string, maxLength = MAX_STRING_LENGTH): string {
let sanitized = value
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)([^/?#\s:@]+(?::[^/?#\s@]*)?@)/gi, '$1[REDACTED]@')
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|token|secret|password)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
.replace(/([?&](?:api[_-]?key|token|auth|access_token|refresh_token|key)=)[^&\s]+/gi, '$1[REDACTED]')