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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 15:23:25 +08:00
parent 4ef9729ad6
commit 417e8eaa4f
6 changed files with 417 additions and 45 deletions

View File

@ -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: () => <div data-testid="claude-official-login" />,
@ -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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
@ -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',

View File

@ -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.',

View File

@ -713,26 +713,38 @@ export const zh: Record<TranslationKey, string> = {
'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 headerH5 请求会直接放行。',
'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 的域名预检,避免第三方服务商或受限网络下出现误报失败。',

View File

@ -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<H5ConnectionRequiredError>)
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 () => {

View File

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

View File

@ -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<SettingsTab>('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<string | null>(null)
const [h5TokenVisible, setH5TokenVisible] = useState(false)
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
const [h5QrDataUrl, setH5QrDataUrl] = useState<string | null>(null)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('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 (
<div className="max-w-xl">
{/* Appearance selector */}
@ -1800,7 +1882,43 @@ function GeneralSettings() {
{t('settings.general.h5AccessDescription')}
</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-1 gap-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 items-start gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-[var(--color-border)] accent-[var(--color-primary)]"
checked={h5Access.enabled}
disabled={h5ActionRunning}
aria-label={t('settings.general.h5AccessEnabled')}
onChange={(event) => {
if (event.target.checked) {
setH5EnableConfirmOpen(true)
} else {
void handleH5Disable()
}
}}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.h5AccessEnabled')}
</span>
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessEnabledHint')}
</span>
</span>
</label>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${
h5Access.enabled
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]'
}`}
>
{h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')}
</span>
</div>
<div className="mt-4 grid grid-cols-1 gap-3">
<Input
id="h5-access-public-url"
label={t('settings.general.h5AccessPublicUrl')}
@ -1839,6 +1957,7 @@ function GeneralSettings() {
size="sm"
variant="secondary"
className="shrink-0"
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
aria-label={t('settings.general.h5AccessCopyUrl')}
onClick={() => void handleH5UrlCopy()}
>
@ -1848,6 +1967,96 @@ function GeneralSettings() {
</div>
)}
{h5Access.enabled && h5AccessUrl && (
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex h-48 w-48 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-white p-3">
{h5QrDataUrl ? (
<img
src={h5QrDataUrl}
alt={t('settings.general.h5AccessQrAlt')}
className="h-full w-full"
/>
) : (
<QrCode className="h-12 w-12 text-neutral-400" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessQrTitle')}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
{h5GeneratedToken
? t('settings.general.h5AccessQrHint')
: t('settings.general.h5AccessQrRefreshHint')}
</p>
{h5LaunchUrl && (
<div className="mt-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
{h5LaunchUrl}
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5LaunchUrl || !h5GeneratedToken}
onClick={() => void handleH5LaunchUrlCopy()}
>
{t('settings.general.h5AccessCopyLaunchUrl')}
</Button>
<Button
size="sm"
variant="secondary"
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
loading={h5ActionRunning}
onClick={() => void handleH5Regenerate()}
>
{t('settings.general.h5AccessRegenerate')}
</Button>
</div>
</div>
</div>
</div>
)}
{h5Access.enabled && (
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessTokenPreview')}
</div>
<div className="mt-1 break-all text-sm text-[var(--color-text-primary)]">
{h5TokenVisible && h5GeneratedToken
? h5GeneratedToken
: h5Access.tokenPreview || t('settings.general.h5AccessTokenNotAvailable')}
</div>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button
size="sm"
variant="secondary"
icon={h5TokenVisible ? <EyeOff className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5GeneratedToken}
onClick={() => setH5TokenVisible((visible) => !visible)}
>
{h5TokenVisible ? t('settings.general.h5AccessHideToken') : t('settings.general.h5AccessShowToken')}
</Button>
<Button
size="sm"
variant="danger"
icon={<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />}
loading={h5ActionRunning}
onClick={() => void handleH5Disable()}
>
{t('settings.general.h5AccessDisable')}
</Button>
</div>
</div>
</div>
)}
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.h5AccessSafetyNote')}
</p>
@ -1859,6 +2068,20 @@ function GeneralSettings() {
</div>
</section>
</div>
<ConfirmDialog
open={h5EnableConfirmOpen}
onClose={() => {
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}
/>
</div>
)
}