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')} +

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