From a677163e0dfcc4b5915ff5d002d2e7e816a2485d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 9 May 2026 23:57:52 +0800 Subject: [PATCH] Make H5 browser access manageable from desktop settings Add the desktop-side H5 settings client, store state, and General tab controls so users can opt in, regenerate tokens, manage origins, and copy the browser URL without touching unrelated desktop flows. Constraint: Task 3 is limited to desktop Settings UI and store wiring only Rejected: Expand into AppShell or browser runtime work now | reserved for later tasks in the plan Confidence: high Scope-risk: narrow Directive: Keep H5 fetchAll integration tolerant of missing endpoint responses so existing settings loads do not regress Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint --- .../src/__tests__/generalSettings.test.tsx | 87 +++++++ desktop/src/api/h5Access.ts | 38 +++ desktop/src/i18n/locales/en.ts | 20 ++ desktop/src/i18n/locales/zh.ts | 20 ++ desktop/src/pages/Settings.tsx | 224 ++++++++++++++++++ desktop/src/stores/settingsStore.ts | 80 ++++++- desktop/src/types/settings.ts | 7 + 7 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 desktop/src/api/h5Access.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index e03dbe24..007cb301 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -126,6 +126,13 @@ describe('Settings > General tab', () => { skipWebFetchPreflight: true, desktopNotificationsEnabled: true, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, + h5Access: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + h5AccessGeneratedToken: null, setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ thinkingEnabled: enabled }) }), @@ -138,6 +145,11 @@ describe('Settings > General tab', () => { setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), + enableH5Access: vi.fn(), + disableH5Access: vi.fn(), + regenerateH5AccessToken: vi.fn(), + updateH5AccessSettings: vi.fn(), + clearH5AccessGeneratedToken: vi.fn(), }) useUIStore.setState({ pendingSettingsTab: null }) @@ -237,6 +249,81 @@ describe('Settings > General tab', () => { }) }) + it('renders the H5 section in a disabled state by default', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const section = screen.getByRole('region', { name: 'H5 Access' }) + expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked() + expect(within(section).getByText('Disabled')).toBeInTheDocument() + }) + + it('enables H5 access from the General settings section', async () => { + render() + + fireEvent.click(screen.getByText('General')) + + const section = screen.getByRole('region', { name: 'H5 Access' }) + await act(async () => { + fireEvent.click(within(section).getByLabelText('Enable H5 access')) + }) + + expect(useSettingsStore.getState().enableH5Access).toHaveBeenCalledTimes(1) + }) + + it('regenerates the H5 token from General settings', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5a1b2c3', + allowedOrigins: ['https://phone.example'], + publicBaseUrl: 'https://phone.example/app', + }, + }) + render() + + fireEvent.click(screen.getByText('General')) + + const section = screen.getByRole('region', { name: 'H5 Access' }) + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Regenerate token' })) + }) + + expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1) + }) + + it('updates H5 public URL and allowed origins from General settings', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5a1b2c3', + allowedOrigins: ['https://old.example'], + publicBaseUrl: null, + }, + }) + render() + + fireEvent.click(screen.getByText('General')) + + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.change(within(section).getByLabelText('Public URL'), { + target: { value: 'https://phone.example/app' }, + }) + fireEvent.change(within(section).getByLabelText('Allowed origins'), { + target: { value: 'https://phone.example, https://tablet.example' }, + }) + + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' })) + }) + + expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ + publicBaseUrl: 'https://phone.example/app', + allowedOrigins: ['https://phone.example', 'https://tablet.example'], + }) + }) + it('saves WebSearch fallback provider settings', () => { render() diff --git a/desktop/src/api/h5Access.ts b/desktop/src/api/h5Access.ts new file mode 100644 index 00000000..f2dd7f47 --- /dev/null +++ b/desktop/src/api/h5Access.ts @@ -0,0 +1,38 @@ +import { api } from './client' +import type { H5AccessSettings } from '../types/settings' + +export type { H5AccessSettings } from '../types/settings' + +export type H5AccessStatus = { + settings: H5AccessSettings +} + +export type H5AccessTokenResult = { + settings: H5AccessSettings + token: string +} + +export const h5AccessApi = { + get() { + return api.get('/api/h5-access') + }, + + enable() { + return api.post('/api/h5-access/enable') + }, + + disable() { + return api.post('/api/h5-access/disable') + }, + + regenerate() { + return api.post('/api/h5-access/regenerate') + }, + + update(input: { + allowedOrigins?: string[] + publicBaseUrl?: string | null + }) { + return api.put('/api/h5-access', input) + }, +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bd824673..924cd725 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -667,6 +667,26 @@ export const en = { 'settings.general.notificationsOpenSettings': 'Open Settings', 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.', + 'settings.general.h5AccessTitle': 'H5 Access', + 'settings.general.h5AccessDescription': 'Opt in to browser access from your phone or another device using a generated token and an allowlisted origin set.', + 'settings.general.h5AccessEnabled': 'Enable H5 access', + 'settings.general.h5AccessEnabledHint': 'Turn this on only for networks and browser origins you control.', + 'settings.general.h5AccessTokenPreview': 'Token preview', + 'settings.general.h5AccessDisabledValue': 'Disabled', + 'settings.general.h5AccessRegenerate': 'Regenerate token', + 'settings.general.h5AccessGeneratedToken': 'Generated token', + 'settings.general.h5AccessGeneratedTokenHint': 'Shown once. Copy it now and store it like a password.', + 'settings.general.h5AccessCopy': 'Copy', + 'settings.general.h5AccessHideToken': 'Hide token', + 'settings.general.h5AccessPublicUrl': 'Public URL', + 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', + 'settings.general.h5AccessAllowedOrigins': 'Allowed origins', + 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', + 'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.', + 'settings.general.h5AccessSave': 'Save H5 settings', + 'settings.general.h5AccessUrl': 'H5 URL', + 'settings.general.h5AccessSafetyNote': 'Treat the full token like a password and only allow origins you fully control.', + 'settings.general.h5AccessError': 'Failed to update H5 access settings.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a6f3560c..32199f45 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -669,6 +669,26 @@ export const zh: Record = { 'settings.general.notificationsOpenSettings': '打开系统设置', 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', + 'settings.general.h5AccessTitle': 'H5 访问', + 'settings.general.h5AccessDescription': '通过一次性生成的令牌和允许来源列表,为手机或其他设备上的浏览器访问开启可选 H5 模式。', + 'settings.general.h5AccessEnabled': '启用 H5 访问', + 'settings.general.h5AccessEnabledHint': '只应在你可控的网络和浏览器来源上开启。', + 'settings.general.h5AccessTokenPreview': '令牌预览', + 'settings.general.h5AccessDisabledValue': '未启用', + 'settings.general.h5AccessRegenerate': '重新生成令牌', + 'settings.general.h5AccessGeneratedToken': '生成的令牌', + 'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。', + 'settings.general.h5AccessCopy': '复制', + 'settings.general.h5AccessHideToken': '隐藏令牌', + 'settings.general.h5AccessPublicUrl': '公开访问 URL', + 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', + 'settings.general.h5AccessAllowedOrigins': '允许的来源', + 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', + 'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。', + 'settings.general.h5AccessSave': '保存 H5 设置', + 'settings.general.h5AccessUrl': 'H5 链接', + 'settings.general.h5AccessSafetyNote': '完整令牌等同于密码,只允许你完全信任和可控的来源。', + 'settings.general.h5AccessError': '更新 H5 设置失败。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 14177398..05ecf21d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { useTranslation } from '../i18n' import { Modal } from '../components/shared/Modal' import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' +import { Textarea } from '../components/shared/Textarea' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' @@ -44,6 +45,7 @@ import { restoreSettingsJsonSecrets, stripProviderSettingsJsonEnv, } from '../lib/providerSettingsJson' +import { copyTextToClipboard } from '../components/chat/clipboard' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -1355,17 +1357,37 @@ function GeneralSettings() { setDesktopNotificationsEnabled, webSearch, setWebSearch, + h5Access, + h5AccessGeneratedToken, + enableH5Access, + disableH5Access, + regenerateH5AccessToken, + updateH5AccessSettings, + clearH5AccessGeneratedToken, } = useSettingsStore() const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) + const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '') + const [h5AllowedOriginsDraft, setH5AllowedOriginsDraft] = useState(serializeAllowedOrigins(h5Access.allowedOrigins)) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) + const [h5ActionRunning, setH5ActionRunning] = useState(false) + const [h5Error, setH5Error] = useState(null) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) + const h5AccessUrl = h5Access.enabled && h5Access.publicBaseUrl ? h5Access.publicBaseUrl : null + const h5AccessDirty = + h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') || + !arraysEqual(parseAllowedOriginsDraft(h5AllowedOriginsDraft), h5Access.allowedOrigins) useEffect(() => { setWebSearchDraft(webSearch) }, [webSearch]) + useEffect(() => { + setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '') + setH5AllowedOriginsDraft(serializeAllowedOrigins(h5Access.allowedOrigins)) + }, [h5Access]) + useEffect(() => { let cancelled = false getDesktopNotificationPermission().then((permission) => { @@ -1453,6 +1475,42 @@ function GeneralSettings() { } } + const runH5Action = async (action: () => Promise) => { + setH5ActionRunning(true) + setH5Error(null) + try { + await action() + } catch (error) { + setH5Error(error instanceof Error ? error.message : t('settings.general.h5AccessError')) + } finally { + setH5ActionRunning(false) + } + } + + const handleH5AccessToggle = async (enabled: boolean) => { + await runH5Action(async () => { + if (enabled) { + await enableH5Access() + return + } + + await disableH5Access() + }) + } + + const handleH5SettingsSave = async () => { + await runH5Action(async () => { + await updateH5AccessSettings({ + publicBaseUrl: h5PublicBaseUrlDraft.trim() || null, + allowedOrigins: parseAllowedOriginsDraft(h5AllowedOriginsDraft), + }) + }) + } + + const handleH5Copy = async (value: string) => { + await copyTextToClipboard(value) + } + return (
{/* Appearance selector */} @@ -1580,6 +1638,157 @@ function GeneralSettings() {
+
+
+

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

+

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

+
+ + +
+
+
+
+ {t('settings.general.h5AccessTokenPreview')} +
+
+ {h5Access.tokenPreview ?? t('settings.general.h5AccessDisabledValue')} +
+
+ +
+
+ + {h5AccessGeneratedToken && ( +
+
+
+ {t('settings.general.h5AccessGeneratedToken')} +
+
+ + +
+
+ + {h5AccessGeneratedToken} + +

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

+
+ )} + +
+ setH5PublicBaseUrlDraft(event.target.value)} + /> +