mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Electron's sidecar runs outside app.asar, so H5 static files must be available as normal unpacked files. Point the sidecar at the unpacked renderer dist and keep a server fallback for stale app.asar-style paths. Constraint: Packaged Bun sidecars cannot read app.asar paths with ordinary fs stat calls. Rejected: Serve H5 from app.asar directly | the external sidecar is not Electron and does not get asar filesystem support. Confidence: high Scope-risk: narrow Directive: Keep package-smoke checking app.asar.unpacked/dist/index.html before changing asarUnpack or H5 dist paths. Tested: bun test src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/h5-access-policy.test.ts Tested: bun test desktop/electron/services/sidecarManager.test.ts scripts/quality-gate/package-smoke/index.test.ts Tested: bun run check:server Tested: cd desktop && bun run check:electron Tested: git diff --check Tested: SKIP_INSTALL=1 SIGN_BUILD=0 MAC_TARGETS=dmg desktop/scripts/build-macos-arm64.sh Tested: packaged sidecar curl /?serverUrl=...&h5Token=... returned HTTP 200 Not-tested: Gatekeeper notarization for the local ad-hoc DMG
148 lines
4.0 KiB
TypeScript
148 lines
4.0 KiB
TypeScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
const CACHEABLE_ASSET_RE = /^\/assets\//
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.gif': 'image/gif',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.ico': 'image/x-icon',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.map': 'application/json; charset=utf-8',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.webp': 'image/webp',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
}
|
|
|
|
export async function handleStaticH5Request(req: Request, url: URL): Promise<Response | null> {
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
return null
|
|
}
|
|
|
|
const distDir = await resolveH5DistDir()
|
|
if (!distDir) {
|
|
return null
|
|
}
|
|
|
|
const filePath = await resolveStaticFilePath(distDir, url.pathname)
|
|
if (!filePath) {
|
|
return null
|
|
}
|
|
|
|
const headers = new Headers({
|
|
'Content-Type': contentTypeForPath(filePath),
|
|
'Cache-Control': CACHEABLE_ASSET_RE.test(url.pathname)
|
|
? 'public, max-age=31536000, immutable'
|
|
: 'no-store',
|
|
})
|
|
|
|
if (req.method === 'HEAD') {
|
|
const stat = await fs.stat(filePath)
|
|
headers.set('Content-Length', String(stat.size))
|
|
return new Response(null, { status: 200, headers })
|
|
}
|
|
|
|
return new Response(Bun.file(filePath), { status: 200, headers })
|
|
}
|
|
|
|
async function resolveH5DistDir(): Promise<string | null> {
|
|
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,
|
|
process.env.CLAUDE_APP_ROOT
|
|
? path.resolve(process.env.CLAUDE_APP_ROOT, 'dist')
|
|
: undefined,
|
|
path.resolve(process.cwd(), 'desktop', 'dist'),
|
|
path.resolve(process.cwd(), 'dist'),
|
|
].filter((candidate): candidate is string => !!candidate)
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const stat = await fs.stat(path.join(candidate, 'index.html'))
|
|
if (stat.isFile()) {
|
|
return path.resolve(candidate)
|
|
}
|
|
} catch {
|
|
// Try the next candidate.
|
|
}
|
|
}
|
|
|
|
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<string | null> {
|
|
const requested = containedPath(distDir, pathname)
|
|
if (!requested) {
|
|
return null
|
|
}
|
|
|
|
const direct = await fileIfExists(requested)
|
|
if (direct) {
|
|
return direct
|
|
}
|
|
|
|
const nestedIndex = await fileIfExists(path.join(requested, 'index.html'))
|
|
if (nestedIndex) {
|
|
return nestedIndex
|
|
}
|
|
|
|
if (path.extname(requested)) {
|
|
return null
|
|
}
|
|
|
|
return fileIfExists(path.join(distDir, 'index.html'))
|
|
}
|
|
|
|
function containedPath(root: string, pathname: string): string | null {
|
|
let decoded: string
|
|
try {
|
|
decoded = decodeURIComponent(pathname)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const relativePath = decoded.replace(/^\/+/, '') || 'index.html'
|
|
const candidate = path.resolve(root, relativePath)
|
|
const relativeToRoot = path.relative(root, candidate)
|
|
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
|
return null
|
|
}
|
|
|
|
return candidate
|
|
}
|
|
|
|
async function fileIfExists(filePath: string): Promise<string | null> {
|
|
try {
|
|
const stat = await fs.stat(filePath)
|
|
return stat.isFile() ? filePath : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function contentTypeForPath(filePath: string): string {
|
|
return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream'
|
|
}
|