From 417e8eaa4fed58b90b592f1c8d73a0c321e8b3ea 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: Tue, 12 May 2026 15:23:25 +0800 Subject: [PATCH] Add token QR workflow for H5 LAN access H5 LAN access now uses an explicit desktop confirmation, then shows a QR launch URL that carries both the serverUrl and one-time-visible H5 token. Browser startup consumes that token from the QR URL, verifies it before storing, and requires auth for non-loopback H5 endpoints while preserving local desktop bootstrap behavior. Constraint: LAN H5 exposes desktop capabilities and must be opt-in with a visible warning. Constraint: QR launch has to preserve routed public URLs, so the link carries serverUrl as well as h5Token. Rejected: Store the raw token in Zustand | it would make accidental UI diagnostics or snapshots more likely to leak it Confidence: high Scope-risk: moderate Directive: Do not remove the serverUrl query parameter from QR links without testing non-root publicBaseUrl deployments Tested: cd desktop && bun run test src/lib/desktopRuntime.test.ts src/__tests__/generalSettings.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop --- .../src/__tests__/generalSettings.test.tsx | 128 +++++++++- desktop/src/i18n/locales/en.ts | 20 +- desktop/src/i18n/locales/zh.ts | 20 +- desktop/src/lib/desktopRuntime.test.ts | 43 ++-- desktop/src/lib/desktopRuntime.ts | 26 +- desktop/src/pages/Settings.tsx | 225 +++++++++++++++++- 6 files changed, 417 insertions(+), 45 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 00025e98..7d4e5c65 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -58,6 +58,11 @@ vi.mock('../api/providers', () => ({ vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock) vi.mock('../components/chat/clipboard', () => clipboardMock) +vi.mock('qrcode', () => ({ + default: { + toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,h5qr'), + }, +})) vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ ClaudeOfficialLogin: () =>
, @@ -160,9 +165,38 @@ describe('Settings > General tab', () => { setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), - enableH5Access: vi.fn().mockResolvedValue('h5_default_generated_token'), - disableH5Access: vi.fn(), - regenerateH5AccessToken: vi.fn().mockResolvedValue('h5_default_regenerated_token'), + enableH5Access: vi.fn().mockImplementation(async () => { + const current = useSettingsStore.getState().h5Access + useSettingsStore.setState({ + h5Access: { + ...current, + enabled: true, + tokenPreview: 'h5_default_generated_token'.slice(0, 8), + }, + }) + return 'h5_default_generated_token' + }), + disableH5Access: vi.fn().mockImplementation(async () => { + const current = useSettingsStore.getState().h5Access + useSettingsStore.setState({ + h5Access: { + ...current, + enabled: false, + tokenPreview: null, + }, + }) + }), + regenerateH5AccessToken: vi.fn().mockImplementation(async () => { + const current = useSettingsStore.getState().h5Access + useSettingsStore.setState({ + h5Access: { + ...current, + enabled: true, + tokenPreview: 'h5_default_regenerated_token'.slice(0, 8), + }, + }) + return 'h5_default_regenerated_token' + }), updateH5AccessSettings: vi.fn(), }) @@ -291,19 +325,99 @@ describe('Settings > General tab', () => { }) }) - it('renders the H5 section without token controls while access is open', () => { + it('renders the H5 section with access disabled by default', () => { render() fireEvent.click(screen.getByText('General')) const section = screen.getByRole('region', { name: 'H5 Access' }) - expect(within(section).getByText('Token access is temporarily disabled; H5 requests are allowed without an Authorization header.')).toBeInTheDocument() - expect(within(section).queryByLabelText('Enable H5 access')).not.toBeInTheDocument() + expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked() + expect(within(section).getByText('Disabled')).toBeInTheDocument() expect(within(section).queryByText('Token preview')).not.toBeInTheDocument() expect(within(section).queryByRole('button', { name: 'Regenerate token' })).not.toBeInTheDocument() expect(within(section).queryByLabelText('Allowed origins')).not.toBeInTheDocument() }) + it('confirms the LAN risk before enabling H5 access and renders a token QR link', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:3456', + }, + }) + render() + + fireEvent.click(screen.getByText('General')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + + fireEvent.click(within(section).getByLabelText('Enable H5 access')) + const dialog = screen.getByRole('dialog', { name: 'Enable LAN H5 access?' }) + expect(within(dialog).getByText(/desktop H5 app on your LAN address and port/i)).toBeInTheDocument() + + await act(async () => { + fireEvent.click(within(dialog).getByRole('button', { name: 'Enable H5 access' })) + }) + + expect(useSettingsStore.getState().enableH5Access).toHaveBeenCalledTimes(1) + expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument() + expect(within(section).getByText('http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_default_generated_token')).toBeInTheDocument() + }) + + it('copies the QR launch URL with the generated H5 token', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:3456', + }, + }) + render() + + fireEvent.click(screen.getByText('General')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.click(within(section).getByLabelText('Enable H5 access')) + + await act(async () => { + fireEvent.click(within(screen.getByRole('dialog', { name: 'Enable LAN H5 access?' })).getByRole('button', { name: 'Enable H5 access' })) + }) + + await within(section).findByAltText('H5 access QR code') + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Copy QR link' })) + }) + + expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith( + 'http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_default_generated_token', + ) + }) + + it('shows the generated H5 token as a fallback when requested', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:3456', + }, + }) + render() + + fireEvent.click(screen.getByText('General')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.click(within(section).getByLabelText('Enable H5 access')) + + await act(async () => { + fireEvent.click(within(screen.getByRole('dialog', { name: 'Enable LAN H5 access?' })).getByRole('button', { name: 'Enable H5 access' })) + }) + + fireEvent.click(within(section).getByRole('button', { name: 'Show token' })) + + expect(within(section).getByText('h5_default_generated_token')).toBeInTheDocument() + }) + it('places H5 access after the common General settings sections', () => { render() @@ -317,7 +431,7 @@ describe('Settings > General tab', () => { it('copies the H5 URL when available', async () => { useSettingsStore.setState({ h5Access: { - enabled: false, + enabled: true, tokenPreview: 'h5url123', allowedOrigins: ['https://phone.example'], publicBaseUrl: 'https://phone.example/app', diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 8d4acabd..4828ae45 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -711,26 +711,38 @@ export const en = { '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': 'Browser access is open while the token gate is paused. Configure the public URL only when you expose the local service through a stable address.', + 'settings.general.h5AccessDescription': 'Expose the desktop H5 app on your local network. Phones connect with a QR link that includes a short-lived H5 token.', 'settings.general.h5AccessEnabled': 'Enable H5 access', - 'settings.general.h5AccessEnabledHint': 'Turn this on only for networks and browser origins you control.', + 'settings.general.h5AccessEnabledHint': 'The desktop server listens on the LAN address and allows access to desktop sessions.', + 'settings.general.h5AccessStatusEnabled': 'Enabled', 'settings.general.h5AccessTokenPreview': 'Token preview', 'settings.general.h5AccessDisabledValue': 'Disabled', + 'settings.general.h5AccessDisable': 'Disable', '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.h5AccessShowToken': 'Show token', 'settings.general.h5AccessCopy': 'Copy', 'settings.general.h5AccessCopyUrl': 'Copy H5 URL', + 'settings.general.h5AccessCopyLaunchUrl': 'Copy QR link', 'settings.general.h5AccessHideToken': 'Hide token', + 'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new 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.h5AccessOpenHint': 'Token access is temporarily disabled; H5 requests are allowed without an Authorization header.', + 'settings.general.h5AccessOpenHint': 'Leave blank to use the detected LAN URL. Set this only when you expose H5 through a stable address.', 'settings.general.h5AccessSave': 'Save H5 settings', 'settings.general.h5AccessUrl': 'H5 URL', - 'settings.general.h5AccessSafetyNote': 'Only expose this URL on networks and devices you trust until token access is restored.', + 'settings.general.h5AccessQrTitle': 'Phone connection', + 'settings.general.h5AccessQrHint': 'Scan to open H5 with the token already filled in.', + 'settings.general.h5AccessQrRefreshHint': 'Regenerate the token to create a QR link that can be scanned.', + 'settings.general.h5AccessQrAlt': 'H5 access QR code', + 'settings.general.h5AccessSafetyNote': 'Only enable this on trusted networks. Anyone with the QR link can reach the desktop capabilities exposed by H5.', + 'settings.general.h5AccessConfirmTitle': 'Enable LAN H5 access?', + 'settings.general.h5AccessConfirmBody': 'This will expose the desktop H5 app on your LAN address and port. Devices with the QR token can access desktop sessions and related controls. Continue only on a trusted network.', + 'settings.general.h5AccessConfirmEnable': 'Enable H5 access', '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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e7001a27..989ce016 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -713,26 +713,38 @@ export const zh: Record = { 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', 'settings.general.h5AccessTitle': 'H5 访问', - 'settings.general.h5AccessDescription': 'H5 token 鉴权已临时暂停,浏览器访问会直接放行。只有需要固定外部访问地址时才配置公开 URL。', + 'settings.general.h5AccessDescription': '在局域网内暴露桌面端 H5 应用。手机通过带 H5 token 的二维码快速连接。', 'settings.general.h5AccessEnabled': '启用 H5 访问', - 'settings.general.h5AccessEnabledHint': '只应在你可控的网络和浏览器来源上开启。', + 'settings.general.h5AccessEnabledHint': '桌面服务会监听局域网地址,并开放桌面会话相关能力。', + 'settings.general.h5AccessStatusEnabled': '已启用', 'settings.general.h5AccessTokenPreview': '令牌预览', 'settings.general.h5AccessDisabledValue': '未启用', + 'settings.general.h5AccessDisable': '关闭', 'settings.general.h5AccessRegenerate': '重新生成令牌', 'settings.general.h5AccessGeneratedToken': '生成的令牌', 'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。', + 'settings.general.h5AccessShowToken': '显示令牌', 'settings.general.h5AccessCopy': '复制', 'settings.general.h5AccessCopyUrl': '复制 H5 链接', + 'settings.general.h5AccessCopyLaunchUrl': '复制扫码链接', 'settings.general.h5AccessHideToken': '隐藏令牌', + 'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。', '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.h5AccessOpenHint': '当前不会要求 Authorization header,H5 请求会直接放行。', + 'settings.general.h5AccessOpenHint': '留空时使用自动探测的局域网地址。只有通过固定地址暴露 H5 时才需要填写。', 'settings.general.h5AccessSave': '保存 H5 设置', 'settings.general.h5AccessUrl': 'H5 链接', - 'settings.general.h5AccessSafetyNote': '恢复 token 鉴权前,只在你信任的网络和设备上暴露这个地址。', + 'settings.general.h5AccessQrTitle': '手机连接', + 'settings.general.h5AccessQrHint': '扫码会直接打开 H5,并自动带上 token。', + 'settings.general.h5AccessQrRefreshHint': '重新生成 token 后会创建可扫码的连接。', + 'settings.general.h5AccessQrAlt': 'H5 访问二维码', + 'settings.general.h5AccessSafetyNote': '只在可信网络中启用。拿到二维码链接的人可以访问 H5 暴露的桌面能力。', + 'settings.general.h5AccessConfirmTitle': '启用局域网 H5 访问?', + 'settings.general.h5AccessConfirmBody': '这会把桌面 H5 应用暴露到局域网地址和端口。持有二维码 token 的设备可以访问桌面会话和相关控制能力。请只在可信网络中继续。', + 'settings.general.h5AccessConfirmEnable': '启用 H5 访问', 'settings.general.h5AccessError': '更新 H5 设置失败。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index b62e7390..58de9304 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -52,16 +52,16 @@ describe('desktopRuntime browser H5 bootstrap', () => { expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(false) + expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true) + expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true) }) - it('does not require H5 auth for LAN or public browser URLs while token access is paused', () => { - expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(false) - expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(false) - expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(false) - expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(false) + it('requires H5 auth for LAN and public browser URLs', () => { + expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(true) + expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(true) + expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(true) + expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true) + expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true) }) it('clears an invalid token but preserves the remembered remote server URL', async () => { @@ -171,38 +171,37 @@ describe('desktopRuntime browser H5 bootstrap', () => { expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull() }) - it('lets localhost browser WebUI connect to a LAN-bound server without H5 token', async () => { + it('requires a token when browser WebUI connects to a LAN-bound server', async () => { window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670') globalThis.fetch = vi.fn().mockResolvedValue( new Response(null, { status: 200 }), ) as typeof fetch - await expect(initializeDesktopServerUrl()).resolves.toBe('http://192.168.0.102:28670') + await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ + name: 'H5ConnectionRequiredError', + serverUrl: 'http://192.168.0.102:28670', + reason: 'missing-token', + } satisfies Partial) expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://192.168.0.102:28670') expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) expect(clientMocks.postVerify).not.toHaveBeenCalled() - expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBeNull() - expect(globalThis.fetch).toHaveBeenCalledWith('http://192.168.0.102:28670/api/status', { - cache: 'no-store', - }) + expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe('http://192.168.0.102:28670') }) - it('lets browser H5 connect to a public server URL without H5 token', async () => { - window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp') - window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token') + it('uses and persists an H5 token from the QR launch URL', async () => { + window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp&h5Token=qr-token') globalThis.fetch = vi.fn().mockResolvedValue( new Response(null, { status: 200 }), ) as typeof fetch + clientMocks.postVerify.mockResolvedValueOnce({ ok: true }) await expect(initializeDesktopServerUrl()).resolves.toBe('https://public.example.com/app') expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('https://public.example.com/app') - expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) - expect(clientMocks.postVerify).not.toHaveBeenCalled() - expect(globalThis.fetch).toHaveBeenCalledWith('https://public.example.com/app/api/status', { - cache: 'no-store', - }) + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('qr-token') + expect(clientMocks.postVerify).toHaveBeenCalledWith('/api/h5-access/verify') + expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('qr-token') }) it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => { diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 4d84c1ea..17b3cac4 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -131,17 +131,18 @@ export async function initializeDesktopServerUrl() { } async function initializeBrowserServerUrl(fallbackUrl: string) { - const queryUrl = - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('serverUrl') - : null + const query = typeof window !== 'undefined' + ? new URLSearchParams(window.location.search) + : null + const queryUrl = query?.get('serverUrl') ?? null + const queryToken = normalizeToken(query?.get('h5Token') ?? query?.get('token')) const stored = readStoredH5Connection() const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? getConfiguredBrowserServerUrl(fallbackUrl) ?? fallbackUrl - const token = stored.token + const token = queryToken ?? stored.token const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl) setBaseUrl(requestedUrl) @@ -181,6 +182,14 @@ async function initializeBrowserServerUrl(fallbackUrl: string) { throw normalizeBrowserH5Error(error, requestedUrl) } + if (queryToken && typeof window !== 'undefined') { + try { + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, queryToken) + } catch { + // Ignore storage failures after successful verification. + } + } + return requestedUrl } @@ -269,9 +278,12 @@ export function isLoopbackHostname(hostname: string) { } export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) { - void serverUrl void browserHostname - return false + try { + return !isLoopbackHostname(new URL(serverUrl).hostname) + } catch { + return false + } } function getBrowserHostname() { diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index c0ea85f4..0dbd1777 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react' +import QRCode from 'qrcode' +import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react' import { useSettingsStore } from '../stores/settingsStore' import { useProviderStore } from '../stores/providerStore' import { useTranslation } from '../i18n' @@ -47,6 +49,23 @@ import { } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' +function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null { + if (!baseUrl) return null + + try { + const url = new URL(baseUrl) + if (token) { + url.searchParams.set('serverUrl', baseUrl) + url.searchParams.set('h5Token', token) + } + return url.toString().replace(/\/$/, '') + } catch { + return token + ? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}serverUrl=${encodeURIComponent(baseUrl)}&h5Token=${encodeURIComponent(token)}` + : baseUrl + } +} + export function Settings() { const [activeTab, setActiveTab] = useState('providers') const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab) @@ -1361,6 +1380,9 @@ function GeneralSettings() { setWebSearch, h5Access, h5AccessError, + enableH5Access, + disableH5Access, + regenerateH5AccessToken, updateH5AccessSettings, responseLanguage, setResponseLanguage, @@ -1368,11 +1390,19 @@ function GeneralSettings() { const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '') + const [h5GeneratedToken, setH5GeneratedToken] = useState(null) + const [h5TokenVisible, setH5TokenVisible] = useState(false) + const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false) + const [h5QrDataUrl, setH5QrDataUrl] = useState(null) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) const [h5ActionRunning, setH5ActionRunning] = useState(false) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) const h5AccessUrl = h5Access.publicBaseUrl + const h5LaunchUrl = useMemo( + () => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken), + [h5AccessUrl, h5GeneratedToken], + ) const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') useEffect(() => { @@ -1383,6 +1413,28 @@ function GeneralSettings() { setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '') }, [h5Access]) + useEffect(() => { + let cancelled = false + if (!h5Access.enabled || !h5LaunchUrl || !h5GeneratedToken) { + setH5QrDataUrl(null) + return () => { + cancelled = true + } + } + + QRCode.toDataURL(h5LaunchUrl, { margin: 1, width: 192 }) + .then((dataUrl) => { + if (!cancelled) setH5QrDataUrl(dataUrl) + }) + .catch(() => { + if (!cancelled) setH5QrDataUrl(null) + }) + + return () => { + cancelled = true + } + }, [h5Access.enabled, h5LaunchUrl, h5GeneratedToken]) + useEffect(() => { let cancelled = false getDesktopNotificationPermission().then((permission) => { @@ -1521,6 +1573,36 @@ function GeneralSettings() { await copyTextToClipboard(h5AccessUrl) } + const handleH5LaunchUrlCopy = async () => { + if (!h5LaunchUrl) return + await copyTextToClipboard(h5LaunchUrl) + } + + const handleH5EnableConfirm = async () => { + await runH5Action(async () => { + const token = await enableH5Access() + setH5GeneratedToken(token) + setH5TokenVisible(false) + setH5EnableConfirmOpen(false) + }) + } + + const handleH5Disable = async () => { + await runH5Action(async () => { + await disableH5Access() + setH5GeneratedToken(null) + setH5TokenVisible(false) + }) + } + + const handleH5Regenerate = async () => { + await runH5Action(async () => { + const token = await regenerateH5AccessToken() + setH5GeneratedToken(token) + setH5TokenVisible(false) + }) + } + return (
{/* Appearance selector */} @@ -1800,7 +1882,43 @@ function GeneralSettings() { {t('settings.general.h5AccessDescription')}

-
+
+ + + {h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')} + +
+ +
} aria-label={t('settings.general.h5AccessCopyUrl')} onClick={() => void handleH5UrlCopy()} > @@ -1848,6 +1967,96 @@ function GeneralSettings() {
)} + {h5Access.enabled && h5AccessUrl && ( +
+
+
+ {h5QrDataUrl ? ( + {t('settings.general.h5AccessQrAlt')} + ) : ( +
+
+
+ {t('settings.general.h5AccessQrTitle')} +
+

+ {h5GeneratedToken + ? t('settings.general.h5AccessQrHint') + : t('settings.general.h5AccessQrRefreshHint')} +

+ {h5LaunchUrl && ( +
+ {h5LaunchUrl} +
+ )} +
+ + +
+
+
+
+ )} + + {h5Access.enabled && ( +
+
+
+
+ {t('settings.general.h5AccessTokenPreview')} +
+
+ {h5TokenVisible && h5GeneratedToken + ? h5GeneratedToken + : h5Access.tokenPreview || t('settings.general.h5AccessTokenNotAvailable')} +
+
+
+ + +
+
+
+ )} +

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

@@ -1859,6 +2068,20 @@ function GeneralSettings() {
+ + { + if (!h5ActionRunning) setH5EnableConfirmOpen(false) + }} + onConfirm={handleH5EnableConfirm} + title={t('settings.general.h5AccessConfirmTitle')} + body={t('settings.general.h5AccessConfirmBody')} + confirmLabel={t('settings.general.h5AccessConfirmEnable')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={h5ActionRunning} + />
) }