From 8669879ad0035897498e0dc8cc3a7e016bbdb26f Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 13:21:50 +0800 Subject: [PATCH] feat(welcome): show first-run welcome dialog on fresh install Introduce a one-time welcome dialog that explains CC Switch's workflow to new users: how their existing config is preserved as a "default" provider and how the bundled "Official" preset enables one-click revert. Upgrade users are excluded by checking is_providers_empty() at startup and never see the dialog. Persistence follows the existing *_confirmed convention in AppSettings (proxy/usage/stream_check/failover), stored in settings.json. The field is only written when the user explicitly clicks the confirm button, keeping its semantics strictly about user acknowledgement. Also adds two reusable DAO helpers: - Database::is_providers_empty for fresh-install detection, using EXISTS(SELECT 1) for a short-circuit query. - Database::get_bool_flag accepting "true" | "1", with init_default_official_providers migrated to use it. Dialog copy in zh/en/ja uses conditional phrasing so it stays accurate whether or not existing live config was found. --- src-tauri/src/database/dao/providers.rs | 24 +++++++-- src-tauri/src/database/dao/settings.rs | 12 +++++ src-tauri/src/lib.rs | 15 ++++++ src-tauri/src/settings.rs | 4 ++ src/App.tsx | 2 + src/components/FirstRunNoticeDialog.tsx | 67 +++++++++++++++++++++++++ src/i18n/locales/en.json | 6 +++ src/i18n/locales/ja.json | 6 +++ src/i18n/locales/zh.json | 6 +++ src/types.ts | 2 + 10 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 src/components/FirstRunNoticeDialog.tsx diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 04f13887..69b4cc3d 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -502,6 +502,20 @@ impl Database { })) } + /// 判断 providers 表是否为空(全 app_type 一起算)。 + /// + /// 用于区分"全新安装"和"升级用户":在启动流程 import/seed 之前调用。 + /// 使用 `EXISTS` 短路查询,比 `COUNT(*)` 在将来表变大时更高效。 + pub fn is_providers_empty(&self) -> Result { + let conn = lock_conn!(self.conn); + let exists: bool = conn + .query_row("SELECT EXISTS(SELECT 1 FROM providers)", [], |row| { + row.get(0) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(!exists) + } + /// 仅获取指定 app 下所有 provider 的 id 集合。 /// /// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询。 @@ -568,11 +582,11 @@ impl Database { pub fn init_default_official_providers(&self) -> Result { use crate::database::dao::providers_seed::OFFICIAL_SEEDS; - // flag 检查:已执行过则跳过 - if let Ok(Some(flag)) = self.get_setting("official_providers_seeded") { - if flag == "true" || flag == "1" { - return Ok(0); - } + if self + .get_bool_flag("official_providers_seeded") + .unwrap_or(false) + { + return Ok(0); } let mut inserted = 0_usize; diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 4ee5431d..85524e80 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -33,6 +33,18 @@ impl Database { } } + /// 以布尔语义读取 flag:`"true"` 或 `"1"` → true,其它全部 false。 + /// + /// 用于一次性启动 flag(`official_providers_seeded` / `first_run_notice_shown` 等)。 + /// 与 `is_legacy_common_config_migrated` 等只认 `"true"` 的历史辅助函数**不同**—— + /// 这里同时接受 `"1"` 是为了兼容 `init_default_official_providers` 既有写法。 + pub fn get_bool_flag(&self, key: &str) -> Result { + Ok(matches!( + self.get_setting(key)?.as_deref(), + Some("true") | Some("1") + )) + } + /// 设置值 pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99c28677..7f17c0b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -472,6 +472,15 @@ pub fn run() { // 先 import 后 seed 是有意为之:先把用户手动配置的 settings.json / auth.json / .env // 落成 "default" provider 设为 current,再追加官方预设(is_current=false)。 // 这样用户切到官方预设时,回填机制会保护原 live 配置不丢失。 + // + // 捕获首次运行快照:所有全新装用户都会看到欢迎弹窗介绍 CC Switch 的工作方式。 + // 读失败时默认不弹,宁可漏弹也不要因为故障打扰用户。 + let first_run_already_confirmed = crate::settings::get_settings() + .first_run_notice_confirmed + .unwrap_or(false); + let fresh_install_at_startup = + app_state.db.is_providers_empty().unwrap_or(false); + for app_type in crate::app_config::AppType::all().filter(|t| !t.is_additive_mode()) { @@ -502,6 +511,12 @@ pub fn run() { Err(e) => log::warn!("✗ Failed to seed official providers: {e}"), } + // 老用户 / 已确认的路径由 `fresh_install_at_startup` 自行拦截,这里不做写入。 + // 字段只由前端在用户点击"我知道了"时 save_settings 回写,语义是"用户显式确认过"。 + if !first_run_already_confirmed && fresh_install_at_startup { + log::info!("✓ First-run welcome notice pending"); + } + // 1.6. 自动同步 OpenCode / OpenClaw 的 live providers 到数据库 // // additive 模式(OpenCode / OpenClaw)的 import 函数本身按 id 幂等, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1d1dd836..8c937092 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -205,6 +205,9 @@ pub struct AppSettings { /// User has confirmed the failover toggle first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub failover_confirmed: Option, + /// User has confirmed the first-run welcome notice + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_run_notice_confirmed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, @@ -297,6 +300,7 @@ impl Default for AppSettings { stream_check_confirmed: None, enable_failover_toggle: false, failover_confirmed: None, + first_run_notice_confirmed: None, language: None, visible_apps: None, claude_config_dir: None, diff --git a/src/App.tsx b/src/App.tsx index 63d6d05c..348ff6d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,6 +62,7 @@ import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; +import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { UniversalProviderPanel } from "@/components/universal"; import { McpIcon } from "@/components/BrandIcons"; @@ -1354,6 +1355,7 @@ function App() { /> + ); } diff --git a/src/components/FirstRunNoticeDialog.tsx b/src/components/FirstRunNoticeDialog.tsx new file mode 100644 index 00000000..ed8d1853 --- /dev/null +++ b/src/components/FirstRunNoticeDialog.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { Sparkles } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useSettingsQuery } from "@/lib/query"; +import { settingsApi } from "@/lib/api"; + +/** 首次运行欢迎提示:仅当后端启动阶段保留 firstRunNoticeConfirmed 为空时弹出。 */ +export function FirstRunNoticeDialog() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { data: settings } = useSettingsQuery(); + + // 后端启动时已经决定好要不要弹:条件不满足的话字段会立即被写成 true, + // 所以前端这里只需要判空即可——完全对齐 streamCheckConfirmed 等既有 flag 的模式。 + const isOpen = settings != null && settings.firstRunNoticeConfirmed !== true; + + const handleAcknowledge = async () => { + if (!settings) return; + try { + const { webdavSync: _, ...rest } = settings; + await settingsApi.save({ ...rest, firstRunNoticeConfirmed: true }); + await queryClient.invalidateQueries({ queryKey: ["settings"] }); + } catch (error) { + console.error("Failed to save firstRunNoticeConfirmed:", error); + } + }; + + return ( + { + if (!open) void handleAcknowledge(); + }} + > + + + + + {t("firstRunNotice.title")} + + +
+ + {t("firstRunNotice.bodyDefault")} + + + {t("firstRunNotice.bodyOfficial")} + +
+ + + +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f2475be2..e41632ed 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -42,6 +42,12 @@ "enabled": "Enabled", "notSet": "Not Set" }, + "firstRunNotice": { + "title": "Welcome to CC Switch", + "bodyDefault": "CC Switch lets you switch between multiple Claude Code / Codex / Gemini CLI providers with a single click. If you already had these tools configured, CC Switch has saved your existing setup as a provider named “default” so none of your previous configuration is lost.", + "bodyOfficial": "An “Official” preset is also included in the list — click it any time you want to switch back to the official default. CC Switch automatically backs up your current config to “default” before switching, so you can move back and forth freely. That's how CC Switch works 😊", + "confirm": "Got it" + }, "apiKeyInput": { "placeholder": "Enter API Key", "show": "Show API Key", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3aa2f700..200d14ac 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -42,6 +42,12 @@ "enabled": "有効", "notSet": "未設定" }, + "firstRunNotice": { + "title": "CC Switch へようこそ", + "bodyDefault": "CC Switch は Claude Code / Codex / Gemini CLI の複数プロバイダーをワンクリックで切り替えられるツールです。すでにこれらのツールを設定済みの場合、CC Switch は現在の設定を「default」という名前のプロバイダーとして自動的に保存するので、既存の設定が失われることはありません。", + "bodyOfficial": "リストには「Official(公式)」プリセットも用意されており、公式バージョンに戻したくなったらクリックするだけで切り替えられます。切り替える前に現在の設定は自動で default にバックアップされるので、自由に行き来できます。これが CC Switch の仕組みです 😊", + "confirm": "了解しました" + }, "apiKeyInput": { "placeholder": "API Key を入力", "show": "API Key を表示", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index be42ae4e..52a31814 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -42,6 +42,12 @@ "enabled": "已开启", "notSet": "未设置" }, + "firstRunNotice": { + "title": "欢迎使用 CC Switch", + "bodyDefault": "CC Switch 可以帮你在 Claude Code / Codex / Gemini CLI 的多个供应商之间一键切换。如果你之前已经配置过这些工具,CC Switch 会自动把现有设置保存为名为 “default” 的供应商,保证你的配置不会丢失。", + "bodyOfficial": "列表里还预置了 “官方(Official)” 供应商,随时点一下即可切回官方默认配置。切换前 CC Switch 会自动把当前配置备份回 default,可以放心来回切。CC Switch 就是这样工作的 😊", + "confirm": "我知道了" + }, "apiKeyInput": { "placeholder": "请输入API Key", "show": "显示API Key", diff --git a/src/types.ts b/src/types.ts index f3abcb0f..d7229ec9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -264,6 +264,8 @@ export interface Settings { enableFailoverToggle?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; + // User has confirmed the first-run welcome notice + firstRunNoticeConfirmed?: boolean; // User has confirmed the auto-sync traffic warning autoSyncConfirmed?: boolean; // 首选语言(可选,默认中文)