From 15e1cbca95619390f5f98b755a2f40ab45674cae 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: Mon, 18 May 2026 14:41:39 +0800 Subject: [PATCH] Let desktop updates use user proxy settings Desktop update downloads currently depend on the updater HTTP stack and can miss the proxy users already rely on for GitHub access. The change enables OS proxy discovery in the native updater client and adds a narrow manual proxy fallback scoped only to update checks and package downloads. Constraint: Tauri updater carries the proxy from check through download, so changing proxy settings after a check must discard the pending update and re-check before install Rejected: Reuse provider proxy settings | provider traffic and updater traffic have different scope and persistence risks Confidence: high Scope-risk: moderate Directive: Keep update proxy settings scoped to app updates; do not mix them with model/provider proxy configuration without a separate migration decision Tested: cd desktop && bun run test -- --run src/__tests__/generalSettings.test.tsx src/stores/updateStore.test.ts src/stores/settingsStore.test.ts src-tauri/tauri-config.test.ts Tested: bun run check:desktop Tested: bun run check:native Not-tested: Full coverage gate intentionally skipped after maintainer said it was not needed --- desktop/src-tauri/Cargo.lock | 55 ++++++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/tauri-config.test.ts | 7 + .../src/__tests__/generalSettings.test.tsx | 62 +++++++- desktop/src/i18n/locales/en.ts | 11 ++ desktop/src/i18n/locales/zh.ts | 11 ++ desktop/src/pages/Settings.tsx | 147 +++++++++++++++++- desktop/src/stores/settingsStore.test.ts | 103 ++++++++++++ desktop/src/stores/settingsStore.ts | 39 +++++ desktop/src/stores/updateStore.test.ts | 67 ++++++++ desktop/src/stores/updateStore.ts | 31 +++- desktop/src/types/settings.ts | 8 + 12 files changed, 532 insertions(+), 10 deletions(-) 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