This commit is contained in:
2026-05-17 03:53:15 +03:30
commit 329ddc9ced
41 changed files with 5558 additions and 0 deletions

35
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
src-tauri/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

BIN
src-tauri/icons/icon-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src-tauri/icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
src-tauri/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src-tauri/icons/icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

248
src-tauri/src/codex_rpc.rs Normal file
View 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
View 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");
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]
}
}