From a8bea486d560b8f73198958895bc9f5d7907c47c Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Sat, 9 May 2026 17:42:03 +0800 Subject: [PATCH 01/17] release: v1.2.5 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/oauth.rs | 20 +++-- src-tauri/src/commands/usage.rs | 27 ++---- src-tauri/src/lib.rs | 1 + src-tauri/src/net.rs | 142 ++++++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- 9 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/net.rs diff --git a/package-lock.json b/package-lock.json index 068caef..f63c51b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.4", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.4", + "version": "1.2.5", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 98c2a3a..0cdc0e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.4", + "version": "1.2.5", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 20d9951..006adc5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.4" +version = "1.2.5" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef7a4a4..8025c3e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.4" +version = "1.2.5" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/src/commands/oauth.rs b/src-tauri/src/commands/oauth.rs index 918083d..7be6e5d 100644 --- a/src-tauri/src/commands/oauth.rs +++ b/src-tauri/src/commands/oauth.rs @@ -15,6 +15,7 @@ use tokio::sync::{oneshot, Mutex}; use crate::commands::accounts; use crate::models::{AuthJson, AuthTokens, OAuthResult, TokenResponse}; +use crate::net::build_http_client; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize"; @@ -149,15 +150,11 @@ async fn exchange_code( verifier: &str, ) -> Result { let settings = accounts::load_settings(app.clone()).await?; - let mut client_builder = reqwest::Client::builder(); - - if !settings.proxy_url.trim().is_empty() { - let proxy = reqwest::Proxy::all(settings.proxy_url.trim()) - .map_err(|e| format!("Invalid proxy URL: {}", e))?; - client_builder = client_builder.proxy(proxy); - } - - let client = client_builder.build().map_err(|e| e.to_string())?; + let client = build_http_client( + &settings, + "codex-manager/1.0", + std::time::Duration::from_secs(30), + )?; let params = [ ("grant_type", "authorization_code"), ("client_id", CLIENT_ID), @@ -176,6 +173,11 @@ async fn exchange_code( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); + if body.contains("unsupported_country_region_territory") { + return Err(format!( + "Token exchange failed ({status}): 当前网络被 OpenAI 判定为不支持的地区。请在设置中配置可用代理,或启用系统代理后重试。{body}" + )); + } return Err(format!("Token exchange failed ({}): {}", status, body)); } diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 1b30919..2442349 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -13,9 +13,10 @@ use crate::{ atomic_io::write_text_atomic_async, commands::{accounts, paths::app_data_dir}, models::{ - AccountRateLimitStatus, AppSettings, AuthJson, CreditsSnapshot, - GetAccountRateLimitsResponse, RateLimitSnapshot, RateLimitWindow, TokenResponse, + AccountRateLimitStatus, AuthJson, CreditsSnapshot, GetAccountRateLimitsResponse, + RateLimitSnapshot, RateLimitWindow, TokenResponse, }, + net::build_http_client, }; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -255,22 +256,6 @@ fn invalid_account_reason(detail: impl Into) -> String { format!("账号已失效或不可用,无法读取官方配额。{}", detail.into()) } -fn build_http_client(settings: &AppSettings) -> Result { - let mut builder = reqwest::Client::builder() - .user_agent("codex-manager/0.1") - .timeout(std::time::Duration::from_secs(18)); - - if !settings.proxy_url.trim().is_empty() { - let proxy = reqwest::Proxy::all(settings.proxy_url.trim()) - .map_err(|e| format!("Invalid proxy URL: {e}"))?; - builder = builder.proxy(proxy); - } - - builder - .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {e}")) -} - async fn request_usage_payload( client: &reqwest::Client, access_token: &str, @@ -498,7 +483,11 @@ pub async fn read_account_rate_limits( serde_json::from_str(&auth_json).map_err(|e| format!("auth.json 解析失败: {e}"))?; let settings = accounts::load_settings(app.clone()).await?; - let client = build_http_client(&settings)?; + let client = build_http_client( + &settings, + "codex-manager/1.0", + std::time::Duration::from_secs(18), + )?; let mut resolved_account_id = match extract_account_id(&auth) { Some(id) => id, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f85b7e7..388fb74 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod atomic_io; pub mod cli; pub mod commands; pub mod models; +pub mod net; pub mod platform; use commands::{accounts, desktop, oauth, paths, sessions, usage}; diff --git a/src-tauri/src/net.rs b/src-tauri/src/net.rs new file mode 100644 index 0000000..2b7337d --- /dev/null +++ b/src-tauri/src/net.rs @@ -0,0 +1,142 @@ +use crate::models::AppSettings; + +fn env_proxy_url() -> Option { + [ + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + ] + .iter() + .filter_map(|key| std::env::var(key).ok()) + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn add_default_scheme(value: &str, scheme: &str) -> String { + if value.contains("://") { + value.to_string() + } else { + format!("{scheme}://{value}") + } +} + +fn parse_proxy_server_entry(proxy_server: &str) -> Option { + let trimmed = proxy_server.trim(); + if trimmed.is_empty() { + return None; + } + + if !trimmed.contains(';') && !trimmed.contains('=') { + return Some(add_default_scheme(trimmed, "http")); + } + + let mut http_proxy = None; + let mut socks_proxy = None; + + for part in trimmed + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + let Some((raw_kind, raw_value)) = part.split_once('=') else { + continue; + }; + let kind = raw_kind.trim().to_ascii_lowercase(); + let value = raw_value.trim(); + if value.is_empty() { + continue; + } + + match kind.as_str() { + "https" => return Some(add_default_scheme(value, "http")), + "http" => http_proxy = Some(add_default_scheme(value, "http")), + "socks" | "socks5" => socks_proxy = Some(add_default_scheme(value, "socks5")), + _ => {} + } + } + + http_proxy.or(socks_proxy) +} + +#[cfg(target_os = "windows")] +fn windows_system_proxy_url() -> Option { + use winreg::{enums::HKEY_CURRENT_USER, RegKey}; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let settings = hkcu + .open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings") + .ok()?; + let enabled = settings.get_value::("ProxyEnable").unwrap_or(0); + if enabled == 0 { + return None; + } + + let proxy_server = settings.get_value::("ProxyServer").ok()?; + parse_proxy_server_entry(&proxy_server) +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_proxy_url() -> Option { + None +} + +fn configured_proxy_url(settings: &AppSettings) -> Option { + let explicit = settings.proxy_url.trim(); + if !explicit.is_empty() { + return Some(explicit.to_string()); + } + + env_proxy_url().or_else(windows_system_proxy_url) +} + +pub fn build_http_client( + settings: &AppSettings, + user_agent: &str, + timeout: std::time::Duration, +) -> Result { + let mut builder = reqwest::Client::builder() + .user_agent(user_agent) + .timeout(timeout); + + if let Some(proxy_url) = configured_proxy_url(settings) { + let proxy = reqwest::Proxy::all(&proxy_url) + .map_err(|e| format!("Invalid proxy URL {proxy_url}: {e}"))?; + builder = builder.proxy(proxy); + } + + builder + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {e}")) +} + +#[cfg(test)] +mod tests { + use super::parse_proxy_server_entry; + + #[test] + fn parses_single_proxy_server() { + assert_eq!( + parse_proxy_server_entry("127.0.0.1:7890").as_deref(), + Some("http://127.0.0.1:7890") + ); + } + + #[test] + fn prefers_https_proxy_server_entry() { + assert_eq!( + parse_proxy_server_entry("http=127.0.0.1:7890;https=127.0.0.1:7891").as_deref(), + Some("http://127.0.0.1:7891") + ); + } + + #[test] + fn parses_socks_proxy_server_entry() { + assert_eq!( + parse_proxy_server_entry("socks=127.0.0.1:1080").as_deref(), + Some("socks5://127.0.0.1:1080") + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5e0b658..9a39f00 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.4", + "version": "1.2.5", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", From e825d7d2a9973f11cf530d4bac19b70eeaced6e5 Mon Sep 17 00:00:00 2001 From: yolanda hao Date: Sat, 9 May 2026 09:00:38 -0700 Subject: [PATCH 02/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=98=BE=E7=A4=BA=E4=B8=8D=E5=87=86=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/usage.rs | 4 +- src-tauri/src/models.rs | 2 +- src-tauri/src/net.rs | 131 +++++++++++++++++++++++++++++- src/components/AccountCard.tsx | 2 +- src/components/AccountList.tsx | 15 +++- src/components/UsageChart.tsx | 12 +-- src/components/UsageStatsPage.tsx | 33 +++++--- src/types/index.ts | 2 +- src/utils/dashboard.ts | 112 ++++++++++++++++--------- src/utils/invoke.ts | 12 +-- tests/dashboard.test.ts | 36 ++++---- 11 files changed, 274 insertions(+), 87 deletions(-) diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 2442349..8fe50a6 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -405,8 +405,10 @@ fn pick_nearest_window(windows: &[UsageWindowRaw], target_seconds: i64) -> Optio } fn to_usage_window(window: UsageWindowRaw) -> RateLimitWindow { + let remaining_percent = (100.0 - window.used_percent).clamp(0.0, 100.0).round() as i32; + RateLimitWindow { - used_percent: window.used_percent.round() as i32, + remaining_percent, resets_at: Some(window.reset_at), window_duration_mins: Some(window.limit_window_seconds / 60), } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 2f2fa2e..f9b9a15 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -116,7 +116,7 @@ pub struct CreditsSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RateLimitWindow { - pub used_percent: i32, + pub remaining_percent: i32, pub resets_at: Option, pub window_duration_mins: Option, } diff --git a/src-tauri/src/net.rs b/src-tauri/src/net.rs index 2b7337d..a8620b4 100644 --- a/src-tauri/src/net.rs +++ b/src-tauri/src/net.rs @@ -1,5 +1,8 @@ use crate::models::AppSettings; +#[cfg(any(target_os = "macos", test))] +use std::collections::HashMap; + fn env_proxy_url() -> Option { [ "HTTPS_PROXY", @@ -83,13 +86,83 @@ fn windows_system_proxy_url() -> Option { None } +#[cfg(any(target_os = "macos", test))] +fn parse_scutil_proxy_output(output: &str) -> Option { + let mut values = HashMap::new(); + + for line in output.lines() { + let Some((raw_key, raw_value)) = line.split_once(':') else { + continue; + }; + let key = raw_key.trim(); + let value = raw_value.trim().trim_matches('"').trim_matches('\''); + if !key.is_empty() && !value.is_empty() { + values.insert(key.to_string(), value.to_string()); + } + } + + fn enabled(values: &HashMap, key: &str) -> bool { + matches!( + values.get(key).map(|value| value.as_str()), + Some("1") | Some("true") | Some("TRUE") + ) + } + + fn proxy_url( + values: &HashMap, + enable_key: &str, + host_key: &str, + port_key: &str, + scheme: &str, + ) -> Option { + if !enabled(values, enable_key) { + return None; + } + let host = values.get(host_key)?.trim(); + let port = values.get(port_key)?.trim(); + if host.is_empty() || port.is_empty() { + return None; + } + let normalized_host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + Some(format!("{scheme}://{normalized_host}:{port}")) + } + + proxy_url(&values, "HTTPSEnable", "HTTPSProxy", "HTTPSPort", "http") + .or_else(|| proxy_url(&values, "HTTPEnable", "HTTPProxy", "HTTPPort", "http")) + .or_else(|| proxy_url(&values, "SOCKSEnable", "SOCKSProxy", "SOCKSPort", "socks5")) +} + +#[cfg(target_os = "macos")] +fn macos_system_proxy_url() -> Option { + let output = std::process::Command::new("/usr/sbin/scutil") + .arg("--proxy") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = std::str::from_utf8(&output.stdout).ok()?; + parse_scutil_proxy_output(stdout) +} + +#[cfg(not(target_os = "macos"))] +fn macos_system_proxy_url() -> Option { + None +} + fn configured_proxy_url(settings: &AppSettings) -> Option { let explicit = settings.proxy_url.trim(); if !explicit.is_empty() { return Some(explicit.to_string()); } - env_proxy_url().or_else(windows_system_proxy_url) + env_proxy_url() + .or_else(macos_system_proxy_url) + .or_else(windows_system_proxy_url) } pub fn build_http_client( @@ -114,7 +187,7 @@ pub fn build_http_client( #[cfg(test)] mod tests { - use super::parse_proxy_server_entry; + use super::{parse_proxy_server_entry, parse_scutil_proxy_output}; #[test] fn parses_single_proxy_server() { @@ -139,4 +212,58 @@ mod tests { Some("socks5://127.0.0.1:1080") ); } + + #[test] + fn parses_macos_https_system_proxy() { + let output = r#" + { + HTTPEnable : 1 + HTTPPort : 7890 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 7891 + HTTPSProxy : 127.0.0.1 +} +"#; + + assert_eq!( + parse_scutil_proxy_output(output).as_deref(), + Some("http://127.0.0.1:7891") + ); + } + + #[test] + fn parses_macos_http_system_proxy() { + let output = r#" + { + HTTPEnable : 1 + HTTPPort : 7890 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 0 +} +"#; + + assert_eq!( + parse_scutil_proxy_output(output).as_deref(), + Some("http://127.0.0.1:7890") + ); + } + + #[test] + fn parses_macos_socks_system_proxy() { + let output = r#" + { + HTTPEnable : 0 + HTTPSEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 1080 + SOCKSProxy : 127.0.0.1 +} +"#; + + assert_eq!( + parse_scutil_proxy_output(output).as_deref(), + Some("socks5://127.0.0.1:1080") + ); + } } diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index 860fb46..688e0ed 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -294,7 +294,7 @@ const AccountCard: React.FC = ({

- 5h 已用 + 5h 剩余

diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 6f02053..8444159 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -8,6 +8,7 @@ import { formatRelativeTime, getAccountStatusReason, getAccountInsight, + getRemainingPercent, getRecommendedAccountId, isAccountInvalid, } from "../utils/dashboard"; @@ -55,7 +56,7 @@ const AccountList: React.FC = ({ const recommendedStandby = sorted.find( (account) => account.id === recommendedId && account.id !== featuredAccount?.id, ); - const featuredQuota = featuredAccount?.rateLimits?.primary?.usedPercent; + const featuredQuota = getRemainingPercent(featuredAccount?.rateLimits?.primary); const featuredIdentity = featuredAccount?.email ?? featuredAccount?.userId ?? "未识别身份"; const featuredInsight = featuredAccount ? getAccountInsight(featuredAccount) : null; @@ -129,12 +130,15 @@ const AccountList: React.FC = ({

- 5h 已用 + 5h 剩余

{typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"}

+ + {featuredInsight?.hourlyQuota.resetLabel ?? ""} +

{featuredInsight?.hourlyQuota.detail ?? "等待同步"} @@ -268,11 +272,16 @@ const AccountList: React.FC = ({

- 5h 已用 + 5h 剩余

{typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"}

+

+ {featuredInsight?.hourlyQuota.resetLabel + ? `重置 ${featuredInsight.hourlyQuota.resetLabel}` + : "--"} +

diff --git a/src/components/UsageChart.tsx b/src/components/UsageChart.tsx index f8945ab..0f212ac 100644 --- a/src/components/UsageChart.tsx +++ b/src/components/UsageChart.tsx @@ -18,7 +18,7 @@ const TONE_COLORS: Record = { }; function buildSeries(metric: QuotaMetric) { - const usedPercent = metric.available && metric.percent !== null ? metric.percent : null; + const remainingPercent = metric.available && metric.percent !== null ? metric.percent : null; return { type: "pie", @@ -30,7 +30,7 @@ function buildSeries(metric: QuotaMetric) { labelLine: { show: false }, emphasis: { disabled: true }, data: - usedPercent === null + remainingPercent === null ? [ { value: 100, @@ -44,7 +44,7 @@ function buildSeries(metric: QuotaMetric) { ] : [ { - value: usedPercent, + value: remainingPercent, itemStyle: { color: TONE_COLORS[metric.tone], shadowBlur: 18, @@ -52,7 +52,7 @@ function buildSeries(metric: QuotaMetric) { }, }, { - value: Math.max(0, 100 - usedPercent), + value: Math.max(0, 100 - remainingPercent), itemStyle: { color: "#f1efeb", }, @@ -62,9 +62,9 @@ function buildSeries(metric: QuotaMetric) { show: true, position: "center", formatter: - usedPercent === null + remainingPercent === null ? "{value|--}\n{name|未获取}" - : `{value|${usedPercent}%}\n{name|已使用}`, + : `{value|${remainingPercent}%}\n{name|剩余}`, rich: { value: { fontSize: 18, diff --git a/src/components/UsageStatsPage.tsx b/src/components/UsageStatsPage.tsx index 639d5d8..6d176c9 100644 --- a/src/components/UsageStatsPage.tsx +++ b/src/components/UsageStatsPage.tsx @@ -3,9 +3,11 @@ import { motion, useReducedMotion } from "motion/react"; import { useAccountStore } from "../store/accountStore"; import { formatRelativeTime, + getAccountInsight, getAccountStatusReason, getBestQuotaAccount, getHourlyUsageEfficiency, + getRemainingPercent, getRecommendedAccountId, isAccountInvalid, } from "../utils/dashboard"; @@ -174,12 +176,12 @@ const UsageStatsPage: React.FC = ({ const now = Date.now(); const sortedAccounts = [...accounts].sort((left, right) => { - const leftPrimary = left.rateLimits?.primary?.usedPercent ?? Number.POSITIVE_INFINITY; - const rightPrimary = right.rateLimits?.primary?.usedPercent ?? Number.POSITIVE_INFINITY; + const leftPrimary = getRemainingPercent(left.rateLimits?.primary) ?? Number.NEGATIVE_INFINITY; + const rightPrimary = getRemainingPercent(right.rateLimits?.primary) ?? Number.NEGATIVE_INFINITY; if (left.isActive) return -1; if (right.isActive) return 1; if (leftPrimary !== rightPrimary) { - return leftPrimary - rightPrimary; + return rightPrimary - leftPrimary; } return left.displayName.localeCompare(right.displayName, "zh-CN"); }); @@ -205,11 +207,12 @@ const UsageStatsPage: React.FC = ({ const mostUnderused = [...efficiencyRows] .filter((row) => typeof row.efficiency.score === "number") .sort((left, right) => (left.efficiency.score ?? 0) - (right.efficiency.score ?? 0))[0]; - const hottestAccount = [...sortedAccounts].sort((left, right) => { - const leftUsage = left.rateLimits?.primary?.usedPercent ?? -1; - const rightUsage = right.rateLimits?.primary?.usedPercent ?? -1; - return rightUsage - leftUsage; + const mostAvailableAccount = [...sortedAccounts].sort((left, right) => { + const leftRemaining = getRemainingPercent(left.rateLimits?.primary) ?? -1; + const rightRemaining = getRemainingPercent(right.rateLimits?.primary) ?? -1; + return rightRemaining - leftRemaining; })[0]; + const mostAvailableInsight = mostAvailableAccount ? getAccountInsight(mostAvailableAccount) : null; return (

@@ -310,13 +313,16 @@ const UsageStatsPage: React.FC = ({

- 当前最热 + 剩余最多

- {hottestAccount?.displayName ?? "暂无数据"} + {mostAvailableAccount?.displayName ?? "暂无数据"}

- 5h 已用 {formatPercent(hottestAccount?.rateLimits?.primary?.usedPercent)} + 5h 剩余 {formatPercent(getRemainingPercent(mostAvailableAccount?.rateLimits?.primary))} + {mostAvailableInsight?.hourlyQuota.resetLabel + ? ` · ${mostAvailableInsight.hourlyQuota.resetLabel}` + : ""}

@@ -489,10 +495,13 @@ const UsageStatsPage: React.FC = ({

- 5h 已用 + 5h 剩余

- {formatPercent(account.rateLimits?.primary?.usedPercent)} + {formatPercent(getRemainingPercent(account.rateLimits?.primary))} +

+

+ {getAccountInsight(account).hourlyQuota.resetLabel ?? "--"}

diff --git a/src/types/index.ts b/src/types/index.ts index 876faa6..d7b7411 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -129,7 +129,7 @@ export interface CreditsSnapshot { } export interface RateLimitWindow { - usedPercent: number; + remainingPercent: number; resetsAt?: number | null; windowDurationMins?: number | null; } diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index e86e928..fc890b4 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -1,12 +1,13 @@ import { format, formatDistanceToNowStrict, isToday, isYesterday } from "date-fns"; import { zhCN } from "date-fns/locale"; -import { Account } from "../types"; +import type { Account, RateLimitWindow } from "../types"; export interface QuotaMetric { label: string; percent: number | null; detail: string; valueLabel: string; + resetLabel: string | null; tone: "critical" | "warning" | "healthy"; available: boolean; } @@ -30,6 +31,7 @@ export interface AccountInsight { export interface UsageEfficiency { score: number | null; + remainingPercent: number | null; usedPercent: number | null; elapsedPercent: number | null; status: "unavailable" | "underused" | "balanced" | "aggressive"; @@ -39,8 +41,8 @@ export interface UsageEfficiency { interface RankedQuotaAccount { account: Account; - primaryUsed: number; - secondaryUsed: number; + primaryRemaining: number; + secondaryRemaining: number; } function clamp(value: number, min: number, max: number): number { @@ -48,11 +50,23 @@ function clamp(value: number, min: number, max: number): number { } function metricTone(percent: number): QuotaMetric["tone"] { - if (percent >= 85) return "critical"; - if (percent >= 55) return "warning"; + if (percent <= 15) return "critical"; + if (percent <= 45) return "warning"; return "healthy"; } +export function getRemainingPercent( + window: RateLimitWindow | null | undefined, +): number | null { + if (!window) { + return null; + } + if (typeof window.remainingPercent === "number") { + return clamp(window.remainingPercent, 0, 100); + } + return null; +} + function formatResetTimestamp(timestampSeconds: number | null | undefined): string { if (!timestampSeconds) { return "时间待定"; @@ -65,6 +79,20 @@ function formatResetTimestamp(timestampSeconds: number | null | undefined): stri } } +function formatResetShort(timestampSeconds: number | null | undefined, mode: "time" | "date"): string { + if (!timestampSeconds) { + return "时间待定"; + } + + try { + return format(new Date(timestampSeconds * 1000), mode === "time" ? "HH:mm" : "M月d日", { + locale: zhCN, + }); + } catch { + return "时间待定"; + } +} + function formatSyncTime(iso: string | null): string { if (!iso) { return "暂无同步记录"; @@ -124,6 +152,7 @@ function createUnavailableMetric(account: Account, label: string, suffix: string percent: null, detail: getAccountStatusReason(account) ?? "账号已失效或不可用", valueLabel: `失效 / ${suffix}`, + resetLabel: null, tone: "critical", available: false, }; @@ -134,41 +163,48 @@ function createUnavailableMetric(account: Account, label: string, suffix: string percent: null, detail: "官方数据未获取", valueLabel: `未获取 / ${suffix}`, + resetLabel: null, tone: "warning", available: false, }; } function deriveHourlyQuota(account: Account): QuotaMetric { - if (account.rateLimits?.primary) { - const percent = clamp(account.rateLimits.primary.usedPercent, 0, 100); + const primary = account.rateLimits?.primary; + const percent = getRemainingPercent(primary); + if (percent !== null) { + const resetLabel = formatResetShort(primary?.resetsAt, "time"); return { - label: "5小时已使用配额", + label: "5小时剩余额度", percent, - detail: `刷新时间 ${formatResetTimestamp(account.rateLimits.primary.resetsAt)}`, - valueLabel: `${percent}% / 5h`, + detail: `重置时间 ${formatResetTimestamp(primary?.resetsAt)}`, + valueLabel: `${percent}% · ${resetLabel}`, + resetLabel, tone: metricTone(percent), available: true, }; } - return createUnavailableMetric(account, "5小时已使用配额", "5h"); + return createUnavailableMetric(account, "5小时剩余额度", "5h"); } function deriveWeeklyQuota(account: Account): QuotaMetric { - if (account.rateLimits?.secondary) { - const percent = clamp(account.rateLimits.secondary.usedPercent, 0, 100); + const secondary = account.rateLimits?.secondary; + const percent = getRemainingPercent(secondary); + if (percent !== null) { + const resetLabel = formatResetShort(secondary?.resetsAt, "date"); return { - label: "每周已使用配额", + label: "每周剩余额度", percent, - detail: `刷新时间 ${formatResetTimestamp(account.rateLimits.secondary.resetsAt)}`, - valueLabel: `${percent}% / week`, + detail: `重置时间 ${formatResetTimestamp(secondary?.resetsAt)}`, + valueLabel: `${percent}% · ${resetLabel}`, + resetLabel, tone: metricTone(percent), available: true, }; } - return createUnavailableMetric(account, "每周已使用配额", "week"); + return createUnavailableMetric(account, "每周剩余额度", "week"); } export function getHourlyUsageEfficiency( @@ -176,16 +212,18 @@ export function getHourlyUsageEfficiency( now = Date.now(), ): UsageEfficiency { const primary = account.rateLimits?.primary; + const remainingPercent = getRemainingPercent(primary); if ( !primary || - typeof primary.usedPercent !== "number" || + remainingPercent === null || typeof primary.resetsAt !== "number" || typeof primary.windowDurationMins !== "number" || primary.windowDurationMins <= 0 ) { return { score: null, - usedPercent: typeof primary?.usedPercent === "number" ? clamp(primary.usedPercent, 0, 100) : null, + remainingPercent, + usedPercent: remainingPercent === null ? null : 100 - remainingPercent, elapsedPercent: null, status: "unavailable", label: "待接入", @@ -193,7 +231,7 @@ export function getHourlyUsageEfficiency( }; } - const usedPercent = clamp(primary.usedPercent, 0, 100); + const usedPercent = 100 - remainingPercent; const windowMs = primary.windowDurationMins * 60 * 1000; const resetAtMs = primary.resetsAt * 1000; const remainingMs = clamp(resetAtMs - now, 0, windowMs); @@ -202,6 +240,7 @@ export function getHourlyUsageEfficiency( if (elapsedPercent <= 0.5) { return { score: null, + remainingPercent, usedPercent, elapsedPercent, status: "unavailable", @@ -216,32 +255,35 @@ export function getHourlyUsageEfficiency( if (paceRatio < 0.75) { return { score, + remainingPercent, usedPercent, elapsedPercent, status: "underused", label: `${Math.round(score)}%`, - detail: "当前用量低于时间进度,节奏偏慢", + detail: "剩余额度消耗低于时间进度,节奏偏慢", }; } if (paceRatio <= 1.25) { return { score, + remainingPercent, usedPercent, elapsedPercent, status: "balanced", label: `${Math.round(score)}%`, - detail: "当前用量与时间进度基本同步", + detail: "剩余额度消耗与时间进度基本同步", }; } return { score, + remainingPercent, usedPercent, elapsedPercent, status: "aggressive", label: `${Math.round(score)}%`, - detail: "当前用量高于时间进度,账号压力偏高", + detail: "剩余额度消耗高于时间进度,账号压力偏高", }; } @@ -267,26 +309,22 @@ function getRankedQuotaAccounts(accounts: Account[]): RankedQuotaAccount[] { .filter( (account) => !isAccountInvalid(account) && - (typeof account.rateLimits?.primary?.usedPercent === "number" || - typeof account.rateLimits?.secondary?.usedPercent === "number"), + (getRemainingPercent(account.rateLimits?.primary) !== null || + getRemainingPercent(account.rateLimits?.secondary) !== null), ) .map((account) => ({ account, - primaryUsed: - typeof account.rateLimits?.primary?.usedPercent === "number" - ? clamp(account.rateLimits.primary.usedPercent, 0, 100) - : Number.POSITIVE_INFINITY, - secondaryUsed: - typeof account.rateLimits?.secondary?.usedPercent === "number" - ? clamp(account.rateLimits.secondary.usedPercent, 0, 100) - : Number.POSITIVE_INFINITY, + primaryRemaining: + getRemainingPercent(account.rateLimits?.primary) ?? Number.NEGATIVE_INFINITY, + secondaryRemaining: + getRemainingPercent(account.rateLimits?.secondary) ?? Number.NEGATIVE_INFINITY, })) .sort((left, right) => { - if (left.primaryUsed !== right.primaryUsed) { - return left.primaryUsed - right.primaryUsed; + if (left.primaryRemaining !== right.primaryRemaining) { + return right.primaryRemaining - left.primaryRemaining; } - if (left.secondaryUsed !== right.secondaryUsed) { - return left.secondaryUsed - right.secondaryUsed; + if (left.secondaryRemaining !== right.secondaryRemaining) { + return right.secondaryRemaining - left.secondaryRemaining; } return left.account.createdAt.localeCompare(right.account.createdAt); }); diff --git a/src/utils/invoke.ts b/src/utils/invoke.ts index 3225361..fa0f29d 100644 --- a/src/utils/invoke.ts +++ b/src/utils/invoke.ts @@ -43,8 +43,8 @@ const demoAccounts: AccountsStore = { rateLimits: { limitId: "codex", planType: "plus", - primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: 1773813607 }, - secondary: { usedPercent: 88, windowDurationMins: 10080, resetsAt: 1773878873 }, + primary: { remainingPercent: 100, windowDurationMins: 300, resetsAt: 1773813607 }, + secondary: { remainingPercent: 88, windowDurationMins: 10080, resetsAt: 1773878873 }, }, }, { @@ -66,8 +66,8 @@ const demoAccounts: AccountsStore = { rateLimits: { limitId: "codex", planType: "plus", - primary: { usedPercent: 19, windowDurationMins: 300, resetsAt: 1773806407 }, - secondary: { usedPercent: 29, windowDurationMins: 10080, resetsAt: 1774144400 }, + primary: { remainingPercent: 19, windowDurationMins: 300, resetsAt: 1773806407 }, + secondary: { remainingPercent: 29, windowDurationMins: 10080, resetsAt: 1774144400 }, }, }, { @@ -89,8 +89,8 @@ const demoAccounts: AccountsStore = { rateLimits: { limitId: "codex", planType: "plus", - primary: { usedPercent: 98, windowDurationMins: 300, resetsAt: 1773801007 }, - secondary: { usedPercent: 96, windowDurationMins: 10080, resetsAt: 1773965273 }, + primary: { remainingPercent: 98, windowDurationMins: 300, resetsAt: 1773801007 }, + secondary: { remainingPercent: 96, windowDurationMins: 10080, resetsAt: 1773965273 }, }, }, ], diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 3be0924..a7c8966 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -36,18 +36,20 @@ describe("getAccountInsight", () => { const account = createAccount({ rateLimits: { planType: "pro", - primary: { usedPercent: 92, resetsAt: 1_800_000_000 }, - secondary: { usedPercent: 44, resetsAt: 1_800_100_000 }, + primary: { remainingPercent: 92, resetsAt: 1_800_000_000 }, + secondary: { remainingPercent: 44, resetsAt: 1_800_100_000 }, }, }); const insight = getAccountInsight(account); expect(insight.roleLabel).toBe("Pro"); - expect(insight.hourlyQuota.valueLabel).toBe("92% / 5h"); - expect(insight.hourlyQuota.tone).toBe("critical"); - expect(insight.weeklyQuota.valueLabel).toBe("44% / week"); - expect(insight.weeklyQuota.tone).toBe("healthy"); + expect(insight.hourlyQuota.valueLabel).toMatch(/^92% · /); + expect(insight.hourlyQuota.detail).toContain("重置时间"); + expect(insight.hourlyQuota.tone).toBe("healthy"); + expect(insight.weeklyQuota.valueLabel).toMatch(/^44% · /); + expect(insight.weeklyQuota.detail).toContain("重置时间"); + expect(insight.weeklyQuota.tone).toBe("warning"); expect(insight.syncLabel).toBe("2026-03-11 10:00"); expect(insight.hasRealRateLimits).toBe(true); }); @@ -75,8 +77,8 @@ describe("quota ranking", () => { isActive: true, rateLimits: { planType: "plus", - primary: { usedPercent: 70 }, - secondary: { usedPercent: 30 }, + primary: { remainingPercent: 70 }, + secondary: { remainingPercent: 30 }, }, }); const candidate = createAccount({ @@ -85,8 +87,8 @@ describe("quota ranking", () => { isActive: false, rateLimits: { planType: "plus", - primary: { usedPercent: 15 }, - secondary: { usedPercent: 25 }, + primary: { remainingPercent: 15 }, + secondary: { remainingPercent: 25 }, }, }); const exhausted = createAccount({ @@ -94,13 +96,13 @@ describe("quota ranking", () => { displayName: "Exhausted", rateLimits: { planType: "plus", - primary: { usedPercent: 99 }, - secondary: { usedPercent: 99 }, + primary: { remainingPercent: 99 }, + secondary: { remainingPercent: 99 }, }, }); - expect(getRecommendedAccountId([active, exhausted, candidate])).toBe("candidate"); - expect(getBestQuotaAccount([active, exhausted, candidate])?.id).toBe("candidate"); + expect(getRecommendedAccountId([active, exhausted, candidate])).toBe("exhausted"); + expect(getBestQuotaAccount([active, exhausted, candidate])?.id).toBe("exhausted"); }); it("returns null when there is no usable quota data", () => { @@ -117,7 +119,7 @@ describe("getHourlyUsageEfficiency", () => { rateLimits: { planType: "plus", primary: { - usedPercent: 48, + remainingPercent: 52, resetsAt: Math.floor(new Date("2026-03-11T12:30:00Z").getTime() / 1000), windowDurationMins: 300, }, @@ -137,7 +139,7 @@ describe("getHourlyUsageEfficiency", () => { rateLimits: { planType: "plus", primary: { - usedPercent: 20, + remainingPercent: 80, resetsAt: Math.floor(new Date("2026-03-11T11:00:00Z").getTime() / 1000), windowDurationMins: 300, }, @@ -155,7 +157,7 @@ describe("getHourlyUsageEfficiency", () => { createAccount({ rateLimits: { planType: "plus", - primary: { usedPercent: 20 }, + primary: { remainingPercent: 80 }, }, }), ); From d4b6cd5374340254f23d57fc29de0291f81bcbf8 Mon Sep 17 00:00:00 2001 From: yolanda hao Date: Sun, 10 May 2026 01:52:33 -0700 Subject: [PATCH 03/17] Release 1.2.6 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/gen/schemas/macOS-schema.json | 2477 +++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 2 +- src/components/AccountCard.tsx | 33 +- src/components/AccountList.tsx | 331 ++- src/components/Header.tsx | 6 +- src/components/UsageStatsPage.tsx | 6 +- src/utils/dashboard.ts | 96 +- tests/dashboard.test.ts | 22 +- 13 files changed, 2725 insertions(+), 260 deletions(-) create mode 100644 src-tauri/gen/schemas/macOS-schema.json diff --git a/package-lock.json b/package-lock.json index f63c51b..58fc61d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.5", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.5", + "version": "1.2.6", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 0cdc0e0..7994c2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.5", + "version": "1.2.6", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 006adc5..c3d2b94 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.5" +version = "1.2.6" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8025c3e..4327139 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.5" +version = "1.2.6" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json new file mode 100644 index 0000000..4917625 --- /dev/null +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -0,0 +1,2477 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + }, + "deny": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "Application": { + "description": "Opener scope application.", + "anyOf": [ + { + "description": "Open in default application.", + "type": "null" + }, + { + "description": "If true, allow open with any application.", + "type": "boolean" + }, + { + "description": "Allow specific application to open with.", + "type": "string" + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9a39f00..8050a08 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.5", + "version": "1.2.6", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index a1be73b..fade4b4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -466,7 +466,7 @@ const App: React.FC = () => { className={ isTrayMode ? "h-full" - : "relative z-10 mx-auto w-full max-w-[1520px] overflow-auto px-4 pb-10 pt-1 sm:px-6 sm:pt-2 lg:px-8 lg:pb-14" + : "relative z-10 mx-auto w-full max-w-[1320px] overflow-auto px-4 pb-8 pt-1 sm:px-6 sm:pt-2 lg:px-7 lg:pb-12" } > {isTrayMode ? ( diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index 688e0ed..34be99c 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -72,6 +72,19 @@ const AccountCard: React.FC = ({ ? "切换中" : "待命"; const invalidReason = getAccountStatusReason(account); + const renderCompactQuota = (metric: typeof insight.hourlyQuota) => ( +
+
+ {metric.label.startsWith("5") ? "5h" : "Week"} +
+
+ {typeof metric.percent === "number" ? `${Math.round(metric.percent)}%` : metric.valueLabel} +
+
+ {metric.resetLabel ? `重置 ${metric.resetLabel}` : metric.detail} +
+
+ ); useEffect(() => { setDraftName(account.displayName); @@ -147,23 +160,9 @@ const AccountCard: React.FC = ({
-
-
-
- 5h -
-
- {insight.hourlyQuota.valueLabel} -
-
-
-
- Week -
-
- {insight.weeklyQuota.valueLabel} -
-
+
+ {renderCompactQuota(insight.hourlyQuota)} + {renderCompactQuota(insight.weeklyQuota)}
Sync diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 8444159..bf96287 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -5,7 +5,6 @@ import { Account } from "../types"; import AccountCard from "./AccountCard"; import EmptyState from "./EmptyState"; import { - formatRelativeTime, getAccountStatusReason, getAccountInsight, getRemainingPercent, @@ -75,257 +74,173 @@ const AccountList: React.FC = ({ : "待命"; return ( -
+
{featuredAccount && ( -
+
-
-
-
+
-
-
-
- +
+
+
+ Current -
-

- {featuredAccount.displayName} -

- - {featuredStatus} - -
-
- - {featuredInsight?.roleLabel ?? "账号"} - - {featuredIdentity} -
+ + {featuredStatus} + +
+

+ {featuredAccount.displayName} +

+
+ + {featuredInsight?.roleLabel ?? "账号"} + + {featuredIdentity}
+
+ +
+

+ 5h 剩余 +

+
+

+ {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} +

+ + {featuredInsight?.hourlyQuota.resetLabel + ? `重置 ${featuredInsight.hourlyQuota.resetLabel}` + : ""} + +
+
-
+
+
+

+ 本周 +

+

+ {featuredInsight?.weeklyQuota.valueLabel ?? "--"} +

+
+
+

+ 更新 +

+

+ {featuredInsight?.syncLabel ?? "--"} +

+
+
+ +
+ +
+
-
-
-

- 5h 剩余 -

-
-

- {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} -

- - {featuredInsight?.hourlyQuota.resetLabel ?? ""} - -
-

- {featuredInsight?.hourlyQuota.detail ?? "等待同步"} -

- -
- - -
-
- -
-
-

- 下一位 -

-

- {recommendedStandby?.displayName ?? "继续当前账号"} -

-

- {recommendedStandby ? "需要切换时,优先交给它。" : "当前账号仍然最合适。"} -

-
- -
-
-
-

- 本周 -

-

- {featuredInsight?.weeklyQuota.valueLabel ?? "--"} -

-
-
-

- 待命 -

-

- {standbyAccounts.length} -

-
-
-

- 账户数 -

-

- {sorted.length} -

-
-
-

- 上次切换 -

-

- {formatRelativeTime(featuredAccount.lastSwitchedAt)} -

-
-
-
- -
- - {featuredInvalid - ? `已失效 · ${getAccountStatusReason(featuredAccount) ?? "请重新登录该账号"}` - : `最近更新 ${featuredInsight?.syncLabel ?? "--"}`} - - -
+ {featuredInvalid && ( +
+ {getAccountStatusReason(featuredAccount) ?? "请重新登录该账号"}
-
+ )}

Next

-

+

{recommendedStandby?.displayName ?? "继续当前账号"}

-

- {recommendedStandby ? "需要切换时,优先交给它。" : "当前账号仍然最合适。"} +

+ {recommendedStandby ? "需要切换时优先交给它" : "当前账号仍然最合适"}

-
+
-
+
-

- 当前识别 +

+ 5h 剩余 +

+

+ {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} +

+

+ {featuredInsight?.hourlyQuota.resetLabel + ? `重置 ${featuredInsight.hourlyQuota.resetLabel}` + : "--"}

-

{featuredIdentity}

-
-
-
-

- 5h 剩余 -

-

- {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} -

-

- {featuredInsight?.hourlyQuota.resetLabel - ? `重置 ${featuredInsight.hourlyQuota.resetLabel}` - : "--"} -

-
-
-

- 待命 -

-

- {standbyAccounts.length} -

-
-
-

- 账户数 -

-

- {sorted.length} -

-
-
-

- 上次切换 -

-

- {featuredAccount.lastSwitchedAt - ? formatRelativeTime(featuredAccount.lastSwitchedAt) - : "--"} -

-
-
- -
+
)}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 78147ca..ed74aa6 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,6 +2,7 @@ import React, { useRef } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useAccountStore } from "../store/accountStore"; import { exportBackupBundle } from "../utils/backup"; +import { DISPLAY_TIME_ZONE_LABEL } from "../utils/dashboard"; import { hoverLift, revealUp } from "../utils/motion"; interface HeaderProps { @@ -81,7 +82,7 @@ const Header: React.FC = ({ className="sticky top-0 z-20 px-4 pb-3 pt-3 sm:px-6 lg:px-8" {...revealUp(prefersReducedMotion, 0)} > -
+
@@ -106,6 +107,9 @@ const Header: React.FC = ({ {APP_VERSION} + + 时间 {DISPLAY_TIME_ZONE_LABEL} +

{subtitle}

diff --git a/src/components/UsageStatsPage.tsx b/src/components/UsageStatsPage.tsx index 6d176c9..82053fe 100644 --- a/src/components/UsageStatsPage.tsx +++ b/src/components/UsageStatsPage.tsx @@ -321,7 +321,7 @@ const UsageStatsPage: React.FC = ({

5h 剩余 {formatPercent(getRemainingPercent(mostAvailableAccount?.rateLimits?.primary))} {mostAvailableInsight?.hourlyQuota.resetLabel - ? ` · ${mostAvailableInsight.hourlyQuota.resetLabel}` + ? ` · 重置 ${mostAvailableInsight.hourlyQuota.resetLabel}` : ""}

@@ -501,7 +501,9 @@ const UsageStatsPage: React.FC = ({ {formatPercent(getRemainingPercent(account.rateLimits?.primary))}

- {getAccountInsight(account).hourlyQuota.resetLabel ?? "--"} + {getAccountInsight(account).hourlyQuota.resetLabel + ? `重置 ${getAccountInsight(account).hourlyQuota.resetLabel}` + : "--"}

diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index fc890b4..f1fac40 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -1,7 +1,10 @@ -import { format, formatDistanceToNowStrict, isToday, isYesterday } from "date-fns"; +import { formatDistanceToNowStrict } from "date-fns"; import { zhCN } from "date-fns/locale"; import type { Account, RateLimitWindow } from "../types"; +const RESET_TIME_ZONE = "Asia/Shanghai"; +export const DISPLAY_TIME_ZONE_LABEL = "UTC+8"; + export interface QuotaMetric { label: string; percent: number | null; @@ -67,30 +70,71 @@ export function getRemainingPercent( return null; } -function formatResetTimestamp(timestampSeconds: number | null | undefined): string { - if (!timestampSeconds) { - return "时间待定"; - } +type ZonedParts = { + year: string; + month: string; + day: string; + hour: string; + minute: string; +}; +function getZonedParts(date: Date): ZonedParts | null { try { - return format(new Date(timestampSeconds * 1000), "yyyy-MM-dd HH:mm"); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: RESET_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => + parts.find((item) => item.type === type)?.value; + const year = part("year"); + const month = part("month"); + const day = part("day"); + const hour = part("hour"); + const minute = part("minute"); + + if (!year || !month || !day || !hour || !minute) { + return null; + } + + return { year, month, day, hour, minute }; } catch { + return null; + } +} + +function formatZonedDateKey(parts: Pick): string { + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function formatResetTimestamp(timestampSeconds: number | null | undefined): string { + if (typeof timestampSeconds !== "number" || !Number.isFinite(timestampSeconds)) { return "时间待定"; } + + const parts = getZonedParts(new Date(timestampSeconds * 1000)); + return parts ? `${formatZonedDateKey(parts)} ${parts.hour}:${parts.minute}` : "时间待定"; } function formatResetShort(timestampSeconds: number | null | undefined, mode: "time" | "date"): string { - if (!timestampSeconds) { + if (typeof timestampSeconds !== "number" || !Number.isFinite(timestampSeconds)) { return "时间待定"; } - try { - return format(new Date(timestampSeconds * 1000), mode === "time" ? "HH:mm" : "M月d日", { - locale: zhCN, - }); - } catch { + const parts = getZonedParts(new Date(timestampSeconds * 1000)); + if (!parts) { return "时间待定"; } + + if (mode === "time") { + return `${parts.hour}:${parts.minute}`; + } + + return `${Number(parts.month)}月${Number(parts.day)}日`; } function formatSyncTime(iso: string | null): string { @@ -100,13 +144,31 @@ function formatSyncTime(iso: string | null): string { try { const date = new Date(iso); - if (isToday(date)) { - return `今天 ${format(date, "HH:mm")}`; + const parts = getZonedParts(date); + const nowParts = getZonedParts(new Date()); + if (!parts || !nowParts) { + return "时间未知"; + } + + const dateKey = formatZonedDateKey(parts); + const todayKey = formatZonedDateKey(nowParts); + const yesterdayDate = new Date( + Date.UTC(Number(nowParts.year), Number(nowParts.month) - 1, Number(nowParts.day)) - + 24 * 60 * 60 * 1000, + ); + const yesterdayKey = formatZonedDateKey({ + year: String(yesterdayDate.getUTCFullYear()), + month: String(yesterdayDate.getUTCMonth() + 1).padStart(2, "0"), + day: String(yesterdayDate.getUTCDate()).padStart(2, "0"), + }); + + if (dateKey === todayKey) { + return `今天 ${parts.hour}:${parts.minute}`; } - if (isYesterday(date)) { - return `昨天 ${format(date, "HH:mm")}`; + if (dateKey === yesterdayKey) { + return `昨天 ${parts.hour}:${parts.minute}`; } - return format(date, "yyyy-MM-dd HH:mm"); + return `${dateKey} ${parts.hour}:${parts.minute}`; } catch { return "时间未知"; } diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index a7c8966..ccea2ed 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -20,7 +20,7 @@ function createAccount(overrides: Partial = {}): Account { sessionInfo: { fileCount: 12, totalBytes: 1_024, - lastSessionObservedAt: "2026-03-11T10:00:00", + lastSessionObservedAt: "2026-03-11T10:00:00Z", currentSessionId: null, currentThreadName: null, currentUpdatedAt: null, @@ -36,21 +36,27 @@ describe("getAccountInsight", () => { const account = createAccount({ rateLimits: { planType: "pro", - primary: { remainingPercent: 92, resetsAt: 1_800_000_000 }, - secondary: { remainingPercent: 44, resetsAt: 1_800_100_000 }, + primary: { + remainingPercent: 92, + resetsAt: Date.UTC(2026, 4, 9, 18, 43) / 1000, + }, + secondary: { + remainingPercent: 44, + resetsAt: Date.UTC(2026, 4, 15, 16, 30) / 1000, + }, }, }); const insight = getAccountInsight(account); expect(insight.roleLabel).toBe("Pro"); - expect(insight.hourlyQuota.valueLabel).toMatch(/^92% · /); - expect(insight.hourlyQuota.detail).toContain("重置时间"); + expect(insight.hourlyQuota.valueLabel).toBe("92% · 02:43"); + expect(insight.hourlyQuota.detail).toBe("重置时间 2026-05-10 02:43"); expect(insight.hourlyQuota.tone).toBe("healthy"); - expect(insight.weeklyQuota.valueLabel).toMatch(/^44% · /); - expect(insight.weeklyQuota.detail).toContain("重置时间"); + expect(insight.weeklyQuota.valueLabel).toBe("44% · 5月16日"); + expect(insight.weeklyQuota.detail).toBe("重置时间 2026-05-16 00:30"); expect(insight.weeklyQuota.tone).toBe("warning"); - expect(insight.syncLabel).toBe("2026-03-11 10:00"); + expect(insight.syncLabel).toBe("2026-03-11 18:00"); expect(insight.hasRealRateLimits).toBe(true); }); From 02444584e24166003c9c0859520133866313721b Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Mon, 11 May 2026 11:01:46 +0800 Subject: [PATCH 04/17] Improve smart switch quota decisions --- README.en.md | 6 +- README.md | 6 +- src/App.tsx | 21 +++--- src/utils/dashboard.ts | 48 +++++++++++-- tests/dashboard.test.ts | 147 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 208 insertions(+), 20 deletions(-) diff --git a/README.en.md b/README.en.md index 8951671..df35175 100644 --- a/README.en.md +++ b/README.en.md @@ -141,9 +141,9 @@ If the auth state already belongs to an existing account, the app updates that a Current rule set: -- Prefer the account with the lowest `5h` usage -- If `5h` usage is tied, compare weekly usage -- If the active account is already the best choice, do nothing +- Switch when the active account has less than 5% `5h` quota left, or less than 2% weekly quota left +- Candidate accounts must have valid quota data +- Candidate accounts are ranked by `5h` remaining quota first, then weekly remaining quota ## Token Tracking diff --git a/README.md b/README.md index a4f55bb..5d93c85 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ npm link 当前规则: -- 优先选择 `5h` 使用比例更低的账号 -- 如果 `5h` 相同,再比较每周使用比例 -- 如果当前账号已经是最佳选择,则不重复切换 +- 当前账号 `5h` 剩余小于 5%,或每周剩余小于 2% 时触发切换 +- 候选账号必须拥有有效额度数据 +- 候选账号按 `5h` 剩余优先、每周剩余次之排序 ## Token 统计 diff --git a/src/App.tsx b/src/App.tsx index fade4b4..6ede2fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,11 @@ import { formatAuthIdentityLabel, parseAuthIdentity, } from "./utils/auth"; -import { getAccountStatusReason, getBestQuotaAccount, isAccountInvalid } from "./utils/dashboard"; +import { + getAccountStatusReason, + getSmartSwitchDecision, + isAccountInvalid, +} from "./utils/dashboard"; import { useAccountSwitch } from "./hooks/useAccountSwitch"; import { Account } from "./types"; import { MOTION_EASE, revealUp } from "./utils/motion"; @@ -233,21 +237,22 @@ const App: React.FC = () => { const hydrated = await hydrateAccounts(accounts); await persistAccounts(hydrated); const invalidCount = hydrated.filter((account) => isAccountInvalid(account)).length; + const smartSwitchDecision = getSmartSwitchDecision(hydrated); + + if (smartSwitchDecision.status === "hold") { + showToast(`${smartSwitchDecision.activeAccount.displayName} 当前额度仍充足`); + return; + } - const bestAccount = getBestQuotaAccount(hydrated); - if (!bestAccount) { + if (smartSwitchDecision.status !== "switch") { throw new Error( invalidCount > 0 ? `当前没有可用账号,已检测到 ${invalidCount} 个失效账号` : "当前没有足够数据", ); } - if (bestAccount.isActive) { - showToast(`${bestAccount.displayName} 已是当前最佳选择`); - return; - } - await requestSwitch(bestAccount); + await requestSwitch(smartSwitchDecision.targetAccount); } catch (error) { showToast(`智能切换失败 · ${error instanceof Error ? error.message : String(error)}`); } finally { diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index f1fac40..740ba11 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -48,6 +48,15 @@ interface RankedQuotaAccount { secondaryRemaining: number; } +export type SmartSwitchDecision = + | { status: "hold"; activeAccount: Account } + | { status: "switch"; targetAccount: Account } + | { status: "no_target"; activeAccount: Account } + | { status: "no_data" }; + +const SMART_SWITCH_HOURLY_MIN_REMAINING = 5; +const SMART_SWITCH_WEEKLY_MIN_REMAINING = 2; + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } @@ -392,14 +401,45 @@ function getRankedQuotaAccounts(accounts: Account[]): RankedQuotaAccount[] { }); } -export function getRecommendedAccountId(accounts: Account[]): string | null { +export function shouldSmartSwitchAccount(account: Account): boolean { + const primaryRemaining = getRemainingPercent(account.rateLimits?.primary); + const secondaryRemaining = getRemainingPercent(account.rateLimits?.secondary); + return ( - getRankedQuotaAccounts(accounts) - .find(({ account }) => !account.isActive) - ?.account.id ?? null + (primaryRemaining !== null && primaryRemaining < SMART_SWITCH_HOURLY_MIN_REMAINING) || + (secondaryRemaining !== null && secondaryRemaining < SMART_SWITCH_WEEKLY_MIN_REMAINING) ); } +export function getSmartSwitchDecision(accounts: Account[]): SmartSwitchDecision { + const activeAccount = accounts.find((account) => account.isActive); + if (activeAccount && !shouldSmartSwitchAccount(activeAccount)) { + return { status: "hold", activeAccount }; + } + + const rankedAccounts = getRankedQuotaAccounts(accounts); + const targetAccount = rankedAccounts.find(({ account }) => !account.isActive)?.account; + if (targetAccount) { + return { status: "switch", targetAccount }; + } + + if (activeAccount) { + return { status: "no_target", activeAccount }; + } + + return { status: "no_data" }; +} + +export function getRecommendedAccountId(accounts: Account[]): string | null { + const decision = getSmartSwitchDecision(accounts); + return decision.status === "switch" ? decision.targetAccount.id : null; +} + +export function getSmartSwitchAccount(accounts: Account[]): Account | null { + const decision = getSmartSwitchDecision(accounts); + return decision.status === "switch" ? decision.targetAccount : null; +} + export function getBestQuotaAccount(accounts: Account[]): Account | null { return getRankedQuotaAccounts(accounts)[0]?.account ?? null; } diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index ccea2ed..0c0057b 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -5,6 +5,9 @@ import { getBestQuotaAccount, getHourlyUsageEfficiency, getRecommendedAccountId, + getSmartSwitchDecision, + getSmartSwitchAccount, + shouldSmartSwitchAccount, } from "../src/utils/dashboard"; import type { Account } from "../src/types"; @@ -77,7 +80,7 @@ describe("getAccountInsight", () => { }); describe("quota ranking", () => { - it("recommends the best non-active account and returns the best overall account", () => { + it("returns the best overall account without recommending a switch while active quota is healthy", () => { const active = createAccount({ id: "active", isActive: true, @@ -107,15 +110,155 @@ describe("quota ranking", () => { }, }); - expect(getRecommendedAccountId([active, exhausted, candidate])).toBe("exhausted"); + expect(getRecommendedAccountId([active, exhausted, candidate])).toBeNull(); + expect(getSmartSwitchAccount([active, exhausted, candidate])).toBeNull(); expect(getBestQuotaAccount([active, exhausted, candidate])?.id).toBe("exhausted"); }); + it("returns a hold decision while active quota is healthy", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 5 }, + secondary: { remainingPercent: 2 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 80 }, + }, + }); + + expect(getSmartSwitchDecision([active, candidate])).toEqual({ + status: "hold", + activeAccount: active, + }); + }); + + it("recommends a switch when active 5h quota is below 5%", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + secondary: { remainingPercent: 80 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 40 }, + secondary: { remainingPercent: 20 }, + }, + }); + + expect(shouldSmartSwitchAccount(active)).toBe(true); + expect(getRecommendedAccountId([active, candidate])).toBe("candidate"); + expect(getSmartSwitchAccount([active, candidate])?.id).toBe("candidate"); + }); + + it("recommends a switch when active weekly quota is below 2%", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 50 }, + secondary: { remainingPercent: 1 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 30 }, + secondary: { remainingPercent: 10 }, + }, + }); + + expect(shouldSmartSwitchAccount(active)).toBe(true); + expect(getRecommendedAccountId([active, candidate])).toBe("candidate"); + }); + + it("does not switch at the exact smart switch thresholds", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 5 }, + secondary: { remainingPercent: 2 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 80 }, + }, + }); + + expect(shouldSmartSwitchAccount(active)).toBe(false); + expect(getRecommendedAccountId([active, candidate])).toBeNull(); + }); + + it("can recommend another account even when it is below a smart switch threshold", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + secondary: { remainingPercent: 80 }, + }, + }); + const depletedCandidate = createAccount({ + id: "depleted", + displayName: "Depleted", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 1 }, + }, + }); + + expect(getRecommendedAccountId([active, depletedCandidate])).toBe("depleted"); + expect(getSmartSwitchAccount([active, depletedCandidate])?.id).toBe("depleted"); + }); + it("returns null when there is no usable quota data", () => { const account = createAccount({ rateLimits: null }); expect(getRecommendedAccountId([account])).toBeNull(); expect(getBestQuotaAccount([account])).toBeNull(); }); + + it("returns no target when active quota is low and no standby account has quota data", () => { + const account = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + }, + }); + + expect(getSmartSwitchDecision([account])).toEqual({ + status: "no_target", + activeAccount: account, + }); + }); }); describe("getHourlyUsageEfficiency", () => { From 73ccad67347f0f5668590a957e6cbdeca89a8635 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Mon, 11 May 2026 14:02:18 +0800 Subject: [PATCH 05/17] Release v1.2.7 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/Header.tsx | 3 +- src/utils/backup.ts | 104 ++++++++++++++++++++++++++++++++------ src/utils/dashboard.ts | 15 +++--- tests/backup.test.ts | 53 +++++++++++++++++++ tests/dashboard.test.ts | 25 +++++++++ 10 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 tests/backup.test.ts diff --git a/package-lock.json b/package-lock.json index 58fc61d..0b8fe76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.6", + "version": "1.2.7", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 7994c2c..fe3a264 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.6", + "version": "1.2.7", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c3d2b94..03b091b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.6" +version = "1.2.7" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4327139..0114d8e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.6" +version = "1.2.7" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8050a08..9308444 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.6", + "version": "1.2.7", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ed74aa6..7965ff4 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -4,6 +4,7 @@ import { useAccountStore } from "../store/accountStore"; import { exportBackupBundle } from "../utils/backup"; import { DISPLAY_TIME_ZONE_LABEL } from "../utils/dashboard"; import { hoverLift, revealUp } from "../utils/motion"; +import packageJson from "../../package.json"; interface HeaderProps { onImportConfig: (file: File) => Promise; @@ -16,7 +17,7 @@ interface HeaderProps { unmanagedCurrentAuthLabel: string | null; } -const APP_VERSION = "v1.2.4"; +const APP_VERSION = `v${packageJson.version}`; const Header: React.FC = ({ onImportConfig, diff --git a/src/utils/backup.ts b/src/utils/backup.ts index 019ad49..ea79cef 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -1,6 +1,7 @@ import { Account, AppSettings, BackupBundle, BackupBundleAccount } from "../types"; import { api } from "./invoke"; import { hydrateAccounts } from "./accounts"; +import { matchesAccountIdentity, parseAuthIdentity } from "./auth"; interface NormalizedBackupImport { accounts: Account[]; @@ -61,6 +62,74 @@ function normalizeBackupImport(parsed: BackupBundle): NormalizedBackupImport { }; } +function isPresentCredential(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function matchesCurrentAuth(account: Account, authJson: string): boolean { + const identity = parseAuthIdentity(authJson); + const accountUserId = account.userId?.trim().toLowerCase() ?? null; + const identityAccountId = identity.accountId?.trim().toLowerCase() ?? null; + + return ( + matchesAccountIdentity(account, identity) || + Boolean(accountUserId && identityAccountId && accountUserId === identityAccountId) + ); +} + +function resolveBackupCredentials( + account: Account, + credentials: string | null | undefined, + currentAuthJson: string | null | undefined, +): string | null { + if (isPresentCredential(credentials)) { + return credentials; + } + + if ( + account.isActive && + isPresentCredential(currentAuthJson) && + matchesCurrentAuth(account, currentAuthJson) + ) { + return currentAuthJson; + } + + return null; +} + +function formatAccountList(accounts: Account[]): string { + return accounts.map((account) => account.displayName || account.id).join("、"); +} + +export function collectBackupCredentialsForImport(parsed: BackupBundle): Map { + const credentialsByAccountId = new Map(); + const missingAccounts: Account[] = []; + + for (const entry of parsed.accounts) { + const account = entry.account; + const resolved = resolveBackupCredentials( + account, + entry.credentials, + parsed.currentAuthJson, + ); + + if (!resolved) { + missingAccounts.push(account); + continue; + } + + credentialsByAccountId.set(account.id, resolved); + } + + if (missingAccounts.length > 0) { + throw new Error( + `备份缺少账号凭据:${formatAccountList(missingAccounts)}。请在源电脑重新导出完整备份。`, + ); + } + + return credentialsByAccountId; +} + function downloadJson(content: string, fileName: string) { const blob = new Blob([content], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -75,18 +144,32 @@ export async function exportBackupBundle( accounts: Account[], settings: AppSettings, ): Promise { + const currentAuthJson = await api.readAuthJson().catch(() => null); const exportedAccounts: BackupBundleAccount[] = await Promise.all( accounts.map(async (account) => ({ account, - credentials: await api.readAccountCredentials(account.id).catch(() => null), + credentials: resolveBackupCredentials( + account, + await api.readAccountCredentials(account.id).catch(() => null), + currentAuthJson, + ), })), ); + const missingAccounts = exportedAccounts + .filter((entry) => !entry.credentials) + .map((entry) => entry.account); + + if (missingAccounts.length > 0) { + throw new Error( + `无法导出完整备份,缺少账号凭据:${formatAccountList(missingAccounts)}。请先重新导入这些账号的当前授权。`, + ); + } const bundle: BackupBundle = { version: "1.0", exportedAt: new Date().toISOString(), settings, - currentAuthJson: await api.readAuthJson().catch(() => null), + currentAuthJson, accounts: exportedAccounts, }; @@ -109,14 +192,8 @@ export async function importBackupBundle( (account) => !nextAccounts.some((item) => item.id === account.id), ); const previousCredentials = new Map(); - const accountIdsToWrite = new Set( - parsed.accounts - .filter( - (entry): entry is BackupBundleAccount & { account: Account } => - Boolean(entry.account?.id), - ) - .map((entry) => entry.account.id), - ); + const credentialsByAccountId = collectBackupCredentialsForImport(parsed); + const accountIdsToWrite = new Set(credentialsByAccountId.keys()); const shouldReplaceCurrentAuth = Boolean(parsed.currentAuthJson) && nextAccounts.some((account) => account.isActive); const previousAuthJson = shouldReplaceCurrentAuth @@ -130,11 +207,8 @@ export async function importBackupBundle( previousCredentials.set(accountId, existingCredentials); } - for (const { account, credentials } of parsed.accounts) { - if (!credentials) { - continue; - } - await api.saveAccountCredentials(account.id, credentials); + for (const [accountId, credentials] of credentialsByAccountId) { + await api.saveAccountCredentials(accountId, credentials); } if (shouldReplaceCurrentAuth) { diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index 740ba11..188b656 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -79,6 +79,11 @@ export function getRemainingPercent( return null; } +function getUsableRemainingPercent(window: RateLimitWindow | null | undefined): number | null { + const remainingPercent = getRemainingPercent(window); + return remainingPercent !== null && remainingPercent > 0 ? remainingPercent : null; +} + type ZonedParts = { year: string; month: string; @@ -380,15 +385,13 @@ function getRankedQuotaAccounts(accounts: Account[]): RankedQuotaAccount[] { .filter( (account) => !isAccountInvalid(account) && - (getRemainingPercent(account.rateLimits?.primary) !== null || - getRemainingPercent(account.rateLimits?.secondary) !== null), + getUsableRemainingPercent(account.rateLimits?.primary) !== null && + getUsableRemainingPercent(account.rateLimits?.secondary) !== null, ) .map((account) => ({ account, - primaryRemaining: - getRemainingPercent(account.rateLimits?.primary) ?? Number.NEGATIVE_INFINITY, - secondaryRemaining: - getRemainingPercent(account.rateLimits?.secondary) ?? Number.NEGATIVE_INFINITY, + primaryRemaining: getUsableRemainingPercent(account.rateLimits?.primary) ?? 0, + secondaryRemaining: getUsableRemainingPercent(account.rateLimits?.secondary) ?? 0, })) .sort((left, right) => { if (left.primaryRemaining !== right.primaryRemaining) { diff --git a/tests/backup.test.ts b/tests/backup.test.ts new file mode 100644 index 0000000..55408f5 --- /dev/null +++ b/tests/backup.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { collectBackupCredentialsForImport } from "../src/utils/backup"; +import { Account, BackupBundle } from "../src/types"; + +function createAccount(overrides: Partial = {}): Account { + return { + id: "73fb88a8-8637-4950-ac0c-2a4f9fc89930", + displayName: "Imported Account", + email: "imported@example.com", + userId: "acct-imported", + isActive: false, + createdAt: "2026-05-11T00:00:00.000Z", + lastSwitchedAt: null, + sessionInfo: null, + ...overrides, + }; +} + +function createBundle(account: Account, credentials: string | null): BackupBundle { + return { + version: "1.0", + exportedAt: "2026-05-11T00:00:00.000Z", + settings: { + autoRefreshInterval: 0, + autoRestartCodexAfterSwitch: true, + theme: "system", + proxyUrl: "", + }, + currentAuthJson: null, + accounts: [{ account, credentials }], + }; +} + +describe("backup import credentials", () => { + it("rejects account entries without credentials", () => { + const bundle = createBundle(createAccount(), null); + + expect(() => collectBackupCredentialsForImport(bundle)).toThrow( + "备份缺少账号凭据:Imported Account", + ); + }); + + it("uses current auth for the active matching account", () => { + const account = createAccount({ isActive: true }); + const currentAuthJson = JSON.stringify({ tokens: { account_id: account.userId } }); + const bundle = { + ...createBundle(account, null), + currentAuthJson, + }; + + expect(collectBackupCredentialsForImport(bundle).get(account.id)).toBe(currentAuthJson); + }); +}); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 0c0057b..40908c7 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -238,6 +238,31 @@ describe("quota ranking", () => { expect(getSmartSwitchAccount([active, depletedCandidate])?.id).toBe("depleted"); }); + it("does not recommend an account with depleted weekly quota", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + secondary: { remainingPercent: 80 }, + }, + }); + const weeklyDepletedCandidate = createAccount({ + id: "weekly-depleted", + displayName: "Weekly Depleted", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 0 }, + }, + }); + + expect(getRecommendedAccountId([active, weeklyDepletedCandidate])).toBeNull(); + expect(getSmartSwitchAccount([active, weeklyDepletedCandidate])).toBeNull(); + expect(getBestQuotaAccount([active, weeklyDepletedCandidate])?.id).toBe("active"); + }); + it("returns null when there is no usable quota data", () => { const account = createAccount({ rateLimits: null }); expect(getRecommendedAccountId([account])).toBeNull(); From c402d2a1da3395125ddfd9fd54b9547112b83dd3 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Tue, 12 May 2026 12:56:33 +0800 Subject: [PATCH 06/17] Release v1.2.8 --- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/utils/dashboard.ts | 2 +- tests/dashboard.test.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b8fe76..88da296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.7", + "version": "1.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.7", + "version": "1.2.8", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index fe3a264..ec69288 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.7", + "version": "1.2.8", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03b091b..1e5bf32 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.7" +version = "1.2.8" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0114d8e..a74ccf9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.7" +version = "1.2.8" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9308444..111e2b2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.7", + "version": "1.2.8", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index 188b656..b944e8b 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -148,7 +148,7 @@ function formatResetShort(timestampSeconds: number | null | undefined, mode: "ti return `${parts.hour}:${parts.minute}`; } - return `${Number(parts.month)}月${Number(parts.day)}日`; + return `${Number(parts.month)}月${Number(parts.day)}日 ${parts.hour}:${parts.minute}`; } function formatSyncTime(iso: string | null): string { diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 40908c7..0c4b1c8 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -56,7 +56,7 @@ describe("getAccountInsight", () => { expect(insight.hourlyQuota.valueLabel).toBe("92% · 02:43"); expect(insight.hourlyQuota.detail).toBe("重置时间 2026-05-10 02:43"); expect(insight.hourlyQuota.tone).toBe("healthy"); - expect(insight.weeklyQuota.valueLabel).toBe("44% · 5月16日"); + expect(insight.weeklyQuota.valueLabel).toBe("44% · 5月16日 00:30"); expect(insight.weeklyQuota.detail).toBe("重置时间 2026-05-16 00:30"); expect(insight.weeklyQuota.tone).toBe("warning"); expect(insight.syncLabel).toBe("2026-03-11 18:00"); From c554f36b7c1cdc39ca7bf97355a24e61c862ea68 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Thu, 14 May 2026 09:07:54 +0800 Subject: [PATCH 07/17] Release v1.2.9 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/usage.rs | 67 +++++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- src/components/AccountList.tsx | 36 ++++++------- src/utils/accounts.ts | 43 ++++++++------- tests/accounts.test.ts | 94 +++++++++++++++++++++++++++++++++ 9 files changed, 207 insertions(+), 45 deletions(-) create mode 100644 tests/accounts.test.ts diff --git a/package-lock.json b/package-lock.json index 88da296..3295a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.8", + "version": "1.2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.8", + "version": "1.2.9", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index ec69288..ae93400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.8", + "version": "1.2.9", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1e5bf32..bf9665b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.8" +version = "1.2.9" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a74ccf9..3e20209 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.8" +version = "1.2.9" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 8fe50a6..ac467ee 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -397,9 +397,14 @@ async fn refresh_auth_tokens( Ok(()) } -fn pick_nearest_window(windows: &[UsageWindowRaw], target_seconds: i64) -> Option { +fn pick_nearest_window( + windows: &[UsageWindowRaw], + target_seconds: i64, + max_delta_seconds: i64, +) -> Option { windows .iter() + .filter(|window| (window.limit_window_seconds - target_seconds).abs() <= max_delta_seconds) .min_by_key(|window| (window.limit_window_seconds - target_seconds).abs()) .cloned() } @@ -448,8 +453,9 @@ fn map_usage_payload(payload: UsageApiResponse) -> GetAccountRateLimitsResponse unlimited: Some(credit.unlimited), balance: credit.balance, }), - primary: pick_nearest_window(&windows, 5 * 60 * 60).map(to_usage_window), - secondary: pick_nearest_window(&windows, 7 * 24 * 60 * 60).map(to_usage_window), + primary: pick_nearest_window(&windows, 5 * 60 * 60, 60 * 60).map(to_usage_window), + secondary: pick_nearest_window(&windows, 7 * 24 * 60 * 60, 24 * 60 * 60) + .map(to_usage_window), }; let mut by_limit_id = HashMap::new(); @@ -566,3 +572,58 @@ pub async fn read_account_rate_limits( Err(err) => Err(err.message), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn window(used_percent: f64, limit_window_seconds: i64, reset_at: i64) -> UsageWindowRaw { + UsageWindowRaw { + used_percent, + limit_window_seconds, + reset_at, + } + } + + #[test] + fn maps_weekly_quota_from_week_window_not_daily_window() { + let response = map_usage_payload(UsageApiResponse { + plan_type: Some("team".to_string()), + rate_limit: Some(RateLimitDetails { + primary_window: Some(window(1.0, 5 * 60 * 60, 1_800)), + secondary_window: Some(window(0.0, 24 * 60 * 60, 86_400)), + }), + additional_rate_limits: Some(vec![AdditionalRateLimitDetails { + rate_limit: Some(RateLimitDetails { + primary_window: None, + secondary_window: Some(window(41.0, 7 * 24 * 60 * 60, 604_800)), + }), + }]), + credits: None, + }); + + let snapshot = response.rate_limits.expect("rate limits should be present"); + assert_eq!(snapshot.primary.expect("primary").remaining_percent, 99); + + let weekly = snapshot.secondary.expect("weekly"); + assert_eq!(weekly.remaining_percent, 59); + assert_eq!(weekly.resets_at, Some(604_800)); + assert_eq!(weekly.window_duration_mins, Some(7 * 24 * 60)); + } + + #[test] + fn does_not_treat_daily_window_as_weekly_quota() { + let response = map_usage_payload(UsageApiResponse { + plan_type: Some("team".to_string()), + rate_limit: Some(RateLimitDetails { + primary_window: Some(window(1.0, 5 * 60 * 60, 1_800)), + secondary_window: Some(window(0.0, 24 * 60 * 60, 86_400)), + }), + additional_rate_limits: None, + credits: None, + }); + + let snapshot = response.rate_limits.expect("rate limits should be present"); + assert!(snapshot.secondary.is_none()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 111e2b2..c62597b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.8", + "version": "1.2.9", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index bf96287..05091c9 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -85,7 +85,7 @@ const AccountList: React.FC = ({
-
+
@@ -141,29 +141,23 @@ const AccountList: React.FC = ({
-
+
+ -
- - -
+
{featuredInvalid && ( diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index 676838d..a9c84dd 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -50,29 +50,36 @@ export async function hydrateAccounts(accounts: Account[]): Promise { return Promise.all( accounts.map(async (account) => { - const rateLimitResult = await api - .readAccountRateLimits(account.id) - .then((result) => ({ - rateLimits: result.rateLimits ?? null, - rateLimitsError: - result.accountStatus === "invalid" - ? result.accountStatusReason ?? "账号已失效或不可用" - : null, - accountStatus: - result.accountStatus ?? (result.rateLimits ? "available" : "unknown"), - accountStatusReason: result.accountStatusReason ?? null, - })) - .catch((error: unknown) => ({ - rateLimits: null, - rateLimitsError: error instanceof Error ? error.message : String(error), - accountStatus: "unknown" as const, - accountStatusReason: null, - })); const isActive = preserveStoredActive ? account.isActive : activeAccountId ? account.id === activeAccountId : false; + const rateLimitResult = isActive + ? await api + .readAccountRateLimits(account.id) + .then((result) => ({ + rateLimits: result.rateLimits ?? null, + rateLimitsError: + result.accountStatus === "invalid" + ? result.accountStatusReason ?? "账号已失效或不可用" + : null, + accountStatus: + result.accountStatus ?? (result.rateLimits ? "available" : "unknown"), + accountStatusReason: result.accountStatusReason ?? null, + })) + .catch((error: unknown) => ({ + rateLimits: null, + rateLimitsError: error instanceof Error ? error.message : String(error), + accountStatus: "unknown" as const, + accountStatusReason: null, + })) + : { + rateLimits: account.rateLimits ?? null, + rateLimitsError: account.rateLimitsError ?? null, + accountStatus: account.accountStatus ?? (account.rateLimits ? "available" : "unknown"), + accountStatusReason: account.accountStatusReason ?? null, + }; if (isActive) { return { diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts new file mode 100644 index 0000000..05f07b0 --- /dev/null +++ b/tests/accounts.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Account } from "../src/types"; + +const apiMock = vi.hoisted(() => ({ + readAuthJson: vi.fn(), + readAccountCredentials: vi.fn(), + getCurrentSessionsInfo: vi.fn(), + readAccountRateLimits: vi.fn(), +})); + +vi.mock("../src/utils/invoke", () => ({ + api: apiMock, +})); + +import { hydrateAccounts } from "../src/utils/accounts"; + +function createAccount(overrides: Partial = {}): Account { + return { + id: "account-1", + displayName: "Account", + email: "account@example.com", + userId: "user-1", + isActive: false, + createdAt: "2026-05-01T00:00:00.000Z", + lastSwitchedAt: null, + sessionInfo: null, + rateLimits: null, + rateLimitsError: null, + accountStatus: "unknown", + accountStatusReason: null, + ...overrides, + }; +} + +describe("hydrateAccounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMock.readAuthJson.mockResolvedValue(null); + apiMock.readAccountCredentials.mockResolvedValue(null); + apiMock.getCurrentSessionsInfo.mockResolvedValue(null); + apiMock.readAccountRateLimits.mockResolvedValue({ + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 99, windowDurationMins: 300, resetsAt: 1_800 }, + secondary: { remainingPercent: 41, windowDurationMins: 10_080, resetsAt: 604_800 }, + }, + accountStatus: "available", + accountStatusReason: null, + }); + }); + + it("refreshes official quota only for the active account", async () => { + const active = createAccount({ id: "active", isActive: true }); + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 23, windowDurationMins: 300, resetsAt: 900 }, + secondary: { remainingPercent: 65, windowDurationMins: 10_080, resetsAt: 500_000 }, + }, + accountStatus: "available", + }); + + const hydrated = await hydrateAccounts([active, standby]); + + expect(apiMock.readAccountRateLimits).toHaveBeenCalledTimes(1); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("active"); + expect(hydrated.find((account) => account.id === "active")?.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.primary?.remainingPercent).toBe(23); + }); + + it("does not turn a manually refreshed standby account into realtime quota", async () => { + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 23, windowDurationMins: 300, resetsAt: 900 }, + secondary: { remainingPercent: 65, windowDurationMins: 10_080, resetsAt: 500_000 }, + }, + accountStatus: "available", + }); + + const [hydrated] = await hydrateAccounts([standby]); + + expect(apiMock.readAccountRateLimits).not.toHaveBeenCalled(); + expect(hydrated.rateLimits?.primary?.remainingPercent).toBe(23); + expect(hydrated.rateLimits?.secondary?.remainingPercent).toBe(65); + }); +}); From d9eea07204fe46dce773794039cbf5fc7bc3aadb Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Thu, 14 May 2026 09:34:45 +0800 Subject: [PATCH 08/17] Release v1.2.10 --- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 4 +++- src/utils/accounts.ts | 13 +++++++++++-- tests/accounts.test.ts | 20 +++++++++++++++++++- 8 files changed, 39 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3295a60..7af3491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.9", + "version": "1.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index ae93400..b8f9259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.9", + "version": "1.2.10", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf9665b..b2f8695 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.9" +version = "1.2.10" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e20209..4f333ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.9" +version = "1.2.10" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c62597b..99e157d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.9", + "version": "1.2.10", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 6ede2fd..74c2664 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -148,7 +148,9 @@ const App: React.FC = () => { setRefreshingAccountIds((current) => [...current, accountId]); try { - const [hydrated] = await hydrateAccounts([target]); + const [hydrated] = await hydrateAccounts([target], { + refreshRateLimitAccountIds: new Set([accountId]), + }); if (!hydrated) { throw new Error("未获取到账号数据"); } diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index a9c84dd..014b905 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -13,6 +13,10 @@ export interface CurrentAuthState { preserveStoredActive: boolean; } +interface HydrateAccountsOptions { + refreshRateLimitAccountIds?: ReadonlySet; +} + export async function resolveCurrentAuthState(accounts: Account[]): Promise { const storedActiveAccountId = accounts.find((account) => account.isActive)?.id ?? null; const currentAuth = await api.readAuthJson().catch(() => null); @@ -41,7 +45,10 @@ export async function resolveCurrentAuthState(accounts: Account[]): Promise { +export async function hydrateAccounts( + accounts: Account[], + options: HydrateAccountsOptions = {}, +): Promise { const currentAuthState = await resolveCurrentAuthState(accounts); const { activeAccountId, preserveStoredActive } = currentAuthState; const activeSessionInfo = activeAccountId @@ -55,7 +62,9 @@ export async function hydrateAccounts(accounts: Account[]): Promise { : activeAccountId ? account.id === activeAccountId : false; - const rateLimitResult = isActive + const shouldRefreshRateLimits = + isActive || options.refreshRateLimitAccountIds?.has(account.id) === true; + const rateLimitResult = shouldRefreshRateLimits ? await api .readAccountRateLimits(account.id) .then((result) => ({ diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts index 05f07b0..9e9b795 100644 --- a/tests/accounts.test.ts +++ b/tests/accounts.test.ts @@ -72,7 +72,7 @@ describe("hydrateAccounts", () => { expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.primary?.remainingPercent).toBe(23); }); - it("does not turn a manually refreshed standby account into realtime quota", async () => { + it("keeps standby quota unchanged during default hydration", async () => { const standby = createAccount({ id: "standby", email: "standby@example.com", @@ -91,4 +91,22 @@ describe("hydrateAccounts", () => { expect(hydrated.rateLimits?.primary?.remainingPercent).toBe(23); expect(hydrated.rateLimits?.secondary?.remainingPercent).toBe(65); }); + + it("refreshes quota for a selected standby account", async () => { + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: null, + accountStatus: "unknown", + }); + + const [hydrated] = await hydrateAccounts([standby], { + refreshRateLimitAccountIds: new Set(["standby"]), + }); + + expect(apiMock.readAccountRateLimits).toHaveBeenCalledTimes(1); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("standby"); + expect(hydrated.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.rateLimits?.secondary?.remainingPercent).toBe(41); + }); }); From 29217ff00e62380bd2300efb8545f336b4cc5155 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Thu, 14 May 2026 10:08:34 +0800 Subject: [PATCH 09/17] Limit release artifacts to Windows and macOS installers --- .github/workflows/release.yml | 64 +++++++++++++++++++------------ README.en.md | 16 +++----- README.md | 16 +++----- package.json | 2 + src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.windows.conf.json | 1 + 6 files changed, 56 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6438f9..67c41b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,14 @@ jobs: fail-fast: false matrix: include: - - platform: macos-latest - - platform: ubuntu-22.04 - platform: windows-latest + build-command: npm run tauri:build:windows + asset-glob: src-tauri/target/release/bundle/nsis/*.exe + asset-name: codex-manager_x64-setup.exe + - platform: macos-14 + build-command: npm run tauri:build:macos + asset-glob: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + asset-name: codex-manager_aarch64.dmg runs-on: ${{ matrix.platform }} @@ -34,33 +39,44 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Install Linux dependencies - if: startsWith(matrix.platform, 'ubuntu') - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf - - name: Install dependencies run: npm ci - - name: Build & Release - uses: tauri-apps/tauri-action@v0 + - name: Install macOS target + if: matrix.platform == 'macos-14' + run: rustup target add aarch64-apple-darwin + + - name: Build installer + run: ${{ matrix.build-command }} + + - name: Prepare release asset + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p release-assets + matches=(${{ matrix.asset-glob }}) + if [ "${#matches[@]}" -ne 1 ]; then + printf 'Expected one asset for %s, found %s\n' "${{ matrix.asset-glob }}" "${#matches[@]}" + printf '%s\n' "${matches[@]}" + exit 1 + fi + cp "${matches[0]}" "release-assets/${{ matrix.asset-name }}" + + - name: Create release + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tagName: ${{ github.ref_name }} - releaseName: "Codex Manager ${{ github.ref_name }}" - releaseBody: "See the assets to download and install this version." - releaseDraft: false - prerelease: false + run: | + if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --title "Codex Manager ${GITHUB_REF_NAME}" \ + --notes "See the assets to download and install this version." || \ + gh release view "${GITHUB_REF_NAME}" >/dev/null + fi - - name: Upload macOS Unix helper - if: matrix.platform == 'macos-latest' && startsWith(github.ref, 'refs/tags/') + - name: Upload installer + if: startsWith(github.ref, 'refs/tags/') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${GITHUB_REF_NAME}" scripts/install-unix-cli.sh --clobber + run: gh release upload "${GITHUB_REF_NAME}" "release-assets/${{ matrix.asset-name }}" --clobber diff --git a/README.en.md b/README.en.md index df35175..133c5df 100644 --- a/README.en.md +++ b/README.en.md @@ -50,9 +50,8 @@ Codex Manager reduces both to a few desktop and tray actions. Recommended: download a packaged build from GitHub Releases. -- Windows: `.msi` or `.exe` -- macOS: `.dmg` -- Linux: `.deb`, `.rpm`, or `.AppImage` +- Windows: `codex-manager_x64-setup.exe` +- macOS: `codex-manager_aarch64.dmg` Releases: @@ -62,16 +61,13 @@ After installation, the `codex-manager` command behaves like this: | Platform | Recommended package | CLI availability | | --- | --- | --- | -| Windows | `.exe` or `.msi` | Installed to `PATH` automatically | -| macOS | `.dmg` | Use the bundled helper script once | -| Linux | `.deb` or `.rpm` | Available directly as `codex-manager` | -| Linux | `.AppImage` | Use the helper script once, or keep it portable | +| Windows | `codex-manager_x64-setup.exe` | Installed to `PATH` automatically | +| macOS | `codex-manager_aarch64.dmg` | Use the repo helper script to add it to `PATH` | Notes: - On Windows, reopen your terminal after installation so the new `PATH` is picked up. -- On macOS, the release ships as a `.dmg`. If you also want a global `codex-manager` command, run the bundled helper script once after dragging the app into `/Applications`. -- On Linux, prefer `.deb` or `.rpm` if you want a package-managed CLI experience. +- On macOS, the release ships only as `codex-manager_aarch64.dmg`. If you also want a global `codex-manager` command, drag the app into `/Applications`, then run `scripts/install-unix-cli.sh` from this repo. - The app reads and writes `~/.codex/auth.json`, so Codex CLI should already be installed and working. ## Command Line Switching @@ -89,7 +85,7 @@ The CLI updates both the managed `accounts.json` state and the live `~/.codex/au If Codex CLI or the desktop app is already running, restart it after switching so the new auth takes effect. -For `.dmg` and `.AppImage` installs, the release helper script can expose the command globally: +For `.dmg` installs, the repo helper script can expose the command globally: ```bash sudo bash ./install-unix-cli.sh /Applications/codex-manager.app /usr/local/bin/codex-manager diff --git a/README.md b/README.md index 5d93c85..11bff20 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,8 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 推荐直接从 GitHub Releases 下载打包产物: -- Windows:`.msi` 或 `.exe` -- macOS:`.dmg` -- Linux:`.deb`、`.rpm` 或 `.AppImage` +- Windows:`codex-manager_x64-setup.exe` +- macOS:`codex-manager_aarch64.dmg` 下载地址: @@ -62,16 +61,13 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 | 平台 | 推荐安装包 | CLI 可用性 | | --- | --- | --- | -| Windows | `.exe` 或 `.msi` | 自动加入 `PATH` | -| macOS | `.dmg` | 需要额外执行一次 helper 脚本 | -| Linux | `.deb` 或 `.rpm` | 安装后可直接使用 `codex-manager` | -| Linux | `.AppImage` | 需要额外执行一次 helper 脚本,或保持便携运行 | +| Windows | `codex-manager_x64-setup.exe` | 自动加入 `PATH` | +| macOS | `codex-manager_aarch64.dmg` | 可使用仓库脚本加入 `PATH` | 说明: - Windows 安装后请重新打开一个终端窗口,让新的 `PATH` 生效。 -- macOS 版本只提供 `.dmg`。如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行一次 helper 脚本即可。 -- Linux 如果希望获得最稳定的系统级 CLI 体验,优先使用 `.deb` 或 `.rpm`。 +- macOS 版本只提供 `codex-manager_aarch64.dmg`。如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行仓库里的 `scripts/install-unix-cli.sh`。 - 应用会读写 `~/.codex/auth.json`,所以机器上需要先能正常使用 Codex CLI。 ## 命令行切换 @@ -89,7 +85,7 @@ CLI 会同时更新受管账号状态和当前生效的 `~/.codex/auth.json`。 如果 Codex CLI 或桌面应用已经在运行,切换后请重启它们,让新的 auth 生效。 -对于 `.dmg` 和 `.AppImage` 安装方式,可以使用发布包里的 helper 脚本暴露全局命令: +对于 `.dmg` 安装方式,可以使用仓库里的 helper 脚本暴露全局命令: ```bash sudo bash ./install-unix-cli.sh /Applications/codex-manager.app /usr/local/bin/codex-manager diff --git a/package.json b/package.json index b8f9259..c48f220 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "build": "tsc && vite build", "test": "vitest run", "preview": "vite preview", + "tauri:build:windows": "tauri build --bundles nsis", + "tauri:build:macos": "tauri build --target aarch64-apple-darwin --bundles dmg", "tauri": "tauri" }, "dependencies": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 99e157d..f623929 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,7 +28,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["dmg"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index a6d3cf1..303b7f4 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -3,6 +3,7 @@ "beforeBundleCommand": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/build-windows-cli.ps1" }, "bundle": { + "targets": ["nsis"], "windows": { "nsis": { "installerHooks": "windows/installer-hooks.nsh" From b12a0171f01e6fea83eaaa22ab4debbbff08b7c4 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Thu, 14 May 2026 10:20:06 +0800 Subject: [PATCH 10/17] Release v1.2.11 --- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7af3491..044b1f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.10", + "version": "1.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.10", + "version": "1.2.11", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index c48f220..2862ca2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.10", + "version": "1.2.11", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b2f8695..6a5901c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.10" +version = "1.2.11" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4f333ad..b6e5196 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.10" +version = "1.2.11" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f623929..7dad3d2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.10", + "version": "1.2.11", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", From 5ed41cf25587a10b76d966b3997f6bac389f31ce Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Thu, 14 May 2026 10:26:27 +0800 Subject: [PATCH 11/17] Release v1.2.12 --- .github/workflows/release.yml | 11 +++++++---- README.en.md | 10 +++++----- README.md | 10 +++++----- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 8 files changed, 23 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67c41b2..cf52242 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,11 @@ jobs: - platform: windows-latest build-command: npm run tauri:build:windows asset-glob: src-tauri/target/release/bundle/nsis/*.exe - asset-name: codex-manager_x64-setup.exe + asset-suffix: x64-setup.exe - platform: macos-14 build-command: npm run tauri:build:macos asset-glob: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg - asset-name: codex-manager_aarch64.dmg + asset-suffix: aarch64.dmg runs-on: ${{ matrix.platform }} @@ -61,7 +61,10 @@ jobs: printf '%s\n' "${matches[@]}" exit 1 fi - cp "${matches[0]}" "release-assets/${{ matrix.asset-name }}" + version="${GITHUB_REF_NAME#v}" + asset_name="codex-manager_${version}_${{ matrix.asset-suffix }}" + cp "${matches[0]}" "release-assets/${asset_name}" + printf 'ASSET_PATH=release-assets/%s\n' "${asset_name}" >> "${GITHUB_ENV}" - name: Create release if: startsWith(github.ref, 'refs/tags/') @@ -79,4 +82,4 @@ jobs: if: startsWith(github.ref, 'refs/tags/') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "${GITHUB_REF_NAME}" "release-assets/${{ matrix.asset-name }}" --clobber + run: gh release upload "${GITHUB_REF_NAME}" "${ASSET_PATH}" --clobber diff --git a/README.en.md b/README.en.md index 133c5df..631ce0b 100644 --- a/README.en.md +++ b/README.en.md @@ -50,8 +50,8 @@ Codex Manager reduces both to a few desktop and tray actions. Recommended: download a packaged build from GitHub Releases. -- Windows: `codex-manager_x64-setup.exe` -- macOS: `codex-manager_aarch64.dmg` +- Windows: `codex-manager__x64-setup.exe` +- macOS: `codex-manager__aarch64.dmg` Releases: @@ -61,13 +61,13 @@ After installation, the `codex-manager` command behaves like this: | Platform | Recommended package | CLI availability | | --- | --- | --- | -| Windows | `codex-manager_x64-setup.exe` | Installed to `PATH` automatically | -| macOS | `codex-manager_aarch64.dmg` | Use the repo helper script to add it to `PATH` | +| Windows | `codex-manager__x64-setup.exe` | Installed to `PATH` automatically | +| macOS | `codex-manager__aarch64.dmg` | Use the repo helper script to add it to `PATH` | Notes: - On Windows, reopen your terminal after installation so the new `PATH` is picked up. -- On macOS, the release ships only as `codex-manager_aarch64.dmg`. If you also want a global `codex-manager` command, drag the app into `/Applications`, then run `scripts/install-unix-cli.sh` from this repo. +- On macOS, the release ships only as `codex-manager__aarch64.dmg`. If you also want a global `codex-manager` command, drag the app into `/Applications`, then run `scripts/install-unix-cli.sh` from this repo. - The app reads and writes `~/.codex/auth.json`, so Codex CLI should already be installed and working. ## Command Line Switching diff --git a/README.md b/README.md index 11bff20..a4c97d7 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 推荐直接从 GitHub Releases 下载打包产物: -- Windows:`codex-manager_x64-setup.exe` -- macOS:`codex-manager_aarch64.dmg` +- Windows:`codex-manager_<版本号>_x64-setup.exe` +- macOS:`codex-manager_<版本号>_aarch64.dmg` 下载地址: @@ -61,13 +61,13 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 | 平台 | 推荐安装包 | CLI 可用性 | | --- | --- | --- | -| Windows | `codex-manager_x64-setup.exe` | 自动加入 `PATH` | -| macOS | `codex-manager_aarch64.dmg` | 可使用仓库脚本加入 `PATH` | +| Windows | `codex-manager_<版本号>_x64-setup.exe` | 自动加入 `PATH` | +| macOS | `codex-manager_<版本号>_aarch64.dmg` | 可使用仓库脚本加入 `PATH` | 说明: - Windows 安装后请重新打开一个终端窗口,让新的 `PATH` 生效。 -- macOS 版本只提供 `codex-manager_aarch64.dmg`。如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行仓库里的 `scripts/install-unix-cli.sh`。 +- macOS 版本只提供 `codex-manager_<版本号>_aarch64.dmg`。如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行仓库里的 `scripts/install-unix-cli.sh`。 - 应用会读写 `~/.codex/auth.json`,所以机器上需要先能正常使用 Codex CLI。 ## 命令行切换 diff --git a/package-lock.json b/package-lock.json index 044b1f1..afb524a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.11", + "version": "1.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.11", + "version": "1.2.12", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 2862ca2..9efc88f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.11", + "version": "1.2.12", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a5901c..53b98d4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.11" +version = "1.2.12" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b6e5196..a7d5a67 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.11" +version = "1.2.12" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7dad3d2..3130900 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.11", + "version": "1.2.12", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", From 762d02e3ffa7d3f4a98b1fd1c732c84274a9c8fd Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Thu, 14 May 2026 10:37:54 +0800 Subject: [PATCH 12/17] Release v1.2.13 --- .github/workflows/release.yml | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf52242..ae74403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,7 @@ jobs: - name: Create release if: startsWith(github.ref, 'refs/tags/') + shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -80,6 +81,7 @@ jobs: - name: Upload installer if: startsWith(github.ref, 'refs/tags/') + shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload "${GITHUB_REF_NAME}" "${ASSET_PATH}" --clobber diff --git a/package-lock.json b/package-lock.json index afb524a..561e987 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.12", + "version": "1.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.12", + "version": "1.2.13", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 9efc88f..62826eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.12", + "version": "1.2.13", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 53b98d4..1855930 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.12" +version = "1.2.13" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a7d5a67..443682d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.12" +version = "1.2.13" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3130900..2224cb2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.12", + "version": "1.2.13", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", From f407198af58f3bf4058a24ff13ff760b6b67f853 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Fri, 15 May 2026 10:40:12 +0800 Subject: [PATCH 13/17] Release v1.2.14 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/AccountCard.tsx | 6 +- src/components/UsageStatsPage.tsx | 26 +++++- src/utils/dashboard.ts | 23 +----- src/utils/quotaValue.ts | 128 ++++++++++++++++++++++++++++++ tests/quotaValue.test.ts | 84 ++++++++++++++++++++ 10 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 src/utils/quotaValue.ts create mode 100644 tests/quotaValue.test.ts diff --git a/package-lock.json b/package-lock.json index 561e987..56bf95f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.13", + "version": "1.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.13", + "version": "1.2.14", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 62826eb..1bc01cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.13", + "version": "1.2.14", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1855930..8c3ee54 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.13" +version = "1.2.14" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 443682d..4505a8c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.13" +version = "1.2.14" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2224cb2..7fa0052 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.13", + "version": "1.2.14", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index 34be99c..7bc248f 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -108,7 +108,7 @@ const AccountCard: React.FC = ({ return (
@@ -160,7 +160,7 @@ const AccountCard: React.FC = ({
-
+
{renderCompactQuota(insight.hourlyQuota)} {renderCompactQuota(insight.weeklyQuota)}
@@ -173,7 +173,7 @@ const AccountCard: React.FC = ({
-
+
-
+

模型 @@ -506,6 +521,15 @@ const UsageStatsPage: React.FC = ({ : "--"}

+
+

+ 美元上限 +

+

+ {formatAccountQuotaUsdEstimate(account, usageStats)} +

+

按当前模型估算

+

建议 diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index b944e8b..8d9638d 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -159,30 +159,11 @@ function formatSyncTime(iso: string | null): string { try { const date = new Date(iso); const parts = getZonedParts(date); - const nowParts = getZonedParts(new Date()); - if (!parts || !nowParts) { + if (!parts) { return "时间未知"; } - const dateKey = formatZonedDateKey(parts); - const todayKey = formatZonedDateKey(nowParts); - const yesterdayDate = new Date( - Date.UTC(Number(nowParts.year), Number(nowParts.month) - 1, Number(nowParts.day)) - - 24 * 60 * 60 * 1000, - ); - const yesterdayKey = formatZonedDateKey({ - year: String(yesterdayDate.getUTCFullYear()), - month: String(yesterdayDate.getUTCMonth() + 1).padStart(2, "0"), - day: String(yesterdayDate.getUTCDate()).padStart(2, "0"), - }); - - if (dateKey === todayKey) { - return `今天 ${parts.hour}:${parts.minute}`; - } - if (dateKey === yesterdayKey) { - return `昨天 ${parts.hour}:${parts.minute}`; - } - return `${dateKey} ${parts.hour}:${parts.minute}`; + return `${formatZonedDateKey(parts)} ${parts.hour}:${parts.minute}`; } catch { return "时间未知"; } diff --git a/src/utils/quotaValue.ts b/src/utils/quotaValue.ts new file mode 100644 index 0000000..14e1647 --- /dev/null +++ b/src/utils/quotaValue.ts @@ -0,0 +1,128 @@ +import type { Account, TokenUsageInfo, UsageStatsSummary } from "../types"; +import { getRemainingPercent } from "./dashboard"; +import { getAccountTokenUsage } from "./tokenLedger"; + +type ModelTokenPrice = { + input: number; + cachedInput: number; + output: number; +}; + +export type QuotaUsdEstimate = { + model: string; + spentUsd: number; + hourlyLimitUsd: number | null; + weeklyLimitUsd: number | null; +}; + +const PRICE_PER_1M_TOKENS: Record = { + "gpt-5.5-pro": { input: 30, cachedInput: 30, output: 180 }, + "gpt-5.5": { input: 5, cachedInput: 0.5, output: 30 }, + "gpt-5.4-pro": { input: 30, cachedInput: 30, output: 180 }, + "gpt-5.4": { input: 2.5, cachedInput: 0.25, output: 15 }, + "gpt-5.4-mini": { input: 0.75, cachedInput: 0.075, output: 4.5 }, + "gpt-5.4-nano": { input: 0.2, cachedInput: 0.02, output: 1.25 }, + "gpt-5.3-codex": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5.2-codex": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5.2-pro": { input: 21, cachedInput: 21, output: 168 }, + "gpt-5.2": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5-codex": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5-pro": { input: 15, cachedInput: 15, output: 120 }, + "gpt-5": { input: 1.25, cachedInput: 0.125, output: 10 }, + "gpt-4.1": { input: 2, cachedInput: 0.5, output: 8 }, + "gpt-4.1-mini": { input: 0.4, cachedInput: 0.1, output: 1.6 }, + "gpt-4.1-nano": { input: 0.1, cachedInput: 0.025, output: 0.4 }, +}; + +function normalizeModel(model: string | null | undefined): string | null { + const normalized = model?.trim().toLowerCase(); + if (!normalized) { + return null; + } + + const exact = PRICE_PER_1M_TOKENS[normalized]; + if (exact) { + return normalized; + } + + const candidates = Object.keys(PRICE_PER_1M_TOKENS).sort((left, right) => right.length - left.length); + return candidates.find((candidate) => normalized.startsWith(candidate)) ?? null; +} + +export function estimateTokenSpendUsd( + usage: TokenUsageInfo | null | undefined, + model: string | null | undefined, +): number | null { + const normalizedModel = normalizeModel(model); + const price = normalizedModel ? PRICE_PER_1M_TOKENS[normalizedModel] : null; + if (!usage || !price || usage.totalTokens <= 0) { + return null; + } + + const cachedInputTokens = Math.min(Math.max(usage.cachedInputTokens, 0), usage.inputTokens); + const inputTokens = Math.max(usage.inputTokens - cachedInputTokens, 0); + const outputTokens = + usage.outputTokens > 0 ? usage.outputTokens : Math.max(usage.totalTokens - usage.inputTokens, 0); + + return ( + (inputTokens / 1_000_000) * price.input + + (cachedInputTokens / 1_000_000) * price.cachedInput + + (outputTokens / 1_000_000) * price.output + ); +} + +function estimateLimitUsd(spentUsd: number, remainingPercent: number | null): number | null { + if (spentUsd <= 0 || remainingPercent === null) { + return null; + } + + const usedPercent = 100 - remainingPercent; + if (usedPercent <= 0) { + return null; + } + + return spentUsd / (usedPercent / 100); +} + +export function getDominantUsageModel(usageStats: UsageStatsSummary | null): string | null { + if (usageStats?.latestModel) { + return usageStats.latestModel; + } + + return usageStats?.models[0]?.model ?? null; +} + +export function getAccountQuotaUsdEstimate( + account: Account, + usageStats: UsageStatsSummary | null, +): QuotaUsdEstimate | null { + const usage = getAccountTokenUsage(account, usageStats?.latestTotalTokens); + const model = account.isActive ? usageStats?.latestModel : getDominantUsageModel(usageStats); + const spentUsd = estimateTokenSpendUsd(usage, model); + if (!model || spentUsd === null) { + return null; + } + + return { + model, + spentUsd, + hourlyLimitUsd: estimateLimitUsd(spentUsd, getRemainingPercent(account.rateLimits?.primary)), + weeklyLimitUsd: estimateLimitUsd(spentUsd, getRemainingPercent(account.rateLimits?.secondary)), + }; +} + +export function formatUsdEstimate(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return "--"; + } + + if (value < 0.01) { + return "<$0.01"; + } + + if (value < 10) { + return `$${value.toFixed(2)}`; + } + + return `$${Math.round(value).toLocaleString("zh-CN")}`; +} diff --git a/tests/quotaValue.test.ts b/tests/quotaValue.test.ts new file mode 100644 index 0000000..7d8aa5b --- /dev/null +++ b/tests/quotaValue.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { Account, UsageStatsSummary } from "../src/types"; +import { + estimateTokenSpendUsd, + formatUsdEstimate, + getAccountQuotaUsdEstimate, +} from "../src/utils/quotaValue"; + +function createAccount(overrides: Partial = {}): Account { + return { + id: "account-1", + displayName: "Work", + email: "dev@example.com", + userId: "user-1", + isActive: true, + createdAt: "2026-03-01T00:00:00Z", + lastSwitchedAt: "2026-03-10T10:00:00Z", + sessionInfo: null, + rateLimits: { + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 50 }, + }, + rateLimitsError: null, + usageLedger: null, + ...overrides, + }; +} + +const usageStats: UsageStatsSummary = { + sessionsAnalyzed: 1, + latestModel: "gpt-5-codex", + totalTokens: { + inputTokens: 100_000, + cachedInputTokens: 20_000, + outputTokens: 10_000, + reasoningOutputTokens: 5_000, + totalTokens: 110_000, + }, + latestTotalTokens: { + inputTokens: 100_000, + cachedInputTokens: 20_000, + outputTokens: 10_000, + reasoningOutputTokens: 5_000, + totalTokens: 110_000, + }, + models: [{ model: "gpt-5-codex", sessions: 1, totalTokens: 110_000 }], +}; + +describe("quota value estimates", () => { + it("estimates token spend with cached input pricing", () => { + const spent = estimateTokenSpendUsd(usageStats.totalTokens, "gpt-5-codex"); + + expect(spent).toBeCloseTo(0.2835, 6); + }); + + it("projects hourly and weekly USD limits from remaining quota", () => { + const estimate = getAccountQuotaUsdEstimate(createAccount(), usageStats); + + expect(estimate?.spentUsd).toBeCloseTo(0.2835, 6); + expect(estimate?.hourlyLimitUsd).toBeCloseTo(1.4175, 6); + expect(estimate?.weeklyLimitUsd).toBeCloseTo(0.567, 6); + }); + + it("does not project a limit when quota usage is still zero", () => { + const estimate = getAccountQuotaUsdEstimate( + createAccount({ + rateLimits: { + primary: { remainingPercent: 100 }, + secondary: { remainingPercent: 100 }, + }, + }), + usageStats, + ); + + expect(estimate?.hourlyLimitUsd).toBeNull(); + expect(estimate?.weeklyLimitUsd).toBeNull(); + }); + + it("formats tiny and normal USD values", () => { + expect(formatUsdEstimate(0.004)).toBe("<$0.01"); + expect(formatUsdEstimate(1.235)).toBe("$1.24"); + expect(formatUsdEstimate(null)).toBe("--"); + }); +}); From 8308e65b9688320358f431fb706218164bd8d351 Mon Sep 17 00:00:00 2001 From: yolanda hao Date: Fri, 15 May 2026 21:47:49 -0700 Subject: [PATCH 14/17] Release 1.2.15 --- .github/workflows/release.yml | 27 ++----- README.en.md | 9 ++- README.md | 9 ++- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/accounts.rs | 1 + src-tauri/src/commands/desktop.rs | 7 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/models.rs | 3 + src-tauri/src/platform/codex.rs | 125 ++++++++++++++++++++++++++++- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 34 ++++++-- src/components/AccountList.tsx | 54 +++++-------- src/components/SettingsModal.tsx | 108 +++++++++++++++++-------- src/hooks/useAccountSwitch.ts | 28 ++++++- src/store/accountStore.ts | 1 + src/types/index.ts | 2 + src/utils/backup.ts | 4 + src/utils/invoke.ts | 6 ++ tests/backup.test.ts | 1 + 22 files changed, 317 insertions(+), 115 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae74403..0815b96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,20 +11,7 @@ permissions: jobs: build: - strategy: - fail-fast: false - matrix: - include: - - platform: windows-latest - build-command: npm run tauri:build:windows - asset-glob: src-tauri/target/release/bundle/nsis/*.exe - asset-suffix: x64-setup.exe - - platform: macos-14 - build-command: npm run tauri:build:macos - asset-glob: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg - asset-suffix: aarch64.dmg - - runs-on: ${{ matrix.platform }} + runs-on: windows-latest steps: - name: Checkout @@ -42,12 +29,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Install macOS target - if: matrix.platform == 'macos-14' - run: rustup target add aarch64-apple-darwin - - name: Build installer - run: ${{ matrix.build-command }} + run: npm run tauri:build:windows - name: Prepare release asset shell: bash @@ -55,14 +38,14 @@ jobs: set -euo pipefail shopt -s nullglob mkdir -p release-assets - matches=(${{ matrix.asset-glob }}) + matches=(src-tauri/target/release/bundle/nsis/*.exe) if [ "${#matches[@]}" -ne 1 ]; then - printf 'Expected one asset for %s, found %s\n' "${{ matrix.asset-glob }}" "${#matches[@]}" + printf 'Expected one asset for src-tauri/target/release/bundle/nsis/*.exe, found %s\n' "${#matches[@]}" printf '%s\n' "${matches[@]}" exit 1 fi version="${GITHUB_REF_NAME#v}" - asset_name="codex-manager_${version}_${{ matrix.asset-suffix }}" + asset_name="codex-manager_${version}_x64-setup.exe" cp "${matches[0]}" "release-assets/${asset_name}" printf 'ASSET_PATH=release-assets/%s\n' "${asset_name}" >> "${GITHUB_ENV}" diff --git a/README.en.md b/README.en.md index 631ce0b..dbdf205 100644 --- a/README.en.md +++ b/README.en.md @@ -48,13 +48,14 @@ Codex Manager reduces both to a few desktop and tray actions. ## Installation -Recommended: download a packaged build from GitHub Releases. +Recommended: download the Windows packaged build from GitHub Releases. - Windows: `codex-manager__x64-setup.exe` -- macOS: `codex-manager__aarch64.dmg` Releases: +For macOS, build the DMG locally with `npm run tauri:build:macos`. The output is written to `src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/`. + ### CLI Availability After installation, the `codex-manager` command behaves like this: @@ -62,12 +63,12 @@ After installation, the `codex-manager` command behaves like this: | Platform | Recommended package | CLI availability | | --- | --- | --- | | Windows | `codex-manager__x64-setup.exe` | Installed to `PATH` automatically | -| macOS | `codex-manager__aarch64.dmg` | Use the repo helper script to add it to `PATH` | +| macOS | Locally built `codex-manager__aarch64.dmg` | Use the repo helper script to add it to `PATH` | Notes: - On Windows, reopen your terminal after installation so the new `PATH` is picked up. -- On macOS, the release ships only as `codex-manager__aarch64.dmg`. If you also want a global `codex-manager` command, drag the app into `/Applications`, then run `scripts/install-unix-cli.sh` from this repo. +- After building the macOS DMG locally, if you also want a global `codex-manager` command, drag the app into `/Applications`, then run `scripts/install-unix-cli.sh` from this repo. - The app reads and writes `~/.codex/auth.json`, so Codex CLI should already be installed and working. ## Command Line Switching diff --git a/README.md b/README.md index a4c97d7..7d396e5 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,14 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 ## 安装 -推荐直接从 GitHub Releases 下载打包产物: +推荐直接从 GitHub Releases 下载 Windows 打包产物: - Windows:`codex-manager_<版本号>_x64-setup.exe` -- macOS:`codex-manager_<版本号>_aarch64.dmg` 下载地址: +macOS 安装包请在本机执行 `npm run tauri:build:macos` 构建,产物位于 `src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/`。 + ### 安装后 CLI 可用性 安装完成后,`codex-manager` 命令在各平台的行为如下: @@ -62,12 +63,12 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 | 平台 | 推荐安装包 | CLI 可用性 | | --- | --- | --- | | Windows | `codex-manager_<版本号>_x64-setup.exe` | 自动加入 `PATH` | -| macOS | `codex-manager_<版本号>_aarch64.dmg` | 可使用仓库脚本加入 `PATH` | +| macOS | 本地构建的 `codex-manager_<版本号>_aarch64.dmg` | 可使用仓库脚本加入 `PATH` | 说明: - Windows 安装后请重新打开一个终端窗口,让新的 `PATH` 生效。 -- macOS 版本只提供 `codex-manager_<版本号>_aarch64.dmg`。如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行仓库里的 `scripts/install-unix-cli.sh`。 +- macOS 本地 DMG 构建完成后,如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行仓库里的 `scripts/install-unix-cli.sh`。 - 应用会读写 `~/.codex/auth.json`,所以机器上需要先能正常使用 Codex CLI。 ## 命令行切换 diff --git a/package-lock.json b/package-lock.json index 56bf95f..1da21c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.14", + "version": "1.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.14", + "version": "1.2.15", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 1bc01cc..cdfd0b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.14", + "version": "1.2.15", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8c3ee54..01977f6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.14" +version = "1.2.15" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4505a8c..a740742 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.14" +version = "1.2.15" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/src/commands/accounts.rs b/src-tauri/src/commands/accounts.rs index 98e4c32..bedd231 100644 --- a/src-tauri/src/commands/accounts.rs +++ b/src-tauri/src/commands/accounts.rs @@ -96,6 +96,7 @@ fn default_settings() -> AppSettings { AppSettings { auto_refresh_interval: 0, auto_restart_codex_after_switch: true, + auto_restart_vscode_after_switch: false, theme: "system".to_string(), proxy_url: String::new(), } diff --git a/src-tauri/src/commands/desktop.rs b/src-tauri/src/commands/desktop.rs index a8931f5..320c11e 100644 --- a/src-tauri/src/commands/desktop.rs +++ b/src-tauri/src/commands/desktop.rs @@ -12,6 +12,13 @@ pub async fn restart_codex_desktop() -> Result<(), String> { .map_err(|e| e.to_string())? } +#[tauri::command] +pub async fn restart_vscode() -> Result<(), String> { + tokio::task::spawn_blocking(codex::restart_vscode) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn resume_session_in_terminal(session_id: String) -> Result<(), String> { tokio::task::spawn_blocking(move || codex::resume_session_in_terminal(session_id)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 388fb74..670af95 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -501,6 +501,7 @@ pub fn run() { sessions::delete_account_sessions, desktop::resume_session_in_terminal, desktop::restart_codex_desktop, + desktop::restart_vscode, desktop::get_platform_capabilities, usage::read_account_rate_limits, // oauth diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index f9b9a15..c85992d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -85,6 +85,8 @@ pub struct AppSettings { pub auto_refresh_interval: u32, #[serde(default = "default_auto_restart_codex_after_switch")] pub auto_restart_codex_after_switch: bool, + #[serde(default)] + pub auto_restart_vscode_after_switch: bool, pub theme: String, pub proxy_url: String, } @@ -98,6 +100,7 @@ fn default_auto_restart_codex_after_switch() -> bool { pub struct DesktopPlatformCapabilities { pub platform: String, pub supports_auto_restart_codex_desktop: bool, + pub supports_auto_restart_vscode: bool, pub supports_resume_session_in_terminal: bool, pub supports_system_tray: bool, pub supports_taskbar_shortcuts: bool, diff --git a/src-tauri/src/platform/codex.rs b/src-tauri/src/platform/codex.rs index 627f3ec..6263760 100644 --- a/src-tauri/src/platform/codex.rs +++ b/src-tauri/src/platform/codex.rs @@ -1,10 +1,11 @@ use std::{ env, path::{Path, PathBuf}, + process::Command, }; -#[cfg(target_os = "windows")] -use std::process::Command; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::{thread, time::Duration}; use crate::models::DesktopPlatformCapabilities; @@ -36,6 +37,11 @@ pub fn desktop_platform_capabilities() -> DesktopPlatformCapabilities { DesktopPlatformCapabilities { platform: current_platform().to_string(), supports_auto_restart_codex_desktop: cfg!(target_os = "windows"), + supports_auto_restart_vscode: cfg!(any( + target_os = "windows", + target_os = "macos", + target_os = "linux" + )), supports_resume_session_in_terminal: cfg!(target_os = "windows"), supports_system_tray: true, supports_taskbar_shortcuts: cfg!(target_os = "windows"), @@ -176,6 +182,121 @@ pub fn restart_codex_desktop() -> Result<(), String> { Err("当前仅支持 Windows 自动重启 Codex 桌面应用".to_string()) } +#[cfg(target_os = "windows")] +pub fn restart_vscode() -> Result<(), String> { + let restart_script = r#"$ErrorActionPreference = 'Stop' +$candidates = @( + "$env:LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe", + "$env:ProgramFiles\Microsoft VS Code\Code.exe", + "${env:ProgramFiles(x86)}\Microsoft VS Code\Code.exe" +) +$code = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $code) { + $command = Get-Command code -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + $code = $command.Source + } +} +if (-not $code) { + throw '未找到 VSCode 可执行文件' +} +$targets = Get-Process -Name 'Code' -ErrorAction SilentlyContinue +if ($targets) { + $targets | Stop-Process -Force +} +Start-Sleep -Milliseconds 900 +Start-Process -FilePath $code"#; + + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + restart_script, + ]) + .output() + .map_err(|e| format!("重启 VSCode 失败: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + + if detail.is_empty() { + Err("重启 VSCode 失败".to_string()) + } else { + Err(format!("重启 VSCode 失败: {detail}")) + } +} + +#[cfg(target_os = "macos")] +pub fn restart_vscode() -> Result<(), String> { + let _ = Command::new("osascript") + .args(["-e", r#"tell application "Visual Studio Code" to quit"#]) + .output(); + + thread::sleep(Duration::from_millis(900)); + + let output = Command::new("open") + .args(["-a", "Visual Studio Code"]) + .output() + .map_err(|e| format!("重启 VSCode 失败: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + + if detail.is_empty() { + Err("重启 VSCode 失败".to_string()) + } else { + Err(format!("重启 VSCode 失败: {detail}")) + } +} + +#[cfg(target_os = "linux")] +fn resolve_vscode_cli_executable() -> Result { + if let Some(path_var) = env::var_os("PATH") { + for path in env::split_paths(&path_var) { + for name in ["code", "code-insiders"] { + let candidate = path.join(name); + if path_has_file(&candidate) { + return Ok(candidate); + } + } + } + } + + Err("未找到 VSCode 可执行文件".to_string()) +} + +#[cfg(target_os = "linux")] +pub fn restart_vscode() -> Result<(), String> { + let code_path = resolve_vscode_cli_executable()?; + let _ = Command::new("pkill").args(["-x", "code"]).output(); + let _ = Command::new("pkill").args(["-x", "code-insiders"]).output(); + + thread::sleep(Duration::from_millis(900)); + + Command::new(code_path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("重启 VSCode 失败: {e}")) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +pub fn restart_vscode() -> Result<(), String> { + Err("当前平台暂不支持自动重启 VSCode".to_string()) +} + #[cfg(target_os = "windows")] pub fn resume_session_in_terminal(session_id: String) -> Result<(), String> { if session_id.trim().is_empty() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7fa0052..a30c890 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.14", + "version": "1.2.15", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 74c2664..69f1c41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,10 @@ type ConfirmState = | { kind: "switch"; account: Account } | null; +function formatRestartTargets(targets: string[]): string { + return targets.length <= 1 ? targets.join("") : targets.join(" 和 "); +} + const App: React.FC = () => { const { setAccounts, @@ -64,6 +68,23 @@ const App: React.FC = () => { (new URLSearchParams(window.location.search).get("tray") === "1" || window.location.hash === "#tray"); + const getEnabledRestartTargets = () => { + const targets: string[] = []; + if ( + settings.autoRestartCodexAfterSwitch && + platformCapabilities?.supportsAutoRestartCodexDesktop === true + ) { + targets.push("Codex"); + } + if ( + settings.autoRestartVscodeAfterSwitch && + platformCapabilities?.supportsAutoRestartVscode === true + ) { + targets.push("VSCode"); + } + return targets; + }; + const executeSwitch = async (account: Account) => { await switchAccount(account); }; @@ -73,11 +94,9 @@ const App: React.FC = () => { return; } - const canAutoRestartCodex = - settings.autoRestartCodexAfterSwitch && - platformCapabilities?.supportsAutoRestartCodexDesktop === true; + const restartTargets = getEnabledRestartTargets(); - if (canAutoRestartCodex) { + if (restartTargets.length > 0) { setConfirmState({ kind: "switch", account }); return; } @@ -408,6 +427,10 @@ const App: React.FC = () => { } }; + const switchRestartTargets = + confirmState?.kind === "switch" ? getEnabledRestartTargets() : []; + const switchRestartTargetLabel = formatRestartTargets(switchRestartTargets) || "相关应用"; + return ( @@ -492,7 +515,6 @@ const App: React.FC = () => { refreshingAccountIds={refreshingAccountIds} onDelete={(id) => setConfirmState({ kind: "delete", accountId: id })} onRefreshAccount={refreshAccount} - onRefreshUsage={() => refreshAccounts(false)} onRename={handleRename} onSwitch={(account) => void requestSwitch(account)} /> @@ -522,7 +544,7 @@ const App: React.FC = () => { {confirmState?.kind === "switch" && ( void handleConfirmSwitch()} diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 05091c9..c439553 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -18,7 +18,6 @@ interface AccountListProps { refreshingAccountIds: string[]; onDelete: (id: string) => void; onRefreshAccount: (id: string) => Promise; - onRefreshUsage: () => Promise; onRename: (id: string, displayName: string) => Promise; onSwitch: (account: Account) => void; } @@ -28,7 +27,6 @@ const AccountList: React.FC = ({ refreshingAccountIds, onDelete, onRefreshAccount, - onRefreshUsage, onRename, onSwitch, }) => { @@ -151,37 +149,27 @@ const AccountList: React.FC = ({ ? "刷新中" : "刷新当前"} - - + {!featuredAccount.isActive && ( + + )}

-
-
- +
+
+ +
+
-
+ +
+
+
+ +
+ + > + + +
+ {!canAutoRestartVscode && ( +

+ 当前平台暂不支持自动重启 VSCode。 +

+ )}
- {!canAutoRestartCodex && ( -

- 当前平台暂不支持自动重启。 -

- )} -
- - -
- void handleRefreshStats()} + disabled={isRefreshing} + className="primary-action rounded-full px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60" > -
-
-
-
-
- -
-
-
- - Recommendation - -
-

- {bestAccount?.displayName ?? (invalidAccountsCount > 0 ? "暂无可用账号" : "暂无建议")} -

- - {bestAccount - ? bestAccount.isActive - ? "当前最优" - : "建议切换" - : invalidAccountsCount > 0 - ? "需要处理" - : "等待刷新"} - -
-

- {bestAccount - ? bestAccount.isActive - ? "当前账号就是最稳的选择。" - : "下一轮高强度请求更适合交给它。" - : invalidAccountsCount > 0 - ? `已检测到 ${invalidAccountsCount} 个失效账号,请先在主窗口重新登录或替换。` - : "刷新后再看会更准确。"} -

-
- -
-

- 当前账号 -

-

- {activeAccount?.displayName ?? "未匹配"} -

-

- {activeAccount - ? `最近切换 ${formatRelativeTime(activeAccount.lastSwitchedAt)}` - : "未匹配当前授权"} -

-
-
- -
-
-

- 5h 效率 -

-
-

- {averageEfficiency === null ? "--" : `${averageEfficiency}%`} -

-
-

100% 左右最稳

- -
-
-

- 剩余最多 -

-

- {mostAvailableAccount?.displayName ?? "暂无数据"} -

-

- 5h 剩余 {formatPercent(getRemainingPercent(mostAvailableAccount?.rateLimits?.primary))} - {mostAvailableInsight?.hourlyQuota.resetLabel - ? ` · 重置 ${mostAvailableInsight.hourlyQuota.resetLabel}` - : ""} -

-
-
-

- 当前主模型 -

-

- {usageStats?.latestModel ?? "暂无数据"} -

-
-
-

- 累计 Token -

-

- {usageStats ? formatTokenNumber(usageStats.totalTokens.totalTokens) : "--"} -

-
-
-
- -
-
-

- 最空闲 -

-

- {mostUnderused?.account.displayName ?? "暂无数据"} -

-

- {mostUnderused?.efficiency.detail ?? "当前还没有足够数据。"} -

-
- -
-
-
-

- 最近一轮 -

-

- {usageStats?.latestTotalTokens - ? formatTokenNumber(usageStats.latestTotalTokens.totalTokens) - : "--"} -

-
-
-

- 模型数 -

-

- {usageStats?.models.length ?? 0} -

-
-
-
-
-
-
- + {isRefreshing ? "刷新中..." : "刷新统计"} + + - -

Models

-
- {usageStats?.models.length ? ( - usageStats.models.slice(0, 4).map((model, index) => { - const ratio = - usageStats.totalTokens.totalTokens > 0 - ? (model.totalTokens / usageStats.totalTokens.totalTokens) * 100 - : 0; - return ( -
-
-
- - {index + 1} - -
-

{model.model}

-

{model.sessions} 个会话

-
-
- - {Math.round(ratio)}% - -
-
-
-
-
- ); - }) - ) : ( -

当前还没有模型分布数据。

- )} -
- +
+ {displayedAccounts.map((account, index) => ( + + ))}
- - -
-
-

Matrix

-

- 调度矩阵 -

-
-
- -
- {efficiencyRows.map(({ account, efficiency }, index) => ( - -
-
-
-

- {account.displayName} -

- {account.isActive && ( - - 当前 - - )} - {isAccountInvalid(account) && ( - - 失效 - - )} - {account.id === recommendedId && !account.isActive && ( - - 推荐 - - )} -
-

- {account.email ?? account.userId ?? "未识别身份"} -

-
- -
-
-

- 模型 -

-

- {formatAccountSessionModel(account, usageStats)} -

-
-
-

- Token -

-

- {formatAccountSessionToken(account, usageStats)} -

-
-
-

- 5h 剩余 -

-

- {formatPercent(getRemainingPercent(account.rateLimits?.primary))} -

-

- {getAccountInsight(account).hourlyQuota.resetLabel - ? `重置 ${getAccountInsight(account).hourlyQuota.resetLabel}` - : "--"} -

-
-
-

- 美元上限 -

-

- {formatAccountQuotaUsdEstimate(account, usageStats)} -

-

按当前模型估算

-
-
-

- 建议 -

-

- {describeAction(account, recommendedId)} -

-
-
-
- -
-
- - 5h 效率 {efficiency.label} - - - {isAccountInvalid(account) - ? getAccountStatusReason(account) ?? "账号已失效或不可用" - : efficiency.detail} - -
-
- 最近切换 {formatRelativeTime(account.lastSwitchedAt)} -
-
-
- ))} -
-
); }; diff --git a/src/types/index.ts b/src/types/index.ts index 41e1e8a..c3acd8c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -124,6 +124,26 @@ export interface UsageStatsSummary { models: ModelUsageSummary[]; } +export interface DailyWorkspaceUsageTotals { + credits?: number | null; + turns?: number | null; + textTotalTokens?: number | null; + cachedTextInputTokens?: number | null; + uncachedTextInputTokens?: number | null; + textOutputTokens?: number | null; +} + +export interface DailyWorkspaceUsage { + date: string; + totals?: DailyWorkspaceUsageTotals | null; +} + +export interface DailyWorkspaceUsageResponse { + data: DailyWorkspaceUsage[]; + startDate: string; + endDate: string; +} + export interface CreditsSnapshot { hasCredits?: boolean | null; unlimited?: boolean | null; @@ -132,6 +152,7 @@ export interface CreditsSnapshot { export interface RateLimitWindow { remainingPercent: number; + usedPercent?: number | null; resetsAt?: number | null; windowDurationMins?: number | null; } diff --git a/src/utils/invoke.ts b/src/utils/invoke.ts index 7b0d0eb..94d714c 100644 --- a/src/utils/invoke.ts +++ b/src/utils/invoke.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { AppSettings, AccountsStore, + DailyWorkspaceUsageResponse, DesktopPlatformCapabilities, GetAccountRateLimitsResponse, OAuthResult, @@ -145,6 +146,55 @@ const mockUsageStatsSummary: UsageStatsSummary = { ], }; +const mockDailyWorkspaceUsage: DailyWorkspaceUsageResponse = { + startDate: "2026-02-17", + endDate: "2026-03-19", + data: [ + { + date: "2026-03-10", + totals: { + credits: 5.42, + turns: 18, + textTotalTokens: 1_820_000, + }, + }, + { + date: "2026-03-11", + totals: { + credits: 8.16, + turns: 27, + cachedTextInputTokens: 450_000, + uncachedTextInputTokens: 980_000, + textOutputTokens: 320_000, + }, + }, + { + date: "2026-03-13", + totals: { + credits: 12.74, + turns: 34, + textTotalTokens: 3_410_000, + }, + }, + { + date: "2026-03-15", + totals: { + credits: 6.08, + turns: 21, + textTotalTokens: 1_990_000, + }, + }, + { + date: "2026-03-17", + totals: { + credits: 3.95, + turns: 13, + textTotalTokens: 960_000, + }, + }, + ], +}; + function readJson(key: string, fallback: T): T { if (typeof window === "undefined") { return fallback; @@ -398,6 +448,12 @@ const browserApi = { async readUsageStatsSummary(): Promise { return mockUsageStatsSummary; }, + async readAccountDailyWorkspaceUsage( + _accountId: string, + _days = 30, + ): Promise { + return mockDailyWorkspaceUsage; + }, async deleteAccountSessions(accountId: string): Promise { const store = readMockAccounts(); writeMockAccounts({ @@ -469,6 +525,11 @@ export const api = isTauriRuntime invoke("read_account_rate_limits", { accountId }), getCurrentSessionsInfo: () => invoke("get_current_sessions_info"), readUsageStatsSummary: () => invoke("read_usage_stats_summary"), + readAccountDailyWorkspaceUsage: (accountId: string, days = 30) => + invoke("read_account_daily_workspace_usage", { + accountId, + days, + }), deleteAccountSessions: (accountId: string) => invoke("delete_account_sessions", { accountId }), resumeSessionInTerminal: (sessionId: string) => diff --git a/src/utils/quotaCompass.ts b/src/utils/quotaCompass.ts new file mode 100644 index 0000000..bbc6ddd --- /dev/null +++ b/src/utils/quotaCompass.ts @@ -0,0 +1,141 @@ +import type { + DailyWorkspaceUsage, + DailyWorkspaceUsageTotals, + RateLimitWindow, +} from "../types"; + +export const USD_PER_CODEX_CREDIT = 40 / 1000; + +export interface QuotaCompassStats { + credits: number; + turns: number; + tokens: number; + usd: number; +} + +export interface QuotaCompassSummary { + currentCycleList: DailyWorkspaceUsage[]; + historyList: DailyWorkspaceUsage[]; + currentStats: QuotaCompassStats; + historyStats: QuotaCompassStats; + usedPercent: number | null; + estimatedTotalCredits: number | null; + estimatedTotalUsd: number | null; +} + +function numberOrZero(value: number | null | undefined): number { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function dayTime(date: string): number { + const time = new Date(`${date}T00:00:00Z`).getTime(); + return Number.isFinite(time) ? time : 0; +} + +export function getDailyTokenTotal(totals: DailyWorkspaceUsageTotals | null | undefined): number { + if (!totals) { + return 0; + } + + const explicitTotal = numberOrZero(totals.textTotalTokens); + if (explicitTotal > 0) { + return explicitTotal; + } + + return ( + numberOrZero(totals.cachedTextInputTokens) + + numberOrZero(totals.uncachedTextInputTokens) + + numberOrZero(totals.textOutputTokens) + ); +} + +export function getQuotaCompassStats(list: DailyWorkspaceUsage[]): QuotaCompassStats { + const totals = list.reduce( + (sum, day) => ({ + credits: sum.credits + numberOrZero(day.totals?.credits), + turns: sum.turns + numberOrZero(day.totals?.turns), + tokens: sum.tokens + getDailyTokenTotal(day.totals), + }), + { credits: 0, turns: 0, tokens: 0 }, + ); + + return { + ...totals, + usd: totals.credits * USD_PER_CODEX_CREDIT, + }; +} + +export function getWindowUsedPercent(window: RateLimitWindow | null | undefined): number | null { + if (!window) { + return null; + } + + if (typeof window.usedPercent === "number" && Number.isFinite(window.usedPercent)) { + return Math.max(0, Math.min(100, window.usedPercent)); + } + + return Math.max(0, Math.min(100, 100 - window.remainingPercent)); +} + +export function getCycleStartDate( + weeklyWindow: RateLimitWindow | null | undefined, + fallbackStartDate: string, +): string { + if (!weeklyWindow?.resetsAt || !weeklyWindow.windowDurationMins) { + return fallbackStartDate; + } + + return new Date((weeklyWindow.resetsAt - weeklyWindow.windowDurationMins * 60) * 1000) + .toISOString() + .split("T")[0]; +} + +export function buildQuotaCompassSummary( + dailyList: DailyWorkspaceUsage[], + cycleStartDate: string, + weeklyWindow: RateLimitWindow | null | undefined, +): QuotaCompassSummary { + const cycleStartTime = dayTime(cycleStartDate); + const currentCycleList: DailyWorkspaceUsage[] = []; + const historyList: DailyWorkspaceUsage[] = []; + + [...dailyList] + .sort((left, right) => dayTime(left.date) - dayTime(right.date)) + .forEach((item) => { + if (dayTime(item.date) >= cycleStartTime) { + currentCycleList.push(item); + } else { + historyList.push(item); + } + }); + + const currentStats = getQuotaCompassStats(currentCycleList); + const historyStats = getQuotaCompassStats(historyList); + const usedPercent = getWindowUsedPercent(weeklyWindow); + const estimatedTotalCredits = + usedPercent && usedPercent > 0 ? currentStats.credits / (usedPercent / 100) : null; + + return { + currentCycleList, + historyList, + currentStats, + historyStats, + usedPercent, + estimatedTotalCredits, + estimatedTotalUsd: + estimatedTotalCredits === null ? null : estimatedTotalCredits * USD_PER_CODEX_CREDIT, + }; +} + +export function formatCompactTokenNumber(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "--"; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}K`; + } + return value.toLocaleString("zh-CN"); +} diff --git a/tests/quotaCompass.test.ts b/tests/quotaCompass.test.ts new file mode 100644 index 0000000..c6dd733 --- /dev/null +++ b/tests/quotaCompass.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + buildQuotaCompassSummary, + formatCompactTokenNumber, + getDailyTokenTotal, +} from "../src/utils/quotaCompass"; + +describe("quota compass", () => { + it("uses explicit token totals before component totals", () => { + expect( + getDailyTokenTotal({ + textTotalTokens: 1_200_000, + cachedTextInputTokens: 100, + uncachedTextInputTokens: 100, + textOutputTokens: 100, + }), + ).toBe(1_200_000); + }); + + it("splits current cycle and history while projecting total credits", () => { + const summary = buildQuotaCompassSummary( + [ + { date: "2026-03-08", totals: { credits: 1, turns: 2, textTotalTokens: 1_000 } }, + { + date: "2026-03-11", + totals: { + credits: 2, + turns: 3, + cachedTextInputTokens: 100, + uncachedTextInputTokens: 200, + textOutputTokens: 300, + }, + }, + { date: "2026-03-12", totals: { credits: 3, turns: 4, textTotalTokens: 2_000 } }, + ], + "2026-03-10", + { remainingPercent: 75 }, + ); + + expect(summary.historyStats.credits).toBe(1); + expect(summary.currentStats.credits).toBe(5); + expect(summary.currentStats.turns).toBe(7); + expect(summary.currentStats.tokens).toBe(2_600); + expect(summary.usedPercent).toBe(25); + expect(summary.estimatedTotalCredits).toBe(20); + expect(summary.estimatedTotalUsd).toBe(0.8); + }); + + it("formats token numbers with K and M units", () => { + expect(formatCompactTokenNumber(980)).toBe("980"); + expect(formatCompactTokenNumber(12_340)).toBe("12.34K"); + expect(formatCompactTokenNumber(1_234_000)).toBe("1.23M"); + }); +}); From 5556345f46dd94c5010c8763c9ce7d3f1c6411e8 Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Tue, 19 May 2026 10:20:36 +0800 Subject: [PATCH 16/17] Release v1.2.18 --- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 2 +- src/utils/accounts.ts | 5 ++++- tests/accounts.test.ts | 24 ++++++++++++++++++++++++ 8 files changed, 35 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56d8ac6..b5765b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.17", + "version": "1.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.17", + "version": "1.2.18", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 9a3a841..e3607d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.17", + "version": "1.2.18", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9e0f28b..3bcd86b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.17" +version = "1.2.18" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a0a08cf..3c56025 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.17" +version = "1.2.18" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c7a6c69..944d7a0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.17", + "version": "1.2.18", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index 69f1c41..60336cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -116,7 +116,7 @@ const App: React.FC = () => { setIsRefreshing(true); try { const store = await api.loadAccounts(); - const hydrated = await hydrateAccounts(store.accounts); + const hydrated = await hydrateAccounts(store.accounts, { refreshAllRateLimits: true }); setAccounts(hydrated); const invalidAccounts = hydrated.filter((account) => isAccountInvalid(account)); const rateLimitFailures = hydrated.filter( diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index 014b905..e9dfa55 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -14,6 +14,7 @@ export interface CurrentAuthState { } interface HydrateAccountsOptions { + refreshAllRateLimits?: boolean; refreshRateLimitAccountIds?: ReadonlySet; } @@ -63,7 +64,9 @@ export async function hydrateAccounts( ? account.id === activeAccountId : false; const shouldRefreshRateLimits = - isActive || options.refreshRateLimitAccountIds?.has(account.id) === true; + options.refreshAllRateLimits === true || + isActive || + options.refreshRateLimitAccountIds?.has(account.id) === true; const rateLimitResult = shouldRefreshRateLimits ? await api .readAccountRateLimits(account.id) diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts index 9e9b795..54f9300 100644 --- a/tests/accounts.test.ts +++ b/tests/accounts.test.ts @@ -92,6 +92,30 @@ describe("hydrateAccounts", () => { expect(hydrated.rateLimits?.secondary?.remainingPercent).toBe(65); }); + it("refreshes quota for every account when requested", async () => { + const active = createAccount({ id: "active", isActive: true }); + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 23, windowDurationMins: 300, resetsAt: 900 }, + secondary: { remainingPercent: 65, windowDurationMins: 10_080, resetsAt: 500_000 }, + }, + accountStatus: "available", + }); + + const hydrated = await hydrateAccounts([active, standby], { refreshAllRateLimits: true }); + + expect(apiMock.readAccountRateLimits).toHaveBeenCalledTimes(2); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("active"); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("standby"); + expect(hydrated.find((account) => account.id === "active")?.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.secondary?.remainingPercent).toBe(41); + }); + it("refreshes quota for a selected standby account", async () => { const standby = createAccount({ id: "standby", From 28301e399a8d43f35cd0c89f1d7f7d7441e83f8f Mon Sep 17 00:00:00 2001 From: "nat.yu" Date: Tue, 19 May 2026 14:35:58 +0800 Subject: [PATCH 17/17] Release v1.2.19 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/models.rs | 36 ++++++++++ src-tauri/tauri.conf.json | 2 +- src/components/UsageStatsPage.tsx | 109 +++++++++++++++++++++++++++++- src/types/index.ts | 9 +++ src/utils/quotaCompass.ts | 45 ++++++++++-- tests/quotaCompass.test.ts | 34 ++++++++++ 10 files changed, 232 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5765b8..3249cef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.18", + "version": "1.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.18", + "version": "1.2.19", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index e3607d0..461f209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.18", + "version": "1.2.19", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3bcd86b..fe3478f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.18" +version = "1.2.19" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3c56025..50a7dfa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.18" +version = "1.2.19" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 4a4e159..e693380 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -210,12 +210,48 @@ pub struct DailyWorkspaceUsageTotals { pub text_output_tokens: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DailyWorkspaceUsageBreakdown { + #[serde(default)] + pub model: Option, + #[serde(default)] + #[serde(alias = "client_id")] + pub client_id: Option, + #[serde(default)] + pub users: Option, + #[serde(default)] + pub threads: Option, + #[serde(default)] + pub turns: Option, + #[serde(default)] + pub credits: Option, + #[serde(default)] + pub on_demand_credits: Option, + #[serde(default)] + #[serde(alias = "text_total_tokens")] + pub text_total_tokens: Option, + #[serde(default)] + #[serde(alias = "cached_text_input_tokens")] + pub cached_text_input_tokens: Option, + #[serde(default)] + #[serde(alias = "uncached_text_input_tokens")] + pub uncached_text_input_tokens: Option, + #[serde(default)] + #[serde(alias = "text_output_tokens")] + pub text_output_tokens: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DailyWorkspaceUsage { pub date: String, #[serde(default)] pub totals: Option, + #[serde(default)] + pub models: Option>, + #[serde(default)] + pub clients: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 944d7a0..f6baa81 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.18", + "version": "1.2.19", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", diff --git a/src/components/UsageStatsPage.tsx b/src/components/UsageStatsPage.tsx index 4e50d1b..d963331 100644 --- a/src/components/UsageStatsPage.tsx +++ b/src/components/UsageStatsPage.tsx @@ -4,8 +4,13 @@ import { useAccountStore } from "../store/accountStore"; import { isAccountInvalid } from "../utils/dashboard"; import { api } from "../utils/invoke"; import { revealUp } from "../utils/motion"; -import { buildQuotaCompassSummary, getCycleStartDate } from "../utils/quotaCompass"; -import type { Account, DailyWorkspaceUsageResponse } from "../types"; +import { + buildQuotaCompassSummary, + formatCompactTokenNumber, + getCycleStartDate, + getQuotaCompassStats, +} from "../utils/quotaCompass"; +import type { Account, DailyWorkspaceUsage, DailyWorkspaceUsageResponse } from "../types"; interface UsageStatsPageProps { isRefreshing: boolean; @@ -26,6 +31,13 @@ function formatUsd(value: number | null | undefined): string { return `$${value.toFixed(2)}`; } +function formatTableCredits(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "0.000"; + } + return value.toFixed(3); +} + function formatCompassPercent(value: number | null | undefined): string { if (typeof value !== "number" || !Number.isFinite(value)) { return "--"; @@ -33,6 +45,79 @@ function formatCompassPercent(value: number | null | undefined): string { return `${value.toFixed(value % 1 === 0 ? 0 : 1)}%`; } +function formatHistoryTitle(list: DailyWorkspaceUsage[]): string { + if (list.length === 0) { + return "历史记录 (本周期外)"; + } + + const sorted = [...list].sort((left, right) => left.date.localeCompare(right.date)); + return `历史记录 (本周期外 ${sorted[0].date} 至 ${sorted[sorted.length - 1].date})`; +} + +function UsageDetailTable({ + rows, + totalLabel, +}: { + rows: DailyWorkspaceUsage[]; + totalLabel: string; +}) { + const stats = getQuotaCompassStats(rows); + const displayRows = [...rows].sort((left, right) => right.date.localeCompare(left.date)); + + return ( +
+
+ + + + + + + + + + + + {displayRows.map((row) => { + const rowStats = getQuotaCompassStats([row]); + return ( + + + + + + + + ); + })} + + + + + + + + + + +
日期CreditsTokens金额轮数
{row.date} + {formatTableCredits(rowStats.credits)} + + {formatCompactTokenNumber(rowStats.tokens)} + + {formatUsd(rowStats.usd)} + {rowStats.turns}
{totalLabel} + {formatTableCredits(stats.credits)} + + {formatCompactTokenNumber(stats.tokens)} + + {formatUsd(stats.usd)} + {stats.turns}
+
+
+ ); +} + function AccountQuotaSummary({ account, dailyUsage, @@ -139,6 +224,26 @@ function AccountQuotaSummary({
))}
+ + {summary && ( +
+
+

+ 本周期明细 (始于 {cycleStartDate}) +

+ +
+ + {summary.historyList.length > 0 && ( +
+

+ {formatHistoryTitle(summary.historyList)} +

+ +
+ )} +
+ )} ); } diff --git a/src/types/index.ts b/src/types/index.ts index c3acd8c..e4255e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -133,9 +133,18 @@ export interface DailyWorkspaceUsageTotals { textOutputTokens?: number | null; } +export interface DailyWorkspaceUsageBreakdown extends DailyWorkspaceUsageTotals { + model?: string | null; + clientId?: string | null; + users?: number | null; + threads?: number | null; +} + export interface DailyWorkspaceUsage { date: string; totals?: DailyWorkspaceUsageTotals | null; + models?: DailyWorkspaceUsageBreakdown[] | null; + clients?: DailyWorkspaceUsageBreakdown[] | null; } export interface DailyWorkspaceUsageResponse { diff --git a/src/utils/quotaCompass.ts b/src/utils/quotaCompass.ts index bbc6ddd..77b8a5a 100644 --- a/src/utils/quotaCompass.ts +++ b/src/utils/quotaCompass.ts @@ -1,8 +1,11 @@ import type { DailyWorkspaceUsage, + DailyWorkspaceUsageBreakdown, DailyWorkspaceUsageTotals, RateLimitWindow, + TokenUsageInfo, } from "../types"; +import { estimateTokenSpendUsd } from "./quotaValue"; export const USD_PER_CODEX_CREDIT = 40 / 1000; @@ -49,19 +52,50 @@ export function getDailyTokenTotal(totals: DailyWorkspaceUsageTotals | null | un ); } +function toTokenUsage(usage: DailyWorkspaceUsageTotals | DailyWorkspaceUsageBreakdown): TokenUsageInfo { + const inputTokens = + numberOrZero(usage.cachedTextInputTokens) + numberOrZero(usage.uncachedTextInputTokens); + const outputTokens = numberOrZero(usage.textOutputTokens); + const totalTokens = getDailyTokenTotal(usage); + + return { + inputTokens, + cachedInputTokens: numberOrZero(usage.cachedTextInputTokens), + outputTokens, + reasoningOutputTokens: 0, + totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens, + }; +} + +function getDailyUsdTotal(day: DailyWorkspaceUsage): number { + const creditedUsd = numberOrZero(day.totals?.credits) * USD_PER_CODEX_CREDIT; + if (creditedUsd > 0) { + return creditedUsd; + } + + return (day.models ?? []).reduce((sum, modelUsage) => { + const spentUsd = estimateTokenSpendUsd(toTokenUsage(modelUsage), modelUsage.model); + return sum + numberOrZero(spentUsd); + }, 0); +} + export function getQuotaCompassStats(list: DailyWorkspaceUsage[]): QuotaCompassStats { const totals = list.reduce( (sum, day) => ({ credits: sum.credits + numberOrZero(day.totals?.credits), turns: sum.turns + numberOrZero(day.totals?.turns), tokens: sum.tokens + getDailyTokenTotal(day.totals), + usd: sum.usd + getDailyUsdTotal(day), }), - { credits: 0, turns: 0, tokens: 0 }, + { credits: 0, turns: 0, tokens: 0, usd: 0 }, ); + const credits = totals.credits > 0 ? totals.credits : totals.usd / USD_PER_CODEX_CREDIT; return { - ...totals, - usd: totals.credits * USD_PER_CODEX_CREDIT, + credits, + turns: totals.turns, + tokens: totals.tokens, + usd: totals.usd, }; } @@ -114,6 +148,8 @@ export function buildQuotaCompassSummary( const usedPercent = getWindowUsedPercent(weeklyWindow); const estimatedTotalCredits = usedPercent && usedPercent > 0 ? currentStats.credits / (usedPercent / 100) : null; + const estimatedTotalUsd = + usedPercent && usedPercent > 0 ? currentStats.usd / (usedPercent / 100) : null; return { currentCycleList, @@ -122,8 +158,7 @@ export function buildQuotaCompassSummary( historyStats, usedPercent, estimatedTotalCredits, - estimatedTotalUsd: - estimatedTotalCredits === null ? null : estimatedTotalCredits * USD_PER_CODEX_CREDIT, + estimatedTotalUsd, }; } diff --git a/tests/quotaCompass.test.ts b/tests/quotaCompass.test.ts index c6dd733..f425513 100644 --- a/tests/quotaCompass.test.ts +++ b/tests/quotaCompass.test.ts @@ -46,6 +46,40 @@ describe("quota compass", () => { expect(summary.estimatedTotalUsd).toBe(0.8); }); + it("estimates credits from model token usage when official credits are zero", () => { + const summary = buildQuotaCompassSummary( + [ + { + date: "2026-03-12", + totals: { + credits: 0, + turns: 4, + cachedTextInputTokens: 1_000_000, + uncachedTextInputTokens: 1_000_000, + textOutputTokens: 100_000, + }, + models: [ + { + model: "gpt-5.5", + credits: 0, + cachedTextInputTokens: 1_000_000, + uncachedTextInputTokens: 1_000_000, + textOutputTokens: 100_000, + textTotalTokens: 2_100_000, + }, + ], + }, + ], + "2026-03-10", + { remainingPercent: 75 }, + ); + + expect(summary.currentStats.usd).toBeCloseTo(8.5, 6); + expect(summary.currentStats.credits).toBeCloseTo(212.5, 6); + expect(summary.estimatedTotalUsd).toBeCloseTo(34, 6); + expect(summary.estimatedTotalCredits).toBeCloseTo(850, 6); + }); + it("formats token numbers with K and M units", () => { expect(formatCompactTokenNumber(980)).toBe("980"); expect(formatCompactTokenNumber(12_340)).toBe("12.34K");