From 6afa6a78778519c2f2422f7d2e2c6848f8b267d2 Mon Sep 17 00:00:00 2001 From: Ahmed Khaire Date: Sat, 5 Sep 2026 15:20:02 +0400 Subject: [PATCH] feat: add --page launch argument to open the app on a given page Lets launchers, tray helpers and scripts open EasyCLIProxyAPI directly on a page (for example `--page quota` for OAuth > Quota Lookup) instead of always landing on Home. - Rust: parse `--page ` / `--page=`; keep it in managed state and expose `take_launch_page` for the first render. If another instance already holds the instance lock, write the request next to the lock and exit, and let the running instance pick it up (500 ms poll), reveal its window and emit `navigate-page`. Values are restricted to `[A-Za-z0-9/-]` and 64 chars; the frontend validates the actual target. - Frontend: `parseLaunchTarget` accepts page ids plus `oauth/` and the bare OAuth subpage aliases (`quota`, `authFiles`, `login`). The target is held until `canOpenAppPage` allows it, since non-Home pages unlock only once the core runs; the OAuth page accepts a requested subpage. - README: document the argument. Tested on Linux: `--page usage-records` on first launch, `--page home` forwarded to a running instance (single window, request consumed), and `--page quota` landing on Quota Lookup once the core was running. --- README.md | 10 ++ src-tauri/src/launch_navigation.rs | 128 +++++++++++++++++++++++ src-tauri/src/main.rs | 11 ++ src-tauri/src/tests.rs | 1 + src-tauri/src/tests/launch_navigation.rs | 46 ++++++++ src/App.tsx | 51 ++++++++- src/launchNavigation.ts | 37 +++++++ src/pages/ManagementPages.tsx | 12 ++- tests/launchNavigation.test.ts | 27 +++++ 9 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/launch_navigation.rs create mode 100644 src-tauri/src/tests/launch_navigation.rs create mode 100644 src/launchNavigation.ts create mode 100644 tests/launchNavigation.test.ts 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(); + }); +});