diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b632e51f..decb4e28 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -486,6 +486,7 @@ version = "0.2.7" dependencies = [ "anyhow", "portable-pty", + "reqwest", "serde", "serde_json", "tauri", @@ -535,6 +536,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -558,7 +569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -571,7 +582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1698,9 +1709,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3392,7 +3405,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -3512,7 +3525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4053,6 +4066,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4074,7 +4108,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -5487,6 +5521,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index dfb533c0..22e55098 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -24,3 +24,4 @@ anyhow = "1.0.102" portable-pty = "0.9.0" tauri-plugin-notification = "2" tauri-plugin-single-instance = "2" +reqwest = { version = "0.13", default-features = false, features = ["system-proxy"] } diff --git a/desktop/src-tauri/tauri-config.test.ts b/desktop/src-tauri/tauri-config.test.ts index ff32f223..21fbe15b 100644 --- a/desktop/src-tauri/tauri-config.test.ts +++ b/desktop/src-tauri/tauri-config.test.ts @@ -23,4 +23,11 @@ describe('tauri security config', () => { expect(csp).toContain('http://127.0.0.1:*') expect(csp).toContain('http://localhost:*') }) + + it('enables OS proxy discovery for updater downloads', () => { + const cargoToml = readFileSync(join(currentDir, 'Cargo.toml'), 'utf8') + + expect(cargoToml).toContain('reqwest = { version = "0.13"') + expect(cargoToml).toContain('features = ["system-proxy"]') + }) }) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 3cde1548..2cba509e 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -8,7 +8,7 @@ import { useUIStore } from '../stores/uiStore' import { useUpdateStore } from '../stores/updateStore' import type { SavedProvider } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' -import type { ThemeMode } from '../types/settings' +import type { ThemeMode, UpdateProxySettings } from '../types/settings' const MOCK_DELETE_PROVIDER = vi.fn() const MOCK_GET_SETTINGS = vi.fn() @@ -799,6 +799,13 @@ describe('Settings > Providers tab', () => { describe('Settings > About tab', () => { beforeEach(() => { useUIStore.setState({ pendingSettingsTab: 'about' }) + useSettingsStore.setState({ + locale: 'en', + updateProxy: { mode: 'system', url: '' }, + setUpdateProxy: vi.fn().mockImplementation(async (next: UpdateProxySettings) => { + useSettingsStore.setState({ updateProxy: next }) + }), + }) useUpdateStore.setState({ status: 'available', availableVersion: '0.1.5', @@ -846,4 +853,57 @@ describe('Settings > About tab', () => { expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument() expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument() }) + + it('saves a manual update proxy from the advanced update controls', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /Advanced update proxy/i })) + expect(screen.getByRole('button', { name: /System proxy/i })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByText('This only affects app update checks and downloads.')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Manual proxy/i })) + const proxyInput = screen.getByLabelText('Proxy URL') + const saveButton = screen.getByRole('button', { name: 'Save' }) + + expect(screen.getByText('Enter a proxy URL.')).toBeInTheDocument() + expect(saveButton).toBeDisabled() + + fireEvent.change(proxyInput, { target: { value: 'socks5://127.0.0.1:7890' } }) + expect(screen.getByText('Enter an HTTP or HTTPS proxy URL.')).toBeInTheDocument() + expect(saveButton).toBeDisabled() + + fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } }) + expect(screen.getByText('HTTP and HTTPS proxy URLs are supported, for example http://127.0.0.1:7890.')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(saveButton) + }) + + expect(useSettingsStore.getState().setUpdateProxy).toHaveBeenCalledWith({ + mode: 'manual', + url: 'http://127.0.0.1:7890', + }) + }) + + it('can switch update proxy settings back to system mode', async () => { + useSettingsStore.setState({ + updateProxy: { mode: 'manual', url: 'http://127.0.0.1:7890' }, + }) + render() + + fireEvent.click(screen.getByRole('button', { name: /Advanced update proxy/i })) + expect(screen.getByRole('button', { name: /Manual proxy/i })).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(screen.getByRole('button', { name: /System proxy/i })) + const saveButton = screen.getByRole('button', { name: 'Save' }) + + await act(async () => { + fireEvent.click(saveButton) + }) + + expect(useSettingsStore.getState().setUpdateProxy).toHaveBeenCalledWith({ + mode: 'system', + url: 'http://127.0.0.1:7890', + }) + }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 235dce8a..3c44a663 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1387,6 +1387,17 @@ export const en = { 'update.later': 'Later', 'update.progress': 'Downloading update... {progress}%', 'update.progressBytes': 'Downloading update... {downloaded} downloaded', + 'update.proxyAdvanced': 'Advanced update proxy', + 'update.proxyModeManual': 'Manual proxy', + 'update.proxyModeManualDescription': 'Use a local HTTP proxy address you provide.', + 'update.proxyModeSystem': 'System proxy', + 'update.proxyModeSystemDescription': 'Use the OS HTTP/HTTPS proxy automatically.', + 'update.proxySave': 'Save', + 'update.proxyScopeHint': 'This only affects app update checks and downloads.', + 'update.proxyUrl': 'Proxy URL', + 'update.proxyUrlHint': 'HTTP and HTTPS proxy URLs are supported, for example http://127.0.0.1:7890.', + 'update.proxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.', + 'update.proxyUrlRequired': 'Enter a proxy URL.', 'update.releaseNotes': 'Release Notes', 'update.restarting': 'Restarting to finish update...', 'update.upToDate': 'You are up to date on v{version}.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 750cfd9f..07ecbea3 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1389,6 +1389,17 @@ export const zh: Record = { 'update.later': '稍后', 'update.progress': '正在下载更新... {progress}%', 'update.progressBytes': '正在下载更新... 已下载 {downloaded}', + 'update.proxyAdvanced': '高级更新代理', + 'update.proxyModeManual': '手动代理', + 'update.proxyModeManualDescription': '使用你填写的本地 HTTP 代理地址。', + 'update.proxyModeSystem': '系统代理', + 'update.proxyModeSystemDescription': '自动使用系统 HTTP/HTTPS 代理。', + 'update.proxySave': '保存', + 'update.proxyScopeHint': '这里只影响应用自身的更新检查和安装包下载。', + 'update.proxyUrl': '代理地址', + 'update.proxyUrlHint': '支持 HTTP 和 HTTPS 代理,例如 http://127.0.0.1:7890。', + 'update.proxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。', + 'update.proxyUrlRequired': '请输入代理地址。', 'update.releaseNotes': '更新说明', 'update.restarting': '正在重启以完成更新...', 'update.upToDate': '当前已是最新版本 v{version}。', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index e6599d74..7eae08cb 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' -import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' +import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, WebSearchMode } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' @@ -2818,9 +2818,20 @@ const SOCIAL_LINKS = [ { name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' }, ] as const +function isValidUpdateProxyUrl(value: string) { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + function AboutSettings() { const t = useTranslation() const [version, setVersion] = useState('') + const updateProxy = useSettingsStore((s) => s.updateProxy) + const setUpdateProxy = useSettingsStore((s) => s.setUpdateProxy) const updateStatus = useUpdateStore((s) => s.status) const availableVersion = useUpdateStore((s) => s.availableVersion) const releaseNotes = useUpdateStore((s) => s.releaseNotes) @@ -2832,6 +2843,10 @@ function AboutSettings() { const checkForUpdates = useUpdateStore((s) => s.checkForUpdates) const installUpdate = useUpdateStore((s) => s.installUpdate) const initialize = useUpdateStore((s) => s.initialize) + const [showUpdateProxyAdvanced, setShowUpdateProxyAdvanced] = useState(false) + const [updateProxyDraft, setUpdateProxyDraft] = useState(updateProxy) + const [updateProxySaveError, setUpdateProxySaveError] = useState(null) + const [isSavingUpdateProxy, setIsSavingUpdateProxy] = useState(false) useEffect(() => { let cancelled = false @@ -2854,6 +2869,11 @@ function AboutSettings() { void initialize() }, [initialize]) + useEffect(() => { + setUpdateProxyDraft(updateProxy) + setUpdateProxySaveError(null) + }, [updateProxy]) + const openUrl = (url: string) => { import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank')) } @@ -2867,6 +2887,48 @@ function AboutSettings() { day: 'numeric', }) : null + const updateProxyModes: Array<{ value: UpdateProxyMode; label: string; description: string }> = [ + { + value: 'system', + label: t('update.proxyModeSystem'), + description: t('update.proxyModeSystemDescription'), + }, + { + value: 'manual', + label: t('update.proxyModeManual'), + description: t('update.proxyModeManualDescription'), + }, + ] + const manualProxyUrl = updateProxyDraft.url.trim() + const manualProxyError = + updateProxyDraft.mode === 'manual' && !manualProxyUrl + ? t('update.proxyUrlRequired') + : updateProxyDraft.mode === 'manual' && !isValidUpdateProxyUrl(manualProxyUrl) + ? t('update.proxyUrlInvalid') + : null + const updateProxyDirty = + updateProxyDraft.mode !== updateProxy.mode || + updateProxyDraft.url.trim() !== updateProxy.url.trim() + + const saveUpdateProxy = async () => { + if (manualProxyError) { + setUpdateProxySaveError(manualProxyError) + return + } + + setIsSavingUpdateProxy(true) + setUpdateProxySaveError(null) + try { + await setUpdateProxy({ + mode: updateProxyDraft.mode, + url: manualProxyUrl, + }) + } catch (error) { + setUpdateProxySaveError(error instanceof Error ? error.message : String(error)) + } finally { + setIsSavingUpdateProxy(false) + } + } const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0 const downloadedText = formatBytes(downloadedBytes) @@ -2970,6 +3032,89 @@ function AboutSettings() {

)} +
+ + + {showUpdateProxyAdvanced && ( +
+
+ {updateProxyModes.map((mode) => ( + + ))} +
+ + {updateProxyDraft.mode === 'manual' && ( +
+ { + setUpdateProxyDraft((current) => ({ ...current, url: event.target.value })) + setUpdateProxySaveError(null) + }} + /> +

+ {manualProxyError ?? t('update.proxyUrlHint')} +

+
+ )} + +
+

+ {t('update.proxyScopeHint')} +

+ +
+ + {updateProxySaveError && ( +

+ {updateProxySaveError} +

+ )} +
+ )} +
+ {(updateStatus === 'downloading' || updateStatus === 'restarting') && (
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index d57a27d7..aae26472 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -63,6 +63,109 @@ describe('settingsStore UI zoom', () => { }) }) +describe('settingsStore update proxy persistence', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('defaults old user settings to automatic system proxy mode', 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().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') + + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().updateProxy).toEqual({ + mode: 'system', + url: '', + }) + }) + + it('persists manual update proxy settings trimmed', async () => { + const updateUser = vi.fn().mockResolvedValue({}) + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(), + updateUser, + 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(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().setUpdateProxy({ + mode: 'manual', + url: ' http://127.0.0.1:7890 ', + }) + + expect(useSettingsStore.getState().updateProxy).toEqual({ + mode: 'manual', + url: 'http://127.0.0.1:7890', + }) + expect(updateUser).toHaveBeenCalledWith({ + updateProxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }) + }) +}) + describe('settingsStore desktop notification persistence', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index e0d10d99..f2a24a2a 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -12,6 +12,8 @@ import { type EffortLevel, type ModelInfo, type ThemeMode, + type UpdateProxyMode, + type UpdateProxySettings, type WebSearchSettings, } from '../types/settings' import type { Locale } from '../i18n' @@ -54,6 +56,7 @@ type SettingsStore = { desktopNotificationsEnabled: boolean desktopTerminal: DesktopTerminalSettings webSearch: WebSearchSettings + updateProxy: UpdateProxySettings h5Access: H5AccessSettings h5AccessError: string | null responseLanguage: string @@ -73,6 +76,7 @@ type SettingsStore = { setDesktopNotificationsEnabled: (enabled: boolean) => Promise setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise setWebSearch: (settings: WebSearchSettings) => Promise + setUpdateProxy: (settings: UpdateProxySettings) => Promise enableH5Access: () => Promise disableH5Access: () => Promise regenerateH5AccessToken: () => Promise @@ -96,6 +100,11 @@ const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = { customShellPath: '', } +const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = { + mode: 'system', + url: '', +} + export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, @@ -109,6 +118,7 @@ export const useSettingsStore = create((set, get) => ({ desktopNotificationsEnabled: false, desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, + updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessError: null, responseLanguage: '', @@ -148,6 +158,7 @@ export const useSettingsStore = create((set, get) => ({ desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true, desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal), webSearch: normalizeWebSearchSettings(userSettings.webSearch), + updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), h5Access: h5AccessResult.settings, h5AccessError: h5AccessResult.error, responseLanguage: typeof userSettings.language === 'string' ? userSettings.language : '', @@ -274,6 +285,18 @@ export const useSettingsStore = create((set, get) => ({ } }, + setUpdateProxy: async (settings) => { + const prev = get().updateProxy + const next = normalizeUpdateProxySettings(settings) + set({ updateProxy: next }) + try { + await settingsApi.updateUser({ updateProxy: next }) + } catch (error) { + set({ updateProxy: prev }) + throw error + } + }, + enableH5Access: async () => { set({ h5AccessError: null }) try { @@ -351,6 +374,22 @@ function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): We } } +function isUpdateProxyMode(value: unknown): value is UpdateProxyMode { + return value === 'system' || value === 'manual' +} + +function normalizeUpdateProxySettings( + settings: Partial | undefined, +): UpdateProxySettings { + const mode = isUpdateProxyMode(settings?.mode) + ? settings.mode + : DEFAULT_UPDATE_PROXY_SETTINGS.mode + return { + mode, + url: typeof settings?.url === 'string' ? settings.url.trim() : '', + } +} + function normalizeDesktopTerminalSettings( settings: Partial | undefined, ): DesktopTerminalSettings { diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index 2d9cbf37..fad6532f 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -48,6 +48,29 @@ describe('updateStore', () => { expect(useUpdateStore.getState().shouldPrompt).toBe(true) }) + it('passes the configured manual update proxy to update checks', async () => { + const update = { + version: '0.2.0', + body: 'Bug fixes and performance improvements', + close: vi.fn().mockResolvedValue(undefined), + } + check.mockResolvedValue(update) + + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ + updateProxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }) + const { useUpdateStore } = await import('./updateStore') + + await useUpdateStore.getState().checkForUpdates() + + expect(check).toHaveBeenCalledWith({ proxy: 'http://127.0.0.1:7890' }) + }) + it('does not re-prompt for the same version after dismissing once', async () => { check.mockResolvedValue({ version: '0.2.0', @@ -133,6 +156,50 @@ describe('updateStore', () => { expect(relaunch).toHaveBeenCalledTimes(1) }) + it('refreshes the pending update when the proxy changes before install', async () => { + const staleClose = vi.fn().mockResolvedValue(undefined) + const freshDownload = vi.fn(async (onEvent?: (event: unknown) => void) => { + onEvent?.({ event: 'Started', data: { contentLength: 100 } }) + onEvent?.({ event: 'Progress', data: { chunkLength: 100 } }) + onEvent?.({ event: 'Finished' }) + }) + const freshInstall = vi.fn().mockResolvedValue(undefined) + + check + .mockResolvedValueOnce({ + version: '0.2.0', + body: 'Notes', + close: staleClose, + }) + .mockResolvedValueOnce({ + version: '0.2.0', + body: 'Notes', + download: freshDownload, + install: freshInstall, + close: vi.fn().mockResolvedValue(undefined), + }) + invoke.mockResolvedValue(undefined) + relaunch.mockResolvedValue(undefined) + + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + const { useUpdateStore } = await import('./updateStore') + + await useUpdateStore.getState().checkForUpdates() + useSettingsStore.setState({ + updateProxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }) + await useUpdateStore.getState().installUpdate() + + expect(staleClose).toHaveBeenCalledTimes(1) + expect(check).toHaveBeenNthCalledWith(2, { proxy: 'http://127.0.0.1:7890' }) + expect(freshDownload).toHaveBeenCalledTimes(1) + expect(freshInstall).toHaveBeenCalledTimes(1) + }) + it('clears the native exit guard when install fails after sidecars stop', async () => { const download = vi.fn(async (onEvent?: (event: unknown) => void) => { onEvent?.({ event: 'Started', data: { contentLength: 100 } }) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index 1feaa5cd..b345cf3f 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand' import type { Update } from '@tauri-apps/plugin-updater' import { isTauriRuntime } from '../lib/desktopRuntime' +import type { UpdateProxySettings } from '../types/settings' +import { useSettingsStore } from './settingsStore' export type UpdateStatus = | 'idle' @@ -34,6 +36,7 @@ type UpdateStore = { } let pendingUpdate: Update | null = null +let pendingUpdateProxyKey: string | null = null let startupCheckPromise: Promise | null = null function readDismissedUpdateVersion(): string | null { @@ -60,9 +63,26 @@ function writeDismissedUpdateVersion(version: string | null) { } } -async function setPendingUpdate(next: Update | null) { +function getUpdateProxyUrl(settings: UpdateProxySettings = useSettingsStore.getState().updateProxy) { + if (settings.mode !== 'manual') return null + const proxy = settings.url.trim() + return proxy || null +} + +function getUpdateProxyKey(settings: UpdateProxySettings = useSettingsStore.getState().updateProxy) { + const proxy = getUpdateProxyUrl(settings) + return proxy ? `manual:${proxy}` : 'system' +} + +function getUpdateCheckOptions() { + const proxy = getUpdateProxyUrl() + return proxy ? { proxy } : undefined +} + +async function setPendingUpdate(next: Update | null, proxyKey: string | null) { const previous = pendingUpdate pendingUpdate = next + pendingUpdateProxyKey = next ? proxyKey : null if (previous && previous !== next) { try { @@ -113,8 +133,9 @@ export const useUpdateStore = create((set, get) => ({ try { const { check } = await import('@tauri-apps/plugin-updater') - const update = await check() - await setPendingUpdate(update) + const updateProxyKey = getUpdateProxyKey() + const update = await check(getUpdateCheckOptions()) + await setPendingUpdate(update, updateProxyKey) const checkedAt = Date.now() @@ -174,6 +195,10 @@ export const useUpdateStore = create((set, get) => ({ if (!isTauriRuntime()) return let update = pendingUpdate + if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) { + await setPendingUpdate(null, null) + update = null + } if (!update) { update = await get().checkForUpdates() if (!update) return diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 4b1d985b..24c5e213 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -18,6 +18,13 @@ export type WebSearchSettings = { braveApiKey?: string } +export type UpdateProxyMode = 'system' | 'manual' + +export type UpdateProxySettings = { + mode: UpdateProxyMode + url: string +} + export type H5AccessSettings = { enabled: boolean tokenPreview: string | null @@ -54,6 +61,7 @@ export type UserSettings = { skipWebFetchPreflight?: boolean desktopNotificationsEnabled?: boolean webSearch?: WebSearchSettings + updateProxy?: Partial language?: string desktopTerminal?: Partial [key: string]: unknown