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