init
35
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "codex-limit-monitor"
|
||||
version = "0.1.0"
|
||||
description = "Windows-first desktop monitor for Codex limits and usage"
|
||||
authors = ["meghdad"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "codex_limit_monitor"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "codex-limit-monitor"
|
||||
path = "src/main.rs"
|
||||
test = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6", features = [] }
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "6"
|
||||
regex = "1"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-notification = "2.3"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "sync", "time"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for Codex Limit Monitor",
|
||||
"windows": ["main", "badge"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-show"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 745 B |
BIN
src-tauri/icons/icon-24.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/icon-256.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/icon-64.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
248
src-tauri/src/codex_rpc.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use crate::{models::RateLimitBucket, settings::AppSettings};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::{collections::BTreeMap, process::Stdio, time::Duration};
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
process::{ChildStdout, Command},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
const RPC_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodexRpcError {
|
||||
#[error("failed to start Codex app-server: {0}")]
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("Codex app-server did not expose stdio")]
|
||||
MissingPipe,
|
||||
#[error("Codex app-server timed out")]
|
||||
Timeout,
|
||||
#[error("Codex app-server closed stdout")]
|
||||
Closed,
|
||||
#[error("invalid app-server JSON: {0}")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
#[error("app-server returned error: {0}")]
|
||||
Rpc(String),
|
||||
#[error("app-server response was missing result")]
|
||||
MissingResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RateLimitsEnvelope {
|
||||
rate_limits: Option<RawRateLimitBucket>,
|
||||
rate_limits_by_limit_id: Option<BTreeMap<String, RawRateLimitBucket>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawRateLimitBucket {
|
||||
limit_id: String,
|
||||
limit_name: Option<String>,
|
||||
primary: Option<RawLimitWindow>,
|
||||
secondary: Option<RawLimitWindow>,
|
||||
rate_limit_reached_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawLimitWindow {
|
||||
used_percent: Option<f64>,
|
||||
window_duration_mins: Option<u64>,
|
||||
resets_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn fetch_rate_limits(
|
||||
settings: &AppSettings,
|
||||
) -> Result<Vec<RateLimitBucket>, CodexRpcError> {
|
||||
let executable = settings.resolve_codex_executable();
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.arg("app-server")
|
||||
.arg("--listen")
|
||||
.arg("stdio://")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
|
||||
let mut stdin = child.stdin.take().ok_or(CodexRpcError::MissingPipe)?;
|
||||
let stdout = child.stdout.take().ok_or(CodexRpcError::MissingPipe)?;
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
|
||||
write_json(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex_limit_monitor",
|
||||
"title": "Codex Limit Monitor",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
read_response(&mut reader, 0).await?;
|
||||
|
||||
write_json(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialized",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
write_json(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "account/rateLimits/read",
|
||||
"id": 1
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = read_response(&mut reader, 1).await?;
|
||||
let _ = child.kill().await;
|
||||
|
||||
let result = response
|
||||
.get("result")
|
||||
.cloned()
|
||||
.ok_or(CodexRpcError::MissingResult)?;
|
||||
parse_rate_limits(result)
|
||||
}
|
||||
|
||||
async fn write_json(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
value: Value,
|
||||
) -> Result<(), CodexRpcError> {
|
||||
let mut line = serde_json::to_vec(&value)?;
|
||||
line.push(b'\n');
|
||||
stdin.write_all(&line).await?;
|
||||
stdin.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_response(
|
||||
reader: &mut tokio::io::Lines<BufReader<ChildStdout>>,
|
||||
id: u64,
|
||||
) -> Result<Value, CodexRpcError> {
|
||||
loop {
|
||||
let line = timeout(RPC_TIMEOUT, reader.next_line())
|
||||
.await
|
||||
.map_err(|_| CodexRpcError::Timeout)?
|
||||
.map_err(CodexRpcError::Spawn)?
|
||||
.ok_or(CodexRpcError::Closed)?;
|
||||
|
||||
let value: Value = serde_json::from_str(&line)?;
|
||||
if value.get("id").and_then(Value::as_u64) != Some(id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(error) = value.get("error") {
|
||||
return Err(CodexRpcError::Rpc(error.to_string()));
|
||||
}
|
||||
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rate_limits(result: Value) -> Result<Vec<RateLimitBucket>, CodexRpcError> {
|
||||
let envelope: RateLimitsEnvelope = serde_json::from_value(result)?;
|
||||
let mut buckets: Vec<RateLimitBucket> = Vec::new();
|
||||
|
||||
if let Some(by_id) = envelope.rate_limits_by_limit_id {
|
||||
for raw in by_id.into_values() {
|
||||
buckets.push(raw.into());
|
||||
}
|
||||
} else if let Some(raw) = envelope.rate_limits {
|
||||
buckets.push(raw.into());
|
||||
}
|
||||
|
||||
buckets.sort_by(|a, b| a.limit_id.cmp(&b.limit_id));
|
||||
Ok(buckets)
|
||||
}
|
||||
|
||||
impl From<RawRateLimitBucket> for RateLimitBucket {
|
||||
fn from(raw: RawRateLimitBucket) -> Self {
|
||||
let primary = raw.primary.unwrap_or_default();
|
||||
let secondary = raw.secondary;
|
||||
Self {
|
||||
limit_id: raw.limit_id,
|
||||
limit_name: raw.limit_name,
|
||||
used_percent: primary.used_percent.unwrap_or(0.0),
|
||||
window_duration_mins: primary.window_duration_mins,
|
||||
resets_at: primary.resets_at,
|
||||
secondary_used_percent: secondary.as_ref().and_then(|value| value.used_percent),
|
||||
secondary_resets_at: secondary.and_then(|value| value.resets_at),
|
||||
reached_type: raw.rate_limit_reached_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RawLimitWindow {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
used_percent: Some(0.0),
|
||||
window_duration_mins: None,
|
||||
resets_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_multi_bucket_rate_limits() {
|
||||
let value = json!({
|
||||
"rateLimitsByLimitId": {
|
||||
"codex": {
|
||||
"limitId": "codex",
|
||||
"limitName": null,
|
||||
"primary": {
|
||||
"usedPercent": 25,
|
||||
"windowDurationMins": 15,
|
||||
"resetsAt": 1730947200
|
||||
},
|
||||
"secondary": null,
|
||||
"rateLimitReachedType": null
|
||||
},
|
||||
"codex_other": {
|
||||
"limitId": "codex_other",
|
||||
"limitName": "codex_other",
|
||||
"primary": {
|
||||
"usedPercent": 42,
|
||||
"windowDurationMins": 60,
|
||||
"resetsAt": 1730950800
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let buckets = parse_rate_limits(value).unwrap();
|
||||
|
||||
assert_eq!(buckets.len(), 2);
|
||||
assert_eq!(buckets[0].limit_id, "codex");
|
||||
assert_eq!(buckets[0].used_percent, 25.0);
|
||||
assert_eq!(buckets[1].limit_id, "codex_other");
|
||||
assert_eq!(buckets[1].window_duration_mins, Some(60));
|
||||
}
|
||||
}
|
||||
201
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
pub mod codex_rpc;
|
||||
pub mod local_usage;
|
||||
pub mod models;
|
||||
#[cfg(not(test))]
|
||||
pub mod monitor;
|
||||
pub mod settings;
|
||||
#[cfg(not(test))]
|
||||
pub mod tray;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use monitor::MonitorService;
|
||||
#[cfg(not(test))]
|
||||
use settings::{AppSettings, BadgePosition};
|
||||
#[cfg(not(test))]
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(test))]
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, State};
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
monitor: Arc<MonitorService>,
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn get_snapshot(state: State<'_, AppState>) -> Result<models::MonitorSnapshot, String> {
|
||||
state.monitor.snapshot_or_refresh(None).await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn refresh_now(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<models::MonitorSnapshot, String> {
|
||||
state.monitor.refresh(Some(app)).await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn get_usage_history(
|
||||
range: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<models::UsageHistory, String> {
|
||||
let settings = state.monitor.settings().await;
|
||||
local_usage::read_usage_history(&settings, &range).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||
Ok(state.monitor.settings().await)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn update_settings(
|
||||
app: AppHandle,
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
settings.save().map_err(|err| err.to_string())?;
|
||||
state.monitor.replace_settings(settings.clone()).await;
|
||||
apply_badge_visibility(&app, settings.badge_visible)?;
|
||||
apply_badge_position(&app, &settings.badge_position)?;
|
||||
let snapshot = state.monitor.refresh(Some(app.clone())).await?;
|
||||
let _ = app.emit("settings_updated", &settings);
|
||||
let _ = app.emit("rate_limit_updated", &snapshot);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn toggle_badge(
|
||||
app: AppHandle,
|
||||
visible: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let mut settings = state.monitor.settings().await;
|
||||
settings.badge_visible = visible;
|
||||
settings.save().map_err(|err| err.to_string())?;
|
||||
state.monitor.replace_settings(settings.clone()).await;
|
||||
apply_badge_visibility(&app, visible)?;
|
||||
let _ = app.emit("settings_updated", &settings);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn set_badge_position(
|
||||
app: AppHandle,
|
||||
position: BadgePosition,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let mut settings = state.monitor.settings().await;
|
||||
settings.badge_position = position;
|
||||
settings.save().map_err(|err| err.to_string())?;
|
||||
state.monitor.replace_settings(settings.clone()).await;
|
||||
apply_badge_position(&app, &settings.badge_position)?;
|
||||
let _ = app.emit("settings_updated", &settings);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn move_badge(app: AppHandle, position: BadgePosition) -> Result<(), String> {
|
||||
apply_badge_position(&app, &position)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn pause_notifications(
|
||||
until: Option<i64>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let mut settings = state.monitor.settings().await;
|
||||
settings.notifications_muted_until = until;
|
||||
settings.save().map_err(|err| err.to_string())?;
|
||||
state.monitor.replace_settings(settings.clone()).await;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
async fn open_dashboard(app: AppHandle) -> Result<(), String> {
|
||||
show_dashboard(&app)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn show_dashboard(app: &AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().map_err(|err| err.to_string())?;
|
||||
window.set_focus().map_err(|err| err.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn apply_badge_visibility(app: &AppHandle, visible: bool) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("badge") {
|
||||
if visible {
|
||||
window.show().map_err(|err| err.to_string())?;
|
||||
} else {
|
||||
window.hide().map_err(|err| err.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn apply_badge_position(app: &AppHandle, position: &BadgePosition) -> Result<(), String> {
|
||||
let Some(window) = app.get_webview_window("badge") else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
window
|
||||
.set_position(PhysicalPosition::new(position.x, position.y))
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
let settings = AppSettings::load().unwrap_or_default();
|
||||
let monitor = Arc::new(MonitorService::new(settings.clone()));
|
||||
app.manage(AppState {
|
||||
monitor: monitor.clone(),
|
||||
});
|
||||
|
||||
tray::setup_tray(app.handle())?;
|
||||
apply_badge_visibility(app.handle(), settings.badge_visible)?;
|
||||
apply_badge_position(app.handle(), &settings.badge_position)?;
|
||||
if let Some(window) = app.get_webview_window("badge") {
|
||||
let _ = window.set_shadow(false);
|
||||
}
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
monitor.run(app_handle).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_snapshot,
|
||||
refresh_now,
|
||||
get_usage_history,
|
||||
get_settings,
|
||||
update_settings,
|
||||
toggle_badge,
|
||||
set_badge_position,
|
||||
move_badge,
|
||||
pause_notifications,
|
||||
open_dashboard
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Codex Limit Monitor");
|
||||
}
|
||||
275
src-tauri/src/local_usage.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use crate::{
|
||||
models::{ModelUsage, ThreadUsage, TokenBreakdown, UsageHistory, UsagePoint, UsageSummary},
|
||||
settings::AppSettings,
|
||||
};
|
||||
use chrono::{Local, TimeZone, Utc};
|
||||
use regex::Regex;
|
||||
use rusqlite::{Connection, OpenFlags, Row};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LocalUsageError {
|
||||
#[error("Codex state database was not found at {0}")]
|
||||
MissingStateDb(String),
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
pub fn read_usage_summary(settings: &AppSettings) -> UsageSummary {
|
||||
match read_usage_summary_inner(settings) {
|
||||
Ok(summary) => summary,
|
||||
Err(error) => UsageSummary {
|
||||
source_path: Some(settings.codex_home.clone()),
|
||||
error: Some(error.to_string()),
|
||||
..UsageSummary::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_usage_history(
|
||||
settings: &AppSettings,
|
||||
range: &str,
|
||||
) -> Result<UsageHistory, LocalUsageError> {
|
||||
let state_path = state_db_path(settings);
|
||||
if !state_path.exists() {
|
||||
return Err(LocalUsageError::MissingStateDb(
|
||||
state_path.to_string_lossy().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let conn = open_read_only(&state_path)?;
|
||||
let days = match range {
|
||||
"24h" => 1,
|
||||
"30d" => 30,
|
||||
_ => 7,
|
||||
};
|
||||
let since = Utc::now().timestamp() - days * 86_400;
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT date(updated_at, 'unixepoch', 'localtime') AS bucket,
|
||||
COALESCE(SUM(tokens_used), 0) AS tokens,
|
||||
COUNT(*) AS threads
|
||||
FROM threads
|
||||
WHERE updated_at >= ?1
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC",
|
||||
)?;
|
||||
|
||||
let points = statement
|
||||
.query_map([since], |row| {
|
||||
Ok(UsagePoint {
|
||||
label: row.get(0)?,
|
||||
tokens: row.get(1)?,
|
||||
threads: row.get(2)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(UsageHistory {
|
||||
range: range.to_string(),
|
||||
points,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_usage_summary_inner(settings: &AppSettings) -> Result<UsageSummary, LocalUsageError> {
|
||||
let state_path = state_db_path(settings);
|
||||
if !state_path.exists() {
|
||||
return Err(LocalUsageError::MissingStateDb(
|
||||
state_path.to_string_lossy().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let conn = open_read_only(&state_path)?;
|
||||
let today_start = local_midnight_timestamp();
|
||||
let week_start = Utc::now().timestamp() - 7 * 86_400;
|
||||
|
||||
let (total_tokens, thread_count) = sum_tokens(&conn, None)?;
|
||||
let (today_tokens, today_thread_count) = sum_tokens(&conn, Some(today_start))?;
|
||||
let (week_tokens, week_thread_count) = sum_tokens(&conn, Some(week_start))?;
|
||||
let recent_threads = recent_threads(&conn)?;
|
||||
let current_thread = recent_threads.first().cloned();
|
||||
let model_breakdown = model_breakdown(&conn)?;
|
||||
let token_breakdown = read_token_breakdown(settings, week_start).unwrap_or_default();
|
||||
|
||||
Ok(UsageSummary {
|
||||
total_tokens,
|
||||
today_tokens,
|
||||
week_tokens,
|
||||
thread_count,
|
||||
today_thread_count,
|
||||
week_thread_count,
|
||||
current_thread,
|
||||
recent_threads,
|
||||
model_breakdown,
|
||||
token_breakdown,
|
||||
source_path: Some(state_path.to_string_lossy().to_string()),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn open_read_only(path: &Path) -> Result<Connection, rusqlite::Error> {
|
||||
Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
}
|
||||
|
||||
fn state_db_path(settings: &AppSettings) -> PathBuf {
|
||||
settings.codex_home_path().join("state_5.sqlite")
|
||||
}
|
||||
|
||||
fn logs_db_path(settings: &AppSettings) -> PathBuf {
|
||||
settings.codex_home_path().join("logs_2.sqlite")
|
||||
}
|
||||
|
||||
fn local_midnight_timestamp() -> i64 {
|
||||
let date = Local::now().date_naive();
|
||||
let midnight = date.and_hms_opt(0, 0, 0).expect("valid midnight");
|
||||
Local
|
||||
.from_local_datetime(&midnight)
|
||||
.earliest()
|
||||
.expect("valid local midnight")
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
fn sum_tokens(conn: &Connection, since: Option<i64>) -> Result<(i64, i64), rusqlite::Error> {
|
||||
if let Some(since) = since {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(SUM(tokens_used), 0), COUNT(*) FROM threads WHERE updated_at >= ?1",
|
||||
[since],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(SUM(tokens_used), 0), COUNT(*) FROM threads",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn recent_threads(conn: &Connection) -> Result<Vec<ThreadUsage>, rusqlite::Error> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, title, tokens_used, model, reasoning_effort, updated_at, cwd
|
||||
FROM threads
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 12",
|
||||
)?;
|
||||
|
||||
let rows = statement
|
||||
.query_map([], row_to_thread)?
|
||||
.collect::<Result<Vec<_>, _>>();
|
||||
rows
|
||||
}
|
||||
|
||||
fn model_breakdown(conn: &Connection) -> Result<Vec<ModelUsage>, rusqlite::Error> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT COALESCE(NULLIF(model, ''), 'unknown') AS model,
|
||||
COALESCE(SUM(tokens_used), 0) AS tokens,
|
||||
COUNT(*) AS threads
|
||||
FROM threads
|
||||
GROUP BY model
|
||||
ORDER BY tokens DESC
|
||||
LIMIT 8",
|
||||
)?;
|
||||
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ModelUsage {
|
||||
model: row.get(0)?,
|
||||
tokens: row.get(1)?,
|
||||
threads: row.get(2)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>();
|
||||
rows
|
||||
}
|
||||
|
||||
fn row_to_thread(row: &Row<'_>) -> Result<ThreadUsage, rusqlite::Error> {
|
||||
Ok(ThreadUsage {
|
||||
id: row.get(0)?,
|
||||
title: row
|
||||
.get::<_, String>(1)
|
||||
.unwrap_or_else(|_| "Untitled".to_string()),
|
||||
tokens_used: row.get(2)?,
|
||||
model: row.get(3)?,
|
||||
reasoning_effort: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
cwd: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_token_breakdown(
|
||||
settings: &AppSettings,
|
||||
since: i64,
|
||||
) -> Result<TokenBreakdown, LocalUsageError> {
|
||||
let logs_path = logs_db_path(settings);
|
||||
if !logs_path.exists() {
|
||||
return Ok(TokenBreakdown::default());
|
||||
}
|
||||
|
||||
let conn = open_read_only(&logs_path)?;
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT feedback_log_body
|
||||
FROM logs
|
||||
WHERE ts >= ?1
|
||||
AND feedback_log_body LIKE '%event.kind=response.completed%'
|
||||
ORDER BY ts DESC
|
||||
LIMIT 5000",
|
||||
)?;
|
||||
|
||||
let mut rows = statement.query([since])?;
|
||||
let mut breakdown = TokenBreakdown::default();
|
||||
while let Some(row) = rows.next()? {
|
||||
let body: Option<String> = row.get(0)?;
|
||||
if let Some(body) = body {
|
||||
add_response_tokens(&mut breakdown, &body);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(breakdown)
|
||||
}
|
||||
|
||||
fn add_response_tokens(breakdown: &mut TokenBreakdown, body: &str) {
|
||||
let regex = Regex::new(r"(input|output|cached|reasoning|tool)_token_count=(\d+)")
|
||||
.expect("static token regex is valid");
|
||||
let mut matched = false;
|
||||
|
||||
for capture in regex.captures_iter(body) {
|
||||
let count = capture
|
||||
.get(2)
|
||||
.and_then(|value| value.as_str().parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
match capture.get(1).map(|value| value.as_str()) {
|
||||
Some("input") => breakdown.input += count,
|
||||
Some("output") => breakdown.output += count,
|
||||
Some("cached") => breakdown.cached += count,
|
||||
Some("reasoning") => breakdown.reasoning += count,
|
||||
Some("tool") => breakdown.tool += count,
|
||||
_ => {}
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if matched {
|
||||
breakdown.responses += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_response_token_counts() {
|
||||
let mut breakdown = TokenBreakdown::default();
|
||||
add_response_tokens(
|
||||
&mut breakdown,
|
||||
"event.kind=response.completed input_token_count=40917 output_token_count=410 cached_token_count=39296 reasoning_token_count=32 tool_token_count=41327",
|
||||
);
|
||||
|
||||
assert_eq!(breakdown.input, 40917);
|
||||
assert_eq!(breakdown.output, 410);
|
||||
assert_eq!(breakdown.cached, 39296);
|
||||
assert_eq!(breakdown.reasoning, 32);
|
||||
assert_eq!(breakdown.tool, 41327);
|
||||
assert_eq!(breakdown.responses, 1);
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
codex_limit_monitor::run();
|
||||
}
|
||||
125
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RateLimitBucket {
|
||||
pub limit_id: String,
|
||||
pub limit_name: Option<String>,
|
||||
pub used_percent: f64,
|
||||
pub window_duration_mins: Option<u64>,
|
||||
pub resets_at: Option<i64>,
|
||||
pub secondary_used_percent: Option<f64>,
|
||||
pub secondary_resets_at: Option<i64>,
|
||||
pub reached_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveStatus {
|
||||
pub state: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl LiveStatus {
|
||||
pub fn online() -> Self {
|
||||
Self {
|
||||
state: "online".to_string(),
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fallback(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
state: "fallback".to_string(),
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn offline(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
state: "offline".to_string(),
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenBreakdown {
|
||||
pub input: i64,
|
||||
pub output: i64,
|
||||
pub cached: i64,
|
||||
pub reasoning: i64,
|
||||
pub tool: i64,
|
||||
pub responses: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelUsage {
|
||||
pub model: String,
|
||||
pub tokens: i64,
|
||||
pub threads: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadUsage {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub tokens_used: i64,
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub updated_at: i64,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageSummary {
|
||||
pub total_tokens: i64,
|
||||
pub today_tokens: i64,
|
||||
pub week_tokens: i64,
|
||||
pub thread_count: i64,
|
||||
pub today_thread_count: i64,
|
||||
pub week_thread_count: i64,
|
||||
pub current_thread: Option<ThreadUsage>,
|
||||
pub recent_threads: Vec<ThreadUsage>,
|
||||
pub model_breakdown: Vec<ModelUsage>,
|
||||
pub token_breakdown: TokenBreakdown,
|
||||
pub source_path: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl UsageSummary {
|
||||
pub fn has_data(&self) -> bool {
|
||||
self.thread_count > 0 || self.total_tokens > 0 || !self.recent_threads.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MonitorSnapshot {
|
||||
pub source: String,
|
||||
pub fetched_at: i64,
|
||||
pub live_status: LiveStatus,
|
||||
pub buckets: Vec<RateLimitBucket>,
|
||||
pub usage_summary: UsageSummary,
|
||||
pub current_thread: Option<ThreadUsage>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsagePoint {
|
||||
pub label: String,
|
||||
pub tokens: i64,
|
||||
pub threads: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageHistory {
|
||||
pub range: String,
|
||||
pub points: Vec<UsagePoint>,
|
||||
}
|
||||
185
src-tauri/src/monitor.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use crate::{
|
||||
codex_rpc, local_usage,
|
||||
models::{LiveStatus, MonitorSnapshot, RateLimitBucket},
|
||||
settings::AppSettings,
|
||||
tray,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
#[derive(Default)]
|
||||
struct NotificationMemory {
|
||||
seen: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub struct MonitorService {
|
||||
settings: RwLock<AppSettings>,
|
||||
snapshot: RwLock<Option<MonitorSnapshot>>,
|
||||
notifications: Mutex<NotificationMemory>,
|
||||
}
|
||||
|
||||
impl MonitorService {
|
||||
pub fn new(settings: AppSettings) -> Self {
|
||||
Self {
|
||||
settings: RwLock::new(settings),
|
||||
snapshot: RwLock::new(None),
|
||||
notifications: Mutex::new(NotificationMemory::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn settings(&self) -> AppSettings {
|
||||
self.settings.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn replace_settings(&self, settings: AppSettings) {
|
||||
*self.settings.write().await = settings;
|
||||
}
|
||||
|
||||
pub async fn snapshot_or_refresh(
|
||||
&self,
|
||||
app: Option<AppHandle>,
|
||||
) -> Result<MonitorSnapshot, String> {
|
||||
if let Some(snapshot) = self.snapshot.read().await.clone() {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
self.refresh(app).await
|
||||
}
|
||||
|
||||
pub async fn refresh(&self, app: Option<AppHandle>) -> Result<MonitorSnapshot, String> {
|
||||
let settings = self.settings().await;
|
||||
if let Some(app) = app.as_ref() {
|
||||
let _ = app.emit("refresh_started", ());
|
||||
}
|
||||
let usage_summary = local_usage::read_usage_summary(&settings);
|
||||
let live = codex_rpc::fetch_rate_limits(&settings).await;
|
||||
let fetched_at = Utc::now().timestamp();
|
||||
|
||||
let (source, live_status, buckets, message) = match live {
|
||||
Ok(buckets) => (
|
||||
"codex-app-server".to_string(),
|
||||
LiveStatus::online(),
|
||||
buckets,
|
||||
None,
|
||||
),
|
||||
Err(error) if usage_summary.has_data() => (
|
||||
"local-sqlite-fallback".to_string(),
|
||||
LiveStatus::fallback(error.to_string()),
|
||||
Vec::new(),
|
||||
Some("Live Codex limits are unavailable; showing local usage history.".to_string()),
|
||||
),
|
||||
Err(error) => (
|
||||
"offline".to_string(),
|
||||
LiveStatus::offline(error.to_string()),
|
||||
Vec::new(),
|
||||
Some("No live Codex limits or local usage data could be read.".to_string()),
|
||||
),
|
||||
};
|
||||
|
||||
let current_thread = usage_summary.current_thread.clone();
|
||||
let snapshot = MonitorSnapshot {
|
||||
source,
|
||||
fetched_at,
|
||||
live_status,
|
||||
buckets,
|
||||
usage_summary,
|
||||
current_thread,
|
||||
message,
|
||||
};
|
||||
|
||||
*self.snapshot.write().await = Some(snapshot.clone());
|
||||
|
||||
if let Some(app) = app {
|
||||
let _ = app.emit("rate_limit_updated", &snapshot);
|
||||
let _ = app.emit("usage_updated", &snapshot.usage_summary);
|
||||
tray::update_tray(&app, &snapshot);
|
||||
self.maybe_notify(&app, &settings, &snapshot.buckets).await;
|
||||
}
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub async fn run(self: Arc<Self>, app: AppHandle) {
|
||||
loop {
|
||||
let settings = self.settings().await;
|
||||
let interval = settings.refresh_interval_secs.max(15);
|
||||
let _ = self.refresh(Some(app.clone())).await;
|
||||
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_notify(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
settings: &AppSettings,
|
||||
buckets: &[RateLimitBucket],
|
||||
) {
|
||||
if settings
|
||||
.notifications_muted_until
|
||||
.is_some_and(|until| until > Utc::now().timestamp())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut memory = self.notifications.lock().await;
|
||||
for bucket in buckets {
|
||||
let Some(level) = threshold_level(settings, bucket.used_percent) else {
|
||||
continue;
|
||||
};
|
||||
let reset = bucket.resets_at.unwrap_or_default();
|
||||
let key = format!("{}:{reset}", bucket.limit_id);
|
||||
if memory.seen.get(&key).is_some_and(|seen| seen == level) {
|
||||
continue;
|
||||
}
|
||||
|
||||
memory.seen.insert(key, level.to_string());
|
||||
let title = match level {
|
||||
"exhausted" => "Codex limit reached",
|
||||
"critical" => "Codex limit nearly reached",
|
||||
_ => "Codex limit warning",
|
||||
};
|
||||
let body = format!(
|
||||
"{} is at {:.0}% and resets {}.",
|
||||
bucket
|
||||
.limit_name
|
||||
.as_deref()
|
||||
.unwrap_or(bucket.limit_id.as_str()),
|
||||
bucket.used_percent,
|
||||
bucket
|
||||
.resets_at
|
||||
.map(format_reset)
|
||||
.unwrap_or_else(|| "soon".to_string())
|
||||
);
|
||||
|
||||
let _ = app.notification().builder().title(title).body(body).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn threshold_level(settings: &AppSettings, percent: f64) -> Option<&'static str> {
|
||||
if percent >= settings.thresholds.exhausted {
|
||||
Some("exhausted")
|
||||
} else if percent >= settings.thresholds.critical {
|
||||
Some("critical")
|
||||
} else if percent >= settings.thresholds.warning {
|
||||
Some("warning")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reset(timestamp: i64) -> String {
|
||||
let now = Utc::now().timestamp();
|
||||
if timestamp <= now {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let seconds = timestamp - now;
|
||||
if seconds < 60 {
|
||||
format!("in {seconds}s")
|
||||
} else {
|
||||
format!("in {}m", seconds / 60)
|
||||
}
|
||||
}
|
||||
173
src-tauri/src/settings.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BadgePosition {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
impl Default for BadgePosition {
|
||||
fn default() -> Self {
|
||||
Self { x: 1530, y: 18 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationThresholds {
|
||||
pub warning: f64,
|
||||
pub critical: f64,
|
||||
pub exhausted: f64,
|
||||
}
|
||||
|
||||
impl Default for NotificationThresholds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warning: 70.0,
|
||||
critical: 90.0,
|
||||
exhausted: 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppSettings {
|
||||
pub codex_executable: Option<String>,
|
||||
pub codex_home: String,
|
||||
pub refresh_interval_secs: u64,
|
||||
pub badge_visible: bool,
|
||||
pub badge_position: BadgePosition,
|
||||
pub privacy_mode: bool,
|
||||
pub thresholds: NotificationThresholds,
|
||||
pub notifications_muted_until: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
codex_executable: None,
|
||||
codex_home: default_codex_home().to_string_lossy().to_string(),
|
||||
refresh_interval_secs: 60,
|
||||
badge_visible: true,
|
||||
badge_position: BadgePosition::default(),
|
||||
privacy_mode: false,
|
||||
thresholds: NotificationThresholds::default(),
|
||||
notifications_muted_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
pub fn load() -> io::Result<Self> {
|
||||
let path = settings_path();
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
let body = fs::read_to_string(path)?;
|
||||
let mut settings = serde_json::from_str::<Self>(&body).unwrap_or_default();
|
||||
settings.normalize();
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> io::Result<()> {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let body = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
|
||||
fs::write(path, body)
|
||||
}
|
||||
|
||||
pub fn codex_home_path(&self) -> PathBuf {
|
||||
PathBuf::from(&self.codex_home)
|
||||
}
|
||||
|
||||
pub fn resolve_codex_executable(&self) -> String {
|
||||
if let Some(path) = self
|
||||
.codex_executable
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return path.clone();
|
||||
}
|
||||
|
||||
for candidate in codex_executable_candidates() {
|
||||
if candidate.exists() {
|
||||
return candidate.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
"codex".to_string()
|
||||
}
|
||||
|
||||
fn normalize(&mut self) {
|
||||
if self.refresh_interval_secs < 15 {
|
||||
self.refresh_interval_secs = 15;
|
||||
}
|
||||
if self.codex_home.trim().is_empty() {
|
||||
self.codex_home = default_codex_home().to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("CodexLimitMonitor")
|
||||
.join("settings.json")
|
||||
}
|
||||
|
||||
fn default_codex_home() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".codex")
|
||||
}
|
||||
|
||||
fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(app_data) = std::env::var_os("APPDATA") {
|
||||
candidates.push(Path::new(&app_data).join("npm").join("codex.cmd"));
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
Path::new("C:\\Program Files")
|
||||
.join("nodejs")
|
||||
.join("codex.cmd"),
|
||||
);
|
||||
candidates.push(Path::new("C:\\Program Files").join("nodejs").join("codex"));
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(
|
||||
home.join(".config")
|
||||
.join("herd")
|
||||
.join("bin")
|
||||
.join("nvm")
|
||||
.join("v25.2.1")
|
||||
.join("node_modules")
|
||||
.join(".bin")
|
||||
.join("codex.cmd"),
|
||||
);
|
||||
candidates.push(
|
||||
home.join(".config")
|
||||
.join("herd")
|
||||
.join("bin")
|
||||
.join("nvm")
|
||||
.join("v25.2.1")
|
||||
.join("node_modules")
|
||||
.join("@openai")
|
||||
.join("codex")
|
||||
.join("bin")
|
||||
.join("codex.js"),
|
||||
);
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
120
src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::{models::MonitorSnapshot, show_dashboard};
|
||||
use chrono::{Local, TimeZone};
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Emitter, Manager,
|
||||
};
|
||||
|
||||
const TRAY_ID: &str = "codex-limit-monitor";
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let open = MenuItem::with_id(app, "open", "Open Dashboard", true, None::<&str>)?;
|
||||
let refresh = MenuItem::with_id(app, "refresh", "Refresh Now", true, None::<&str>)?;
|
||||
let badge = MenuItem::with_id(app, "badge", "Toggle Badge", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &refresh, &badge, &quit])?;
|
||||
|
||||
TrayIconBuilder::with_id(TRAY_ID)
|
||||
.icon(app_tray_icon())
|
||||
.tooltip("Codex Limit Monitor")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"open" => {
|
||||
let _ = show_dashboard(app);
|
||||
}
|
||||
"refresh" => {
|
||||
let app = app.clone();
|
||||
let monitor = app
|
||||
.try_state::<crate::AppState>()
|
||||
.map(|state| state.monitor.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(monitor) = monitor {
|
||||
let _ = monitor.refresh(Some(app.clone())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"badge" => {
|
||||
let app = app.clone();
|
||||
let monitor = app
|
||||
.try_state::<crate::AppState>()
|
||||
.map(|state| state.monitor.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(monitor) = monitor {
|
||||
let mut settings = monitor.settings().await;
|
||||
settings.badge_visible = !settings.badge_visible;
|
||||
if settings.save().is_ok() {
|
||||
monitor.replace_settings(settings.clone()).await;
|
||||
let _ = crate::apply_badge_visibility(&app, settings.badge_visible);
|
||||
let _ = app.emit("settings_updated", &settings);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let _ = show_dashboard(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_tray(app: &AppHandle, snapshot: &MonitorSnapshot) {
|
||||
let Some(tray) = app.tray_by_id(TRAY_ID) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = tray.set_tooltip(Some(&tooltip(snapshot)));
|
||||
}
|
||||
|
||||
fn app_tray_icon() -> Image<'static> {
|
||||
Image::from_bytes(include_bytes!("../icons/icon.png")).expect("valid embedded tray icon")
|
||||
}
|
||||
|
||||
fn tooltip(snapshot: &MonitorSnapshot) -> String {
|
||||
if let Some(bucket) = snapshot.buckets.first() {
|
||||
let reset = bucket
|
||||
.resets_at
|
||||
.map(format_reset_datetime)
|
||||
.map(|value| format!(", resets {value}"))
|
||||
.unwrap_or_default();
|
||||
let weekly_reset = bucket
|
||||
.secondary_resets_at
|
||||
.map(format_reset_datetime)
|
||||
.map(|value| format!(", weekly resets {value}"))
|
||||
.unwrap_or_default();
|
||||
let weekly = bucket
|
||||
.secondary_used_percent
|
||||
.map(|percent| format!("{percent:.0}% weekly"))
|
||||
.unwrap_or_else(|| "weekly unavailable".to_string());
|
||||
format!(
|
||||
"Codex: {:.0}% current, {}{}{}",
|
||||
bucket.used_percent, weekly, reset, weekly_reset
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Codex usage: {} tokens today ({})",
|
||||
snapshot.usage_summary.today_tokens, snapshot.live_status.state
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reset_datetime(timestamp: i64) -> String {
|
||||
Local
|
||||
.timestamp_opt(timestamp, 0)
|
||||
.single()
|
||||
.map(|date| date.format("%b %-d, %I:%M %p").to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
54
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex Limit Monitor",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.meghdad.codex-limit-monitor",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Codex Limit Monitor",
|
||||
"width": 1180,
|
||||
"height": 760,
|
||||
"minWidth": 900,
|
||||
"minHeight": 620,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
},
|
||||
{
|
||||
"label": "badge",
|
||||
"title": "Codex Badge",
|
||||
"url": "badge.html",
|
||||
"width": 164,
|
||||
"height": 82,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"shadow": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"resizable": false,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png",
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png"
|
||||
]
|
||||
}
|
||||
}
|
||||