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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 19:41:25 +08:00
parent 1749d3f392
commit 775ddb9c51
13 changed files with 590 additions and 147 deletions

View File

@ -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<u16, String> {
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<u16, String> {
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<PathBuf, String> {
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<ServerRuntime, String> {
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<ServerRuntime, String> {
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<ServerRuntime, String> {
}
});
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();

View File

@ -50,7 +50,9 @@
"externalBin": [
"binaries/claude-sidecar"
],
"resources": [],
"resources": [
"../dist"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@ -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(<Settings />)
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(<Settings />)
@ -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(<Settings />)
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(<Settings />)

View File

@ -13,6 +13,7 @@ type DropdownProps<T extends string> = {
onChange: (value: T) => void
trigger: ReactNode
width?: CSSProperties['width']
maxHeight?: CSSProperties['maxHeight']
align?: 'left' | 'right'
className?: string
}
@ -23,6 +24,7 @@ export function Dropdown<T extends string>({
onChange,
trigger,
width = 320,
maxHeight,
align = 'left',
className = '',
}: DropdownProps<T>) {
@ -56,13 +58,14 @@ export function Dropdown<T extends string>({
{open && (
<div
className={`
absolute z-50 mt-1 overflow-hidden rounded-[var(--radius-lg)]
absolute z-50 mt-1 rounded-[var(--radius-lg)]
bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)]
shadow-[var(--shadow-dropdown)]
animate-in fade-in slide-in-from-top-1
${maxHeight ? 'overflow-y-auto' : 'overflow-hidden'}
${align === 'right' ? 'right-0' : 'left-0'}
`}
style={{ width }}
style={{ width, maxHeight }}
>
{items.map((item, i) => (
<button

View File

@ -94,6 +94,23 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(clientMocks.postVerify).not.toHaveBeenCalled()
})
it('uses the current browser origin when the H5 shell is served by the desktop server', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe(window.location.origin)
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
expect(globalThis.fetch).toHaveBeenCalledWith(`${window.location.origin}/health`, {
cache: 'no-store',
})
expect(globalThis.fetch).toHaveBeenCalledWith(`${window.location.origin}/api/status`, {
cache: 'no-store',
})
})
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
vi.useFakeTimers()
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch

View File

@ -130,7 +130,11 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
? new URLSearchParams(window.location.search).get('serverUrl')
: null
const stored = readStoredH5Connection()
const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? fallbackUrl
const requestedUrl =
normalizeServerUrl(queryUrl) ??
stored.serverUrl ??
getSameOriginServerUrl() ??
fallbackUrl
const token = stored.token
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
@ -233,6 +237,18 @@ function normalizeToken(value: string | null | undefined) {
return trimmed ? trimmed : null
}
function getSameOriginServerUrl() {
if (typeof window === 'undefined') {
return null
}
if (window.location.protocol !== 'http:' && window.location.protocol !== 'https:') {
return null
}
return normalizeServerUrl(window.location.origin)
}
export function isLoopbackHostname(hostname: string) {
const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'

View File

@ -1452,6 +1452,8 @@ function GeneralSettings() {
{ value: 'swedish', label: 'Svenska (Swedish)' },
{ value: 'norwegian', label: 'Norsk (Norwegian)' },
]
const selectedResponseLanguageLabel =
RESPONSE_LANGUAGES.find(({ value }) => value === responseLanguage)?.label ?? RESPONSE_LANGUAGES[0]!.label
const THEMES: Array<{ value: ThemeMode; label: string }> = [
{ value: 'light', label: t('settings.general.appearance.light') },
@ -1607,17 +1609,24 @@ function GeneralSettings() {
{/* Response Language */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.responseLangTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.responseLangDescription')}</p>
<div className="flex gap-2 mb-8">
<select
value={responseLangDraft}
onChange={(e) => void setResponseLanguage(e.target.value)}
className="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-brand)] transition-colors cursor-pointer"
>
{RESPONSE_LANGUAGES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<Dropdown<string>
items={RESPONSE_LANGUAGES}
value={responseLanguage}
onChange={(value) => void setResponseLanguage(value)}
width="100%"
maxHeight={320}
className="mb-8 block w-full"
trigger={
<button
type="button"
aria-label={t('settings.general.responseLangTitle')}
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedResponseLanguageLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
{/* Effort Level */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
@ -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"
/>
<SettingsCheckboxMark checked={thinkingEnabled} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{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"
/>
<SettingsCheckboxMark checked={desktopNotificationsEnabled} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.notificationsEnabled')}
@ -1706,6 +1717,122 @@ function GeneralSettings() {
</div>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.webFetchPreflightEnabled')}
checked={skipWebFetchPreflight}
onChange={(e) => void setSkipWebFetchPreflight(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={skipWebFetchPreflight} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.webFetchPreflightEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.webFetchPreflightHint')}
</div>
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webSearchTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webSearchDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="grid grid-cols-5 gap-1.5 mb-4">
{WEB_SEARCH_MODES.map(({ value, label }) => (
<button
key={value}
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
(webSearchDraft.mode ?? 'auto') === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
title={label}
>
{label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-3">
<Input
id="web-search-tavily-key"
type="password"
label={t('settings.general.webSearchTavilyKey')}
value={webSearchDraft.tavilyApiKey ?? ''}
placeholder="tvly-..."
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
tavilyApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchTavilyFreeHint')}</span>
<a
href="https://app.tavily.com/home"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchTavilyApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
<Input
id="web-search-brave-key"
type="password"
label={t('settings.general.webSearchBraveKey')}
value={webSearchDraft.braveApiKey ?? ''}
placeholder={t('settings.general.webSearchBravePlaceholder')}
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
braveApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchBraveFreeHint')}</span>
<a
href="https://api-dashboard.search.brave.com/app/keys"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchBraveApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<p className="text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.webSearchHint')}
</p>
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!webSearchDirty}
onClick={() => void setWebSearch(webSearchDraft)}
>
{t('settings.general.webSearchSave')}
</Button>
</div>
</div>
</div>
</div>
<div className="mt-8">
<section aria-labelledby="general-h5-access-title" role="region">
<h2
@ -1725,8 +1852,9 @@ function GeneralSettings() {
checked={h5Access.enabled}
onChange={(event) => 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"
/>
<SettingsCheckboxMark checked={h5Access.enabled} disabled={h5ActionRunning} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.h5AccessEnabled')}
@ -1860,125 +1988,27 @@ function GeneralSettings() {
</div>
</section>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.webFetchPreflightEnabled')}
checked={skipWebFetchPreflight}
onChange={(e) => void setSkipWebFetchPreflight(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)]"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.webFetchPreflightEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.webFetchPreflightHint')}
</div>
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webSearchTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webSearchDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="grid grid-cols-5 gap-1.5 mb-4">
{WEB_SEARCH_MODES.map(({ value, label }) => (
<button
key={value}
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
(webSearchDraft.mode ?? 'auto') === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
title={label}
>
{label}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-3">
<Input
id="web-search-tavily-key"
type="password"
label={t('settings.general.webSearchTavilyKey')}
value={webSearchDraft.tavilyApiKey ?? ''}
placeholder="tvly-..."
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
tavilyApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchTavilyFreeHint')}</span>
<a
href="https://app.tavily.com/home"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchTavilyApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
<Input
id="web-search-brave-key"
type="password"
label={t('settings.general.webSearchBraveKey')}
value={webSearchDraft.braveApiKey ?? ''}
placeholder={t('settings.general.webSearchBravePlaceholder')}
autoComplete="off"
onChange={(event) =>
setWebSearchDraft({
...webSearchDraft,
braveApiKey: event.target.value,
})
}
/>
<div className="-mt-1 flex items-center justify-between gap-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.general.webSearchBraveFreeHint')}</span>
<a
href="https://api-dashboard.search.brave.com/app/keys"
target="_blank"
rel="noreferrer"
aria-label={t('settings.general.webSearchBraveApiKeyLink')}
className="font-medium text-[var(--color-brand)] hover:underline whitespace-nowrap"
>
{t('settings.general.webSearchGetApiKey')}
</a>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<p className="text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.webSearchHint')}
</p>
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!webSearchDirty}
onClick={() => void setWebSearch(webSearchDraft)}
>
{t('settings.general.webSearchSave')}
</Button>
</div>
</div>
</div>
</div>
</div>
)
}
function SettingsCheckboxMark({ checked, disabled = false }: { checked: boolean; disabled?: boolean }) {
return (
<span
aria-hidden="true"
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-brand)]/40 ${
checked
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border-focus)] bg-[var(--color-surface)] text-transparent'
} ${disabled ? 'opacity-50' : ''}`}
>
<span className="material-symbols-outlined text-[16px] leading-none" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
</span>
)
}
function serializeAllowedOrigins(origins: string[]) {
return origins.join(', ')
}

View File

@ -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<void> {
@ -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'),
'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body>H5 Shell</body></html>',
'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'],

View File

@ -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()

View File

@ -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 })
},

View File

@ -35,7 +35,10 @@ export type CorsResolution = {
headers: Record<string, string>
}
export async function resolveCors(origin?: string | null): Promise<CorsResolution> {
export async function resolveCors(
origin?: string | null,
requestOrigin?: string | null,
): Promise<CorsResolution> {
if (!origin) {
return {
allowed: true,
@ -52,6 +55,17 @@ export async function resolveCors(origin?: string | null): Promise<CorsResolutio
}
}
if (requestOrigin && origin === requestOrigin) {
return {
allowed: true,
rejected: false,
headers: {
...baseCorsHeaders(),
'Access-Control-Allow-Origin': origin,
},
}
}
const h5AccessService = new H5AccessService()
if (await h5AccessService.isOriginAllowed(origin)) {
return {

View File

@ -1,6 +1,8 @@
import { createHash, randomBytes } from 'node:crypto'
import os from 'node:os'
import { ApiError } from '../middleware/errorHandler.js'
import { ManagedSettingsService } from './managedSettingsService.js'
import { ProviderService } from './providerService.js'
export type H5AccessSettings = {
enabled: boolean
@ -37,7 +39,7 @@ function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings {
enabled: settings.enabled,
tokenPreview: settings.tokenPreview,
allowedOrigins: settings.allowedOrigins,
publicBaseUrl: settings.publicBaseUrl,
publicBaseUrl: settings.publicBaseUrl ?? (settings.enabled ? resolveAutoPublicBaseUrl() : null),
}
}
@ -119,6 +121,55 @@ function normalizePublicBaseUrl(input: unknown): string | null {
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`
}
function resolveAutoPublicBaseUrl(): string | null {
const configured = process.env.CLAUDE_H5_PUBLIC_BASE_URL
if (configured) {
try {
return normalizePublicBaseUrl(configured)
} catch {
return null
}
}
if (process.env.CLAUDE_H5_AUTO_PUBLIC_URL !== '1') {
return null
}
const host = findPrivateLanAddress()
if (!host) {
return null
}
return `http://${host}:${ProviderService.getServerPort()}`
}
function findPrivateLanAddress(): string | null {
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries ?? []) {
if (entry.family !== 'IPv4' || entry.internal || !isPrivateIPv4(entry.address)) {
continue
}
return entry.address
}
}
return null
}
function isPrivateIPv4(address: string): boolean {
const parts = address.split('.')
if (parts.length !== 4 || !parts.every((part) => /^\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 }

135
src/server/staticH5.ts Normal file
View File

@ -0,0 +1,135 @@
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'
}