cc-haha/src/server/staticH5.ts
程序员阿江(Relakkes) 775ddb9c51 fix(desktop): make H5 access reachable from LAN
Packaged desktop builds started the sidecar on loopback only, so phones on the same WiFi could not connect to the H5 URL. The server now binds to LAN interfaces while keeping the desktop control URL on loopback, serves the bundled H5 frontend, and reports a LAN public URL when H5 access is enabled.

Constraint: Packaged Tauri resources place ../dist under Contents/Resources/_up_/dist.
Rejected: Bind the desktop control URL to 0.0.0.0 | desktop clients should keep using loopback for local control.
Confidence: high
Scope-risk: moderate
Directive: Keep packaged H5 resource lookup aligned with Tauri resource mapping when changing bundle resources.
Tested: bun run check:server
Tested: cd desktop/src-tauri && cargo test --lib
Tested: cd desktop && bun run test -- desktopRuntime.test.ts generalSettings.test.tsx
Tested: SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh
Tested: Packaged claude-sidecar smoke served H5 HTML while listening on *:39876
Not-tested: Physical phone browser test on the local WiFi network
2026-05-10 19:41:25 +08:00

136 lines
3.6 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,
process.env.CLAUDE_APP_ROOT
? path.resolve(process.env.CLAUDE_APP_ROOT, '..', 'Resources', '_up_', '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
}
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'
}