From d41d5a87f075471d4c601cb3c784e8c6135aafd8 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 18:52:14 +0800 Subject: [PATCH] Make H5 access easier to discover in Settings H5 access is a user-facing desktop sharing entrypoint, so it should not be buried inside the broad General settings surface. Move it into its own Settings tab and make the empty QR state point users at the token generation action before a scan link exists. Constraint: Preserve the existing H5 store API and token lifecycle while changing only the desktop settings surface. Rejected: Keep H5 access inside General with a larger visual block | still hides a strong product surface in a catch-all page. Confidence: high Scope-risk: narrow Directive: Keep H5 access as a first-class settings entry unless the navigation model is redesigned holistically. Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx src/i18n/index.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun run verify Not-tested: Live H5 token generation against an enabled local H5 service; current local desktop state had H5 disabled. --- .../src/__tests__/generalSettings.test.tsx | 56 +- desktop/src/i18n/locales/en.ts | 5 +- desktop/src/i18n/locales/zh.ts | 5 +- desktop/src/pages/Settings.tsx | 560 +++++++++--------- desktop/src/stores/uiStore.ts | 1 + 5 files changed, 341 insertions(+), 286 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 7d4e5c65..f06f23ca 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -325,10 +325,16 @@ describe('Settings > General tab', () => { }) }) - it('renders the H5 section with access disabled by default', () => { + it('moves H5 access out of General into its own Settings tab', () => { render() fireEvent.click(screen.getByText('General')) + expect(screen.queryByRole('region', { name: 'H5 Access' })).not.toBeInTheDocument() + + const generalTab = screen.getByText('General') + const h5Tab = screen.getByText('H5 Access') + expect((generalTab.compareDocumentPosition(h5Tab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + fireEvent.click(h5Tab) const section = screen.getByRole('region', { name: 'H5 Access' }) expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked() @@ -349,7 +355,7 @@ describe('Settings > General tab', () => { }) render() - fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) fireEvent.click(within(section).getByLabelText('Enable H5 access')) @@ -376,7 +382,7 @@ describe('Settings > General tab', () => { }) render() - fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) fireEvent.click(within(section).getByLabelText('Enable H5 access')) @@ -394,6 +400,32 @@ describe('Settings > General tab', () => { ) }) + it('guides enabled H5 users to generate a token before the QR code exists', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5oldtok', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:3456', + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + + expect(within(section).getByText('Generate a token to create the QR code.')).toBeInTheDocument() + expect(within(section).getByText('Click Generate token to create a QR link that can be scanned.')).toBeInTheDocument() + expect(within(section).queryByAltText('H5 access QR code')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Generate token' })) + }) + + expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1) + expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument() + }) + it('shows the generated H5 token as a fallback when requested', async () => { useSettingsStore.setState({ h5Access: { @@ -405,7 +437,7 @@ describe('Settings > General tab', () => { }) render() - fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) fireEvent.click(within(section).getByLabelText('Enable H5 access')) @@ -418,16 +450,6 @@ describe('Settings > General tab', () => { expect(within(section).getByText('h5_default_generated_token')).toBeInTheDocument() }) - it('places H5 access after the common General settings sections', () => { - render() - - 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('copies the H5 URL when available', async () => { useSettingsStore.setState({ h5Access: { @@ -439,7 +461,7 @@ describe('Settings > General tab', () => { }) render() - fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) await act(async () => { @@ -453,7 +475,7 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5AccessError: 'H5 unavailable' }) render() - fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) expect(within(section).getByText('H5 unavailable')).toBeInTheDocument() @@ -470,7 +492,7 @@ describe('Settings > General tab', () => { }) render() - fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) fireEvent.change(within(section).getByLabelText('Public URL'), { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 69907aaf..46de2bb2 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -103,6 +103,7 @@ export const en = { 'settings.tab.permissions': 'Permissions', 'settings.tab.activity': 'Token usage', 'settings.tab.general': 'General', + 'settings.tab.h5Access': 'H5 Access', 'settings.tab.terminal': 'Terminal', 'settings.tab.skills': 'Skills', 'settings.tab.mcp': 'MCP', @@ -716,6 +717,7 @@ export const en = { 'settings.general.h5AccessTokenPreview': 'Token preview', 'settings.general.h5AccessDisabledValue': 'Disabled', 'settings.general.h5AccessDisable': 'Disable', + 'settings.general.h5AccessGenerateToken': 'Generate token', 'settings.general.h5AccessRegenerate': 'Regenerate token', 'settings.general.h5AccessGeneratedToken': 'Generated token', 'settings.general.h5AccessGeneratedTokenHint': 'Shown once. Copy it now and store it like a password.', @@ -735,7 +737,8 @@ export const en = { 'settings.general.h5AccessUrl': 'H5 URL', '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.h5AccessQrRefreshHint': 'Click Generate token to create a QR link that can be scanned.', + 'settings.general.h5AccessQrEmptyHint': 'Generate a token to create the QR code.', '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?', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a1d97761..c2e46a9e 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -105,6 +105,7 @@ export const zh: Record = { 'settings.tab.permissions': '权限', 'settings.tab.activity': 'Token 用量', 'settings.tab.general': '通用', + 'settings.tab.h5Access': 'H5 访问', 'settings.tab.terminal': '终端', 'settings.tab.skills': '技能', 'settings.tab.mcp': 'MCP', @@ -718,6 +719,7 @@ export const zh: Record = { 'settings.general.h5AccessTokenPreview': '令牌预览', 'settings.general.h5AccessDisabledValue': '未启用', 'settings.general.h5AccessDisable': '关闭', + 'settings.general.h5AccessGenerateToken': '生成令牌', 'settings.general.h5AccessRegenerate': '重新生成令牌', 'settings.general.h5AccessGeneratedToken': '生成的令牌', 'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。', @@ -737,7 +739,8 @@ export const zh: Record = { 'settings.general.h5AccessUrl': 'H5 链接', 'settings.general.h5AccessQrTitle': '手机连接', 'settings.general.h5AccessQrHint': '扫码会直接打开 H5,并自动带上 token。', - 'settings.general.h5AccessQrRefreshHint': '重新生成 token 后会创建可扫码的连接。', + 'settings.general.h5AccessQrRefreshHint': '点击“生成令牌”后会创建可扫码的连接。', + 'settings.general.h5AccessQrEmptyHint': '生成令牌后会在这里显示二维码。', 'settings.general.h5AccessQrAlt': 'H5 访问二维码', 'settings.general.h5AccessSafetyNote': '只在可信网络中启用。拿到二维码链接的人可以访问 H5 暴露的桌面能力。', 'settings.general.h5AccessConfirmTitle': '启用局域网 H5 访问?', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 0dbd1777..1f81d895 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -86,6 +86,7 @@ export function Settings() { setActiveTab('providers')} /> setActiveTab('permissions')} /> setActiveTab('general')} /> + setActiveTab('h5Access')} /> setActiveTab('adapters')} /> setActiveTab('terminal')} /> setActiveTab('mcp')} /> @@ -107,6 +108,7 @@ export function Settings() { {activeTab === 'permissions' && } {activeTab === 'activity' && } {activeTab === 'general' && } + {activeTab === 'h5Access' && } {activeTab === 'adapters' && } {activeTab === 'terminal' && } {activeTab === 'mcp' && } @@ -1378,63 +1380,19 @@ function GeneralSettings() { setDesktopNotificationsEnabled, webSearch, setWebSearch, - h5Access, - h5AccessError, - enableH5Access, - disableH5Access, - regenerateH5AccessToken, - updateH5AccessSettings, responseLanguage, setResponseLanguage, } = useSettingsStore() 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(() => { setWebSearchDraft(webSearch) }, [webSearch]) - useEffect(() => { - 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) => { @@ -1549,60 +1507,6 @@ function GeneralSettings() { } } - const runH5Action = async (action: () => Promise) => { - setH5ActionRunning(true) - try { - await action() - } catch { - // The store owns H5-specific error state. - } finally { - setH5ActionRunning(false) - } - } - - const handleH5SettingsSave = async () => { - await runH5Action(async () => { - await updateH5AccessSettings({ - publicBaseUrl: h5PublicBaseUrlDraft.trim() || null, - }) - }) - } - - const handleH5UrlCopy = async () => { - if (!h5AccessUrl) return - 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 */} @@ -1870,204 +1774,326 @@ function GeneralSettings() {
-
-
-

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

-

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

-
-
- - - {h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')} - -
+
+ ) +} -
- setH5PublicBaseUrlDraft(event.target.value)} +// ─── H5 Access Settings ────────────────────────────────────── + +function H5AccessSettings() { + const { + h5Access, + h5AccessError, + enableH5Access, + disableH5Access, + regenerateH5AccessToken, + updateH5AccessSettings, + } = useSettingsStore() + const t = useTranslation() + 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 [h5ActionRunning, setH5ActionRunning] = useState(false) + const h5AccessUrl = h5Access.publicBaseUrl + const h5LaunchUrl = useMemo( + () => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken), + [h5AccessUrl, h5GeneratedToken], + ) + const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') + + useEffect(() => { + 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]) + + const runH5Action = async (action: () => Promise) => { + setH5ActionRunning(true) + try { + await action() + } catch { + // The store owns H5-specific error state. + } finally { + setH5ActionRunning(false) + } + } + + const handleH5SettingsSave = async () => { + await runH5Action(async () => { + await updateH5AccessSettings({ + publicBaseUrl: h5PublicBaseUrlDraft.trim() || null, + }) + }) + } + + const handleH5UrlCopy = async () => { + if (!h5AccessUrl) return + 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 ( +
+
+
+
+
+
+

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

+

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

+
+
+ +
+
+
-
+ )} +
+