feat(desktop): first-run onboarding, OS-language default, native polish

This commit is contained in:
zhayujie
2026-06-24 16:38:51 +08:00
parent ab6f49a822
commit 44b61684ed
13 changed files with 470 additions and 41 deletions

4
.gitignore vendored
View File

@@ -54,3 +54,7 @@ desktop/build/*
!desktop/build/cowagent-backend.spec !desktop/build/cowagent-backend.spec
!desktop/build/requirements-desktop.txt !desktop/build/requirements-desktop.txt
!desktop/build/build-backend.sh !desktop/build/build-backend.sh
# Icon authoring scratch dir: intermediate assets used to produce the final
# icons. Only the finished icons under desktop/resources/ should be committed.
desktop/resources/.icon-work/

View File

@@ -60,9 +60,7 @@
"from": "resources", "from": "resources",
"to": ".", "to": ".",
"filter": [ "filter": [
"icon.png", "icon.png"
"trayTemplate.png",
"trayTemplate@2x.png"
] ]
} }
], ],

Binary file not shown.

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -7,6 +7,10 @@ import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray' import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater' import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
// where the default Electron binary would otherwise report "Electron".
app.setName('CowAgent')
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null let pythonBackend: PythonBackend | null = null
// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed. // True once the user explicitly quits (menu/tray), so close-to-tray is bypassed.
@@ -43,16 +47,6 @@ function getIconPath(ext: string = 'png'): string | undefined {
return undefined return undefined
} }
// Resolve the optional monochrome macOS menu-bar template icon. Returns
// undefined when the asset isn't present, so the tray falls back gracefully.
function getTrayTemplatePath(): string | undefined {
const file = 'trayTemplate.png'
const p = isDev
? path.resolve(__dirname, '../../resources', file)
: path.join(process.resourcesPath, file)
return fs.existsSync(p) ? p : undefined
}
const isMac = process.platform === 'darwin' const isMac = process.platform === 'darwin'
const isWin = process.platform === 'win32' const isWin = process.platform === 'win32'
@@ -224,6 +218,12 @@ function setupIPC() {
ipcMain.handle('update-check', () => checkForUpdates()) ipcMain.handle('update-check', () => checkForUpdates())
ipcMain.handle('update-download', () => startDownload()) ipcMain.handle('update-download', () => startDownload())
ipcMain.handle('update-install', () => quitAndInstall()) ipcMain.handle('update-install', () => quitAndInstall())
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
// to pick a sensible default UI language on first run before any paint.
ipcMain.on('get-system-locale', (event) => {
event.returnValue = app.getLocale() || app.getSystemLocale?.() || ''
})
} }
function emitMaximizeState() { function emitMaximizeState() {
@@ -265,17 +265,18 @@ app.whenReady().then(async () => {
setupIPC() setupIPC()
createWindow() createWindow()
buildAppMenu(() => mainWindow) buildAppMenu(() => mainWindow)
// No menu-bar tray on macOS — the Dock + window controls are enough there.
// Keep the tray on Windows/Linux where minimizing to a tray icon is expected.
if (!isMac) {
createTray({ createTray({
getWindow: () => mainWindow, getWindow: () => mainWindow,
iconPath: getIconPath('png'), iconPath: getIconPath('png'),
// Monochrome menu-bar icon for macOS; falls back to the colored icon when
// the template asset is absent (see createTray).
templateIconPath: getTrayTemplatePath(),
onQuit: () => { onQuit: () => {
isQuitting = true isQuitting = true
app.quit() app.quit()
}, },
}) })
}
await startBackend() await startBackend()
// Wire auto-update and do a first silent check a few seconds after launch so // Wire auto-update and do a first silent check a few seconds after launch so

View File

@@ -50,4 +50,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
}, },
platform: process.platform, platform: process.platform,
// OS UI language (e.g. "zh-CN"), read synchronously so the renderer can pick
// a default language on first run. Falls back to '' if unavailable.
systemLocale: (() => {
try {
return ipcRenderer.sendSync('get-system-locale') as string
} catch {
return ''
}
})(),
}) })

View File

@@ -6,31 +6,21 @@ interface TrayDeps {
getWindow: () => BrowserWindow | null getWindow: () => BrowserWindow | null
// Colored icon used on Windows/Linux trays. // Colored icon used on Windows/Linux trays.
iconPath?: string iconPath?: string
// Monochrome (black + alpha) template icon for the macOS menu bar; renders
// correctly in both light and dark menu bars when set as a template image.
templateIconPath?: string
// Called when the user picks "Quit" so the app can fully exit. // Called when the user picks "Quit" so the app can fully exit.
onQuit: () => void onQuit: () => void
} }
// Build a system tray icon with a minimal menu. The tray lets users restore the // Build a system tray icon with a minimal menu (Windows/Linux only — macOS
// window after closing it to the background and start a new chat quickly. // uses the Dock instead). Lets users restore the window after closing it to the
export function createTray({ getWindow, iconPath, templateIconPath, onQuit }: TrayDeps): Tray | null { // background and start a new chat quickly.
export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null {
if (tray) return tray if (tray) return tray
if (!iconPath) return null
const isMac = process.platform === 'darwin' let image = nativeImage.createFromPath(iconPath)
// Prefer the monochrome template icon on macOS (menu-bar convention).
const sourcePath = isMac && templateIconPath ? templateIconPath : iconPath
if (!sourcePath) return null
let image = nativeImage.createFromPath(sourcePath)
if (image.isEmpty()) return null if (image.isEmpty()) return null
// Tray icons render small; resize to avoid an oversized image on some platforms. // Tray icons render small; resize to avoid an oversized image on some platforms.
image = image.resize({ width: 18, height: 18 }) image = image.resize({ width: 18, height: 18 })
// A template image must be pure black + alpha; macOS then auto-inverts it for
// light/dark menu bars. Only mark it as such when we actually loaded the
// dedicated template asset (a colored icon as template would render as a blob).
if (isMac && templateIconPath) image.setTemplateImage(true)
tray = new Tray(image) tray = new Tray(image)
tray.setToolTip(app.name) tray.setToolTip(app.name)

View File

@@ -10,6 +10,8 @@ import { usePlatform } from './hooks/usePlatform'
import { useUIStore } from './store/uiStore' import { useUIStore } from './store/uiStore'
import { useSessionStore } from './store/sessionStore' import { useSessionStore } from './store/sessionStore'
import { initUpdateListener } from './store/updateStore' import { initUpdateListener } from './store/updateStore'
import { useOnboardingStore } from './store/onboardingStore'
import OnboardingWizard from './components/OnboardingWizard'
import apiClient from './api/client' import apiClient from './api/client'
import { t } from './i18n' import { t } from './i18n'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
@@ -27,12 +29,44 @@ const App: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { isWin } = usePlatform() const { isWin } = usePlatform()
const { sessionsCollapsed, toggleSessions } = useUIStore() const { sessionsCollapsed, toggleSessions } = useUIStore()
const onboardingOpen = useOnboardingStore((s) => s.open)
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
const [, forceUpdate] = useState(0) const [, forceUpdate] = useState(0)
useEffect(() => { useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl) if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl]) }, [backend.status, backend.baseUrl])
// First-run check: once the backend is ready, decide whether to show the
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
// configured (and not dismissed earlier this session); no persisted flag.
useEffect(() => {
if (backend.status !== 'ready') return
let cancelled = false
apiClient
.getModels()
.then((data) => {
if (cancelled) return
const chat = data.capabilities?.chat
// "Configured" needs a chat provider+model AND that provider's API key
// set. A default config can ship a model name with no key, which
// shouldn't count as ready — otherwise we'd skip onboarding for users
// who still need to enter a key.
const providerId = chat?.current_provider
const provider = data.providers?.find((p) => p.id === providerId)
const keyReady = !!provider && (provider.configured || (provider.is_custom && !!provider.custom_name))
const configured = !!providerId && !!chat?.current_model && keyReady
maybeOpenOnboarding(configured)
})
.catch(() => {
// If models can't be loaded, fall back to the flag-only decision.
if (!cancelled) maybeOpenOnboarding(false)
})
return () => {
cancelled = true
}
}, [backend.status, maybeOpenOnboarding])
// Subscribe to auto-update status from the main process (no-op in dev). // Subscribe to auto-update status from the main process (no-op in dev).
useEffect(() => initUpdateListener(), []) useEffect(() => initUpdateListener(), [])
@@ -62,6 +96,7 @@ const App: React.FC = () => {
return ( return (
<div className="flex h-screen overflow-hidden bg-base text-content"> <div className="flex h-screen overflow-hidden bg-base text-content">
{onboardingOpen && <OnboardingWizard onDone={handleLangChange} />}
<NavRail onLangChange={handleLangChange} /> <NavRail onLangChange={handleLangChange} />
{showSessions && <SessionList />} {showSessions && <SessionList />}

View File

@@ -0,0 +1,293 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Sparkles, KeyRound, Loader2, ArrowRight, ArrowLeft, ExternalLink } from 'lucide-react'
import { t, getLang, setLang, type Lang } from '../i18n'
import apiClient from '../api/client'
import type { ModelsData } from '../types'
import { Field, Dropdown, TextInput, type DropdownOption } from '../pages/settings/primitives'
import { resolveModels, providerLabel } from '../pages/settings/modelsHelpers'
import { useOnboardingStore } from '../store/onboardingStore'
interface OnboardingWizardProps {
// Called after the wizard finishes so the host can refresh language/state.
onDone: () => void
}
const TOTAL_STEPS = 2
// Optional "where to get an API key" console link, per provider.
const PROVIDER_KEY_CONSOLE: Record<string, string> = {
linkai: 'https://link-ai.tech/console/interface',
}
// First-run guided setup: language -> chat model (provider + key + model).
// After saving the model the user goes straight into the chat (no extra
// confirmation step). Rendered as a full-screen overlay above the main UI;
// reuses the same models API and primitives as the settings page.
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onDone }) => {
const finish = useOnboardingStore((s) => s.finish)
const [step, setStep] = useState(1)
const [lang, setLangState] = useState<Lang>(getLang())
const [models, setModels] = useState<ModelsData | null>(null)
// Step 2 form state.
const [provider, setProvider] = useState('')
const [apiKey, setApiKey] = useState('')
const [apiBase, setApiBase] = useState('')
const [model, setModel] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
// Load the models console data once for the provider/model dropdowns.
useEffect(() => {
apiClient
.getModels()
.then(setModels)
.catch(() => setError(t('onboarding_save_failed')))
}, [])
// Persist the auto-detected default language on first show so the pre-selected
// option (driven by OS locale) also reaches the backend, even if the user
// doesn't tap the language buttons.
useEffect(() => {
if (!localStorage.getItem('cow_lang')) switchLang(lang)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const providerOptions: DropdownOption[] = useMemo(() => {
const chat = models?.capabilities?.chat
const ids = chat?.providers || []
return ids.map((id) => ({ value: id, label: providerLabel(models, id) }))
}, [models])
const modelOptions: DropdownOption[] = useMemo(() => {
return resolveModels(models, provider, models?.capabilities?.chat?.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
}, [models, provider])
// The currently selected provider's api_base placeholder/default, if any.
const providerMeta = models?.providers?.find((p) => p.id === provider)
const apiBasePlaceholder = providerMeta?.api_base_placeholder || providerMeta?.api_base_default
const handleProvider = (id: string) => {
setProvider(id)
setApiBase('')
const first = resolveModels(models, id, models?.capabilities?.chat?.provider_models)[0]
setModel(first?.value || '')
}
const switchLang = (next: Lang) => {
setLang(next)
setLangState(next)
// Mirror the choice to the backend so the agent/logs use the same language
// (matches BasicSettings). Non-blocking: the UI already switched locally.
apiClient.updateConfig({ cow_lang: next }).catch(() => {})
}
// Step 1 (language) can always advance; step 2 needs a provider, key, model.
const canNext = step === 1 || (!!provider && !!apiKey.trim() && !!model)
const goNext = async () => {
setError('')
// Step 1 (language) just advances to the model step.
if (step === 1) {
setStep(2)
return
}
// Step 2 is the last step: persist the provider credentials, point the chat
// capability at it, then finish straight into the chat (no extra step).
setSaving(true)
try {
await apiClient.modelsAction({
action: 'set_provider',
provider_id: provider,
api_key: apiKey.trim(),
...(apiBase.trim() ? { api_base: apiBase.trim() } : {}),
})
await apiClient.modelsAction({
action: 'set_capability',
capability: 'chat',
provider_id: provider,
model,
})
} catch {
setSaving(false)
setError(t('onboarding_save_failed'))
return
}
setSaving(false)
complete()
}
const goBack = () => {
setError('')
setStep((s) => Math.max(1, s - 1))
}
const complete = () => {
finish()
onDone()
}
const stepLabel = t('onboarding_step').replace('{n}', String(step)).replace('{total}', String(TOTAL_STEPS))
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-base">
<div className="w-full max-w-lg px-8">
{/* Progress dots */}
<div className="flex items-center justify-center gap-2 mb-8">
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i + 1 === step ? 'w-8 bg-accent' : i + 1 < step ? 'w-4 bg-accent/50' : 'w-4 bg-surface-2'
}`}
/>
))}
</div>
{step === 1 && (
<div className="text-center space-y-6">
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
<Sparkles size={30} />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-content">{t('onboarding_welcome_title')}</h1>
<p className="text-sm text-content-secondary">{t('onboarding_welcome_desc')}</p>
</div>
<div className="max-w-xs mx-auto text-left">
<Field label={t('onboarding_lang_label')}>
<div className="grid grid-cols-2 gap-2">
{(['zh', 'en'] as Lang[]).map((l) => (
<button
key={l}
onClick={() => switchLang(l)}
className={`px-4 py-2.5 rounded-btn border text-sm font-medium cursor-pointer transition-colors ${
lang === l
? 'border-accent bg-accent-soft text-accent'
: 'border-strong text-content-secondary hover:bg-surface-2'
}`}
>
{l === 'zh' ? '简体中文' : 'English'}
</button>
))}
</div>
</Field>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
<KeyRound size={28} />
</div>
<h1 className="text-2xl font-bold text-content">{t('onboarding_model_title')}</h1>
<p className="text-sm text-content-secondary">{t('onboarding_model_desc')}</p>
</div>
<div className="space-y-4">
<Field label={t('onboarding_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('onboarding_select_provider')}
onChange={handleProvider}
/>
</Field>
{provider && (
<>
<Field label={t('onboarding_apikey')}>
<TextInput
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('onboarding_apikey_placeholder')}
className="font-mono"
/>
{PROVIDER_KEY_CONSOLE[provider] && (
<a
href={PROVIDER_KEY_CONSOLE[provider]}
target="_blank"
rel="noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:underline"
>
{t('onboarding_key_guide')}
<ExternalLink size={11} />
</a>
)}
</Field>
{providerMeta?.api_base_field && (
<Field label={t('onboarding_apibase')}>
<TextInput
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={apiBasePlaceholder || ''}
className="font-mono"
/>
</Field>
)}
<Field label={t('onboarding_model')}>
<Dropdown
value={model}
options={modelOptions}
placeholder={t('onboarding_select_model')}
onChange={setModel}
/>
</Field>
</>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
</div>
)}
{/* Footer controls */}
<div className="mt-10 flex items-center justify-between">
<div className="text-xs text-content-tertiary">{stepLabel}</div>
<div className="flex items-center gap-2">
{/* Step 2: back to language. */}
{step === 2 && (
<button
onClick={goBack}
disabled={saving}
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
<ArrowLeft size={15} />
{t('onboarding_back')}
</button>
)}
{/* Skip is available on every step: dismiss and go straight to chat. */}
<button
onClick={complete}
disabled={saving}
className="px-4 py-2 rounded-btn text-sm font-medium text-content-tertiary hover:text-content cursor-pointer transition-colors disabled:opacity-50"
>
{t('onboarding_skip')}
</button>
{/* Primary action: advance on step 1, save + finish on the last step. */}
<button
onClick={goNext}
disabled={!canNext || saving}
className="px-5 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{saving && <Loader2 size={15} className="animate-spin" />}
{saving
? t('onboarding_saving')
: step === TOTAL_STEPS
? t('onboarding_finish')
: t('onboarding_next')}
{!saving && <ArrowRight size={15} />}
</button>
</div>
</div>
</div>
</div>
)
}
export default OnboardingWizard

View File

@@ -37,6 +37,29 @@ const translations: Record<string, Record<string, string>> = {
update_restart: '重启以更新', update_restart: '重启以更新',
update_later: '稍后', update_later: '稍后',
update_latest: '已是最新版本', update_latest: '已是最新版本',
// onboarding
onboarding_welcome_title: '欢迎使用 CowAgent',
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
onboarding_lang_label: '界面语言',
onboarding_model_title: '配置对话模型',
onboarding_model_desc: '选择模型厂商并填入 API Key即可开始使用。',
onboarding_provider: '模型厂商',
onboarding_select_provider: '选择厂商',
onboarding_apikey: 'API Key',
onboarding_apikey_placeholder: '输入 API 密钥',
onboarding_key_guide: '没有秘钥?前往创建',
onboarding_apibase: 'API 地址(可选)',
onboarding_model: '模型',
onboarding_select_model: '选择模型',
onboarding_done_title: '一切就绪',
onboarding_done_desc: '配置完成,开始你的第一次对话吧。',
onboarding_next: '下一步',
onboarding_back: '上一步',
onboarding_skip: '跳过',
onboarding_finish: '开始对话',
onboarding_step: '第 {n} / {total} 步',
onboarding_saving: '保存中...',
onboarding_save_failed: '保存失败,请检查后重试',
sessions_title: '会话', sessions_title: '会话',
session_new: '新对话', session_new: '新对话',
session_rename: '重命名', session_rename: '重命名',
@@ -310,6 +333,29 @@ const translations: Record<string, Record<string, string>> = {
update_restart: 'Restart to update', update_restart: 'Restart to update',
update_later: 'Later', update_later: 'Later',
update_latest: 'You are up to date', update_latest: 'You are up to date',
// onboarding
onboarding_welcome_title: 'Welcome to CowAgent',
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
onboarding_lang_label: 'Language',
onboarding_model_title: 'Set up your chat model',
onboarding_model_desc: 'Pick a provider and paste its API key to get started.',
onboarding_provider: 'Provider',
onboarding_select_provider: 'Select a provider',
onboarding_apikey: 'API Key',
onboarding_apikey_placeholder: 'Enter your API key',
onboarding_key_guide: 'No key yet? Create one',
onboarding_apibase: 'API base (optional)',
onboarding_model: 'Model',
onboarding_select_model: 'Select a model',
onboarding_done_title: 'All set',
onboarding_done_desc: 'Setup complete. Start your first conversation.',
onboarding_next: 'Next',
onboarding_back: 'Back',
onboarding_skip: 'Skip',
onboarding_finish: 'Start chatting',
onboarding_step: 'Step {n} of {total}',
onboarding_saving: 'Saving...',
onboarding_save_failed: 'Save failed, please check and retry',
sessions_title: 'Chats', sessions_title: 'Chats',
session_new: 'New chat', session_new: 'New chat',
session_rename: 'Rename', session_rename: 'Rename',
@@ -549,7 +595,16 @@ const translations: Record<string, Record<string, string>> = {
export type Lang = 'zh' | 'en' export type Lang = 'zh' | 'en'
let currentLang: Lang = (localStorage.getItem('cow_lang') as Lang) || 'zh' // First-run default: follow the OS language so zh-* systems start in Chinese
// and everyone else in English. Once the user picks a language it's persisted
// in cow_lang and always wins. Falls back to 'en' when the locale is unknown.
function detectDefaultLang(): Lang {
const locale = (window.electronAPI?.systemLocale || navigator.language || '').toLowerCase()
return locale.startsWith('zh') ? 'zh' : 'en'
}
const savedLang = localStorage.getItem('cow_lang') as Lang | null
let currentLang: Lang = savedLang === 'zh' || savedLang === 'en' ? savedLang : detectDefaultLang()
export function t(key: string): string { export function t(key: string): string {
return translations[currentLang]?.[key] || translations['en']?.[key] || key return translations[currentLang]?.[key] || translations['en']?.[key] || key

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand'
// Onboarding is config-driven: the wizard auto-opens whenever the chat model
// isn't configured yet, and stops appearing once it is — no persisted "seen"
// flag that could strand a user who skipped without finishing setup.
//
// `dismissedThisSession` is an in-memory guard so that skipping doesn't
// immediately re-open the wizard within the same run; it resets on relaunch,
// so an unconfigured app will guide the user again next time.
interface OnboardingState {
// Whether the wizard overlay is currently visible.
open: boolean
// True if the user skipped/finished during THIS app session (not persisted).
dismissedThisSession: boolean
// Decide whether to auto-open on launch. Opens only when chat isn't
// configured AND it wasn't dismissed earlier this session.
maybeOpen: (chatConfigured: boolean) => void
// Open manually (e.g. from a "setup guide" entry point later).
openWizard: () => void
// Finish/skip: close and don't auto-reopen this session.
finish: () => void
// Close without marking dismissed (rarely used; kept for symmetry).
close: () => void
}
export const useOnboardingStore = create<OnboardingState>((set) => ({
open: false,
dismissedThisSession: false,
maybeOpen: (chatConfigured) =>
set((s) => {
if (chatConfigured || s.dismissedThisSession) return { open: false }
return { open: true }
}),
openWizard: () => set({ open: true }),
finish: () => set({ open: false, dismissedThisSession: true }),
close: () => set({ open: false }),
}))

View File

@@ -23,6 +23,8 @@ export interface ElectronAPI {
installUpdate?: () => Promise<void> installUpdate?: () => Promise<void>
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
platform: string platform: string
// OS UI language (e.g. "zh-CN"); used to default the language on first run.
systemLocale?: string
} }
// Mirrors UpdateStatus in src/main/updater.ts. // Mirrors UpdateStatus in src/main/updater.ts.