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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"preview": "vite preview",
"tauri": "sh ./scripts/tauri.sh",
"lan": "pnpm build && cargo run --manifest-path src-tauri/Cargo.toml --bin codex-web",
"desktop:update": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-desktop.ps1",
"desktop:update:no-launch": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-desktop.ps1 -NoLaunch",
"version:bump": "node scripts/bump-version.mjs",
"version:patch": "node scripts/bump-version.mjs patch",
"version:minor": "node scripts/bump-version.mjs minor",
Expand Down
76 changes: 76 additions & 0 deletions scripts/install-desktop.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
param(
[switch]$NoLaunch
)

$ErrorActionPreference = "Stop"

$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$installDir = Join-Path $env:LOCALAPPDATA "Codex Switcher"
$releaseDir = Join-Path $repoRoot "src-tauri\target\release"
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"

function New-Shortcut {
param(
[string]$ShortcutPath,
[string]$TargetPath,
[string]$WorkingDirectory
)

$parent = Split-Path -Parent $ShortcutPath
New-Item -ItemType Directory -Path $parent -Force | Out-Null

$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($ShortcutPath)
$shortcut.TargetPath = $TargetPath
$shortcut.WorkingDirectory = $WorkingDirectory
$shortcut.IconLocation = $TargetPath
$shortcut.Save()
}

Push-Location $repoRoot
try {
Get-Process -Name codex-switcher,codex-web -ErrorAction SilentlyContinue |
Stop-Process -Force

pnpm exec tauri build --no-bundle

New-Item -ItemType Directory -Path $installDir -Force | Out-Null

foreach ($name in @("codex-switcher.exe", "codex-web.exe")) {
$source = Join-Path $releaseDir $name
$target = Join-Path $installDir $name

if (!(Test-Path -LiteralPath $source)) {
throw "Missing release binary: $source"
}

if (Test-Path -LiteralPath $target) {
Copy-Item -LiteralPath $target -Destination "$target.bak-$timestamp" -Force
}

Copy-Item -LiteralPath $source -Destination $target -Force
$item = Get-Item -LiteralPath $target
Write-Host "Installed $($item.FullName) ($($item.Length) bytes)"
}

$exe = Join-Path $installDir "codex-switcher.exe"
$desktopShortcut = Join-Path ([Environment]::GetFolderPath("DesktopDirectory")) "Codex Switcher.lnk"
$startMenuShortcut = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Codex Switcher.lnk"

New-Shortcut -ShortcutPath $desktopShortcut -TargetPath $exe -WorkingDirectory $installDir
New-Shortcut -ShortcutPath $startMenuShortcut -TargetPath $exe -WorkingDirectory $installDir

Write-Host "Updated shortcuts:"
Write-Host "- $desktopShortcut"
Write-Host "- $startMenuShortcut"

if (!$NoLaunch) {
$process = Start-Process -FilePath $exe -PassThru
Start-Sleep -Seconds 2
$running = Get-Process -Id $process.Id
Write-Host "Launched $($running.ProcessName) PID $($running.Id)"
}
}
finally {
Pop-Location
}
11 changes: 11 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ url = "2"
flate2 = "1"
chacha20poly1305 = "0.10"
pbkdf2 = "0.12"

[target.'cfg(windows)'.dependencies]
junction = "1"
1 change: 1 addition & 0 deletions src-tauri/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! API client module

pub mod usage;
pub mod usage_poller;

pub use usage::*;
68 changes: 62 additions & 6 deletions src-tauri/src/api/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ pub async fn fetch_chatgpt_account_metadata(
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Accounts check API error: {status} - {body}");
anyhow::bail!(
"{}",
format_chatgpt_api_error("Accounts check", status, &body)
);
}

let payload: AccountsCheckResponse = response
Expand Down Expand Up @@ -166,11 +169,9 @@ async fn parse_usage_response(

if !status.is_success() {
let body = response.text().await.unwrap_or_default();
println!("[Usage] Error response: {body}");
return Ok(UsageInfo::error(
account_id.to_string(),
format!("API error: {status}"),
));
let message = format_chatgpt_api_error("Usage", status, &body);
println!("[Usage] Error response: {}", truncate_text(&message, 300));
return Ok(UsageInfo::error(account_id.to_string(), message));
}

let body_text = response
Expand Down Expand Up @@ -395,6 +396,42 @@ fn truncate_text(text: &str, max_len: usize) -> String {
out
}

fn format_chatgpt_api_error(operation: &str, status: StatusCode, body: &str) -> String {
if is_cloudflare_challenge(body) {
return format!(
"{operation} blocked by ChatGPT Cloudflare challenge ({status}). Open chatgpt.com, complete the browser check or sign in again, then retry."
);
}

let trimmed = body.trim();
if trimmed.is_empty() {
return format!("{operation} failed: {status}");
}

if looks_like_html(trimmed) {
return format!(
"{operation} failed: {status} (ChatGPT returned an HTML page instead of API JSON). Sign in again, then retry."
);
}

format!(
"{operation} failed: {status} - {}",
truncate_text(trimmed, 240)
)
}

fn is_cloudflare_challenge(body: &str) -> bool {
let body = body.to_ascii_lowercase();
body.contains("__cf_chl")
|| body.contains("challenge-platform")
|| body.contains("enable javascript and cookies to continue")
}

fn looks_like_html(body: &str) -> bool {
let body = body.to_ascii_lowercase();
body.starts_with("<!doctype html") || body.starts_with("<html") || body.contains("<body")
}

fn extract_text_from_sse(body: &str) -> Option<String> {
let mut last_text: Option<String> = None;
for line in body.lines() {
Expand Down Expand Up @@ -511,3 +548,22 @@ pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec<UsageInfo> {
println!("[Usage] Refresh complete");
results
}

#[cfg(test)]
mod tests {
use super::format_chatgpt_api_error;
use reqwest::StatusCode;

#[test]
fn cloudflare_challenge_error_is_short_and_actionable() {
let html = r#"<html><body><noscript>Enable JavaScript and cookies to continue</noscript><script src="/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1"></script></body></html>"#;

let message = format_chatgpt_api_error("Accounts check", StatusCode::FORBIDDEN, html);

assert!(message.contains("Cloudflare challenge"));
assert!(message.contains("403 Forbidden"));
assert!(message.contains("sign in again"));
assert!(!message.contains("<html>"));
assert!(message.len() < 220);
}
}
146 changes: 146 additions & 0 deletions src-tauri/src/api/usage_poller.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Background Usage Poller — auto-refreshes quota data at configurable intervals.
//!
//! Inspired by cockpit-tools `codex_quota.rs`.
//! Periodically polls `wham/usage` for all ChatGPT accounts and emits
//! Tauri events so the frontend can update in realtime.

use std::sync::atomic::{AtomicBool, Ordering};

use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};

use super::usage::refresh_all_usage;
use crate::auth::load_accounts;
use crate::types::AuthData;

/// Default polling interval in minutes.
const DEFAULT_INTERVAL_MINUTES: u64 = 5;

/// Minimum allowed interval (prevent abuse).
const MIN_INTERVAL_MINUTES: u64 = 1;

/// Global state for the poller.
struct PollerState {
running: AtomicBool,
interval_minutes: Mutex<u64>,
stop_signal: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
}

static POLLER: std::sync::OnceLock<PollerState> = std::sync::OnceLock::new();

fn get_poller() -> &'static PollerState {
POLLER.get_or_init(|| PollerState {
running: AtomicBool::new(false),
interval_minutes: Mutex::new(DEFAULT_INTERVAL_MINUTES),
stop_signal: Mutex::new(None),
})
}

/// Start the auto-usage-poll background loop.
/// Returns `true` if started, `false` if already running.
pub async fn start_polling(app_handle: AppHandle, interval_minutes: Option<u64>) -> bool {
let poller = get_poller();

if poller.running.load(Ordering::SeqCst) {
println!("[UsagePoller] Already running, skipping duplicate start");
return false;
}

let interval = interval_minutes
.unwrap_or(DEFAULT_INTERVAL_MINUTES)
.max(MIN_INTERVAL_MINUTES);
*poller.interval_minutes.lock().await = interval;

let (tx, rx) = tokio::sync::watch::channel(false);
*poller.stop_signal.lock().await = Some(tx);
poller.running.store(true, Ordering::SeqCst);

println!("[UsagePoller] Starting auto-poll every {interval} minutes");

tauri::async_runtime::spawn(async move {
run_poll_loop(app_handle, rx).await;
});

true
}

/// Stop the auto-usage-poll background loop.
/// Returns `true` if stopped, `false` if wasn't running.
pub async fn stop_polling() -> bool {
let poller = get_poller();

if !poller.running.load(Ordering::SeqCst) {
return false;
}

if let Some(tx) = poller.stop_signal.lock().await.take() {
let _ = tx.send(true);
}
poller.running.store(false, Ordering::SeqCst);
println!("[UsagePoller] Stopped");
true
}

/// Check if the poller is currently active.
pub fn is_running() -> bool {
get_poller().running.load(Ordering::SeqCst)
}

async fn run_poll_loop(app_handle: AppHandle, mut stop_rx: tokio::sync::watch::Receiver<bool>) {
loop {
let interval = {
let minutes = get_poller().interval_minutes.lock().await;
Duration::from_secs(*minutes * 60)
};

// Wait for the interval OR a stop signal
tokio::select! {
_ = sleep(interval) => {}
_ = stop_rx.changed() => {
println!("[UsagePoller] Received stop signal");
break;
}
}

// Check if we should stop
if *stop_rx.borrow() {
break;
}

println!("[UsagePoller] Auto-polling usage for all accounts...");

let accounts = match load_accounts() {
Ok(store) => store.accounts,
Err(err) => {
println!("[UsagePoller] Failed to load accounts: {err}");
continue;
}
};

// Only poll ChatGPT accounts (API key accounts don't have usage info)
let chatgpt_accounts: Vec<_> = accounts
.into_iter()
.filter(|a| matches!(a.auth_data, AuthData::ChatGPT { .. }))
.collect();

if chatgpt_accounts.is_empty() {
continue;
}

let results = refresh_all_usage(&chatgpt_accounts).await;

// Emit event to frontend
if let Err(err) = app_handle.emit("usage-auto-refreshed", &results) {
println!("[UsagePoller] Failed to emit event: {err}");
} else {
println!(
"[UsagePoller] Emitted usage-auto-refreshed ({} accounts)",
results.len()
);
}
}

get_poller().running.store(false, Ordering::SeqCst);
println!("[UsagePoller] Loop exited");
}
Loading