mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
136 lines
3.6 KiB
TypeScript
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'
|
|
}
|