From 4865b4f206f4e80034b78741734e8cf5ff264ac9 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: Sun, 10 May 2026 00:14:34 +0800 Subject: [PATCH] Keep H5 settings failures visible without persisting raw tokens Limit the legacy fallback to missing H5 endpoints, preserve last known H5 settings on real load failures, and move the generated token lifetime into the General settings component so the raw token clears after copy or timeout instead of living in global state. Constraint: Task 3 remains limited to desktop settings store, UI, and tests Rejected: Treat every H5 load failure as disabled defaults | hides real outages and overwrites useful last-known state Rejected: Keep raw tokens in Zustand until manually dismissed | re-exposes secrets after reopening Settings Confidence: high Scope-risk: narrow Directive: Only 404/405 H5 endpoint misses should degrade to disabled defaults; all other H5 load failures must preserve state and surface h5AccessError Tested: cd desktop && bunx vitest run src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint Tested: git diff --check -- desktop/src/stores/settingsStore.ts desktop/src/pages/Settings.tsx desktop/src/stores/settingsStore.test.ts desktop/src/__tests__/generalSettings.test.tsx desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts --- .../src/__tests__/generalSettings.test.tsx | 122 ++++++++- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/pages/Settings.tsx | 61 +++-- desktop/src/stores/settingsStore.test.ts | 238 ++++++++++++++++++ desktop/src/stores/settingsStore.ts | 133 +++++++--- 6 files changed, 498 insertions(+), 58 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 007cb301..68a3932b 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -18,6 +18,9 @@ const desktopNotificationsMock = vi.hoisted(() => ({ requestDesktopNotificationPermission: vi.fn(), openDesktopNotificationSettings: vi.fn(), })) +const clipboardMock = vi.hoisted(() => ({ + copyTextToClipboard: vi.fn(), +})) const providerStoreState = { providers: [] as SavedProvider[], activeId: null as string | null, @@ -54,6 +57,7 @@ vi.mock('../api/providers', () => ({ })) vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock) +vi.mock('../components/chat/clipboard', () => clipboardMock) vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ ClaudeOfficialLogin: () =>
, @@ -94,6 +98,7 @@ vi.mock('../components/chat/CodeViewer', () => ({ describe('Settings > General tab', () => { beforeEach(() => { + vi.useRealTimers() MOCK_DELETE_PROVIDER.mockReset() desktopNotificationsMock.getDesktopNotificationPermission.mockReset() desktopNotificationsMock.notifyDesktop.mockReset() @@ -103,6 +108,8 @@ describe('Settings > General tab', () => { desktopNotificationsMock.notifyDesktop.mockResolvedValue(true) desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted') desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true) + clipboardMock.copyTextToClipboard.mockReset() + clipboardMock.copyTextToClipboard.mockResolvedValue(true) MOCK_GET_SETTINGS.mockResolvedValue({}) MOCK_UPDATE_SETTINGS.mockResolvedValue({}) providerStoreState.providers = [] @@ -132,7 +139,7 @@ describe('Settings > General tab', () => { allowedOrigins: [], publicBaseUrl: null, }, - h5AccessGeneratedToken: null, + h5AccessError: null, setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ thinkingEnabled: enabled }) }), @@ -145,11 +152,10 @@ describe('Settings > General tab', () => { setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), - enableH5Access: vi.fn(), + enableH5Access: vi.fn().mockResolvedValue('h5_default_generated_token'), disableH5Access: vi.fn(), - regenerateH5AccessToken: vi.fn(), + regenerateH5AccessToken: vi.fn().mockResolvedValue('h5_default_regenerated_token'), updateH5AccessSettings: vi.fn(), - clearH5AccessGeneratedToken: vi.fn(), }) useUIStore.setState({ pendingSettingsTab: null }) @@ -280,6 +286,17 @@ describe('Settings > General tab', () => { allowedOrigins: ['https://phone.example'], publicBaseUrl: 'https://phone.example/app', }, + regenerateH5AccessToken: vi.fn().mockImplementation(async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5d4e5f6', + allowedOrigins: ['https://phone.example'], + publicBaseUrl: 'https://phone.example/app', + }, + }) + return 'h5_regenerated_secret_token' + }), }) render() @@ -293,6 +310,103 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1) }) + it('copies the generated H5 token and clears it after a successful copy', async () => { + useSettingsStore.setState({ + enableH5Access: vi.fn().mockImplementation(async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5z1y2x3', + allowedOrigins: [], + publicBaseUrl: null, + }, + }) + return 'h5_secret_token' + }), + }) + 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(await within(section).findByText('h5_secret_token')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Copy' })) + }) + + expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('h5_secret_token') + expect(within(section).queryByText('h5_secret_token')).not.toBeInTheDocument() + }) + + it('copies the H5 URL when available', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5url123', + 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: 'Copy H5 URL' })) + }) + + expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('https://phone.example/app') + }) + + it('clears the generated token after the visibility timeout', async () => { + vi.useFakeTimers() + useSettingsStore.setState({ + enableH5Access: vi.fn().mockImplementation(async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5timeout', + allowedOrigins: [], + publicBaseUrl: null, + }, + }) + return 'h5_timeout_token' + }), + }) + 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(within(section).getByText('h5_timeout_token')).toBeInTheDocument() + + await act(async () => { + vi.advanceTimersByTime(30_000) + }) + + expect(within(section).queryByText('h5_timeout_token')).not.toBeInTheDocument() + }) + + it('shows the H5-specific store error when the H5 settings load failed', () => { + useSettingsStore.setState({ h5AccessError: 'H5 unavailable' }) + render() + + fireEvent.click(screen.getByText('General')) + + const section = screen.getByRole('region', { name: 'H5 Access' }) + expect(within(section).getByText('H5 unavailable')).toBeInTheDocument() + }) + it('updates H5 public URL and allowed origins from General settings', async () => { useSettingsStore.setState({ h5Access: { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 924cd725..09548318 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -677,6 +677,7 @@ export const en = { '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.h5AccessCopyUrl': 'Copy H5 URL', 'settings.general.h5AccessHideToken': 'Hide token', 'settings.general.h5AccessPublicUrl': 'Public URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 32199f45..3f1be41b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -679,6 +679,7 @@ export const zh: Record = { 'settings.general.h5AccessGeneratedToken': '生成的令牌', 'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。', 'settings.general.h5AccessCopy': '复制', + 'settings.general.h5AccessCopyUrl': '复制 H5 链接', 'settings.general.h5AccessHideToken': '隐藏令牌', 'settings.general.h5AccessPublicUrl': '公开访问 URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 05ecf21d..909af0c2 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -47,6 +47,8 @@ import { } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' +const H5_GENERATED_TOKEN_TIMEOUT_MS = 30_000 + export function Settings() { const [activeTab, setActiveTab] = useState('providers') const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab) @@ -1358,21 +1360,20 @@ function GeneralSettings() { webSearch, setWebSearch, h5Access, - h5AccessGeneratedToken, + h5AccessError, 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 [generatedH5Token, setGeneratedH5Token] = useState(null) 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 = @@ -1388,6 +1389,18 @@ function GeneralSettings() { setH5AllowedOriginsDraft(serializeAllowedOrigins(h5Access.allowedOrigins)) }, [h5Access]) + useEffect(() => { + if (!generatedH5Token) return + + const timeout = window.setTimeout(() => { + setGeneratedH5Token(null) + }, H5_GENERATED_TOKEN_TIMEOUT_MS) + + return () => { + window.clearTimeout(timeout) + } + }, [generatedH5Token]) + useEffect(() => { let cancelled = false getDesktopNotificationPermission().then((permission) => { @@ -1477,11 +1490,10 @@ 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')) + } catch { + // The store owns H5-specific error state. } finally { setH5ActionRunning(false) } @@ -1490,10 +1502,12 @@ function GeneralSettings() { const handleH5AccessToggle = async (enabled: boolean) => { await runH5Action(async () => { if (enabled) { - await enableH5Access() + const token = await enableH5Access() + setGeneratedH5Token(token) return } + setGeneratedH5Token(null) await disableH5Access() }) } @@ -1507,8 +1521,17 @@ function GeneralSettings() { }) } - const handleH5Copy = async (value: string) => { - await copyTextToClipboard(value) + const handleGeneratedH5TokenCopy = async () => { + if (!generatedH5Token) return + const copied = await copyTextToClipboard(generatedH5Token) + if (copied) { + setGeneratedH5Token(null) + } + } + + const handleH5UrlCopy = async () => { + if (!h5AccessUrl) return + await copyTextToClipboard(h5AccessUrl) } return ( @@ -1682,7 +1705,10 @@ function GeneralSettings() {
- {h5AccessGeneratedToken && ( + {generatedH5Token && (
@@ -1700,21 +1726,21 @@ function GeneralSettings() {
- {h5AccessGeneratedToken} + {generatedH5Token}

{t('settings.general.h5AccessGeneratedTokenHint')} @@ -1769,7 +1795,8 @@ function GeneralSettings() { size="sm" variant="secondary" className="shrink-0" - onClick={() => void handleH5Copy(h5AccessUrl)} + aria-label={t('settings.general.h5AccessCopyUrl')} + onClick={() => void handleH5UrlCopy()} > {t('settings.general.h5AccessCopy')} @@ -1780,9 +1807,9 @@ function GeneralSettings() {

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

- {h5Error && ( + {h5AccessError && (

- {h5Error} + {h5AccessError}

)}
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 15a84217..8559854a 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '../api/client' describe('settingsStore locale defaults', () => { beforeEach(() => { @@ -47,6 +48,22 @@ describe('settingsStore desktop notification persistence', () => { setEffort: vi.fn(), }, })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) const { useSettingsStore } = await import('./settingsStore') @@ -72,6 +89,22 @@ describe('settingsStore desktop notification persistence', () => { setEffort: vi.fn(), }, })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) const { useSettingsStore } = await import('./settingsStore') @@ -107,6 +140,22 @@ describe('settingsStore desktop notification persistence', () => { setEffort: vi.fn(), }, })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) const { useSettingsStore } = await import('./settingsStore') @@ -129,3 +178,192 @@ describe('settingsStore desktop notification persistence', () => { expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true) }) }) + +describe('settingsStore H5 access behavior', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + }) + + it.each([404, 405])('falls back to disabled defaults only for legacy H5 endpoint status %s', async (status) => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn().mockResolvedValue({}), + updateUser: vi.fn(), + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + setCurrent: vi.fn(), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockRejectedValue(new ApiError(status, { message: 'legacy' })), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5_prev', + allowedOrigins: ['https://prev.example'], + publicBaseUrl: 'https://prev.example/app', + }, + }) + + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().h5Access).toEqual({ + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }) + expect(useSettingsStore.getState().h5AccessError).toBeNull() + }) + + it('preserves the last known H5 state and surfaces an H5 error on non-legacy load failures', async () => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn().mockResolvedValue({}), + updateUser: vi.fn(), + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + setCurrent: vi.fn(), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockRejectedValue(new ApiError(500, { message: 'H5 unavailable' })), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5_prev', + allowedOrigins: ['https://prev.example'], + publicBaseUrl: 'https://prev.example/app', + }, + }) + + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().h5Access).toEqual({ + enabled: true, + tokenPreview: 'h5_prev', + allowedOrigins: ['https://prev.example'], + publicBaseUrl: 'https://prev.example/app', + }) + expect(useSettingsStore.getState().h5AccessError).toBe('H5 unavailable') + }) + + it('handles H5 enable, regenerate, and disable transitions without persisting a raw token in store state', async () => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(), + updateUser: vi.fn(), + getPermissionMode: vi.fn(), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn(), + getCurrent: vi.fn(), + setCurrent: vi.fn(), + getEffort: vi.fn(), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn(), + enable: vi.fn().mockResolvedValue({ + settings: { + enabled: true, + tokenPreview: 'h5_first', + allowedOrigins: [], + publicBaseUrl: null, + }, + token: 'raw-enable-token', + }), + disable: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + regenerate: vi.fn().mockResolvedValue({ + settings: { + enabled: true, + tokenPreview: 'h5_second', + allowedOrigins: ['https://phone.example'], + publicBaseUrl: 'https://phone.example/app', + }, + token: 'raw-regenerated-token', + }), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await expect(useSettingsStore.getState().enableH5Access()).resolves.toBe('raw-enable-token') + expect(useSettingsStore.getState().h5Access).toEqual({ + enabled: true, + tokenPreview: 'h5_first', + allowedOrigins: [], + publicBaseUrl: null, + }) + + await expect(useSettingsStore.getState().regenerateH5AccessToken()).resolves.toBe('raw-regenerated-token') + expect(useSettingsStore.getState().h5Access).toEqual({ + enabled: true, + tokenPreview: 'h5_second', + allowedOrigins: ['https://phone.example'], + publicBaseUrl: 'https://phone.example/app', + }) + + await expect(useSettingsStore.getState().disableH5Access()).resolves.toBeUndefined() + expect(useSettingsStore.getState().h5Access).toEqual({ + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }) + expect(useSettingsStore.getState().h5AccessError).toBeNull() + expect('h5AccessGeneratedToken' in useSettingsStore.getState()).toBe(false) + }) +}) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 0dd58078..82e82f35 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { ApiError } from '../api/client' import { settingsApi } from '../api/settings' import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' @@ -30,7 +31,7 @@ type SettingsStore = { desktopNotificationsEnabled: boolean webSearch: WebSearchSettings h5Access: H5AccessSettings - h5AccessGeneratedToken: string | null + h5AccessError: string | null isLoading: boolean error: string | null @@ -45,14 +46,13 @@ type SettingsStore = { setSkipWebFetchPreflight: (enabled: boolean) => Promise setDesktopNotificationsEnabled: (enabled: boolean) => Promise setWebSearch: (settings: WebSearchSettings) => Promise - enableH5Access: () => Promise + enableH5Access: () => Promise disableH5Access: () => Promise - regenerateH5AccessToken: () => Promise + regenerateH5AccessToken: () => Promise updateH5AccessSettings: (input: { allowedOrigins?: string[] publicBaseUrl?: string | null }) => Promise - clearH5AccessGeneratedToken: () => void } const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = { @@ -75,20 +75,21 @@ export const useSettingsStore = create((set, get) => ({ desktopNotificationsEnabled: false, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, h5Access: DEFAULT_H5_ACCESS_SETTINGS, - h5AccessGeneratedToken: null, + h5AccessError: null, isLoading: false, error: null, fetchAll: async () => { set({ isLoading: true, error: null }) try { - const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessRes] = await Promise.all([ + const previousH5Access = get().h5Access + const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult] = await Promise.all([ settingsApi.getPermissionMode(), modelsApi.list(), modelsApi.getCurrent(), modelsApi.getEffort(), settingsApi.getUser(), - h5AccessApi.get().catch(() => ({ settings: DEFAULT_H5_ACCESS_SETTINGS })), + loadH5AccessSettings(previousH5Access), ]) const theme = userSettings.theme === 'dark' ? 'dark' : 'light' useUIStore.getState().setTheme(theme) @@ -103,8 +104,8 @@ export const useSettingsStore = create((set, get) => ({ skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true, webSearch: normalizeWebSearchSettings(userSettings.webSearch), - h5Access: normalizeH5AccessSettings(h5AccessRes.settings), - h5AccessGeneratedToken: null, + h5Access: h5AccessResult.settings, + h5AccessError: h5AccessResult.error, isLoading: false, error: null, }) @@ -117,11 +118,8 @@ export const useSettingsStore = create((set, get) => ({ }, fetchH5Access: async () => { - const { settings } = await h5AccessApi.get() - set({ - h5Access: normalizeH5AccessSettings(settings), - h5AccessGeneratedToken: null, - }) + const result = await loadH5AccessSettings(get().h5Access) + set({ h5Access: result.settings, h5AccessError: result.error }) }, setPermissionMode: async (mode) => { @@ -220,38 +218,61 @@ export const useSettingsStore = create((set, get) => ({ }, enableH5Access: async () => { - const { settings, token } = await h5AccessApi.enable() - set({ - h5Access: normalizeH5AccessSettings(settings), - h5AccessGeneratedToken: token, - }) + set({ h5AccessError: null }) + try { + const { settings, token } = await h5AccessApi.enable() + set({ + h5Access: normalizeH5AccessSettings(settings), + h5AccessError: null, + }) + return token + } catch (error) { + set({ h5AccessError: getErrorMessage(error, 'Failed to enable H5 access.') }) + throw error + } }, disableH5Access: async () => { - const { settings } = await h5AccessApi.disable() - set({ - h5Access: normalizeH5AccessSettings(settings), - h5AccessGeneratedToken: null, - }) + set({ h5AccessError: null }) + try { + const { settings } = await h5AccessApi.disable() + set({ + h5Access: normalizeH5AccessSettings(settings), + h5AccessError: null, + }) + } catch (error) { + set({ h5AccessError: getErrorMessage(error, 'Failed to disable H5 access.') }) + throw error + } }, regenerateH5AccessToken: async () => { - const { settings, token } = await h5AccessApi.regenerate() - set({ - h5Access: normalizeH5AccessSettings(settings), - h5AccessGeneratedToken: token, - }) + set({ h5AccessError: null }) + try { + const { settings, token } = await h5AccessApi.regenerate() + set({ + h5Access: normalizeH5AccessSettings(settings), + h5AccessError: null, + }) + return token + } catch (error) { + set({ h5AccessError: getErrorMessage(error, 'Failed to regenerate the H5 token.') }) + throw error + } }, updateH5AccessSettings: async (input) => { - const { settings } = await h5AccessApi.update(input) - set({ - h5Access: normalizeH5AccessSettings(settings), - }) - }, - - clearH5AccessGeneratedToken: () => { - set({ h5AccessGeneratedToken: null }) + set({ h5AccessError: null }) + try { + const { settings } = await h5AccessApi.update(input) + set({ + h5Access: normalizeH5AccessSettings(settings), + h5AccessError: null, + }) + } catch (error) { + set({ h5AccessError: getErrorMessage(error, 'Failed to update H5 access settings.') }) + throw error + } }, })) @@ -271,3 +292,41 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac publicBaseUrl: settings?.publicBaseUrl ?? null, } } + +async function loadH5AccessSettings(previousH5Access: H5AccessSettings): Promise<{ + settings: H5AccessSettings + error: string | null +}> { + try { + const { settings } = await h5AccessApi.get() + return { + settings: normalizeH5AccessSettings(settings), + error: null, + } + } catch (error) { + if (isLegacyH5EndpointError(error)) { + return { + settings: DEFAULT_H5_ACCESS_SETTINGS, + error: null, + } + } + + return { + settings: previousH5Access, + error: getErrorMessage(error, 'Failed to load H5 access settings.'), + } + } +} + +function isLegacyH5EndpointError(error: unknown) { + const status = error instanceof ApiError + ? error.status + : typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number' + ? error.status + : null + return status === 404 || status === 405 +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback +}