mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
8ff5353455
commit
15e1cbca95
55
desktop/src-tauri/Cargo.lock
generated
55
desktop/src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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"]')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}.',
|
||||
|
||||
@ -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}。',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 } })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user