From 2888042645d11016d3b7b7118337ffc2fb902964 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 14 Aug 2026 01:20:02 +0800 Subject: [PATCH 1/3] feat(oauth): add browser selection and refreshable login links - detect installed browsers and remember the selected option - support generating OAuth links without opening a browser - refresh login links after cancelling the previous OAuth session - update multilingual labels and responsive styles - bump app version to 0.2.21 and core version to 7.2.131 --- core-version.txt | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 4 + src-tauri/src/management_api.rs | 5 +- src-tauri/src/oauth_browser.rs | 206 ++++++++++++++++++++++++++++++++ src/i18n/ja.ts | 10 +- src/i18n/locales/en.ts | 10 +- src/i18n/locales/zh-CN.ts | 10 +- src/pages/ManagementPages.tsx | 113 +++++++++++++++++- src/styles.css | 39 ++++++ 11 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/oauth_browser.rs diff --git a/core-version.txt b/core-version.txt index a4e4cf0..d227091 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.128 +7.2.131 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb14c78..055678d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.19" +version = "0.2.21" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72f0aa2..9205527 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.19" +version = "0.2.21" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 774e9e8..f7be081 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,7 @@ mod configuration_watcher; mod core_config; mod core_runtime; mod management_api; +mod oauth_browser; mod provider_health; #[cfg(any(target_os = "macos", target_os = "windows"))] mod tray; @@ -21,6 +22,7 @@ use management_api::{ management_authorization, management_endpoint, management_http_client, read_management_text, read_management_value, }; +use oauth_browser::*; #[cfg(test)] use provider_health::{provider_health_content_type_is_streaming, provider_health_stream_has_text}; @@ -1921,6 +1923,8 @@ fn main() { management_api::start_oauth_login, management_api::get_oauth_status, management_api::submit_oauth_callback, + list_oauth_browsers, + open_oauth_url, open_external_url, check_app_update, get_app_update_task, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 4ada6df..ea862f3 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -2,7 +2,7 @@ use super::windows_explorer_executable; use super::{ auth_dir_path_for_core, configure_background_command, core_install_dir, - is_hashed_management_secret_key, open_external_url_inner, path_to_string, truncate_for_error, + is_hashed_management_secret_key, open_oauth_url_inner, path_to_string, truncate_for_error, GuiConfigFile, GuiConfigState, }; use serde::{Deserialize, Serialize}; @@ -162,6 +162,7 @@ pub(crate) async fn start_oauth_login( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, provider: String, + browser: Option, ) -> Result { let config = gui_config_state.snapshot()?; let provider_key = normalize_management_oauth_provider(&provider)?; @@ -197,7 +198,7 @@ pub(crate) async fn start_oauth_login( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - let (opened, open_error) = match open_external_url_inner(&app, &url) { + let (opened, open_error) = match open_oauth_url_inner(&app, &url, browser.as_deref()) { Ok(()) => (true, None), Err(error) => (false, Some(error)), }; diff --git a/src-tauri/src/oauth_browser.rs b/src-tauri/src/oauth_browser.rs new file mode 100644 index 0000000..8ccbe3a --- /dev/null +++ b/src-tauri/src/oauth_browser.rs @@ -0,0 +1,206 @@ +use super::*; + +const DEFAULT_BROWSER: &str = "default"; +const NO_AUTO_OPEN: &str = "none"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OAuthBrowserOption { + id: &'static str, + label: &'static str, +} + +struct BrowserSpec { + id: &'static str, + label: &'static str, + executable: PathBuf, +} + +#[tauri::command] +pub(crate) fn list_oauth_browsers() -> Vec { + let mut options = vec![OAuthBrowserOption { + id: DEFAULT_BROWSER, + label: "System Default", + }]; + options.extend( + browser_specs() + .into_iter() + .map(|browser| OAuthBrowserOption { + id: browser.id, + label: browser.label, + }), + ); + options +} + +#[tauri::command] +pub(crate) fn open_oauth_url( + app: tauri::AppHandle, + url: String, + browser: Option, +) -> Result<(), String> { + open_oauth_url_inner(&app, &url, browser.as_deref()) +} + +pub(crate) fn open_oauth_url_inner( + app: &tauri::AppHandle, + url: &str, + browser: Option<&str>, +) -> Result<(), String> { + let url = validate_http_url(url)?; + let browser = browser + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_BROWSER); + if browser == NO_AUTO_OPEN { + return Ok(()); + } + if browser == DEFAULT_BROWSER { + return open_external_url_inner(app, url); + } + + let selected = browser_specs() + .into_iter() + .find(|candidate| candidate.id == browser) + .ok_or_else(|| format!("The selected browser is not installed: {browser}"))?; + app.opener() + .open_url( + url, + Some(selected.executable.to_string_lossy().into_owned()), + ) + .map_err(|error| format!("Failed to open {}: {error}", selected.label)) +} + +fn validate_http_url(url: &str) -> Result<&str, String> { + let url = url.trim(); + if url.starts_with("http://") || url.starts_with("https://") { + Ok(url) + } else { + Err("Only http/https links can be opened".to_string()) + } +} + +#[cfg(target_os = "windows")] +fn browser_specs() -> Vec { + let find = |id, label, roots: &[&str], relative: &str| { + roots + .iter() + .filter_map(env::var_os) + .map(|root| PathBuf::from(root).join(relative)) + .find(|path| path.is_file()) + .map(|executable| BrowserSpec { + id, + label, + executable, + }) + }; + [ + find( + "edge", + "Microsoft Edge", + &["LOCALAPPDATA", "ProgramFiles(x86)", "ProgramFiles"], + "Microsoft/Edge/Application/msedge.exe", + ), + find( + "chrome", + "Google Chrome", + &["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"], + "Google/Chrome/Application/chrome.exe", + ), + find( + "brave", + "Brave", + &["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"], + "BraveSoftware/Brave-Browser/Application/brave.exe", + ), + find( + "firefox", + "Mozilla Firefox", + &["ProgramFiles", "ProgramFiles(x86)"], + "Mozilla Firefox/firefox.exe", + ), + ] + .into_iter() + .flatten() + .collect() +} + +#[cfg(target_os = "macos")] +fn browser_specs() -> Vec { + let roots = [ + PathBuf::from("/Applications"), + env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join("Applications"), + ]; + let find = |id, label, bundle: &str| { + roots + .iter() + .map(|root| root.join(bundle)) + .find(|path| path.is_dir()) + .map(|executable| BrowserSpec { + id, + label, + executable, + }) + }; + [ + find("safari", "Safari", "Safari.app"), + find("chrome", "Google Chrome", "Google Chrome.app"), + find("firefox", "Mozilla Firefox", "Firefox.app"), + find("brave", "Brave", "Brave Browser.app"), + find("edge", "Microsoft Edge", "Microsoft Edge.app"), + ] + .into_iter() + .flatten() + .collect() +} + +#[cfg(target_os = "linux")] +fn browser_specs() -> Vec { + let find = |id, label, names: &[&str]| { + env::var_os("PATH") + .into_iter() + .flat_map(|path| env::split_paths(&path).collect::>()) + .flat_map(|root| names.iter().map(move |name| root.join(name))) + .find(|path| path.is_file()) + .map(|executable| BrowserSpec { + id, + label, + executable, + }) + }; + [ + find( + "chrome", + "Google Chrome", + &["google-chrome", "google-chrome-stable"], + ), + find("firefox", "Mozilla Firefox", &["firefox"]), + find("brave", "Brave", &["brave-browser", "brave"]), + find( + "edge", + "Microsoft Edge", + &["microsoft-edge", "microsoft-edge-stable"], + ), + ] + .into_iter() + .flatten() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oauth_browser_urls_only_allow_http_and_https() { + assert_eq!( + validate_http_url(" https://example.com ").unwrap(), + "https://example.com" + ); + assert!(validate_http_url("file:///tmp/token").is_err()); + assert!(validate_http_url("javascript:alert(1)").is_err()); + } +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index a57608e..2c83c5d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -83,7 +83,7 @@ export const jaOverrides = { 'oauth.loginAnother': '別のアカウントでログイン', 'oauth.loggingIn': 'ログイン中…', 'oauth.startLogin': 'ログインを開始', - 'oauth.hint': 'ログインを開始するとブラウザーが開き、認証を行います。自動で開かない場合はリンクを手動で開いてください', + 'oauth.hint': 'ログインを開始すると上で選択したブラウザーが開きます。「自動で開かない」を選ぶとリンクのみ生成します', 'oauth.authorizationLink': '認証リンク', 'oauth.copyLink': 'リンクをコピー', 'oauth.openLink': 'リンクを開く', @@ -92,7 +92,13 @@ export const jaOverrides = { 'oauth.submitCallback': 'コールバックを送信', 'oauth.callbackSubmitted': 'コールバックを送信しました。認証完了を待っています', 'oauth.callbackFailed': 'コールバックの送信に失敗しました{detail}', - 'oauth.openBrowser': 'ブラウザーを開く', + 'oauth.refreshLink': 'リンクを更新', + 'oauth.refreshingLink': '更新中…', + 'oauth.browser.label': 'ブラウザーを選択', + 'oauth.browser.detecting': 'ブラウザーを検出中…', + 'oauth.browser.systemDefault': 'システムのデフォルトブラウザー', + 'oauth.browser.noAutoOpen': '自動で開かない', + 'oauth.browser.remembered': '利用可能なブラウザーを自動選択し、選択内容を記憶します', 'oauth.status.completed': '完了', 'oauth.status.failed': '失敗', 'oauth.status.waiting': 'コールバック待ち', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 85654b9..3850d9a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -83,7 +83,7 @@ export const en: Record = { 'oauth.loginAnother': 'Sign In Another Account', 'oauth.loggingIn': 'Signing in…', 'oauth.startLogin': 'Start Sign-In', - 'oauth.hint': 'Starting sign-in opens your browser for authorization. If it does not open, use the link below.', + 'oauth.hint': 'Starting sign-in uses the browser selected above. Choose “Do Not Open Automatically” to generate the link only.', 'oauth.authorizationLink': 'Authorization link', 'oauth.copyLink': 'Copy Link', 'oauth.openLink': 'Open Link', @@ -92,7 +92,13 @@ export const en: Record = { 'oauth.submitCallback': 'Submit Callback', 'oauth.callbackSubmitted': 'Callback submitted. Waiting for authentication.', 'oauth.callbackFailed': 'Callback submission failed{detail}', - 'oauth.openBrowser': 'Open Browser', + 'oauth.refreshLink': 'Refresh Link', + 'oauth.refreshingLink': 'Refreshing…', + 'oauth.browser.label': 'Choose Browser', + 'oauth.browser.detecting': 'Detecting browsers…', + 'oauth.browser.systemDefault': 'System Default Browser', + 'oauth.browser.noAutoOpen': 'Do Not Open Automatically', + 'oauth.browser.remembered': 'An available browser is selected automatically and your choice is remembered', 'oauth.status.completed': 'Completed', 'oauth.status.failed': 'Failed', 'oauth.status.waiting': 'Waiting for Callback', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 081d2d2..e6764ef 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -81,7 +81,7 @@ export const zhCN = { 'oauth.loginAnother': '登录另一个账号', 'oauth.loggingIn': '登录中…', 'oauth.startLogin': '开始登录', - 'oauth.hint': '点击开始登录后,将自动打开浏览器完成授权;如未自动跳转,可手动打开链接', + 'oauth.hint': '点击开始登录后,将按上方选择打开浏览器;选择“不自动打开”时仅生成登录链接', 'oauth.authorizationLink': '授权链接', 'oauth.copyLink': '复制链接', 'oauth.openLink': '打开链接', @@ -90,7 +90,13 @@ export const zhCN = { 'oauth.submitCallback': '提交回调', 'oauth.callbackSubmitted': '回调已提交,等待认证完成', 'oauth.callbackFailed': '回调提交失败{detail}', - 'oauth.openBrowser': '打开浏览器', + 'oauth.refreshLink': '刷新链接', + 'oauth.refreshingLink': '刷新中…', + 'oauth.browser.label': '选择浏览器', + 'oauth.browser.detecting': '正在检测浏览器…', + 'oauth.browser.systemDefault': '系统默认浏览器', + 'oauth.browser.noAutoOpen': '不自动打开', + 'oauth.browser.remembered': '已自动选择可用浏览器,并会记住你的选择', 'oauth.status.completed': '已完成', 'oauth.status.failed': '失败', 'oauth.status.waiting': '等待回调', diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index ad256e2..7d90567 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -6,6 +6,7 @@ import { ExternalLink, LoaderCircle, LogIn, + RefreshCw, } from 'lucide-react'; import antigravityIcon from '../assets/icons/antigravity.svg'; import claudeIcon from '../assets/icons/claude.svg'; @@ -40,6 +41,7 @@ type OAuthProviderState = { callbackSubmitting?: boolean; callbackStatus?: 'success' | 'error'; callbackError?: string; + refreshing?: boolean; }; type OAuthStartResult = { @@ -49,6 +51,11 @@ type OAuthStartResult = { openError?: string | null; }; +type OAuthBrowserOption = { + id: string; + label: string; +}; + type OAuthStatusResult = { status: string; error?: string | null; @@ -70,8 +77,29 @@ const OAUTH_CALLBACK_SUPPORTED = new Set([ ]); const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback'; const OAUTH_POLL_INTERVAL_MS = 3000; +const OAUTH_BROWSER_STORAGE_KEY = 'easy-cli-proxy-api.oauth-browser.v3'; +const NO_AUTO_OPEN_BROWSER_ID = 'none'; const oauthLoginSuccessCache = createOAuthLoginSuccessCache(); +const resolveOAuthBrowserSelection = ( + available: OAuthBrowserOption[], + preferred: string, +): string => { + if (preferred === NO_AUTO_OPEN_BROWSER_ID) return preferred; + if (available.some((browser) => browser.id === preferred)) return preferred; + return available.find((browser) => browser.id !== 'default')?.id + ?? available.find((browser) => browser.id === 'default')?.id + ?? NO_AUTO_OPEN_BROWSER_ID; +}; + +const loadOAuthBrowserPreference = (): string => { + try { + return window.localStorage.getItem(OAUTH_BROWSER_STORAGE_KEY)?.trim() || ''; + } catch { + return ''; + } +}; + const cachedOAuthProviderStates = (): Partial> => ( oauthLoginSuccessCache.snapshot().reduce>>( (states, provider) => ({ ...states, [provider]: { status: 'success' } }), @@ -133,11 +161,43 @@ export function OAuthLoginPage() { message: string; tone: 'success' | 'error' | 'info'; } | null>(null); + const [browsers, setBrowsers] = useState([]); + const [browsersLoading, setBrowsersLoading] = useState(true); + const [selectedBrowser, setSelectedBrowser] = useState(loadOAuthBrowserPreference); const pollingTimers = useRef>>({}); const pollingRequests = useRef>>({}); const credentialSnapshots = useRef>>({}); const noticeTimerRef = useRef(null); + useEffect(() => { + let active = true; + void invoke('list_oauth_browsers') + .then((available) => { + if (!active) return; + setBrowsers(available); + setSelectedBrowser((current) => resolveOAuthBrowserSelection(available, current)); + }) + .catch((error) => { + if (!active) return; + console.warn('Failed to detect installed browsers', error); + const fallback = [{ id: 'default', label: 'System Default' }]; + setBrowsers(fallback); + setSelectedBrowser((current) => resolveOAuthBrowserSelection(fallback, current)); + }) + .finally(() => { + if (active) setBrowsersLoading(false); + }); + return () => { active = false; }; + }, []); + + useEffect(() => { + try { + window.localStorage.setItem(OAUTH_BROWSER_STORAGE_KEY, selectedBrowser); + } catch { + // Keep the in-memory selection when persistent storage is unavailable. + } + }, [selectedBrowser]); + const showNotice = useCallback((message: string, tone: 'success' | 'error' | 'info') => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); setNotice({ message, tone }); @@ -287,7 +347,10 @@ export function OAuthLoginPage() { try { await captureCredentialSnapshot(provider); - const result = await invoke('start_oauth_login', { provider }); + const result = await invoke('start_oauth_login', { + provider, + browser: selectedBrowser, + }); if (!result.state) { updateProviderState(provider, { url: result.url, @@ -327,10 +390,31 @@ export function OAuthLoginPage() { } }; + const refreshLoginLink = async (provider: OAuthProviderId) => { + const currentState = states[provider]?.state; + clearPollingTimer(provider); + updateProviderState(provider, { refreshing: true }); + try { + if (currentState) { + await managementApi.delete('/oauth-session', { query: { state: currentState } }); + } + } catch (error) { + console.warn('Failed to cancel the previous OAuth session before refreshing', error); + } + try { + await startLogin(provider); + } finally { + updateProviderState(provider, { refreshing: false }); + } + }; + const openAuthUrl = async (url?: string) => { if (!url) return; try { - await invoke('open_external_url', { url }); + await invoke('open_oauth_url', { + url, + browser: selectedBrowser === NO_AUTO_OPEN_BROWSER_ID ? 'default' : selectedBrowser, + }); } catch (error) { showNotice(String(error), 'error'); } @@ -394,6 +478,24 @@ export function OAuthLoginPage() {
OAuth

{t('oauth.title')}

+
{notice ? ( @@ -476,13 +578,14 @@ export function OAuthLoginPage() {
- {state.url ? ( - ) : null}
diff --git a/src/styles.css b/src/styles.css index fb3ab1f..fd0efef 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2713,6 +2713,34 @@ textarea:focus-visible { gap: 14px; } +.oauth-browser-picker { + display: grid; + grid-template-columns: auto minmax(180px, 240px); + align-items: center; + justify-content: end; + gap: 5px 10px; + color: var(--theme-7a746c); + font-size: var(--font-size-label); + font-weight: 700; +} + +.oauth-browser-picker select { + width: 100%; + height: 38px; + padding: 0 34px 0 11px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 6px; + background: var(--theme-ffffff); + color: var(--theme-2d2a26); +} + +.oauth-browser-picker small { + grid-column: 1 / -1; + font-size: var(--font-size-caption); + font-weight: 400; + text-align: right; +} + .oauth-card { display: grid; grid-template-rows: auto auto auto; @@ -7995,6 +8023,17 @@ textarea:focus-visible { min-height: 0; } + .oauth-browser-picker { + width: 100%; + grid-template-columns: 1fr; + justify-content: stretch; + } + + .oauth-browser-picker small { + grid-column: 1; + text-align: left; + } + .oauth-card-body, .oauth-callback-row { grid-template-columns: 1fr; From 30fb1843848e442849c389acfae8641b276a2cf5 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 14 Aug 2026 01:28:52 +0800 Subject: [PATCH 2/3] feat(settings): add automatic core startup preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add a default-enabled “Start Core on Launch” setting - persist the preference in the GUI configuration - automatically start the CPA core when the app launches - keep the preference independent from manual core start and stop actions - add multilingual labels and migration tests --- src-tauri/src/app_settings.rs | 10 +++++--- src-tauri/src/core_config/settings.rs | 4 +++ src-tauri/src/core_runtime.rs | 4 +++ src-tauri/src/main.rs | 9 ++++++- src-tauri/src/tests/app_settings.rs | 14 +++++++++++ src-tauri/src/tests/core_config.rs | 1 + src-tauri/src/usage.rs | 1 + src/i18n/ja.ts | 2 ++ src/i18n/locales/en.ts | 2 ++ src/i18n/locales/zh-CN.ts | 2 ++ src/pages/ConfigPanel.tsx | 36 +++++++++++++++++++++++++++ 11 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index eca0203..54edabe 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -178,6 +178,7 @@ pub(crate) fn software_settings( Ok(SoftwareSettings { close_behavior: config.close_behavior, autostart_enabled: app_autostart_enabled(app)?, + start_core_on_launch: config.start_core_on_launch, silent_start_enabled: config.silent_start, }) } @@ -206,13 +207,16 @@ pub(crate) fn save_software_settings( } let config = if previous_config.close_behavior == settings.close_behavior + && previous_config.start_core_on_launch == settings.start_core_on_launch && previous_config.silent_start == settings.silent_start_enabled { previous_config } else { - match gui_config_state - .set_software_preferences(settings.close_behavior, settings.silent_start_enabled) - { + match gui_config_state.set_software_preferences( + settings.close_behavior, + settings.start_core_on_launch, + settings.silent_start_enabled, + ) { Ok(config) => config, Err(error) => { let rollback_error = autostart_changed diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 7ffb6a2..3de0c89 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -847,6 +847,9 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.close_behavior.is_none() { changed = true; } + if presence.start_core_on_launch.is_none() { + changed = true; + } if presence.silent_start.is_none() { changed = true; } @@ -1178,6 +1181,7 @@ pub(crate) fn write_gui_config_to_path( ("allow-lan", value(config.allow_lan)), ("host", value(config.host.as_str())), ("run-on-startup", value(config.run_on_startup)), + ("start-core-on-launch", value(config.start_core_on_launch)), ("silent-start", value(config.silent_start)), ("close-behavior", value(config.close_behavior.as_str())), ("auth-dir", value(config.auth_dir.as_str())), diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 803005a..77c80a5 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1820,6 +1820,10 @@ pub(crate) fn should_start_hidden(config: &GuiConfigFile) -> bool { config.silent_start && cfg!(any(target_os = "windows", target_os = "macos")) } +pub(crate) fn should_start_core_on_launch(config: &GuiConfigFile) -> bool { + config.start_core_on_launch +} + pub(crate) fn configure_initial_main_window( app_handle: &tauri::AppHandle, start_hidden: bool, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f7be081..d1a6962 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -552,6 +552,7 @@ struct GuiConfigFile { allow_lan: bool, host: String, run_on_startup: bool, + start_core_on_launch: bool, silent_start: bool, close_behavior: WindowsCloseBehavior, window_width: Option, @@ -662,6 +663,7 @@ impl Default for GuiConfigFile { allow_lan: false, host: "127.0.0.1".to_string(), run_on_startup: false, + start_core_on_launch: true, silent_start: false, close_behavior: WindowsCloseBehavior::Ask, window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH), @@ -698,6 +700,7 @@ struct GuiConfigPresence { api_access_remarks: Option>, management_secret_key: Option, close_behavior: Option, + start_core_on_launch: Option, silent_start: Option, usage_statistics_enabled: Option, plugins_enabled: Option, @@ -725,6 +728,7 @@ struct GuiSettings { struct SoftwareSettings { close_behavior: WindowsCloseBehavior, autostart_enabled: bool, + start_core_on_launch: bool, silent_start_enabled: bool, } @@ -733,6 +737,7 @@ struct SoftwareSettings { struct SoftwareSettingsInput { close_behavior: WindowsCloseBehavior, autostart_enabled: bool, + start_core_on_launch: bool, silent_start_enabled: bool, } @@ -1505,10 +1510,12 @@ impl GuiConfigState { fn set_software_preferences( &self, close_behavior: WindowsCloseBehavior, + start_core_on_launch: bool, silent_start: bool, ) -> Result { self.update(|config| { config.close_behavior = close_behavior; + config.start_core_on_launch = start_core_on_launch; config.silent_start = silent_start; Ok(()) }) @@ -1847,7 +1854,7 @@ fn main() { Err(error) => eprintln!("自动安装 CPA 离线内核失败: {error}"), } - if config.run_on_startup { + if should_start_core_on_launch(&config) { if let Err(error) = start_core_process_inner(process_state.inner(), &config) { eprintln!("自动启动 CPA 内核失败: {error}"); } diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 8149a67..281f1d9 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -77,6 +77,7 @@ fn gui_config_defaults_are_stable() { assert!(content.contains("port = 8317")); assert!(content.contains("allow-lan = false")); assert!(content.contains("run-on-startup = false")); + assert!(content.contains("start-core-on-launch = true")); assert!(content.contains("silent-start = false")); assert!(content.contains("close-behavior = \"ask\"")); assert!(content.contains("window-width = 1531")); @@ -119,6 +120,7 @@ fn gui_window_size_round_trips_through_portable_config() { #[test] fn silent_start_defaults_off_and_requires_tray_support() { let legacy = toml::from_str::("port = 8317\n").unwrap(); + assert!(legacy.start_core_on_launch); assert!(!legacy.silent_start); assert!(!should_start_hidden(&legacy)); @@ -132,6 +134,18 @@ fn silent_start_defaults_off_and_requires_tray_support() { ); } +#[test] +fn core_start_on_launch_defaults_on_and_can_be_disabled() { + let legacy = toml::from_str::("port = 8317\n").unwrap(); + assert!(should_start_core_on_launch(&legacy)); + + let disabled = GuiConfigFile { + start_core_on_launch: false, + ..GuiConfigFile::default() + }; + assert!(!should_start_core_on_launch(&disabled)); +} + #[test] fn gui_window_size_is_clamped_and_requires_both_dimensions() { let mut config = GuiConfigFile { diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index ee0db50..29f1e1d 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -767,6 +767,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { allow_lan: true, host: "0.0.0.0".to_string(), run_on_startup: false, + start_core_on_launch: true, silent_start: false, close_behavior: WindowsCloseBehavior::Ask, window_width: None, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index a225ec5..f2815e1 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3024,6 +3024,7 @@ mod tests { allow_lan: false, host: "127.0.0.1".to_string(), run_on_startup: false, + start_core_on_launch: true, silent_start: false, close_behavior: crate::WindowsCloseBehavior::Ask, window_width: None, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 2c83c5d..2fa359b 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -365,6 +365,8 @@ export const jaOverrides = { 'config.software.title': 'ソフトウェア設定', 'config.software.autostart': 'システム起動時に開始', 'config.software.autostartDescription': 'サインイン後に EasyCLIProxyAPI を自動的に起動します', + 'config.software.startCoreOnLaunch': '起動時にコアを開始', + 'config.software.startCoreOnLaunchDescription': 'EasyCLIProxyAPI の起動時に CPA コアを自動的に開始します', 'config.software.silentStart': 'サイレント起動', 'config.software.silentStartDescription': 'メインウィンドウを表示せず、システムトレイで直接起動します', 'config.software.closeBehavior': '終了動作', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3850d9a..c656185 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -300,6 +300,8 @@ export const en: Record = { 'config.software.title': 'Software Settings', 'config.software.autostart': 'Launch at startup', 'config.software.autostartDescription': 'Automatically launch EasyCLIProxyAPI when you sign in', + 'config.software.startCoreOnLaunch': 'Start Core on Launch', + 'config.software.startCoreOnLaunchDescription': 'Automatically start the CPA core whenever EasyCLIProxyAPI launches', 'config.software.silentStart': 'Silent Launch', 'config.software.silentStartDescription': 'Start without showing the main window and run directly in the system tray', 'config.software.closeBehavior': 'Close behavior', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e6764ef..6e3c340 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -298,6 +298,8 @@ export const zhCN = { 'config.software.title': '软件设置', 'config.software.autostart': '开机自启', 'config.software.autostartDescription': '登录系统后自动启动 EasyCLIProxyAPI', + 'config.software.startCoreOnLaunch': '启动时启动内核', + 'config.software.startCoreOnLaunchDescription': '每次启动 EasyCLIProxyAPI 时自动启动 CPA 内核', 'config.software.silentStart': '静默启动', 'config.software.silentStartDescription': '启动应用时不显示主窗口,直接在系统托盘运行', 'config.software.closeBehavior': '关闭行为', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index c5b1515..4acf9e2 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -18,6 +18,7 @@ import { Settings2, ShieldCheck, Sparkles, + Power, Trash2, X, } from 'lucide-react'; @@ -75,6 +76,7 @@ type NetworkDraftDirty = Record; type SoftwareSettings = { closeBehavior: CloseBehavior; autostartEnabled: boolean; + startCoreOnLaunch: boolean; silentStartEnabled: boolean; }; @@ -103,6 +105,7 @@ export function ConfigPanelPage() { const [softwareSettingsLoading, setSoftwareSettingsLoading] = useState(true); const [softwareCloseBehaviorDraft, setSoftwareCloseBehaviorDraft] = useState('ask'); const [softwareAutostartDraft, setSoftwareAutostartDraft] = useState(false); + const [softwareStartCoreDraft, setSoftwareStartCoreDraft] = useState(true); const [softwareSilentStartDraft, setSoftwareSilentStartDraft] = useState(false); const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false); const [loading, setLoading] = useState(true); @@ -237,6 +240,7 @@ export function ConfigPanelPage() { setSoftwareSettings(result); setSoftwareCloseBehaviorDraft(result.closeBehavior); setSoftwareAutostartDraft(result.autostartEnabled); + setSoftwareStartCoreDraft(result.startCoreOnLaunch); setSoftwareSilentStartDraft(result.silentStartEnabled); } catch (error) { setSoftwareSettings(null); @@ -526,6 +530,7 @@ export function ConfigPanelPage() { if ( softwareCloseBehaviorDraft === softwareSettings.closeBehavior && softwareAutostartDraft === softwareSettings.autostartEnabled + && softwareStartCoreDraft === softwareSettings.startCoreOnLaunch && softwareSilentStartDraft === softwareSettings.silentStartEnabled ) return; @@ -535,18 +540,21 @@ export function ConfigPanelPage() { settings: { closeBehavior: softwareCloseBehaviorDraft, autostartEnabled: softwareAutostartDraft, + startCoreOnLaunch: softwareStartCoreDraft, silentStartEnabled: softwareSilentStartDraft, }, }); setSoftwareSettings(result); setSoftwareCloseBehaviorDraft(result.closeBehavior); setSoftwareAutostartDraft(result.autostartEnabled); + setSoftwareStartCoreDraft(result.startCoreOnLaunch); setSoftwareSilentStartDraft(result.silentStartEnabled); setSoftwareSavedStatusVisible(true); showNotice(t('config.notice.softwareUpdated'), 'success'); } catch (error) { setSoftwareCloseBehaviorDraft(softwareSettings.closeBehavior); setSoftwareAutostartDraft(softwareSettings.autostartEnabled); + setSoftwareStartCoreDraft(softwareSettings.startCoreOnLaunch); setSoftwareSilentStartDraft(softwareSettings.silentStartEnabled); setSoftwareSavedStatusVisible(false); showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); @@ -585,8 +593,11 @@ export function ConfigPanelPage() { && softwareAutostartDraft !== softwareSettings.autostartEnabled; const softwareSilentStartDirty = softwareSettings !== null && softwareSilentStartDraft !== softwareSettings.silentStartEnabled; + const softwareStartCoreDirty = softwareSettings !== null + && softwareStartCoreDraft !== softwareSettings.startCoreOnLaunch; const softwareSettingsDirty = softwareCloseBehaviorDirty || softwareAutostartDirty + || softwareStartCoreDirty || softwareSilentStartDirty; const softwareStatusLabel = softwareSettingsLoading ? t('common.loading') @@ -1272,6 +1283,31 @@ export function ConfigPanelPage() { +
+
+ +
+ {t('config.software.startCoreOnLaunch')} + {t('config.software.startCoreOnLaunchDescription')} +
+
+ +
- {processBusy ? t('common.processing') : statusLabel} + {coreProcessBusy ? t('common.processing') : statusLabel}
@@ -736,7 +737,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {