diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index dbe7a3a9..e2d5a0d8 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -84,7 +84,7 @@ function getServerRuntime() { serverRuntime ??= new ElectronServerRuntime({ desktopRoot: unpackedRoot(), appRoot: appRoot(), - h5DistDir: path.join(appRoot(), 'dist'), + h5DistDir: path.join(unpackedRoot(), 'dist'), }) return serverRuntime } diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index 8814cf9d..98bb0fc4 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -40,18 +40,18 @@ describe('Electron sidecar manager', () => { expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(path.join('/app/desktop', 'dist')) }) - it('can keep sidecar binaries unpacked while pointing app-root at app.asar', () => { + it('can keep sidecar binaries and H5 assets unpacked while pointing app-root at app.asar', () => { const plan = createServerPlan({ desktopRoot: '/Applications/App.app/Contents/Resources/app.asar.unpacked', appRoot: '/Applications/App.app/Contents/Resources/app.asar', - h5DistDir: '/Applications/App.app/Contents/Resources/app.asar/dist', + h5DistDir: '/Applications/App.app/Contents/Resources/app.asar.unpacked/dist', port: 49321, env: {}, }) expect(plan.command).toContain('/Applications/App.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-') expect(plan.args).toContain('/Applications/App.app/Contents/Resources/app.asar') - expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar/dist') + expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar.unpacked/dist') }) it('passes portable config and adapter server URL through the sidecar env', () => { diff --git a/desktop/package.json b/desktop/package.json index 1b22ab90..b4987a3a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,6 +9,7 @@ "productName": "Claude Code Haha", "asar": true, "asarUnpack": [ + "dist/**", "node_modules/node-pty/**", "src-tauri/binaries/**" ], diff --git a/scripts/quality-gate/package-smoke/index.test.ts b/scripts/quality-gate/package-smoke/index.test.ts index 3478154d..05bb0ef4 100644 --- a/scripts/quality-gate/package-smoke/index.test.ts +++ b/scripts/quality-gate/package-smoke/index.test.ts @@ -68,6 +68,7 @@ describe('packaged artifact inspection', () => { writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/dist/index.html') writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') @@ -94,6 +95,26 @@ describe('packaged artifact inspection', () => { expect(report.optionalArtifacts.some((artifact) => artifact.path.endsWith('latest-mac.yml'))).toBe(true) expect(report.passedChecks.some((check) => check.label.includes('update metadata referenced artifact'))).toBe(true) expect(report.passedChecks.some((check) => check.label.includes('macOS update artifact blockmap'))).toBe(true) + expect(report.passedChecks.some((check) => check.label === 'macOS unpacked H5 shell')).toBe(true) + }) + + test('fails macOS inspection when the H5 shell is not unpacked for the sidecar', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'macos', packageKind: 'dir' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label === 'macOS unpacked H5 shell')).toBe(true) }) test('fails macOS archive checks when latest-mac.yml points at missing assets', async () => { diff --git a/scripts/quality-gate/package-smoke/index.ts b/scripts/quality-gate/package-smoke/index.ts index a7901b36..76eb1109 100644 --- a/scripts/quality-gate/package-smoke/index.ts +++ b/scripts/quality-gate/package-smoke/index.ts @@ -480,6 +480,7 @@ function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, opti addPresenceCheck(report, rootDir, 'macOS Info.plist', join(contentsDir, 'Info.plist')) addPresenceCheck(report, rootDir, 'macOS app executable', join(contentsDir, 'MacOS', report.productName)) addPresenceCheck(report, rootDir, 'macOS app.asar', join(resourcesDir, 'app.asar')) + addPresenceCheck(report, rootDir, 'macOS unpacked H5 shell', join(unpackedDir, 'dist', 'index.html')) addInstalledUpdateMetadataCheck( report, rootDir, diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 10c95c9c..7f060069 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -256,6 +256,20 @@ describe('remote H5 auth and CORS integration', () => { await expect(response.text()).resolves.toContain('Mapped H5 Shell') }) + test('finds Electron packaged H5 resources from app.asar.unpacked when the sidecar points at app.asar', async () => { + const asarDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', 'app.asar', 'dist') + const unpackedDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', 'app.asar.unpacked', 'dist') + process.env.CLAUDE_H5_DIST_DIR = asarDistDir + + await fs.mkdir(unpackedDistDir, { recursive: true }) + await fs.writeFile(path.join(unpackedDistDir, 'index.html'), 'Electron H5 Shell', 'utf-8') + + const response = await fetch(`${baseUrl}/`) + + expect(response.status).toBe(200) + await expect(response.text()).resolves.toContain('Electron H5 Shell') + }) + test('allows /api/status by default without H5 token or Anthropic key', async () => { const response = await fetch(`${baseUrl}/api/status`) diff --git a/src/server/staticH5.ts b/src/server/staticH5.ts index d5699719..309807b7 100644 --- a/src/server/staticH5.ts +++ b/src/server/staticH5.ts @@ -53,9 +53,13 @@ export async function handleStaticH5Request(req: Request, url: URL): Promise { const candidates = [ process.env.CLAUDE_H5_DIST_DIR, + unpackedAsarDistDir(process.env.CLAUDE_H5_DIST_DIR), process.env.CLAUDE_APP_ROOT ? path.resolve(process.env.CLAUDE_APP_ROOT, '..', 'Resources', '_up_', 'dist') : undefined, + process.env.CLAUDE_APP_ROOT + ? unpackedAsarDistDir(path.resolve(process.env.CLAUDE_APP_ROOT, 'dist')) + : undefined, process.env.CLAUDE_APP_ROOT ? path.resolve(process.env.CLAUDE_APP_ROOT, '..', 'Resources', 'dist') : undefined, @@ -80,6 +84,14 @@ async function resolveH5DistDir(): Promise { return null } +function unpackedAsarDistDir(value: string | undefined): string | undefined { + if (!value || !value.includes('.asar')) { + return undefined + } + + return value.replace(/\.asar(?=$|[/\\])/, '.asar.unpacked') +} + async function resolveStaticFilePath(distDir: string, pathname: string): Promise { const requested = containedPath(distDir, pathname) if (!requested) {