Land desktop update proxy support from worktree

The updater proxy fix was developed in a detached worktree based on an older local main commit. Merge it into the canonical local main while preserving the feature commit and the main branch's intervening desktop work.

Constraint: Local main contains four commits after the worktree base, so a fast-forward is not available
Rejected: Rebase detached worktree onto main | unnecessary history rewriting for a single verified feature commit
Confidence: high
Scope-risk: moderate
Directive: Do not push this local integration unless explicitly requested
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:42:07 +08:00
commit 4133889893
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