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' +}