mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
commit
4133889893
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