mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
1749d3f392
commit
775ddb9c51
@ -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();
|
||||
|
||||
@ -50,7 +50,9 @@
|
||||
"externalBin": [
|
||||
"binaries/claude-sidecar"
|
||||
],
|
||||
"resources": [],
|
||||
"resources": [
|
||||
"../dist"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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(', ')
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
135
src/server/staticH5.ts
Normal 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'
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user