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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 14:41:39 +08:00
parent 8ff5353455
commit 15e1cbca95
12 changed files with 532 additions and 10 deletions

View File

@ -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"

View File

@ -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"] }

View File

@ -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"]')
})
})

View File

@ -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(<Settings />)
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(<Settings />)
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',
})
})
})

View File

@ -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}.',

View File

@ -1389,6 +1389,17 @@ export const zh: Record<TranslationKey, string> = {
'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}。',

View File

@ -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<string | null>(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() {
</p>
)}
<div className="mt-3 border-t border-[var(--color-border)]/60 pt-3">
<button
type="button"
onClick={() => setShowUpdateProxyAdvanced((value) => !value)}
className="flex w-full items-center justify-between gap-3 rounded-md text-left text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
aria-expanded={showUpdateProxyAdvanced}
>
<span>{t('update.proxyAdvanced')}</span>
<span className="material-symbols-outlined text-[18px]">
{showUpdateProxyAdvanced ? 'expand_less' : 'expand_more'}
</span>
</button>
{showUpdateProxyAdvanced && (
<div className="mt-3 space-y-3">
<div className="grid grid-cols-2 gap-2">
{updateProxyModes.map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => {
setUpdateProxyDraft((current) => ({ ...current, mode: mode.value }))
setUpdateProxySaveError(null)
}}
aria-pressed={updateProxyDraft.mode === mode.value}
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
updateProxyDraft.mode === mode.value
? 'border-[var(--color-brand)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="text-xs font-semibold">{mode.label}</div>
<div className="mt-1 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{mode.description}
</div>
</button>
))}
</div>
{updateProxyDraft.mode === 'manual' && (
<div>
<Input
id="update-proxy-url"
label={t('update.proxyUrl')}
value={updateProxyDraft.url}
placeholder="http://127.0.0.1:7890"
autoComplete="off"
onChange={(event) => {
setUpdateProxyDraft((current) => ({ ...current, url: event.target.value }))
setUpdateProxySaveError(null)
}}
/>
<p className={`mt-1 text-[11px] leading-4 ${manualProxyError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}>
{manualProxyError ?? t('update.proxyUrlHint')}
</p>
</div>
)}
<div className="flex items-center justify-between gap-3">
<p className="min-w-0 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{t('update.proxyScopeHint')}
</p>
<Button
size="sm"
variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!updateProxyDirty || !!manualProxyError || isSavingUpdateProxy}
loading={isSavingUpdateProxy}
onClick={() => void saveUpdateProxy()}
>
{t('update.proxySave')}
</Button>
</div>
{updateProxySaveError && (
<p className="text-[11px] leading-4 text-[var(--color-error)]">
{updateProxySaveError}
</p>
)}
</div>
)}
</div>
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">

View File

@ -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()

View File

@ -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<void>
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
enableH5Access: () => Promise<string>
disableH5Access: () => Promise<void>
regenerateH5AccessToken: () => Promise<string>
@ -96,6 +100,11 @@ const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = {
customShellPath: '',
}
const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = {
mode: 'system',
url: '',
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
@ -109,6 +118,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<UpdateProxySettings> | 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<DesktopTerminalSettings> | undefined,
): DesktopTerminalSettings {

View File

@ -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 } })

View File

@ -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<void> | 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<UpdateStore>((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<UpdateStore>((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

View File

@ -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<UpdateProxySettings>
language?: string
desktopTerminal?: Partial<DesktopTerminalSettings>
[key: string]: unknown