diff --git a/README.md b/README.md index e2728e38..642b1af3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,16 @@ quota inspection, usage records, model aliases, and agent client configuration i The application is built with Tauri, React, and Rust. It can carry a matching CLIProxyAPI core archive, making first-time setup and offline installation easier. + +## Launch arguments + +- `--page ` opens the app on a page instead of Home. Page ids match the sidebar: `home`, `versions`, `config`, `oauth`, `api`, `usage-records`, `agents`; the OAuth subpages are `oauth/login`, `oauth/authFiles`, `oauth/quota` (or just `quota` / `authFiles`). +- If EasyCLIProxyAPI is already running, the running instance is shown and switched to that page; no second window is opened. + +```bash +EasyCLIProxyAPI --page quota +``` + ## Sponsor [![https://go.apimart.ai/gh-easycliproxyapi](./assets/apimart-en.png)](https://go.apimart.ai/gh-easycliproxyapi) diff --git a/src-tauri/src/launch_navigation.rs b/src-tauri/src/launch_navigation.rs new file mode 100644 index 00000000..02b700a9 --- /dev/null +++ b/src-tauri/src/launch_navigation.rs @@ -0,0 +1,128 @@ +//! `--page ` launch argument: open the app on a given page (for example +//! `--page quota` for OAuth → Quota Lookup). When an instance is already running +//! the request is handed to it through a small file next to the instance lock, +//! so launchers and tray helpers can deep-link without a second window. + +use std::{ + ffi::OsString, + fs, + path::{Path, PathBuf}, + sync::Mutex, + thread, + time::Duration, +}; + +use tauri::{Emitter, Manager}; + +use crate::instance_lock::app_instance_key; + +pub(crate) const LAUNCH_PAGE_EVENT: &str = "navigate-page"; +const REQUEST_FILE_PREFIX: &str = "EasyCLIProxyAPI-navigate"; +const REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(500); +const MAX_PAGE_LEN: usize = 64; + +pub(crate) struct LaunchPageState(Mutex>); + +impl LaunchPageState { + pub(crate) fn new(page: Option) -> Self { + Self(Mutex::new(page)) + } +} + +/// Extract `--page ` or `--page=` from the process arguments. +pub(crate) fn launch_page_argument() -> Option { + parse_launch_page_argument(std::env::args_os()) +} + +pub(crate) fn parse_launch_page_argument(args: I) -> Option +where + I: IntoIterator, +{ + let mut args = args.into_iter(); + while let Some(argument) = args.next() { + let argument = argument.to_string_lossy(); + let value = if argument == "--page" { + args.next().map(|v| v.to_string_lossy().to_string()) + } else { + argument.strip_prefix("--page=").map(str::to_string) + }; + if let Some(value) = value { + return sanitize_page(&value); + } + } + None +} + +/// Keep only `a-z A-Z 0-9 - /`, which covers every page and subpage id; the +/// frontend validates the actual target and ignores anything unknown. +pub(crate) fn sanitize_page(value: &str) -> Option { + let value = value.trim(); + let valid = !value.is_empty() + && value.len() <= MAX_PAGE_LEN + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/'); + valid.then(|| value.to_string()) +} + +pub(crate) fn request_file_path(executable_dir: &Path) -> PathBuf { + std::env::temp_dir().join(format!( + "{REQUEST_FILE_PREFIX}-{}.txt", + app_instance_key(executable_dir) + )) +} + +/// Called by a second launch: leave the page request for the running instance. +pub(crate) fn forward_to_running_instance(executable_dir: &Path, page: &str) -> Result<(), String> { + let path = request_file_path(executable_dir); + fs::write(&path, page) + .map_err(|error| format!("写入页面导航请求失败 {}: {error}", path.display())) +} + +/// Take (and clear) the request left by another launch, if any. +pub(crate) fn take_forwarded_request(executable_dir: &Path) -> Option { + let path = request_file_path(executable_dir); + let content = fs::read_to_string(&path).ok()?; + let _ = fs::remove_file(&path); + sanitize_page(&content) +} + +#[tauri::command] +pub(crate) fn take_launch_page(state: tauri::State<'_, LaunchPageState>) -> Option { + state.0.lock().ok().and_then(|mut page| page.take()) +} + +/// Bring the main window back (it may be hidden in the tray or minimised). +fn reveal_main_window(app: &tauri::AppHandle) { + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + crate::tray::show_main_window(app); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + if window.is_minimized().unwrap_or(false) { + let _ = window.unminimize(); + } + let _ = window.set_focus(); + } + } +} + +/// Watch for page requests from later launches and forward them to the webview. +pub(crate) fn start_navigation_request_watcher(app: tauri::AppHandle) { + let Ok(executable_dir) = crate::core_runtime::executable_dir() else { + return; + }; + let _ = fs::remove_file(request_file_path(&executable_dir)); // stale request from a previous run + thread::spawn(move || loop { + thread::sleep(REQUEST_POLL_INTERVAL); + if let Some(page) = take_forwarded_request(&executable_dir) { + reveal_main_window(&app); + if let Err(error) = app.emit(LAUNCH_PAGE_EVENT, page) { + eprintln!("发送页面导航事件失败: {error}"); + } + } + }); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6ce09daa..dda33ce7 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 instance_lock; +mod launch_navigation; mod management_api; mod oauth_browser; mod provider_health; @@ -2246,9 +2247,16 @@ fn main() { } } + let launch_page = launch_navigation::launch_page_argument(); let _instance_guard = match acquire_app_instance_guard() { Ok(guard) => guard, Err(error) => { + if let (Some(page), Ok(dir)) = (launch_page.as_deref(), executable_dir()) { + match launch_navigation::forward_to_running_instance(&dir, page) { + Ok(()) => return, + Err(forward_error) => eprintln!("{forward_error}"), + } + } eprintln!("{error}"); return; } @@ -2346,7 +2354,9 @@ fn main() { }); let app = app + .manage(launch_navigation::LaunchPageState::new(launch_page)) .setup(move |app| { + launch_navigation::start_navigation_request_watcher(app.handle().clone()); if let Err(error) = codex_catalog::validate_embedded_catalog() { eprintln!("Codex 内置模型目录无效: {error}"); } @@ -2467,6 +2477,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + launch_navigation::take_launch_page, health_check, detect_core_platform, get_core_status, diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 9d8f1be8..5cbfb9e3 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -8,6 +8,7 @@ mod app_update; mod core_config; mod core_runtime; mod instance_lock; +mod launch_navigation; mod model_aliases; mod platform; mod provider_health; diff --git a/src-tauri/src/tests/launch_navigation.rs b/src-tauri/src/tests/launch_navigation.rs new file mode 100644 index 00000000..bef97b48 --- /dev/null +++ b/src-tauri/src/tests/launch_navigation.rs @@ -0,0 +1,46 @@ +use crate::launch_navigation::{ + forward_to_running_instance, parse_launch_page_argument, request_file_path, sanitize_page, + take_forwarded_request, +}; +use std::ffi::OsString; + +fn args(list: &[&str]) -> Vec { + list.iter().map(OsString::from).collect() +} + +#[test] +fn parses_page_argument_in_both_forms() { + assert_eq!( + parse_launch_page_argument(args(&["app", "--page", "quota"])), + Some("quota".into()) + ); + assert_eq!( + parse_launch_page_argument(args(&["app", "--page=oauth/quota"])), + Some("oauth/quota".into()) + ); + assert_eq!(parse_launch_page_argument(args(&["app"])), None); + assert_eq!(parse_launch_page_argument(args(&["app", "--page"])), None); +} + +#[test] +fn rejects_unsafe_or_oversized_values() { + assert_eq!(sanitize_page(" home "), Some("home".into())); + assert_eq!(sanitize_page("usage-records"), Some("usage-records".into())); + assert_eq!(sanitize_page(""), None); + assert_eq!(sanitize_page("quota; rm -rf"), None); + assert_eq!(sanitize_page(&"a".repeat(65)), None); +} + +#[test] +fn forwarded_request_round_trips_and_is_consumed_once() { + let dir = std::env::temp_dir().join(format!( + "easycliproxyapi-launch-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + forward_to_running_instance(&dir, "oauth/quota").unwrap(); + assert!(request_file_path(&dir).exists()); + assert_eq!(take_forwarded_request(&dir), Some("oauth/quota".into())); + assert_eq!(take_forwarded_request(&dir), None); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/src/App.tsx b/src/App.tsx index eb173c73..4aa88bb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,8 @@ import { ConfigPanelPage } from './pages/ConfigPanel'; import { ApiAccessPage } from './pages/ApiAccessPage'; import { KernelPage } from './pages/Kernel'; import { VersionManagementPage } from './pages/VersionManagementPage'; -import { OAuthManagementPage } from './pages/ManagementPages'; +import { OAuthManagementPage, type OAuthSubpageRequest } from './pages/ManagementPages'; +import { LAUNCH_PAGE_EVENT, parseLaunchTarget, type LaunchTarget } from './launchNavigation'; import { AgentsPage } from './pages/AgentsPage'; import { EasyModePage } from './pages/EasyModePage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; @@ -89,6 +90,7 @@ const pages = [ ] as const; type PageId = (typeof pages)[number]['id']; +const pageIds = pages.map((page) => page.id); type WindowsCloseAction = 'exit' | 'minimize-to-tray'; type WindowsCloseBehavior = 'ask' | WindowsCloseAction; @@ -127,6 +129,7 @@ function AppContent() { const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate(); const { latest: coreLatest, hasUpdate: coreHasUpdate } = useCoreUpdate(); const [active, setActive] = useState('home'); + const [requestedOAuthSubpage, setRequestedOAuthSubpage] = useState(null); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [theme, setTheme] = useState(detectInitialTheme); const [windowsClosePrompt, setWindowsClosePrompt] = useState(null); @@ -232,6 +235,50 @@ function AppContent() { return () => window.cancelAnimationFrame(frame); }, [windowsClosePrompt]); + // A launch target (from `--page`) waits until its page is allowed, since pages other than + // Home unlock only once the core is running; otherwise the reset-to-home effect would win. + const [pendingLaunchTarget, setPendingLaunchTarget] = useState | null>(null); + + useEffect(() => { + let disposed = false; + let stopListening: (() => void) | null = null; + const requestLaunchTarget = (value: unknown) => { + const target = parseLaunchTarget(typeof value === 'string' ? value : null, pageIds); + if (target) setPendingLaunchTarget(target); + }; + void invoke('take_launch_page') + .then((value) => { + if (!disposed) requestLaunchTarget(value); + }) + .catch((error) => { + console.error('读取启动页面参数失败', error); + }); + void listen(LAUNCH_PAGE_EVENT, (event) => requestLaunchTarget(event.payload)) + .then((stop) => { + if (disposed) { + stop(); + } else { + stopListening = stop; + } + }) + .catch((error) => { + console.error('监听页面导航事件失败', error); + }); + return () => { + disposed = true; + stopListening?.(); + }; + }, []); + + useEffect(() => { + if (!pendingLaunchTarget || !canOpenAppPage(pendingLaunchTarget.page, coreRunning)) return; + setActive(pendingLaunchTarget.page); + if (pendingLaunchTarget.oauthSubpage) { + setRequestedOAuthSubpage({ subpage: pendingLaunchTarget.oauthSubpage, nonce: Date.now() }); + } + setPendingLaunchTarget(null); + }, [pendingLaunchTarget, coreRunning]); + const select = (pageId: PageId) => { if (!canOpenAppPage(pageId, coreRunning)) { return; @@ -438,6 +485,8 @@ function AppContent() { locale={locale} setLocale={setLocale} /> + ) : activePage.id === 'oauth' ? ( + ) : ( ) diff --git a/src/launchNavigation.ts b/src/launchNavigation.ts new file mode 100644 index 00000000..b39819d4 --- /dev/null +++ b/src/launchNavigation.ts @@ -0,0 +1,37 @@ +import { oauthSubpages, type OAuthSubpage } from './oauthNavigation'; + +/** Event emitted by the Rust side when another launch asked for a page (`--page`). */ +export const LAUNCH_PAGE_EVENT = 'navigate-page'; + +export type LaunchTarget = { + page: PageId; + oauthSubpage?: OAuthSubpage; +}; + +/** + * Parse a `--page` value into a page (and optional OAuth subpage). + * Accepts `home`, `oauth`, `oauth/quota`, and the bare subpage aliases `quota`, + * `authFiles`, `login`, which live inside the OAuth page. + */ +export function parseLaunchTarget( + value: string | null | undefined, + pageIds: readonly PageId[], +): LaunchTarget | null { + const raw = (value ?? '').trim(); + if (!raw) return null; + const [head, tail, ...rest] = raw.split('/'); + if (rest.length > 0) return null; + const subpageIds = oauthSubpages.map((subpage) => subpage.id); + const isSubpage = (id: string): id is OAuthSubpage => (subpageIds as string[]).includes(id); + const isPage = (id: string): id is PageId => (pageIds as readonly string[]).includes(id); + + if (tail === undefined) { + if (isPage(head)) return { page: head }; + if (isSubpage(head) && isPage('oauth')) return { page: 'oauth' as PageId, oauthSubpage: head }; + return null; + } + if (head === 'oauth' && isPage(head) && isSubpage(tail)) { + return { page: head, oauthSubpage: tail }; + } + return null; +} diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index 7d905678..433c53ce 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -107,9 +107,17 @@ const cachedOAuthProviderStates = (): Partial('login'); + const [activeSubpage, setActiveSubpage] = useState(requestedSubpage?.subpage ?? 'login'); + + useEffect(() => { + if (requestedSubpage) { + setActiveSubpage(requestedSubpage.subpage); + } + }, [requestedSubpage]); return (
diff --git a/tests/launchNavigation.test.ts b/tests/launchNavigation.test.ts new file mode 100644 index 00000000..697610f2 --- /dev/null +++ b/tests/launchNavigation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { parseLaunchTarget } from '../src/launchNavigation'; + +const pageIds = ['easy', 'home', 'versions', 'config', 'oauth', 'api', 'usage-records', 'agents'] as const; + +describe('--page 启动参数解析', () => { + test('顶层页面直接解析', () => { + expect(parseLaunchTarget('home', pageIds)).toEqual({ page: 'home' }); + expect(parseLaunchTarget('usage-records', pageIds)).toEqual({ page: 'usage-records' }); + }); + + test('OAuth 子页面可用 oauth/<子页面> 或子页面别名', () => { + expect(parseLaunchTarget('oauth/quota', pageIds)).toEqual({ page: 'oauth', oauthSubpage: 'quota' }); + expect(parseLaunchTarget('quota', pageIds)).toEqual({ page: 'oauth', oauthSubpage: 'quota' }); + expect(parseLaunchTarget('authFiles', pageIds)).toEqual({ page: 'oauth', oauthSubpage: 'authFiles' }); + }); + + test('未知或畸形的值被忽略', () => { + expect(parseLaunchTarget('', pageIds)).toBeNull(); + expect(parseLaunchTarget(' ', pageIds)).toBeNull(); + expect(parseLaunchTarget('nope', pageIds)).toBeNull(); + expect(parseLaunchTarget('home/quota', pageIds)).toBeNull(); + expect(parseLaunchTarget('oauth/nope', pageIds)).toBeNull(); + expect(parseLaunchTarget('oauth/quota/extra', pageIds)).toBeNull(); + expect(parseLaunchTarget(undefined, pageIds)).toBeNull(); + }); +});