From 775ddb9c5177ebe6957d1f113197aed40cc5af0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 10 May 2026 19:41:25 +0800 Subject: [PATCH] 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 --- desktop/src-tauri/src/lib.rs | 85 +++++- desktop/src-tauri/tauri.conf.json | 4 +- .../src/__tests__/generalSettings.test.tsx | 30 ++ desktop/src/components/shared/Dropdown.tsx | 7 +- desktop/src/lib/desktopRuntime.test.ts | 17 ++ desktop/src/lib/desktopRuntime.ts | 18 +- desktop/src/pages/Settings.tsx | 288 ++++++++++-------- src/server/__tests__/h5-access-auth.test.ts | 58 ++++ .../__tests__/h5-access-service.test.ts | 18 ++ src/server/index.ts | 8 +- src/server/middleware/cors.ts | 16 +- src/server/services/h5AccessService.ts | 53 +++- src/server/staticH5.ts | 135 ++++++++ 13 files changed, 590 insertions(+), 147 deletions(-) create mode 100644 src/server/staticH5.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b7e70b8f..e6b16def 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ use std::{ fs, io::{Error as IoError, ErrorKind, Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, - path::PathBuf, + path::{Path, PathBuf}, process::{Command as StdCommand, Stdio}, str, sync::{ @@ -218,6 +218,8 @@ mod macos_notifications { } const SERVER_STARTUP_LOG_LIMIT: usize = 80; +const SERVER_BIND_HOST: &str = "0.0.0.0"; +const SERVER_CONTROL_HOST: &str = "127.0.0.1"; const MAIN_WINDOW_LABEL: &str = "main"; const TRAY_SHOW_ID: &str = "tray_show"; const TRAY_QUIT_ID: &str = "tray_quit"; @@ -1040,9 +1042,9 @@ fn default_shell() -> String { } } -fn reserve_local_port() -> Result { - let listener = - TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?; +fn reserve_local_port(bind_host: &str) -> Result { + let listener = TcpListener::bind(format!("{bind_host}:0")) + .map_err(|err| format!("bind local port: {err}"))?; let port = listener .local_addr() .map_err(|err| format!("read local port: {err}"))? @@ -1116,12 +1118,41 @@ fn resolve_app_root(_app: &AppHandle) -> Result { Ok(dir) } +fn select_h5_dist_dir(resource_dir: Option<&Path>, app_root: &Path) -> PathBuf { + let mut candidates = Vec::new(); + if let Some(resource_dir) = resource_dir { + candidates.push(resource_dir.join("_up_").join("dist")); + candidates.push(resource_dir.join("dist")); + } + candidates.push(app_root.join("../Resources/_up_/dist")); + candidates.push(app_root.join("../Resources/dist")); + + candidates + .iter() + .find(|candidate| candidate.join("index.html").is_file()) + .cloned() + .unwrap_or_else(|| { + resource_dir + .map(|dir| dir.join("_up_").join("dist")) + .unwrap_or_else(|| app_root.join("../Resources/_up_/dist")) + }) +} + +fn resolve_h5_dist_dir(app: &AppHandle, app_root: &Path) -> PathBuf { + let resource_dir = app.path().resource_dir().ok(); + select_h5_dist_dir(resource_dir.as_deref(), app_root) +} + fn start_server_sidecar(app: &AppHandle) -> Result { - let host = "127.0.0.1"; - let port = reserve_local_port()?; - let url = format!("http://{host}:{port}"); + let bind_host = SERVER_BIND_HOST; + let control_host = SERVER_CONTROL_HOST; + let port = reserve_local_port(bind_host)?; + let url = format!("http://{control_host}:{port}"); let app_root = resolve_app_root(app)?; let app_root_arg = app_root.to_string_lossy().to_string(); + let h5_dist_dir = resolve_h5_dist_dir(app, &app_root) + .to_string_lossy() + .to_string(); // 单一合并 sidecar:第一个参数选 server / cli / adapters 模式。 let mut sidecar = app @@ -1131,12 +1162,15 @@ fn start_server_sidecar(app: &AppHandle) -> Result { for (key, value) in terminal_environment(&default_shell()) { sidecar = sidecar.env(key, value); } + sidecar = sidecar + .env("CLAUDE_H5_AUTO_PUBLIC_URL", "1") + .env("CLAUDE_H5_DIST_DIR", h5_dist_dir); let sidecar = sidecar.args([ "server", "--app-root", &app_root_arg, "--host", - host, + bind_host, "--port", &port.to_string(), ]); @@ -1176,7 +1210,7 @@ fn start_server_sidecar(app: &AppHandle) -> Result { } }); - if let Err(err) = wait_for_server(host, port) { + if let Err(err) = wait_for_server(control_host, port) { let _ = child.kill(); return Err(format_server_startup_error(&err, &startup_logs)); } @@ -1375,9 +1409,10 @@ mod tests { use super::{ decode_terminal_output, default_utf8_locale, ensure_utf8_locale, has_meaningful_intersection, is_persistable_window_state, parse_env_block, - run_notification_bridge, StoredWindowState, + run_notification_bridge, select_h5_dist_dir, StoredWindowState, SERVER_BIND_HOST, + SERVER_CONTROL_HOST, }; - use std::collections::HashMap; + use std::{collections::HashMap, fs}; #[test] fn window_state_rejects_too_small_sizes() { @@ -1514,6 +1549,34 @@ mod tests { assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); } + #[test] + fn server_sidecar_binds_lan_but_reports_loopback_control_url() { + assert_eq!(SERVER_BIND_HOST, "0.0.0.0"); + assert_eq!(SERVER_CONTROL_HOST, "127.0.0.1"); + } + + #[test] + fn h5_dist_dir_prefers_tauri_parent_resource_mapping() { + let root = std::env::temp_dir().join(format!( + "cchh-h5-dist-test-{}", + std::process::id() + )); + let resource_dir = root.join("Contents").join("Resources"); + let app_root = root.join("Contents").join("MacOS"); + let mapped_dist = resource_dir.join("_up_").join("dist"); + + fs::create_dir_all(&mapped_dist).expect("create mapped dist dir"); + fs::create_dir_all(&app_root).expect("create app root dir"); + fs::write(mapped_dist.join("index.html"), "").expect("write h5 shell"); + + assert_eq!( + select_h5_dist_dir(Some(&resource_dir), &app_root), + mapped_dist + ); + + fs::remove_dir_all(root).expect("remove temp app tree"); + } + #[test] fn notification_bridge_runs_off_the_calling_thread() { let caller_thread = std::thread::current().id(); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index eaf411b3..eb89fb87 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -50,7 +50,9 @@ "externalBin": [ "binaries/claude-sidecar" ], - "resources": [], + "resources": [ + "../dist" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index bb0e06a9..cfb4100a 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -136,6 +136,7 @@ describe('Settings > General tab', () => { thinkingEnabled: true, skipWebFetchPreflight: true, desktopNotificationsEnabled: true, + responseLanguage: '', webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, h5Access: { enabled: false, @@ -153,6 +154,9 @@ describe('Settings > General tab', () => { setDesktopNotificationsEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ desktopNotificationsEnabled: enabled }) }), + setResponseLanguage: vi.fn().mockImplementation(async (language: string) => { + useSettingsStore.setState({ responseLanguage: language }) + }), setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), @@ -224,6 +228,22 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false) }) + it('uses the shared dropdown for response language', () => { + render() + + fireEvent.click(screen.getByText('General')) + + expect(screen.queryByRole('combobox', { name: 'Response Language' })).not.toBeInTheDocument() + expect(screen.queryByRole('radiogroup', { name: 'Response Language' })).not.toBeInTheDocument() + + const trigger = screen.getByRole('button', { name: 'Response Language' }) + expect(trigger).toHaveTextContent('Default (English)') + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: '中文 (Chinese)' })) + + expect(useSettingsStore.getState().setResponseLanguage).toHaveBeenCalledWith('chinese') + }) + it('lets the user disable desktop system notifications', () => { render() @@ -281,6 +301,16 @@ describe('Settings > General tab', () => { expect(within(section).getByText('Disabled')).toBeInTheDocument() }) + it('places H5 access after the common General settings sections', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const webSearchTitle = screen.getByRole('heading', { name: 'WebSearch' }) + const h5Title = screen.getByRole('heading', { name: 'H5 Access' }) + expect((webSearchTitle.compareDocumentPosition(h5Title) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + }) + it('enables H5 access from the General settings section', async () => { render() diff --git a/desktop/src/components/shared/Dropdown.tsx b/desktop/src/components/shared/Dropdown.tsx index 13f3f5bb..c1a87988 100644 --- a/desktop/src/components/shared/Dropdown.tsx +++ b/desktop/src/components/shared/Dropdown.tsx @@ -13,6 +13,7 @@ type DropdownProps = { onChange: (value: T) => void trigger: ReactNode width?: CSSProperties['width'] + maxHeight?: CSSProperties['maxHeight'] align?: 'left' | 'right' className?: string } @@ -23,6 +24,7 @@ export function Dropdown({ onChange, trigger, width = 320, + maxHeight, align = 'left', className = '', }: DropdownProps) { @@ -56,13 +58,14 @@ export function Dropdown({ {open && (
{items.map((item, i) => ( + } + /> {/* Effort Level */}

{t('settings.general.effortTitle')}

@@ -1647,8 +1656,9 @@ function GeneralSettings() { aria-label={t('settings.general.thinkingEnabled')} checked={thinkingEnabled} onChange={(e) => void setThinkingEnabled(e.target.checked)} - className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]" + className="peer sr-only" /> +
{t('settings.general.thinkingEnabled')} @@ -1670,8 +1680,9 @@ function GeneralSettings() { aria-label={t('settings.general.notificationsEnabled')} checked={desktopNotificationsEnabled} onChange={(e) => void handleDesktopNotificationsToggle(e.target.checked)} - className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]" + className="peer sr-only" /> +
{t('settings.general.notificationsEnabled')} @@ -1706,6 +1717,122 @@ function GeneralSettings() {
+
+

{t('settings.general.webFetchPreflightTitle')}

+

{t('settings.general.webFetchPreflightDescription')}

+ +
+ +
+

{t('settings.general.webSearchTitle')}

+

{t('settings.general.webSearchDescription')}

+
+
+ {WEB_SEARCH_MODES.map(({ value, label }) => ( + + ))} +
+
+ + setWebSearchDraft({ + ...webSearchDraft, + tavilyApiKey: event.target.value, + }) + } + /> +
+ {t('settings.general.webSearchTavilyFreeHint')} + + {t('settings.general.webSearchGetApiKey')} + +
+ + setWebSearchDraft({ + ...webSearchDraft, + braveApiKey: event.target.value, + }) + } + /> +
+ {t('settings.general.webSearchBraveFreeHint')} + + {t('settings.general.webSearchGetApiKey')} + +
+
+
+

+ {t('settings.general.webSearchHint')} +

+
+ +
+
+
+
+

void handleH5AccessToggle(event.target.checked)} disabled={h5ActionRunning} - className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]" + className="peer sr-only" /> +
{t('settings.general.h5AccessEnabled')} @@ -1860,125 +1988,27 @@ function GeneralSettings() {

- -
-

{t('settings.general.webFetchPreflightTitle')}

-

{t('settings.general.webFetchPreflightDescription')}

- -
- -
-

{t('settings.general.webSearchTitle')}

-

{t('settings.general.webSearchDescription')}

-
-
- {WEB_SEARCH_MODES.map(({ value, label }) => ( - - ))} -
-
- - setWebSearchDraft({ - ...webSearchDraft, - tavilyApiKey: event.target.value, - }) - } - /> -
- {t('settings.general.webSearchTavilyFreeHint')} - - {t('settings.general.webSearchGetApiKey')} - -
- - setWebSearchDraft({ - ...webSearchDraft, - braveApiKey: event.target.value, - }) - } - /> -
- {t('settings.general.webSearchBraveFreeHint')} - - {t('settings.general.webSearchGetApiKey')} - -
-
-
-

- {t('settings.general.webSearchHint')} -

-
- -
-
-
-
) } +function SettingsCheckboxMark({ checked, disabled = false }: { checked: boolean; disabled?: boolean }) { + return ( + + ) +} + function serializeAllowedOrigins(origins: string[]) { return origins.join(', ') } diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index a40c14b6..402fc025 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -12,6 +12,8 @@ let wsBaseUrl = '' let tmpDir = '' let originalConfigDir: string | undefined let originalAnthropicApiKey: string | undefined +let originalH5DistDir: string | undefined +let originalClaudeAppRoot: string | undefined let originalServerPort = 3456 async function waitForServer(url: string): Promise { @@ -88,9 +90,20 @@ beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-auth-test-')) originalConfigDir = process.env.CLAUDE_CONFIG_DIR originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY + originalH5DistDir = process.env.CLAUDE_H5_DIST_DIR + originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT originalServerPort = ProviderService.getServerPort() process.env.CLAUDE_CONFIG_DIR = tmpDir + const h5DistDir = path.join(tmpDir, 'dist') + process.env.CLAUDE_H5_DIST_DIR = h5DistDir delete process.env.ANTHROPIC_API_KEY + await fs.mkdir(path.join(h5DistDir, 'assets'), { recursive: true }) + await fs.writeFile( + path.join(h5DistDir, 'index.html'), + 'H5 Shell', + 'utf-8', + ) + await fs.writeFile(path.join(h5DistDir, 'assets/app.js'), 'window.__h5 = true', 'utf-8') await startRemoteServer() }) @@ -104,11 +117,42 @@ afterEach(async () => { if (originalAnthropicApiKey === undefined) delete process.env.ANTHROPIC_API_KEY else process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey + if (originalH5DistDir === undefined) delete process.env.CLAUDE_H5_DIST_DIR + else process.env.CLAUDE_H5_DIST_DIR = originalH5DistDir + if (originalClaudeAppRoot === undefined) delete process.env.CLAUDE_APP_ROOT + else process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot await fs.rm(tmpDir, { recursive: true, force: true }) }) describe('remote H5 auth and CORS integration', () => { + test('serves the packaged H5 shell and static assets from the remote server', async () => { + const shellResponse = await fetch(`${baseUrl}/`) + expect(shellResponse.status).toBe(200) + expect(shellResponse.headers.get('Content-Type')).toContain('text/html') + await expect(shellResponse.text()).resolves.toContain('H5 Shell') + + const assetResponse = await fetch(`${baseUrl}/assets/app.js`) + expect(assetResponse.status).toBe(200) + expect(assetResponse.headers.get('Cache-Control')).toContain('immutable') + await expect(assetResponse.text()).resolves.toContain('window.__h5') + }) + + test('finds Tauri packaged H5 resources under Resources/_up_/dist', async () => { + const appRoot = path.join(tmpDir, 'Fake.app', 'Contents', 'MacOS') + const mappedDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', '_up_', 'dist') + delete process.env.CLAUDE_H5_DIST_DIR + process.env.CLAUDE_APP_ROOT = appRoot + + await fs.mkdir(mappedDistDir, { recursive: true }) + await fs.writeFile(path.join(mappedDistDir, 'index.html'), 'Mapped H5 Shell', 'utf-8') + + const response = await fetch(`${baseUrl}/`) + + expect(response.status).toBe(200) + await expect(response.text()).resolves.toContain('Mapped H5 Shell') + }) + test('rejects /api/status when H5 is disabled and no Anthropic key exists', async () => { const response = await fetch(`${baseUrl}/api/status`) @@ -186,6 +230,20 @@ describe('remote H5 auth and CORS integration', () => { expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() }) + test('allows same-origin H5 browser requests without a separate origin allowlist entry', async () => { + const token = await enableH5Access() + + const response = await fetch(`${baseUrl}/api/status`, { + headers: { + Origin: baseUrl, + Authorization: `Bearer ${token}`, + }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe(baseUrl) + }) + test('allows configured CORS origins and includes Vary: Origin', async () => { const token = await enableH5Access({ allowedOrigins: ['https://allowed.example.com'], diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts index 71adab5e..90cdc408 100644 --- a/src/server/__tests__/h5-access-service.test.ts +++ b/src/server/__tests__/h5-access-service.test.ts @@ -7,6 +7,8 @@ import { ProviderService } from '../services/providerService.js' let tmpDir: string let originalConfigDir: string | undefined +let originalH5PublicBaseUrl: string | undefined +let originalH5AutoPublicUrl: string | undefined function getManagedSettingsPath(): string { return path.join(tmpDir, 'cc-haha', 'settings.json') @@ -15,12 +17,18 @@ function getManagedSettingsPath(): string { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-service-test-')) originalConfigDir = process.env.CLAUDE_CONFIG_DIR + originalH5PublicBaseUrl = process.env.CLAUDE_H5_PUBLIC_BASE_URL + originalH5AutoPublicUrl = process.env.CLAUDE_H5_AUTO_PUBLIC_URL process.env.CLAUDE_CONFIG_DIR = tmpDir }) afterEach(async () => { if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + if (originalH5PublicBaseUrl === undefined) delete process.env.CLAUDE_H5_PUBLIC_BASE_URL + else process.env.CLAUDE_H5_PUBLIC_BASE_URL = originalH5PublicBaseUrl + if (originalH5AutoPublicUrl === undefined) delete process.env.CLAUDE_H5_AUTO_PUBLIC_URL + else process.env.CLAUDE_H5_AUTO_PUBLIC_URL = originalH5AutoPublicUrl await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -67,6 +75,16 @@ describe('H5AccessService', () => { expect(await service.validateToken(result.token)).toBe(true) }) + test('enabled public settings use the packaged app LAN URL when provided', async () => { + process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'http://192.168.1.20:28670/' + process.env.CLAUDE_H5_AUTO_PUBLIC_URL = '1' + const service = new H5AccessService() + + const result = await service.enable() + + expect(result.settings.publicBaseUrl).toBe('http://192.168.1.20:28670') + }) + test('regenerateToken invalidates the previous token', async () => { const service = new H5AccessService() diff --git a/src/server/index.ts b/src/server/index.ts index 31527aa1..6143a82e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -19,6 +19,7 @@ import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncher import { enableConfigs } from '../utils/config.js' import { diagnosticsService } from './services/diagnosticsService.js' import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js' +import { handleStaticH5Request } from './staticH5.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -144,7 +145,7 @@ export function startServer(port = PORT, host = HOST) { await ensurePersistentStorageUpgraded() const url = new URL(req.url) const origin = req.headers.get('Origin') - const cors = await resolveCors(origin) + const cors = await resolveCors(origin, url.origin) const authRequired = forceAuth || ( remoteHostAuthRequired && !canBypassRemoteAuthForLocalBrowser(origin, url.hostname) @@ -293,6 +294,11 @@ export function startServer(port = PORT, host = HOST) { ) } + const staticResponse = await handleStaticH5Request(req, url) + if (staticResponse) { + return staticResponse + } + return new Response('Not Found', { status: 404 }) }, diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts index 8b56a4a0..0fdbc0e7 100644 --- a/src/server/middleware/cors.ts +++ b/src/server/middleware/cors.ts @@ -35,7 +35,10 @@ export type CorsResolution = { headers: Record } -export async function resolveCors(origin?: string | null): Promise { +export async function resolveCors( + origin?: string | null, + requestOrigin?: string | null, +): Promise { if (!origin) { return { allowed: true, @@ -52,6 +55,17 @@ export async function resolveCors(origin?: string | null): Promise /^\d+$/.test(part))) { + return false + } + + const [a = -1, b = -1] = parts.map((part) => Number(part)) + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ) +} + function normalizeStoredSettings(value: unknown): StoredH5AccessSettings { if (!isRecord(value)) { return { ...DEFAULT_STORED_SETTINGS } diff --git a/src/server/staticH5.ts b/src/server/staticH5.ts new file mode 100644 index 00000000..d5699719 --- /dev/null +++ b/src/server/staticH5.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +const CACHEABLE_ASSET_RE = /^\/assets\// + +const MIME_TYPES: Record = { + '.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 { + 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 { + 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 { + 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 { + 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' +}