Merge branch 'feat/electron-migration-main-sync-2392'

This commit is contained in:
程序员阿江(Relakkes)
2026-06-02 22:45:40 +08:00
178 changed files with 13564 additions and 1467 deletions
File diff suppressed because it is too large Load Diff
+1 -16
View File
@@ -134,10 +134,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
build-essential curl wget file \
libxdo-dev libssl-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev patchelf \
libxdo-dev libssl-dev patchelf \
libfuse2
- name: Setup Bun
@@ -145,14 +142,6 @@ jobs:
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
- name: Install root dependencies
run: bun install
@@ -160,10 +149,6 @@ jobs:
working-directory: desktop
run: bun install
- name: Install adapter dependencies
working-directory: adapters
run: bun install
- name: Run desktop native checks
run: bun run check:native
+6 -5
View File
@@ -70,9 +70,10 @@ jobs:
'scripts/pr/check-pr.ts',
'scripts/pr/run-server-tests.ts',
'scripts/release.ts',
'desktop/src-tauri/tauri.conf.json',
'desktop/src-tauri/Cargo.toml',
'desktop/src-tauri/Cargo.lock',
'desktop/electron/tsconfig.json',
'desktop/scripts/build-linux.sh',
'desktop/scripts/build-macos-arm64.sh',
'desktop/scripts/build-windows-x64.ps1',
])
for (const filename of filenames) {
@@ -145,7 +146,7 @@ jobs:
file.startsWith('desktop/') ||
file.startsWith('adapters/') ||
file.startsWith('src/server/') ||
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/src-tauri/Cargo.lock', 'desktop/src-tauri/Cargo.toml', 'desktop/src-tauri/tauri.conf.json'].includes(file)
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/electron/tsconfig.json', 'desktop/scripts/build-linux.sh', 'desktop/scripts/build-macos-arm64.sh', 'desktop/scripts/build-windows-x64.ps1'].includes(file)
)
) requiredChecks.push('desktop-native-checks')
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
@@ -172,7 +173,7 @@ jobs:
const riskNotes = []
if (filenames.some((file) => file.startsWith('desktop/src-tauri/'))) {
riskNotes.push('Tauri/native code changed: inspect sidecar build and cargo check.')
riskNotes.push('Legacy native resources changed: inspect Electron sidecar/package output and package-smoke results.')
}
if (filenames.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) {
riskNotes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.')
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -27,7 +27,7 @@ This is a Bun-based Coding Agent product with a CLI, local server, desktop app,
- `bin/claude-haha` is the executable entrypoint; `bun run start` and `./bin/claude-haha` run the CLI locally.
- `src/` contains the CLI/runtime surface: `entrypoints/` for startup paths, `screens/` and `components/` for the Ink TUI, `commands/` for slash commands, `services/` for API/MCP/OAuth logic, `tools/` for agent tools, `utils/` for shared runtime helpers, and `server/` for the local API/WebSocket service.
- `desktop/` contains the desktop product: React UI in `desktop/src/`, API clients in `desktop/src/api/`, shared UI in `desktop/src/components/`, Tauri/Rust glue in `desktop/src-tauri/`, and desktop build scripts in `desktop/scripts/`.
- `desktop/` contains the desktop product: React UI in `desktop/src/`, API clients in `desktop/src/api/`, shared UI in `desktop/src/components/`, Electron host code in `desktop/electron/`, legacy Tauri resources/sidecar assets in `desktop/src-tauri/`, and desktop build scripts in `desktop/scripts/`.
- `adapters/` contains IM adapter sidecars for Telegram, Feishu, WeChat, DingTalk, and shared adapter utilities.
- `docs/` and `docs/en/` are VitePress documentation. Root screenshots and `docs/images/` are reference assets unless a task explicitly updates docs media.
- `release-notes/`, `scripts/release.ts`, and `.github/workflows/` define release and CI behavior. Treat workflow changes as product changes because they alter what future agents and contributors can safely ship.
@@ -54,7 +54,7 @@ Use the narrowest meaningful verification while iterating, then run the correct
| Desktop UI/store/API work | `bun run check:desktop` | Runs desktop lint, Vitest, and production build. For visible UI flows, also use browser/agent-browser smoke when unit tests cannot prove the workflow. |
| Server/API/provider/runtime/MCP/OAuth/WebSocket work | `bun run check:server` | Covers `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, and API behavior. |
| IM adapter work | `bun run check:adapters` | On a fresh checkout, run `cd adapters && bun install` first if dependencies are missing. |
| Tauri/native/sidecar/packaging/version changes | `bun run check:native` | Runs sidecar build and `cargo check` for the desktop native surface. |
| Electron/native/sidecar/packaging/version changes | `bun run check:native` | Runs sidecar build, Electron host checks, Electron `--dir` packaging, and current-platform package-smoke. |
| Docs, README, release notes, or docs workflow changes | `bun run check:docs` | This runs `npm ci`; run it sequentially, not in parallel with commands that depend on root `node_modules`. |
| Persistence shape changes | `bun run check:persistence-upgrade` | Required for local JSON, `localStorage`, app config migrations, and old-fixture upgrade behavior. |
| Coverage during handoff | `bun run check:coverage` or `bun run verify` | Changed executable production lines must meet the changed-line threshold. |
@@ -96,10 +96,10 @@ Every feature, bugfix, and behavior change must ship with proof that matches the
## Release Workflow
- Desktop releases are built remotely by GitHub Actions from tags matching `v*.*.*`; do not upload local build artifacts as the release source of truth.
- The release workflow `.github/workflows/release-desktop.yml` validates that the tag matches `desktop/src-tauri/tauri.conf.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, and packages the desktop app across the matrix.
- The release workflow `.github/workflows/release-desktop.yml` runs a non-live PR-quality preflight, validates that the tag matches `desktop/package.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, and packages the Electron desktop app across the matrix.
- The hosted tag workflow is not a substitute for local release verification. Before tagging or calling a release ready, run `bun run scripts/release.ts <version> --dry`, then run `bun run verify`, and run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` when live provider access is available.
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename, app version, and tag aligned exactly.
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates version files, refreshes `desktop/src-tauri/Cargo.lock`, requires the matching release-notes file, commits it, and creates the annotated tag.
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates Electron desktop version files, requires the matching release-notes file, commits it, and creates the annotated tag.
- The normal release push is `git push origin main --tags`. If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-release blocker explicitly.
- For local macOS test packaging, `desktop/scripts/build-macos-arm64.sh` is the canonical Apple Silicon build entrypoint, with outputs under `desktop/build-artifacts/macos-arm64/`.
+2 -2
View File
@@ -19,7 +19,7 @@ bun run verify
| CLI / Server / 工具 | `bun run check:server` |
| 桌面端 | `bun run check:desktop` |
| IM Adapter | `bun run check:adapters` |
| Tauri / 原生 | `bun run check:native` |
| 桌面 Electron / 原生打包 | `bun run check:native` |
| 文档 | `bun run check:docs` |
门禁失败时,查看最新质量报告和对应 lane 日志定位问题:
@@ -31,7 +31,7 @@ artifacts/quality-runs/<timestamp>/logs/<lane>.log
## 2. 桌面端功能必须手工测试
改动涉及 `desktop/` 的 UI、store、API 或 Tauri 原生层时,除了自动门禁外,还必须在真机上做手工测试:
改动涉及 `desktop/` 的 UI、store、API、Electron host 或 native/packaging 层时,除了自动门禁外,还必须在真机上做手工测试:
- 起本地服务 `SERVER_PORT=3456 bun run src/server/index.ts`
- 起桌面端 `cd desktop && bun run dev`
+488 -56
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { ELECTRON_IPC_CHANNELS } from './channels'
import {
ELECTRON_IPC_VALIDATORS,
isElectronIpcChannel,
validateElectronIpcPayload,
} from './capabilities'
describe('Electron IPC capabilities', () => {
it('has a validator for every exposed invoke channel', () => {
expect(Object.keys(ELECTRON_IPC_VALIDATORS).sort()).toEqual(
Object.values(ELECTRON_IPC_CHANNELS).sort(),
)
})
it('rejects channels outside the desktop host contract', () => {
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetVersion)).toBe(true)
expect(isElectronIpcChannel('ipcRenderer:send-anything')).toBe(false)
})
it('validates structured payloads before they reach ipcRenderer.invoke', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: 1, data: 'pwd\n' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: '1', data: 'pwd\n' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalSpawn, { cols: 80, rows: 24, cwd: '/tmp' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalSpawn, { cols: '80', rows: 24 })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: 'http://127.0.0.1:7890' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: '' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: 'http://127.0.0.1:7890', extra: true })).toBe(false)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './channels'
type Validator = (payload: unknown) => boolean
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const noPayload: Validator = value => value === undefined
const optionalRecord: Validator = value => value === undefined || isRecord(value)
const stringPayload: Validator = value => typeof value === 'string'
const booleanPayload: Validator = value => typeof value === 'boolean'
const hasOnlyKeys = (value: Record<string, unknown>, allowedKeys: string[]) =>
Object.keys(value).every(key => allowedKeys.includes(key))
const commandInvoke: Validator = value =>
isRecord(value)
&& typeof value.command === 'string'
&& value.command.length > 0
&& (value.args === undefined || isRecord(value.args))
const terminalWrite: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
&& typeof value.data === 'string'
const terminalSpawn: Validator = value =>
value === undefined
|| (
isRecord(value)
&& (value.cols === undefined || typeof value.cols === 'number')
&& (value.rows === undefined || typeof value.rows === 'number')
&& (value.cwd === undefined || typeof value.cwd === 'string')
&& (value.shell === undefined || typeof value.shell === 'string')
)
const terminalResize: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
&& typeof value.cols === 'number'
&& typeof value.rows === 'number'
const terminalSessionId: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
const boundsPayload: Validator = value =>
isRecord(value)
&& typeof value.x === 'number'
&& typeof value.y === 'number'
&& typeof value.width === 'number'
&& typeof value.height === 'number'
const urlWithOptionalBounds: Validator = value =>
isRecord(value)
&& typeof value.url === 'string'
&& (value.bounds === undefined || boundsPayload(value.bounds))
const zoomPayload: Validator = value => typeof value === 'number' && Number.isFinite(value)
const updateCheckOptions: Validator = value => {
if (value === undefined) return true
if (!isRecord(value) || !hasOnlyKeys(value, ['proxy'])) return false
return value.proxy === undefined || (typeof value.proxy === 'string' && value.proxy.trim().length > 0)
}
export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord,
[ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord,
[ELECTRON_IPC_CHANNELS.updateCheck]: updateCheckOptions,
[ELECTRON_IPC_CHANNELS.updateDownload]: noPayload,
[ELECTRON_IPC_CHANNELS.updateInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updatePrepareInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateCancelInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateRelaunch]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationPermissionState]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationRequestPermission]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationSend]: optionalRecord,
[ELECTRON_IPC_CHANNELS.notificationActionAck]: optionalRecord,
[ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowClose]: noPayload,
[ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload,
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSpawn]: terminalSpawn,
[ELECTRON_IPC_CHANNELS.terminalWrite]: terminalWrite,
[ELECTRON_IPC_CHANNELS.terminalResize]: terminalResize,
[ELECTRON_IPC_CHANNELS.terminalKill]: terminalSessionId,
[ELECTRON_IPC_CHANNELS.terminalGetBashPath]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSetBashPath]: value => value === null || stringPayload(value),
[ELECTRON_IPC_CHANNELS.previewOpen]: urlWithOptionalBounds,
[ELECTRON_IPC_CHANNELS.previewNavigate]: stringPayload,
[ELECTRON_IPC_CHANNELS.previewSetBounds]: boundsPayload,
[ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload,
[ELECTRON_IPC_CHANNELS.previewClose]: noPayload,
[ELECTRON_IPC_CHANNELS.previewMessage]: () => true,
[ELECTRON_IPC_CHANNELS.appModeGet]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord,
[ELECTRON_IPC_CHANNELS.appModeDetectPortableDir]: noPayload,
[ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload,
[ELECTRON_IPC_CHANNELS.zoomSet]: zoomPayload,
} satisfies Record<ElectronIpcChannel, Validator>
const allowedChannels = new Set<ElectronIpcChannel>(
Object.values(ELECTRON_IPC_CHANNELS),
)
export function isElectronIpcChannel(channel: string): channel is ElectronIpcChannel {
return allowedChannels.has(channel as ElectronIpcChannel)
}
export function validateElectronIpcPayload(channel: ElectronIpcChannel, payload: unknown): boolean {
return ELECTRON_IPC_VALIDATORS[channel](payload)
}
+65
View File
@@ -0,0 +1,65 @@
export const ELECTRON_IPC_CHANNELS = {
appGetVersion: 'desktop:app:get-version',
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
commandInvoke: 'desktop:command:invoke',
shellOpen: 'desktop:shell:open',
shellOpenPath: 'desktop:shell:open-path',
dialogOpen: 'desktop:dialog:open',
dialogSave: 'desktop:dialog:save',
updateCheck: 'desktop:update:check',
updateDownload: 'desktop:update:download',
updateInstall: 'desktop:update:install',
updatePrepareInstall: 'desktop:update:prepare-install',
updateCancelInstall: 'desktop:update:cancel-install',
updateRelaunch: 'desktop:update:relaunch',
notificationPermissionState: 'desktop:notification:permission-state',
notificationRequestPermission: 'desktop:notification:request-permission',
notificationSend: 'desktop:notification:send',
notificationActionAck: 'desktop:notification:action-ack',
windowMinimize: 'desktop:window:minimize',
windowToggleMaximize: 'desktop:window:toggle-maximize',
windowClose: 'desktop:window:close',
windowStartDragging: 'desktop:window:start-dragging',
windowRequestAttention: 'desktop:window:request-attention',
windowFocus: 'desktop:window:focus',
windowIsMaximized: 'desktop:window:is-maximized',
terminalSpawn: 'desktop:terminal:spawn',
terminalWrite: 'desktop:terminal:write',
terminalResize: 'desktop:terminal:resize',
terminalKill: 'desktop:terminal:kill',
terminalGetBashPath: 'desktop:terminal:get-bash-path',
terminalSetBashPath: 'desktop:terminal:set-bash-path',
previewOpen: 'desktop:preview:open',
previewNavigate: 'desktop:preview:navigate',
previewSetBounds: 'desktop:preview:set-bounds',
previewSetVisible: 'desktop:preview:set-visible',
previewClose: 'desktop:preview:close',
previewMessage: 'desktop:preview:message',
appModeGet: 'desktop:app-mode:get',
appModeSet: 'desktop:app-mode:set',
appModeDetectPortableDir: 'desktop:app-mode:detect-portable-dir',
appModePrepareRestart: 'desktop:app-mode:prepare-restart',
appModeRestart: 'desktop:app-mode:restart',
adaptersRestartSidecar: 'desktop:adapters:restart-sidecar',
zoomSet: 'desktop:zoom:set',
} as const
export const ELECTRON_EVENT_CHANNELS = {
event: 'desktop:event',
webviewDragDrop: 'desktop:webview:drag-drop',
notificationAction: 'desktop:notification:action',
updateDownloadEvent: 'desktop:update:download-event',
windowResized: 'desktop:window:resized',
nativeMenuNavigate: 'desktop:window:native-menu-navigate',
terminalOutput: 'desktop:terminal:output',
terminalExit: 'desktop:terminal:exit',
previewEvent: 'desktop:preview:event',
} as const
export const ELECTRON_INTERNAL_CHANNELS = {
previewMessageFromView: 'desktop:preview:message-from-view',
} as const
export type ElectronIpcChannel = typeof ELECTRON_IPC_CHANNELS[keyof typeof ELECTRON_IPC_CHANNELS]
export type ElectronEventChannel = typeof ELECTRON_EVENT_CHANNELS[keyof typeof ELECTRON_EVENT_CHANNELS]
export type ElectronInternalChannel = typeof ELECTRON_INTERNAL_CHANNELS[keyof typeof ELECTRON_INTERNAL_CHANNELS]
+99
View File
@@ -0,0 +1,99 @@
export const MAX_PREVIEW_EVENT_BYTES = 8 * 1024 * 1024
const MAX_PREVIEW_TEXT_LENGTH = 32_768
export type PreviewAgentMessage =
| { v: 1, type: 'ready' }
| { v: 1, type: 'picker-exited' }
| { v: 1, type: 'navigated', url: string, title: string }
| { v: 1, type: 'error', message: string }
| { v: 1, type: 'screenshot', dataUrl: string, kind: 'full' | 'viewport' | 'element' }
| { v: 1, type: 'selection', payload: Record<string, unknown> }
function byteLength(input: string): number {
return new TextEncoder().encode(input).byteLength
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isBoundedString(value: unknown, maxLength = MAX_PREVIEW_TEXT_LENGTH): value is string {
return typeof value === 'string' && value.length <= maxLength
}
function isPreviewDataUrl(value: unknown): value is string {
return typeof value === 'string' &&
value.length <= MAX_PREVIEW_EVENT_BYTES &&
/^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=\r\n]+$/i.test(value)
}
function isPreviewKind(value: unknown): value is 'full' | 'viewport' | 'element' {
return value === 'full' || value === 'viewport' || value === 'element'
}
function isSelectionScreenshotKind(value: unknown): value is 'full' | 'viewport' | 'element' | 'region' {
return isPreviewKind(value) || value === 'region'
}
export function parsePreviewAgentMessage(raw: string): PreviewAgentMessage | null {
if (byteLength(raw) > MAX_PREVIEW_EVENT_BYTES) return null
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return null
}
if (!isPlainRecord(parsed) || parsed.v !== 1 || typeof parsed.type !== 'string') {
return null
}
switch (parsed.type) {
case 'ready':
case 'picker-exited':
return { v: 1, type: parsed.type }
case 'navigated':
if (!isBoundedString(parsed.url) || !isBoundedString(parsed.title)) return null
try {
const url = new URL(parsed.url)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
} catch {
return null
}
return { v: 1, type: 'navigated', url: parsed.url, title: parsed.title }
case 'error':
if (!isBoundedString(parsed.message)) return null
return { v: 1, type: 'error', message: parsed.message }
case 'screenshot':
if (!isPreviewDataUrl(parsed.dataUrl) || !isPreviewKind(parsed.kind)) return null
return { v: 1, type: 'screenshot', dataUrl: parsed.dataUrl, kind: parsed.kind }
case 'selection':
if (!isPlainRecord(parsed.payload)) return null
if ('screenshot' in parsed.payload) {
const screenshot = parsed.payload.screenshot
if (!isPlainRecord(screenshot)) return null
if (screenshot.dataUrl !== undefined && !isPreviewDataUrl(screenshot.dataUrl)) return null
if (screenshot.kind !== undefined && !isSelectionScreenshotKind(screenshot.kind)) return null
}
return { v: 1, type: 'selection', payload: parsed.payload }
default:
return null
}
}
export function shouldForwardPreviewMessage(input: {
raw: unknown
href: string
isTopFrame: boolean
}): input is { raw: string, href: string, isTopFrame: true } {
if (typeof input.raw !== 'string') return false
if (byteLength(input.raw) > MAX_PREVIEW_EVENT_BYTES) return false
if (!input.isTopFrame) return false
try {
const parsed = new URL(input.href)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from 'electron'
import { createElectronHost } from '../src/lib/desktopHost/electronHost'
import type { DesktopHostUnlisten } from '../src/lib/desktopHost/types'
import type { ElectronEventChannel, ElectronIpcChannel } from './ipc/channels'
const electronHost = createElectronHost({
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
return ipcRenderer.invoke(channel, payload) as Promise<T>
},
subscribe<T>(
channel: ElectronEventChannel,
handler: (payload: T) => void,
): Promise<DesktopHostUnlisten> {
const listener = (_event: Electron.IpcRendererEvent, payload: T) => handler(payload)
ipcRenderer.on(channel, listener)
return Promise.resolve(() => {
ipcRenderer.removeListener(channel, listener)
})
},
})
contextBridge.exposeInMainWorld('desktopHost', electronHost)
+29
View File
@@ -0,0 +1,29 @@
import * as electron from 'electron'
import { ELECTRON_INTERNAL_CHANNELS } from './ipc/channels'
import { shouldForwardPreviewMessage } from './ipc/previewMessage'
const { contextBridge, ipcRenderer } = electron
function isTopFrame(): boolean {
try {
return window.top === window
} catch {
return false
}
}
export function installPreviewPostBridge(): void {
if (!contextBridge?.exposeInMainWorld || !ipcRenderer?.send) return
contextBridge.exposeInMainWorld('__DESKTOP_PREVIEW_POST__', (raw: unknown) => {
if (!shouldForwardPreviewMessage({
raw,
href: window.location.href,
isTopFrame: isTopFrame(),
})) {
return
}
ipcRenderer.send(ELECTRON_INTERNAL_CHANNELS.previewMessageFromView, raw)
})
}
installPreviewPostBridge()
+130
View File
@@ -0,0 +1,130 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
applyStartupPortableMode,
defaultPortableDir,
detectPortableDir,
determineStartupPortableDir,
dirHasPortableData,
getAppMode,
setAppMode,
type AppModeAppLike,
} from './appMode'
const tempDirs: string[] = []
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-app-mode-'))
tempDirs.push(dir)
return dir
}
function app(root = tempDir()): AppModeAppLike & { root: string } {
const exe = path.join(root, 'Claude Code Haha.app', 'Contents', 'MacOS', 'Claude Code Haha')
const userData = path.join(root, 'user-data')
fs.mkdirSync(path.dirname(exe), { recursive: true })
fs.writeFileSync(exe, '')
return {
root,
getPath(name) {
return name === 'exe' ? exe : userData
},
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('Electron app mode service', () => {
it('detects portable data using the same sentinel files and directories as Tauri', () => {
const root = tempDir()
expect(dirHasPortableData(root)).toBe(false)
fs.writeFileSync(path.join(root, 'settings.json'), '{}')
expect(dirHasPortableData(root)).toBe(true)
fs.rmSync(path.join(root, 'settings.json'))
fs.mkdirSync(path.join(root, 'projects'))
expect(dirHasPortableData(root)).toBe(true)
})
it('resolves startup portable mode from default portable data or app-mode config', () => {
const fakeApp = app()
const defaultDir = defaultPortableDir(fakeApp)
fs.mkdirSync(defaultDir, { recursive: true })
fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}')
expect(determineStartupPortableDir(fakeApp, {})).toBe(defaultDir)
expect(determineStartupPortableDir(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toBeNull()
fs.writeFileSync(path.join(defaultDir, 'app-mode.json'), JSON.stringify({ mode: 'default' }))
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
})
it('sets portable environment variables before sidecars start', () => {
const fakeApp = app()
const env: NodeJS.ProcessEnv = {}
const defaultDir = defaultPortableDir(fakeApp)
fs.mkdirSync(defaultDir, { recursive: true })
fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}')
expect(applyStartupPortableMode(fakeApp, env)).toBe(defaultDir)
expect(env.CLAUDE_CONFIG_DIR).toBe(defaultDir)
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBe('1')
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(defaultDir, 'EBWebView'))
})
it('returns the active app mode shape expected by settingsStore', () => {
const fakeApp = app()
expect(getAppMode(fakeApp, {})).toEqual({
mode: 'default',
portableDir: defaultPortableDir(fakeApp),
defaultPortableDir: defaultPortableDir(fakeApp),
activeConfigDir: fakeApp.getPath('userData'),
configDirSource: 'system',
})
expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/portable', CC_HAHA_APP_PORTABLE_DIR: '1' })).toMatchObject({
mode: 'portable',
portableDir: '/portable',
activeConfigDir: '/portable',
configDirSource: 'portable',
})
expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toMatchObject({
configDirSource: 'environment',
})
})
it('writes app-mode.json to active, target portable, and system config dirs', () => {
const fakeApp = app()
const active = tempDir()
const selected = path.join(tempDir(), 'portable')
setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active })
const expected = { mode: 'portable', portable_dir: selected }
expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual(expected)
expect(JSON.parse(fs.readFileSync(path.join(selected, 'app-mode.json'), 'utf8'))).toEqual(expected)
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual(expected)
setAppMode(fakeApp, { mode: 'default', portableDir: null }, { CLAUDE_CONFIG_DIR: active })
expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual({
mode: 'default',
portable_dir: null,
})
})
it('reports whether the default portable dir already has data', () => {
const fakeApp = app()
expect(detectPortableDir(fakeApp)).toEqual({
defaultPortableDir: defaultPortableDir(fakeApp),
hasData: false,
})
fs.mkdirSync(defaultPortableDir(fakeApp), { recursive: true })
fs.writeFileSync(path.join(defaultPortableDir(fakeApp), '.mcp.json'), '{}')
expect(detectPortableDir(fakeApp).hasData).toBe(true)
})
})
+157
View File
@@ -0,0 +1,157 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import type { AppModeConfig, AppModeSetInput } from '../../src/lib/desktopHost/types'
const APP_MODE_FILE = 'app-mode.json'
export type AppModeAppLike = {
getPath(name: 'exe' | 'userData'): string
}
type PersistedAppModeConfig = {
mode?: string
portable_dir?: string | null
}
export type PortableDetection = {
defaultPortableDir: string | null
hasData: boolean
}
export function defaultPortableDir(app: AppModeAppLike): string {
return path.join(path.dirname(app.getPath('exe')), 'CLAUDE_CONFIG_DIR')
}
export function dirHasPortableData(dir: string): boolean {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false
return [
'settings.json',
'.claude.json',
'.mcp.json',
'window-state.json',
'terminal-config.json',
].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isFile())
|| [
'Cache',
'EBWebView',
'projects',
'skills',
'plugins',
'cowork_plugins',
'cc-haha',
].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isDirectory())
}
export function readAppModeConfig(configDir: string): PersistedAppModeConfig | null {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(configDir, APP_MODE_FILE), 'utf8')) as PersistedAppModeConfig
return {
mode: typeof parsed.mode === 'string' ? parsed.mode.toLowerCase() : 'default',
portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir : null,
}
} catch {
return null
}
}
export function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void {
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, APP_MODE_FILE), JSON.stringify(config, null, 2))
}
export function determineStartupPortableDir(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (env.CLAUDE_CONFIG_DIR) return null
const defaultDir = defaultPortableDir(app)
const defaultMode = readAppModeConfig(defaultDir)
if (defaultMode) {
if (defaultMode.mode === 'portable') {
return dirHasPortableData(defaultDir) ? defaultDir : defaultMode.portable_dir ?? defaultDir
}
return null
}
const systemMode = readAppModeConfig(app.getPath('userData'))
if (systemMode) {
if (systemMode.mode === 'portable') return systemMode.portable_dir ?? defaultDir
return null
}
return dirHasPortableData(defaultDir) ? defaultDir : null
}
export function applyStartupPortableMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
const portableDir = determineStartupPortableDir(app, env)
if (!portableDir) return null
env.CLAUDE_CONFIG_DIR = portableDir
env.CC_HAHA_APP_PORTABLE_DIR = '1'
env.WEBVIEW2_USER_DATA_FOLDER = path.join(portableDir, 'EBWebView')
fs.mkdirSync(env.WEBVIEW2_USER_DATA_FOLDER, { recursive: true })
return portableDir
}
export function getAppMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): AppModeConfig {
const envConfigDir = env.CLAUDE_CONFIG_DIR || null
const activeConfigDir = envConfigDir || app.getPath('userData')
const portableDir = envConfigDir || defaultPortableDir(app)
return {
mode: envConfigDir ? 'portable' : 'default',
portableDir,
defaultPortableDir: defaultPortableDir(app),
activeConfigDir,
configDirSource: envConfigDir
? env.CC_HAHA_APP_PORTABLE_DIR ? 'portable' : 'environment'
: 'system',
}
}
export function setAppMode(
app: AppModeAppLike,
input: AppModeSetInput,
env: NodeJS.ProcessEnv = process.env,
): void {
const activeConfigDir = env.CLAUDE_CONFIG_DIR || app.getPath('userData')
let config: PersistedAppModeConfig = { mode: 'default', portable_dir: null }
let targetPortableDir: string | null = null
if (input.mode === 'portable') {
const selectedDir = input.portableDir?.trim() || defaultPortableDir(app)
if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) {
throw new Error(`portable config path is not a directory: ${selectedDir}`)
}
fs.mkdirSync(selectedDir, { recursive: true })
targetPortableDir = selectedDir
config = {
mode: 'portable',
portable_dir: selectedDir === defaultPortableDir(app) ? null : selectedDir,
}
}
writeAppModeConfig(activeConfigDir, config)
if (targetPortableDir && targetPortableDir !== activeConfigDir) {
writeAppModeConfig(targetPortableDir, config)
}
const systemConfigDir = app.getPath('userData')
if (systemConfigDir !== activeConfigDir) {
writeAppModeConfig(systemConfigDir, config)
}
}
export function detectPortableDir(app: AppModeAppLike): PortableDetection {
const portableDir = defaultPortableDir(app)
return {
defaultPortableDir: portableDir,
hasData: dirHasPortableData(portableDir),
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { toElectronOpenDialogOptions, toElectronSaveDialogOptions } from './dialogs'
describe('Electron dialog service', () => {
it('maps host open options to Electron file selection options', () => {
expect(toElectronOpenDialogOptions({
directory: true,
multiple: true,
title: 'Choose folder',
defaultPath: '/tmp',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})).toEqual({
properties: ['openDirectory', 'multiSelections'],
title: 'Choose folder',
defaultPath: '/tmp',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
})
it('defaults open dialogs to single-file selection', () => {
expect(toElectronOpenDialogOptions()).toEqual({
properties: ['openFile'],
title: undefined,
defaultPath: undefined,
filters: undefined,
})
})
it('maps host save options to Electron save options', () => {
expect(toElectronSaveDialogOptions({
title: 'Save transcript',
defaultPath: '/tmp/session.md',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})).toEqual({
title: 'Save transcript',
defaultPath: '/tmp/session.md',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
})
})
+41
View File
@@ -0,0 +1,41 @@
import type { BrowserWindow, OpenDialogOptions, SaveDialogOptions } from 'electron'
import type { DialogOpenOptions, DialogSaveOptions } from '../../src/lib/desktopHost/types'
export function toElectronOpenDialogOptions(options: DialogOpenOptions = {}): OpenDialogOptions {
return {
properties: [
options.directory ? 'openDirectory' : 'openFile',
options.multiple ? 'multiSelections' : undefined,
].filter(Boolean) as OpenDialogOptions['properties'],
title: options.title,
defaultPath: options.defaultPath,
filters: options.filters,
}
}
export function toElectronSaveDialogOptions(options: DialogSaveOptions = {}): SaveDialogOptions {
return {
title: options.title,
defaultPath: options.defaultPath,
filters: options.filters,
}
}
export async function openDialog(parentWindow: BrowserWindow | null, options?: DialogOpenOptions): Promise<string | string[] | null> {
const { dialog } = await import('electron')
const dialogOptions = toElectronOpenDialogOptions(options)
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
if (result.canceled) return null
return options?.multiple ? result.filePaths : result.filePaths[0] ?? null
}
export async function saveDialog(parentWindow: BrowserWindow | null, options?: DialogSaveOptions): Promise<string | null> {
const { dialog } = await import('electron')
const dialogOptions = toElectronSaveDialogOptions(options)
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
return result.canceled ? null : result.filePath ?? null
}
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest'
import { installMacOsChromiumKeychainPromptGuard } from './keychain'
describe('Electron Chromium keychain guard', () => {
it('uses Chromium mock keychain on macOS to avoid Safe Storage prompts', () => {
const appendSwitch = vi.fn()
const installed = installMacOsChromiumKeychainPromptGuard(
{ commandLine: { appendSwitch } },
'darwin',
)
expect(installed).toBe(true)
expect(appendSwitch).toHaveBeenCalledWith('use-mock-keychain')
})
it('leaves non-macOS platforms on their default credential backend', () => {
const appendSwitch = vi.fn()
const installed = installMacOsChromiumKeychainPromptGuard(
{ commandLine: { appendSwitch } },
'linux',
)
expect(installed).toBe(false)
expect(appendSwitch).not.toHaveBeenCalled()
})
})
+21
View File
@@ -0,0 +1,21 @@
type ElectronAppWithCommandLine = {
commandLine: {
appendSwitch(name: string, value?: string): void
}
}
/**
* Chromium creates a per-app "Safe Storage" key in macOS Keychain for browser
* profile encryption. Claude Code Haha does not rely on Chromium cookies or
* password storage for auth secrets; OAuth tokens live in the desktop sidecar
* files instead. Using Chromium's mock keychain avoids repeated macOS password
* prompts when dev/unsigned Electron builds cannot reuse the old Keychain ACL.
*/
export function installMacOsChromiumKeychainPromptGuard(
app: ElectronAppWithCommandLine,
platform: NodeJS.Platform = process.platform,
): boolean {
if (platform !== 'darwin') return false
app.commandLine.appendSwitch('use-mock-keychain')
return true
}

Some files were not shown because too many files have changed in this diff Show More