mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
Merge branch 'feat/electron-migration-main-sync-2392'
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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'] }],
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user