Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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)
Expand Down
128 changes: 128 additions & 0 deletions src-tauri/src/launch_navigation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! `--page <id>` 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<Option<String>>);

impl LaunchPageState {
pub(crate) fn new(page: Option<String>) -> Self {
Self(Mutex::new(page))
}
}

/// Extract `--page <id>` or `--page=<id>` from the process arguments.
pub(crate) fn launch_page_argument() -> Option<String> {
parse_launch_page_argument(std::env::args_os())
}

pub(crate) fn parse_launch_page_argument<I>(args: I) -> Option<String>
where
I: IntoIterator<Item = OsString>,
{
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<String> {
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<String> {
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<String> {
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}");
}
}
});
}
11 changes: 11 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions src-tauri/src/tests/launch_navigation.rs
Original file line number Diff line number Diff line change
@@ -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<OsString> {
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);
}
51 changes: 50 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -127,6 +129,7 @@ function AppContent() {
const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate();
const { latest: coreLatest, hasUpdate: coreHasUpdate } = useCoreUpdate();
const [active, setActive] = useState<PageId>('home');
const [requestedOAuthSubpage, setRequestedOAuthSubpage] = useState<OAuthSubpageRequest | null>(null);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [theme, setTheme] = useState<AppTheme>(detectInitialTheme);
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
Expand Down Expand Up @@ -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<LaunchTarget<PageId> | 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<string | null>('take_launch_page')
.then((value) => {
if (!disposed) requestLaunchTarget(value);
})
.catch((error) => {
console.error('读取启动页面参数失败', error);
});
void listen<string>(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;
Expand Down Expand Up @@ -438,6 +485,8 @@ function AppContent() {
locale={locale}
setLocale={setLocale}
/>
) : activePage.id === 'oauth' ? (
<OAuthManagementPage requestedSubpage={requestedOAuthSubpage} />
) : (
<ActivePage />
)
Expand Down
37 changes: 37 additions & 0 deletions src/launchNavigation.ts
Original file line number Diff line number Diff line change
@@ -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<PageId extends string> = {
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<PageId extends string>(
value: string | null | undefined,
pageIds: readonly PageId[],
): LaunchTarget<PageId> | 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;
}
12 changes: 10 additions & 2 deletions src/pages/ManagementPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,17 @@ const cachedOAuthProviderStates = (): Partial<Record<OAuthProviderId, OAuthProvi
)
);

export function OAuthManagementPage() {
export type OAuthSubpageRequest = { subpage: OAuthSubpage; nonce: number };

export function OAuthManagementPage({ requestedSubpage }: { requestedSubpage?: OAuthSubpageRequest | null }) {
const { t } = useI18n();
const [activeSubpage, setActiveSubpage] = useState<OAuthSubpage>('login');
const [activeSubpage, setActiveSubpage] = useState<OAuthSubpage>(requestedSubpage?.subpage ?? 'login');

useEffect(() => {
if (requestedSubpage) {
setActiveSubpage(requestedSubpage.subpage);
}
}, [requestedSubpage]);

return (
<section className="page oauth-management-page">
Expand Down
Loading