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