From e7e44791f1e4be0b2936a41f1df4c3346d746d2a Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:21:50 -0700 Subject: [PATCH 1/6] feat(desktop): write WidgetKit snapshots Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/proxy.rs | 12 + desktop/src-tauri/src/tray.rs | 10 +- desktop/src-tauri/src/widget.rs | 507 ++++++++++++++++++++++++++++++++ 6 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/widget.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 22861cd63e..bc5c4cf389 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2504,6 +2504,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tokio", + "uuid", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9f0c0879c5..49ecab73e5 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-build = { version = "=2.6.3", features = [] } reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "=1.0.219", features = ["derive"] } serde_json = "=1.0.140" +uuid = { version = "=1.18.1", features = ["v4"] } tauri = { version = "=2.11.6", features = ["tray-icon", "image-png"] } tauri-plugin-autostart = "=2.5.0" tauri-plugin-opener = "=2.5.3" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bea1d5d852..cc1758de3a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod formatting; mod proxy; mod sidecar; mod tray; +mod widget; mod window; use std::sync::{ diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index 5101b73d6a..fc8dc71ec5 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -46,10 +46,22 @@ impl ProxyClient { self.get("/api/usage?range=7d").await } + pub async fn usage_today(&self) -> Result { + self.get("/api/usage?range=today").await + } + + pub async fn startup_health(&self) -> Result { + self.get("/api/startup-health").await + } + pub async fn quotas(&self) -> Result { self.get("/api/provider-quotas").await } + pub async fn timeline(&self, query: &str) -> Result { + self.get(&format!("/api/usage/timeline?{query}")).await + } + pub async fn stop(&self) -> Result { self.request(Method::POST, "/api/stop").await } diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 740231d0d4..148b026217 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -1,4 +1,4 @@ -use crate::{formatting, proxy::ProxyClient, window}; +use crate::{formatting, proxy::ProxyClient, widget, window}; use serde_json::Value; use std::sync::atomic::Ordering; use tauri::{ @@ -102,11 +102,17 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { .build(app)?; refresh_title(&tray, &proxy); + widget::refresh(&proxy); let tray = tray.clone(); tauri::async_runtime::spawn(async move { + let mut tick = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; refresh_title(&tray, &proxy); + tick += 1; + if tick % 5 == 0 { + widget::refresh(&proxy); + } } }); Ok(()) @@ -129,7 +135,7 @@ fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) { }); } -fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option { +pub(crate) fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option { let metric = settings .pointer("/settings/menuBarMetric") .and_then(Value::as_str) diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs new file mode 100644 index 0000000000..30d5b5326f --- /dev/null +++ b/desktop/src-tauri/src/widget.rs @@ -0,0 +1,507 @@ +#[cfg(target_os = "macos")] +mod macos { + use crate::{ + proxy::{ProxyClient, ProxyError}, + tray, + }; + use serde::Serialize; + use serde_json::{json, Value}; + use std::{ + collections::HashSet, + fs, + path::PathBuf, + sync::{Mutex, OnceLock}, + time::{SystemTime, UNIX_EPOCH}, + }; + use uuid::Uuid; + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Today { + requests: Option, + total_tokens: Option, + estimated_cost_usd: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Quota { + provider_label: String, + window_label: String, + percent: Option, + reset_at: Option, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Series { + id: String, + points: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Chart { + start: f64, + bucket_seconds: i64, + style: String, + series: Vec, + } + + #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)] + #[serde(rename_all = "camelCase")] + struct Snapshot { + schema_version: i64, + state: String, + state_title: String, + detail: Option, + endpoint_display: String, + menu_title: Option, + today: Option, + quotas: Vec, + chart: Option, + last_updated: Option, + generated_at: f64, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ErrorKind { + Unreachable, + Unauthorized, + Http, + Decode, + } + + fn state_for_error( + kind: ErrorKind, + detail: Option, + ) -> (&'static str, &'static str, Option) { + match kind { + ErrorKind::Unreachable => ( + "unreachable", + "Stopped", + Some("The proxy is not running.".into()), + ), + ErrorKind::Unauthorized => ( + "unauthorized", + "Needs API key", + Some("This proxy requires an API key.".into()), + ), + ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail), + } + } + + fn proxy_error(error: &ProxyError) -> (ErrorKind, Option) { + match error { + ProxyError::Unreachable => (ErrorKind::Unreachable, None), + ProxyError::Unauthorized => (ErrorKind::Unauthorized, None), + ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))), + ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())), + } + } + + fn number(value: Option<&Value>) -> Option { + value.and_then(Value::as_f64) + } + + fn integer(value: Option<&Value>) -> Option { + value.and_then(Value::as_i64) + } + + fn reset_at(value: Option<&Value>) -> Option { + let value = number(value)?; + Some(if value >= 1_000_000_000_000.0 { + value / 1000.0 + } else { + value + }) + } + + fn quotas(value: &Value) -> Vec { + let Some(reports) = value.get("reports").and_then(Value::as_array) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for report in reports { + let provider_label = report + .get("label") + .or_else(|| report.get("provider")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + let Some(quota) = report.get("quota") else { + continue; + }; + let mut push = |percent: Option<&Value>, window_label: &str, reset: Option<&Value>| { + if percent.is_some() || reset.is_some() { + rows.push(Quota { + provider_label: provider_label.clone(), + window_label: window_label.to_owned(), + percent: number(percent), + reset_at: reset_at(reset), + }); + } + }; + push( + quota.get("fiveHourPercent"), + "5h", + quota.get("fiveHourResetAt"), + ); + push( + quota.get("weeklyPercent"), + "week", + quota.get("weeklyResetAt"), + ); + push( + quota.get("monthlyPercent"), + "month", + quota.get("monthlyResetAt"), + ); + if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) { + for window in windows { + let label = window + .get("label") + .and_then(Value::as_str) + .unwrap_or("window"); + push(window.get("percent"), label, window.get("resetAt")); + } + } + } + rows + } + + fn chart(value: &Value, settings: &Value) -> Option { + let start = number(value.get("start"))?; + let bucket_seconds = integer(value.get("bucketSeconds"))?; + let settings = settings.get("settings").unwrap_or(settings); + let style = settings + .get("chartStyle") + .and_then(Value::as_str) + .unwrap_or("line") + .to_owned(); + let series = value + .get("series") + .and_then(Value::as_array)? + .iter() + .take(6) + .filter_map(|item| { + Some(Series { + id: item.get("id")?.as_str()?.to_owned(), + points: item + .get("points")? + .as_array()? + .iter() + .filter_map(Value::as_f64) + .collect(), + }) + }) + .collect(); + Some(Chart { + start, + bucket_seconds, + style, + series, + }) + } + + fn timeline_query(settings: &Value) -> String { + let settings = settings.get("settings").unwrap_or(settings); + let get = |key: &str, fallback: &str| { + settings + .get(key) + .and_then(Value::as_str) + .unwrap_or(fallback) + .to_owned() + }; + let hours = settings + .get("chartHours") + .and_then(Value::as_i64) + .unwrap_or(24); + let bucket_minutes = settings + .get("bucketMinutes") + .and_then(Value::as_i64) + .unwrap_or(60); + let metric = get("tokenMetric", "total"); + let aggregation = get("aggregation", "sum"); + let grouping = get("chartGrouping", "model"); + let mut query = format!( + "hours={hours}&bucketMinutes={bucket_minutes}&metric={metric}&aggregation={aggregation}&grouping={grouping}" + ); + if let Some(models) = settings.get("models").and_then(Value::as_array) { + let models = models + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(","); + if !models.is_empty() { + query.push_str("&models="); + query.push_str(&models); + } + } + query + } + + fn snapshot_path() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + home.join("Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json") + } + + fn now_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + } + + fn log_once(kind: &str, message: &str) { + static LOGGED: OnceLock>> = OnceLock::new(); + let logged = LOGGED.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut logged) = logged.lock() { + if logged.insert(kind.to_owned()) { + eprintln!("widget snapshot {kind} failed: {message}"); + } + } + } + + fn without_generated_at(snapshot: &Snapshot) -> Snapshot { + let mut snapshot = snapshot.clone(); + snapshot.generated_at = 0.0; + snapshot + } + + fn write_if_changed( + path: &std::path::Path, + previous: Option<&Snapshot>, + snapshot: &Snapshot, + ) -> std::io::Result { + if previous.map(without_generated_at).as_ref() == Some(&without_generated_at(snapshot)) { + return Ok(false); + } + let Some(directory) = path.parent() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "snapshot path has no parent", + )); + }; + fs::create_dir_all(directory)?; + let bytes = serde_json::to_vec(snapshot).map_err(std::io::Error::other)?; + let temporary = directory.join(format!(".snapshot-{}.tmp", Uuid::new_v4())); + fs::write(&temporary, bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + } + fs::rename(temporary, path)?; + Ok(true) + } + + fn make_snapshot( + proxy: &ProxyClient, + settings: &Value, + health: &Value, + today: Option<&Value>, + quota_value: Option<&Value>, + timeline_value: Option<&Value>, + ) -> Snapshot { + let endpoint = proxy.endpoint(); + let detail = { + let parts = [health.get("status"), health.get("protection")] + .into_iter() + .filter_map(|value| value.and_then(Value::as_str)) + .filter(|part| !part.is_empty() && *part != "none") + .collect::>(); + (!parts.is_empty()).then(|| parts.join(" · ")) + }; + let today_snapshot = today + .and_then(|value| value.get("summary").or(Some(value))) + .map(|summary| Today { + requests: integer(summary.get("requests")), + total_tokens: integer(summary.get("totalTokens")), + estimated_cost_usd: number(summary.get("estimatedCostUsd")), + }); + let quotas_value = quota_value.unwrap_or(&Value::Null); + let menu_title = tray::render_title(settings, today.unwrap_or(&Value::Null), quotas_value); + let chart = timeline_value.and_then(|value| chart(value, settings)); + Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail, + endpoint_display: format!("{}:{}", endpoint.host, endpoint.port), + menu_title, + today: today_snapshot, + quotas: quotas(quotas_value), + chart, + last_updated: timeline_value.map(|_| now_seconds()), + generated_at: now_seconds(), + } + } + + pub async fn write(proxy: ProxyClient) { + let health = match proxy.startup_health().await { + Ok(value) => value, + Err(error) => { + let (kind, detail) = proxy_error(&error); + let (state, state_title, detail) = state_for_error(kind, detail); + let snapshot = Snapshot { + schema_version: 1, + state: state.into(), + state_title: state_title.into(), + detail, + endpoint_display: format!( + "{}:{}", + proxy.endpoint().host, + proxy.endpoint().port + ), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: now_seconds(), + }; + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + log_once("write", &error.to_string()); + } + log_once("health", state); + return; + } + }; + let settings = proxy + .companion_settings() + .await + .unwrap_or_else(|_| json!({ "settings": {} })); + let today = proxy.usage_today().await.ok(); + let quota_value = proxy.quotas().await.ok(); + let timeline_value = proxy.timeline(&timeline_query(&settings)).await.ok(); + let snapshot = make_snapshot( + &proxy, + &settings, + &health, + today.as_ref(), + quota_value.as_ref(), + timeline_value.as_ref(), + ); + let path = snapshot_path(); + let previous = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) { + log_once("write", &error.to_string()); + } + } + + pub fn refresh(proxy: &ProxyClient) { + let proxy = proxy.clone(); + tauri::async_runtime::spawn(async move { write(proxy).await }); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn serialization_uses_swift_field_names() { + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: Some("ok".into()), + endpoint_display: "127.0.0.1:10100".into(), + menu_title: Some("2K".into()), + today: Some(Today { + requests: Some(2), + total_tokens: Some(1234), + estimated_cost_usd: Some(0.12), + }), + quotas: vec![Quota { + provider_label: "OpenAI".into(), + window_label: "week".into(), + percent: Some(10.0), + reset_at: Some(1.0), + }], + chart: Some(Chart { + start: 1.0, + bucket_seconds: 3600, + style: "line".into(), + series: vec![Series { + id: "openai/gpt".into(), + points: vec![1.0, 2.0], + }], + }), + last_updated: Some(2.0), + generated_at: 3.0, + }; + assert_eq!( + serde_json::to_string(&snapshot).unwrap(), + r#"{"schemaVersion":1,"state":"running","stateTitle":"Running","detail":"ok","endpointDisplay":"127.0.0.1:10100","menuTitle":"2K","today":{"requests":2,"totalTokens":1234,"estimatedCostUsd":0.12},"quotas":[{"providerLabel":"OpenAI","windowLabel":"week","percent":10.0,"resetAt":1.0}],"chart":{"start":1.0,"bucketSeconds":3600,"style":"line","series":[{"id":"openai/gpt","points":[1.0,2.0]}]},"lastUpdated":2.0,"generatedAt":3.0}"# + ); + } + + #[test] + fn error_state_mapping_covers_four_kinds() { + assert_eq!( + state_for_error(ErrorKind::Unreachable, None).0, + "unreachable" + ); + assert_eq!( + state_for_error(ErrorKind::Unauthorized, None).0, + "unauthorized" + ); + assert_eq!( + state_for_error(ErrorKind::Http, Some("HTTP 500".into())).0, + "degraded" + ); + assert_eq!( + state_for_error(ErrorKind::Decode, Some("bad".into())).0, + "degraded" + ); + } + + #[test] + fn write_if_changed_ignores_generated_at() { + let path = std::env::temp_dir().join(format!("ocx-widget-{}.json", std::process::id())); + let snapshot = Snapshot { + schema_version: 1, + state: "running".into(), + state_title: "Running".into(), + detail: None, + endpoint_display: "127.0.0.1:10100".into(), + menu_title: None, + today: None, + quotas: Vec::new(), + chart: None, + last_updated: None, + generated_at: 1.0, + }; + assert!(write_if_changed(&path, None, &snapshot).unwrap()); + let mut changed = snapshot.clone(); + changed.generated_at = 2.0; + assert!(!write_if_changed(&path, Some(&snapshot), &changed).unwrap()); + let _ = fs::remove_file(path); + } + + #[test] + fn chart_series_are_truncated_to_six() { + let series = (0..8) + .map(|index| json!({ "id": index.to_string(), "points": [1] })) + .collect::>(); + let value = json!({ "start": 1, "bucketSeconds": 60, "series": series }); + let result = chart(&value, &json!({ "settings": { "chartStyle": "line" } })).unwrap(); + assert_eq!(result.series.len(), 6); + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::refresh; + +#[cfg(not(target_os = "macos"))] +pub(crate) fn refresh(_: &crate::proxy::ProxyClient) {} From 2f9de0fbb7a06ac05864777af9aac230f79dc96b Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:21:54 -0700 Subject: [PATCH 2/6] feat(desktop): bundle WidgetKit appex Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 21 +++++---- .github/workflows/release.yml | 76 ------------------------------- .gitignore | 1 + desktop/README.md | 12 ++++- desktop/package.json | 3 +- desktop/scripts/build-widget.sh | 76 +++++++++++++++++++++++++++++++ desktop/src-tauri/tauri.conf.json | 5 +- 7 files changed, 107 insertions(+), 87 deletions(-) create mode 100755 desktop/scripts/build-widget.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5954a56970..83a4f9c2d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1151,8 +1151,8 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. - macos-app: - name: macos app + widget: + name: macos widget needs: [changes, gates] if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' runs-on: macos-latest @@ -1171,11 +1171,16 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Test macOS menu bar app + - name: Test MenuBarCore run: bun run test:macos - - name: Build macOS menu bar app - run: bun run build:macos + - name: Build WidgetKit appex + run: bash desktop/scripts/build-widget.sh + + - name: Verify WidgetKit appex + run: | + test -x desktop/src-tauri/widget/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget + codesign -dv desktop/src-tauri/widget/OpenCodexWidget.appex desktop-shell: name: desktop shell @@ -1225,7 +1230,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, macos-app, desktop-shell] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, widget, desktop-shell] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1291,13 +1296,13 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate macos-app" + GATED_JOBS="$GATED_JOBS structure-gate widget" GATED_JOBS="$GATED_JOBS desktop-shell" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|widget) echo "$scoped" ;; desktop-shell) echo "$scoped" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a15d56d404..6669d8e8fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,47 +69,6 @@ jobs: process.exit(1); } NODE - package-macos: - needs: validate-dispatch - runs-on: macos-latest - timeout-minutes: 20 - permissions: - contents: read - outputs: - archive_name: ${{ steps.package.outputs.archive_name }} - checksum_name: ${{ steps.package.outputs.checksum_name }} - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - persist-credentials: false - - - name: Package the macOS companion - id: package - env: - RELEASE_VERSION: ${{ inputs.version }} - UNIVERSAL: "1" - # A monotonic numeric CFBundleVersion. Preview versions carry a suffix that - # Apple does not accept in that field, so the script uses the numeric core - # plus this run number. - MACOS_BUILD_NUMBER: ${{ github.run_number }} - # NOTE: intentionally no MACOS_SIGN_IDENTITY here. The build script honours - # it, but an identity NAME alone cannot sign on a hosted runner — the - # certificate and private key are never imported into a keychain, so codesign - # fails with "no identity found". Real Developer ID signing needs a protected - # P12 import, a temporary keychain, notarytool credentials, and stapling, all - # as one security-reviewed change. Until then the asset is ad-hoc signed and - # the docs carry the Gatekeeper first-launch path. - run: bash scripts/package-macos-release.sh - - - name: Upload the release asset - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: macos-release - path: dist/release/ - if-no-files-found: error - retention-days: 7 - package-standalone: needs: validate-dispatch strategy: @@ -212,41 +171,6 @@ jobs: if-no-files-found: error retention-days: 7 - attach-macos: - runs-on: ubuntu-latest - needs: [publish, package-macos, package-standalone] - if: ${{ inputs.dry-run != true }} - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Download the macOS packaged asset - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: macos-release - path: dist/release - - - name: Download standalone packaged assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: standalone-* - merge-multiple: true - path: dist/release - - - name: Verify the checksum before uploading - run: | - cd dist/release - shasum -a 256 -c ./*.sha256 - - - name: Attach to the release - env: - GH_TOKEN: ${{ github.token }} - # Workflow inputs reach shell code through env, never by interpolation into - # run: source. tests/ci-workflows.test.ts enforces this repo-wide. - RELEASE_VERSION: ${{ inputs.version }} - run: | - gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber - publish: needs: validate-dispatch runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7973851d2c..8f9c5f9fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -74,5 +74,6 @@ dist/macos/ dist/release/ desktop/src-tauri/binaries/ desktop/src-tauri/resources/ +desktop/src-tauri/widget/ desktop/src-tauri/gen/ desktop/src-tauri/target/ diff --git a/desktop/README.md b/desktop/README.md index 1367889fe0..e4621ef6e7 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -5,7 +5,8 @@ in the proxy's loopback origin. During development: ```sh bun run prepare-sidecar -bun run dev +bun run prepare-widget +bunx tauri dev ``` The sidecar is generated from the repository's standalone binary build and is @@ -15,3 +16,12 @@ The CI desktop-shell job performs Rust-only checks. It creates an empty platform-named sidecar stub and a placeholder dashboard resource directory solely for Tauri's external-binary and resource validation; it does not build or run the standalone binary. + +For a macOS release build, prepare the sidecar and WidgetKit extension before invoking +Tauri: + +```sh +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build +``` diff --git a/desktop/package.json b/desktop/package.json index 8b1bc19749..09668ce09b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -4,7 +4,8 @@ "scripts": { "dev": "tauri dev", "build": "tauri build", - "prepare-sidecar": "bun scripts/prepare-sidecar.ts" + "prepare-sidecar": "bun scripts/prepare-sidecar.ts", + "prepare-widget": "bash scripts/build-widget.sh" }, "devDependencies": { "@tauri-apps/cli": "2.5.0" diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh new file mode 100755 index 0000000000..59827dcea2 --- /dev/null +++ b/desktop/scripts/build-widget.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "prepare-widget requires macOS." >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +desktop_dir="$(cd "$script_dir/.." && pwd)" +repo_root="$(cd "$desktop_dir/.." && pwd)" +package_dir="$repo_root/app" +output_dir="$desktop_dir/src-tauri/widget/OpenCodexWidget.appex" +configuration="${CONFIGURATION:-release}" +universal="${UNIVERSAL:-1}" + +if [[ "$universal" != "0" && "$universal" != "1" ]]; then + echo "UNIVERSAL must be 0 or 1." >&2 + exit 1 +fi + +build_root="$(mktemp -d "${TMPDIR:-/tmp}/opencodex-widget.XXXXXX")" +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT + +build_widget() { + local arch="$1" + local scratch="$build_root/$arch" + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --product OpenCodexWidget + swift build \ + --package-path "$package_dir" \ + --scratch-path "$scratch" \ + -c "$configuration" \ + --arch "$arch" \ + --show-bin-path +} + +if [[ "$universal" == "1" ]]; then + arm64_bin="$(build_widget arm64 | tail -n 1)/OpenCodexWidget" + x86_64_bin="$(build_widget x86_64 | tail -n 1)/OpenCodexWidget" + executable="$build_root/OpenCodexWidget" + lipo -create "$arm64_bin" "$x86_64_bin" -output "$executable" +else + executable="$(build_widget "$(uname -m)" | tail -n 1)/OpenCodexWidget" +fi + +[[ -x "$executable" ]] || { echo "Swift build did not produce $executable" >&2; exit 1; } + +rm -rf "$output_dir" +mkdir -p "$output_dir/Contents/MacOS" +cp "$executable" "$output_dir/Contents/MacOS/OpenCodexWidget" +cp "$package_dir/Widget-Info.plist" "$output_dir/Contents/Info.plist" + +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$desktop_dir/src-tauri/tauri.conf.json" | head -n 1)" +version_core="${version%%-*}" +[[ "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid Tauri version: $version" >&2 + exit 1 +} +plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" + +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \ + --timestamp "$output_dir" +else + codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ + --timestamp=none "$output_dir" +fi + +echo "$output_dir" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b5eaa0f9d8..fb5dd306b9 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -25,7 +25,10 @@ "icons/icon.png" ], "macOS": { - "minimumSystemVersion": "13.0" + "minimumSystemVersion": "13.0", + "files": { + "PlugIns/OpenCodexWidget.appex": "widget/OpenCodexWidget.appex" + } }, "windows": { "webviewInstallMode": { From 69da0779710120fb2a575e6b50139918e45bc798 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:22:00 -0700 Subject: [PATCH 3/6] refactor(macOS): retire standalone menu bar app Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 5 +- README.md | 10 +- app/Info.plist | 42 -- app/Package.swift | 23 +- app/Sources/IconProbe/main.swift | 32 -- app/Sources/MenuBarApp/main.swift | 11 - app/Sources/MenuBarCore/Discovery.swift | 2 +- .../MenuBarCore/PollingCoordinator.swift | 2 +- app/Sources/MenuBarCore/ProxyClient.swift | 2 +- app/Sources/MenuBarCore/WidgetSnapshot.swift | 2 +- .../MenuBarCoreTests/TransportSuite.swift | 2 +- app/Sources/MenuBarUI/AppDelegate.swift | 254 ------------ app/Sources/MenuBarUI/CompanionViews.swift | 78 ---- app/Sources/MenuBarUI/PopoverPanel.swift | 170 -------- .../MenuBarUI/PopoverViewController.swift | 384 ------------------ app/Sources/MenuBarUI/ProviderListView.swift | 255 ------------ app/Sources/MenuBarUI/StatusIcon.swift | 73 ---- app/Sources/MenuBarUI/Theme.swift | 103 ----- app/Sources/MenuBarUI/TimelineChartView.swift | 135 ------ app/Sources/MenuBarUI/Views.swift | 264 ------------ app/Sources/MenuBarUITests/Harness.swift | 105 ----- app/Sources/MenuBarUITests/main.swift | 165 -------- app/Sources/OpenCodexWidget/Views.swift | 4 +- app/Sources/UIProbe/main.swift | 165 -------- app/Widget-Info.plist | 2 +- .../docs/fr/reference/cli/lifecycle.md | 1 + .../src/content/docs/guides/macos-menu-bar.md | 12 +- .../content/docs/ja/guides/macos-menu-bar.md | 16 +- .../docs/ja/reference/cli/lifecycle.md | 1 + .../content/docs/ko/guides/macos-menu-bar.md | 16 +- .../docs/ko/reference/cli/lifecycle.md | 2 + .../content/docs/reference/cli/lifecycle.md | 2 + .../content/docs/ru/guides/macos-menu-bar.md | 16 +- .../docs/ru/reference/cli/lifecycle.md | 2 + .../docs/tr/reference/cli/lifecycle.md | 2 + .../docs/zh-cn/guides/macos-menu-bar.md | 15 +- .../docs/zh-cn/reference/cli/lifecycle.md | 1 + .../docs/zh-tw/reference/cli/lifecycle.md | 1 + package.json | 4 +- readme/README.fr.md | 2 +- readme/README.ja.md | 2 +- readme/README.ko.md | 2 +- readme/README.ru.md | 2 +- readme/README.tr.md | 2 +- readme/README.zh-CN.md | 2 +- readme/README.zh-TW.md | 2 +- scripts/build-macos-app.sh | 268 ------------ scripts/package-macos-release.sh | 113 ------ scripts/test-layout/layout.json | 1 - structure/desktop-shell.md | 14 +- structure/overview.md | 8 +- tests/ci-workflows/ci-structure-gate.test.ts | 14 +- tests/fixtures/test-layout-expected.json | 1 - tests/gui/macos-build-script.test.ts | 201 --------- 54 files changed, 113 insertions(+), 2902 deletions(-) delete mode 100644 app/Info.plist delete mode 100644 app/Sources/IconProbe/main.swift delete mode 100644 app/Sources/MenuBarApp/main.swift delete mode 100644 app/Sources/MenuBarUI/AppDelegate.swift delete mode 100644 app/Sources/MenuBarUI/CompanionViews.swift delete mode 100644 app/Sources/MenuBarUI/PopoverPanel.swift delete mode 100644 app/Sources/MenuBarUI/PopoverViewController.swift delete mode 100644 app/Sources/MenuBarUI/ProviderListView.swift delete mode 100644 app/Sources/MenuBarUI/StatusIcon.swift delete mode 100644 app/Sources/MenuBarUI/Theme.swift delete mode 100644 app/Sources/MenuBarUI/TimelineChartView.swift delete mode 100644 app/Sources/MenuBarUI/Views.swift delete mode 100644 app/Sources/MenuBarUITests/Harness.swift delete mode 100644 app/Sources/MenuBarUITests/main.swift delete mode 100644 app/Sources/UIProbe/main.swift delete mode 100755 scripts/build-macos-app.sh delete mode 100755 scripts/package-macos-release.sh delete mode 100644 tests/gui/macos-build-script.test.ts diff --git a/AGENTS.md b/AGENTS.md index 78a63a3360..5fc447e7c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,8 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. -- `app/` — native macOS menu bar companion (Swift + AppKit, no third-party - dependencies). `MenuBarCore` is the testable transport/model layer, - `MenuBarUI` the AppKit views, `MenuBarApp` the entry point. Its tests are +- `app/` — native macOS WidgetKit extension bundled into the Tauri desktop app; + `MenuBarCore` is its snapshot model/formatting layer. Its tests are executables, not XCTest bundles — Command Line Tools ships neither a usable XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. diff --git a/README.md b/README.md index 88a6438b3d..8fb4456fd3 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,13 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### macOS menu bar app +### macOS desktop app and widget -A native companion for proxy status, usage, and provider quotas without opening the -dashboard. The source lives in [`app/`](./app) (Swift + AppKit, no third-party -dependencies). Download it from the +A native desktop app and WidgetKit extension for proxy status, usage, and provider +quotas without opening the dashboard. The snapshot model lives in [`app/`](./app) +(`MenuBarCore`). Download it from the [releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with -`bun run build:macos`. +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. The first launch needs a right-click → Open, because the app is ad-hoc signed rather than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) diff --git a/app/Info.plist b/app/Info.plist deleted file mode 100644 index 3d52873063..0000000000 --- a/app/Info.plist +++ /dev/null @@ -1,42 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - OpenCodexMenuBar - CFBundleIdentifier - com.opencodex.menubar - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - OpenCodex - CFBundleDisplayName - OpenCodex - CFBundlePackageType - APPL - CFBundleIconFile - OpenCodex - CFBundleShortVersionString - 0.0.0 - CFBundleVersion - 0.0.0 - LSUIElement - - LSMinimumSystemVersion - 13.0 - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - NSHumanReadableCopyright - MIT — opencodex contributors - - diff --git a/app/Package.swift b/app/Package.swift index 9e5f137275..0e14e3bd5b 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -2,26 +2,14 @@ import PackageDescription let package = Package( - name: "OpenCodexMenuBar", + name: "OpenCodexWidget", platforms: [.macOS(.v13)], products: [ - .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), - .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), - .executable(name: "UIProbe", targets: ["UIProbe"]), - .executable(name: "IconProbe", targets: ["IconProbe"]), ], targets: [ .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), - // AppKit views live in a library so both the app and the visual-QA probe can - // build the same surface. An executable target cannot be imported. - .target(name: "MenuBarUI", dependencies: ["MenuBarCore"], path: "Sources/MenuBarUI"), - .executableTarget( - name: "MenuBarApp", - dependencies: ["MenuBarCore", "MenuBarUI"], - path: "Sources/MenuBarApp" - ), .executableTarget( name: "OpenCodexWidget", dependencies: ["MenuBarCore"], @@ -41,15 +29,6 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/MenuBarCoreTests" ), - // UI-layer tests need AppKit and an NSApplication, so they are a separate - // executable from the dependency-free core suite. - .executableTarget( - name: "MenuBarUITests", - dependencies: ["MenuBarCore", "MenuBarUI"], - path: "Sources/MenuBarUITests" - ), - .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), - .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), ], swiftLanguageVersions: [.v5] ) diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift deleted file mode 100644 index f9a359114d..0000000000 --- a/app/Sources/IconProbe/main.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Renders every menu bar glyph state to one sheet so the state signal can be verified -// visually. The notch previously did not render at all, which made protected and -// at-risk indistinguishable. -import AppKit -import MenuBarCore -import MenuBarUI - -let states: [(String, ProxyState)] = [ - ("protected", .running(StartupHealth(status: "protected"))), - ("at-risk", .running(StartupHealth(status: "at-risk"))), - ("loading", .loading), - ("stopped", .unreachable), -] - -let scale: CGFloat = 6 -let cell = NSSize(width: 17 * scale, height: 17 * scale) -let sheet = NSImage(size: NSSize(width: cell.width * CGFloat(states.count), height: cell.height)) -sheet.lockFocus() -NSColor.white.setFill() -NSRect(origin: .zero, size: sheet.size).fill() -for (i, entry) in states.enumerated() { - let img = StatusIcon.image(for: entry.1) - let rect = NSRect(x: CGFloat(i) * cell.width, y: 0, width: cell.width, height: cell.height) - NSGraphicsContext.current?.imageInterpolation = .none - img.draw(in: rect.insetBy(dx: 8, dy: 8)) -} -sheet.unlockFocus() -if let tiff = sheet.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), - let png = rep.representation(using: .png, properties: [:]) { - try? png.write(to: URL(fileURLWithPath: "/tmp/glyphs.png")) -} -print("wrote /tmp/glyphs.png:", states.map(\.0).joined(separator: ", ")) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift deleted file mode 100644 index 710ac8c51b..0000000000 --- a/app/Sources/MenuBarApp/main.swift +++ /dev/null @@ -1,11 +0,0 @@ -import AppKit -import MenuBarUI - -let app = NSApplication.shared -// .accessory keeps it out of the Dock; LSUIElement in Info.plist does the same for the -// packaged bundle, and this covers `swift run` during development. -app.setActivationPolicy(.accessory) - -let delegate = AppDelegate() -app.delegate = delegate -app.run() diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift index 412a5ff5c0..23b34e1d0c 100644 --- a/app/Sources/MenuBarCore/Discovery.swift +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -57,7 +57,7 @@ public enum ProxyDiscovery { /// Reads `runtime-port.json`, falling back to the default port on any problem. /// /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to - /// the default rather than throwing. A menu bar app that refuses to start because a + /// the default rather than throwing. A desktop app that refuses to start because a /// cache file is unreadable would be worse than one that probes the usual port. public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { let file = directory.appendingPathComponent("runtime-port.json") diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 4125657a62..5154fd7e83 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -2,7 +2,7 @@ import Foundation /// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. /// -/// Polling is deliberately conservative. A menu bar app that hits a local server every +/// Polling is deliberately conservative. A desktop app that hits a local server every /// five seconds forever is a battery complaint waiting to happen, so heavy aggregation /// endpoints are fetched only while the popover is open, and repeated failures back the /// liveness tick off rather than hammering a proxy the user has stopped on purpose. diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 5318827814..e9a3198424 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -275,7 +275,7 @@ public actor ProxyClient { request.httpMethod = method request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - request.setValue("OpenCodexMenuBar/\(version)", forHTTPHeaderField: "User-Agent") + request.setValue("OpenCodexWidget/\(version)", forHTTPHeaderField: "User-Agent") if let credential = key ?? apiKey { request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") } diff --git a/app/Sources/MenuBarCore/WidgetSnapshot.swift b/app/Sources/MenuBarCore/WidgetSnapshot.swift index 3dc4f9cd98..6ea48c8978 100644 --- a/app/Sources/MenuBarCore/WidgetSnapshot.swift +++ b/app/Sources/MenuBarCore/WidgetSnapshot.swift @@ -127,7 +127,7 @@ public final class WidgetSnapshotStore: @unchecked Sendable { private var loggedFailures = Set() public init( - widgetBundleID: String = "com.opencodex.menubar.widget", + widgetBundleID: String = "com.opencodex.desktop.widget", fileManager: FileManager = .default, homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser ) { diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 570e10bdea..d46a667e0f 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -283,7 +283,7 @@ enum TransportSuite { let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" t.expect(url.contains("range=7d"), "expected range=7d in \(url)") t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") - t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexMenuBar/dev") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexWidget/dev") } t.test("requests: the provider patch sends exactly {\"disabled\":true}") { diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift deleted file mode 100644 index 7ca5c1d8e2..0000000000 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ /dev/null @@ -1,254 +0,0 @@ -import AppKit -import MenuBarCore - -public final class AppDelegate: NSObject, NSApplicationDelegate { - private var statusItem: NSStatusItem? - /// A key-capable panel rather than `NSPopover`. - /// - /// This is the single most-tested decision in this file. `NSPopover` from an - /// accessory (`LSUIElement`) process creates a window that never appears in - /// `NSApp.windows` and reports `canBecomeKey == false`, so macOS will not route key - /// events to it no matter how the process is activated — Escape and the Tab path - /// simply never arrive. A `nonactivatingPanel` that overrides `canBecomeKey` - /// measures as `canBecomeKey=1 isKey=1` under the same conditions. - private let panel = PopoverPanel() - private let controller = PopoverViewController() - private var coordinator: PollingCoordinator? - private var actions: ActionCoordinator? - private var client: ProxyClient? - private let widgetStore = WidgetSnapshotStore() - /// The snapshot the UI is currently showing, for decisions that need context - /// (the start command to display, the default provider to protect). - private var latest: ProxySnapshot? - private var endpoint = ProxyEndpoint.default - private var pollTask: Task? - /// Fallback Escape handling for the case where the panel is visible but another - /// process holds focus. Installed on open, removed on close. - private var escapeMonitor: Any? - - public override init() { super.init() } - - public func applicationDidFinishLaunching(_ notification: Notification) { - endpoint = ProxyDiscovery.resolve() - let client = ProxyClient(endpoint: endpoint) - self.client = client - let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - self.coordinator = coordinator - self.actions = ActionCoordinator(client: client) - - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.image = StatusIcon.image(for: .loading) - item.button?.imagePosition = .imageOnly - item.button?.target = self - item.button?.action = #selector(togglePopover) - item.button?.setAccessibilityLabel("OpenCodex proxy status") - statusItem = item - - controller.onDashboard = { [weak self] in self?.openDashboard() } - controller.onCompanionSettings = { [weak self] in self?.openCompanionSettings() } - controller.onStop = { [weak self] in self?.stopProxy() } - controller.onRefresh = { [weak self] in self?.refreshNow() } - controller.onAddKey = { [weak self] in self?.openDashboard() } - controller.onRetry = { [weak self] in self?.refreshNow() } - controller.onToggleProvider = { [weak self] name, disable in - self?.toggleProvider(name, disable: disable) - } - controller.onQuit = { NSApp.terminate(nil) } - - panel.contentViewController = controller - panel.onDismiss = { [weak self] in self?.handlePanelClosed() } - - // The observer closure is `@Sendable` and crosses actor boundaries, so it must - // not capture the delegate. It hops to the main actor and looks the delegate up - // there instead. - Task { - await coordinator.observe { snapshot in - Task { @MainActor in - (NSApp.delegate as? AppDelegate)?.render(snapshot) - } - } - await MainActor.run { (NSApp.delegate as? AppDelegate)?.startPolling() } - } - } - - public func applicationWillTerminate(_ notification: Notification) { - pollTask?.cancel() - removeEscapeMonitor() - panel.dismiss() - } - - // MARK: - Polling - - @MainActor - fileprivate func startPolling() { - guard let coordinator else { return } - pollTask?.cancel() - pollTask = Task { - while !Task.isCancelled { - await coordinator.refresh() - let interval = await coordinator.currentInterval - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - private func refreshNow() { - Task { [coordinator] in await coordinator?.refresh(includeHeavy: true) } - } - - @MainActor - fileprivate func render(_ snapshot: ProxySnapshot) { - latest = snapshot - let title = snapshot.menuBarTitle ?? "" - statusItem?.button?.title = title - statusItem?.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) - statusItem?.button?.imagePosition = title.isEmpty ? .imageOnly : .imageLeading - statusItem?.button?.image = StatusIcon.image(for: snapshot.state) - statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" - controller.apply(snapshot) - let widgetSnapshot = WidgetSnapshot.make(from: snapshot) - Task.detached { [widgetStore] in widgetStore.writeIfChanged(widgetSnapshot) } - } - - // MARK: - Actions - - #if DEBUG - /// Testing hook: drives the exact presentation path a status-item click uses, so a - /// harness can verify key focus and Escape without Accessibility permission. - /// Debug-only — it is not part of the shipped surface. - public func debugTogglePanel() { togglePopover() } - #endif - - @objc private func togglePopover() { - guard let button = statusItem?.button else { return } - if panel.isShown { - panel.dismiss() - } else { - panel.present(from: button) - installEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(true) } - } - } - - /// Called by the panel whenever it closes, however it was dismissed. - private func handlePanelClosed() { - removeEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(false) } - } - - /// The panel is key-capable, so `cancelOperation(_:)` handles Escape in the normal - /// case. This local monitor is belt-and-braces for the window where the panel is up - /// but focus sits elsewhere in this process, such as the confirmation sheet. - private func installEscapeMonitor() { - removeEscapeMonitor() - escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - // While a confirmation is up, Escape belongs to the alert: consuming it - // here dismissed the panel and stranded the alert with no way to cancel. - guard event.keyCode == 53, - self?.panel.isShown == true, - self?.panel.isPresentingModal == false - else { return event } - self?.panel.dismiss() - return nil - } - } - - - private func removeEscapeMonitor() { - if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) } - escapeMonitor = nil - } - - private func openDashboard() { - NSWorkspace.shared.open(endpoint.baseURL) - } - - private func openCompanionSettings() { - guard let url = URL(string: "\(endpoint.baseURL.absoluteString)/#/usage#usage-section-companion") else { return } - NSWorkspace.shared.open(url) - } - - /// Stopping is destructive: it interrupts in-flight requests and stops the launchd - /// service, so nothing restarts the proxy. It always confirms first. - private func stopProxy() { - let alert = NSAlert() - alert.messageText = "Stop the OpenCodex proxy?" - alert.informativeText = - "In-flight requests will be interrupted, and OpenCodex will not restart on its own." - alert.alertStyle = .warning - alert.addButton(withTitle: "Stop proxy") - alert.addButton(withTitle: "Cancel") - - // The alert takes key focus, which would otherwise trip resignKey and dismiss - // the panel behind it — leaving a user who chose Cancel with nothing. - panel.isPresentingModal = true - NSApp.activate(ignoringOtherApps: true) - let confirmed = alert.runModal() == .alertFirstButtonReturn - panel.isPresentingModal = false - - guard confirmed else { - panel.makeKeyAndOrderFront(nil) - return - } - - let startCommand = latest?.lastKnownStartCommand ?? "ocx start" - controller.showResult("Stopping…", isError: false) - - Task { [actions, coordinator] in - let outcome = await actions?.stop(startCommand: startCommand) ?? .failed("Unavailable.") - await coordinator?.refresh() - await MainActor.run { [weak self] in - switch outcome { - case .succeeded: - self?.controller.showResult("Proxy stopped.", isError: false) - case .requiresManualStart(let command): - // Not a failure — the API has no start endpoint by design. - self?.controller.showResult("Proxy stopped. Start it again with \(command)", isError: false) - case .stoppedWithRestoreFailure(let command): - // The proxy is down but native Codex still points at the dead port. - self?.controller.showResult( - "Proxy stopped, but restoring native Codex failed. Run `ocx restore`, then \(command)", - isError: true - ) - case .failed(let message): - self?.controller.showResult(message, isError: true) - } - } - } - } - - /// Optimistic toggle: the switch has already moved, so a rejection must move it back - /// rather than leave the UI showing a state the proxy refused. - private func toggleProvider(_ name: String, disable: Bool) { - let defaultProvider = latest?.defaultProvider - controller.setProviderBusy(name, true, intended: !disable) - - Task { [actions, coordinator] in - let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) - ?? .failed("Unavailable.") - await MainActor.run { [weak self] in - switch outcome { - case .succeeded: - self?.controller.showResult( - disable ? "\(name) disabled." : "\(name) enabled.", - isError: false - ) - case .failed(let message): - self?.controller.revertProvider(name, to: !disable) - self?.controller.showResult(message, isError: true) - case .requiresManualStart, .stoppedWithRestoreFailure: - // Not reachable for a provider write. - break - } - } - // Re-read so the summary line and switch states match the proxy, not our - // optimistic guess. refreshAndWait rather than refresh: a coalesced refresh - // returns immediately, which would re-enable the switch against pre-write - // data. - await coordinator?.refreshAndWait() - await MainActor.run { [weak self] in - self?.controller.setProviderBusy(name, false) - } - } - } -} diff --git a/app/Sources/MenuBarUI/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift deleted file mode 100644 index a23c4bf42c..0000000000 --- a/app/Sources/MenuBarUI/CompanionViews.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AppKit -import MenuBarCore - -final class ModelsListView: NSView { - private let stack = NSStackView() - private let caption = makeLabel("MODELS", font: Theme.micro, color: Theme.faint) - - init() { - super.init(frame: .zero) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - stack.addArrangedSubview(caption) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - clearRows() - let rows = snapshot.todayRows.sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) }.prefix(5) - isHidden = !snapshot.settings.showModels || rows.isEmpty - for row in rows { - let model = [row.provider, row.model].compactMap { $0 }.joined(separator: "/") - let cost = snapshot.settings.showCost ? " · \(Format.cost(row.estimatedCostUsd))" : "" - stack.addArrangedSubview(makeLabel( - "\(model) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))\(cost)", - font: Theme.caption, color: Theme.text - )) - } - } - - private func clearRows() { - for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } - } -} - -final class AccountsListView: NSView { - private let stack = NSStackView() - private let caption = makeLabel("ACCOUNTS", font: Theme.micro, color: Theme.faint) - - init() { - super.init(frame: .zero) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - stack.addArrangedSubview(caption) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - clearRows() - let rows = (snapshot.today?.accounts ?? []).sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } - isHidden = !snapshot.settings.showAccounts || rows.isEmpty - for row in rows { - stack.addArrangedSubview(makeLabel( - "\(row.accountLogLabel ?? Format.unknown) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))", - font: Theme.caption, color: Theme.text - )) - } - } - - private func clearRows() { - for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } - } -} diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift deleted file mode 100644 index 5c579f88be..0000000000 --- a/app/Sources/MenuBarUI/PopoverPanel.swift +++ /dev/null @@ -1,170 +0,0 @@ -import AppKit - -/// The popover surface. -/// -/// Deliberately a panel rather than `NSPopover`. Measured on macOS 27 from an accessory -/// (`LSUIElement`) process: the window `NSPopover` creates never appears in -/// `NSApp.windows` and reports `canBecomeKey == false`, so the OS refuses to route key -/// events to it — Escape and Tab never arrive regardless of how the process is -/// activated. The same probe against this panel reports `canBecomeKey=1 isKey=1`. -/// -/// `nonactivatingPanel` keeps the click-through feel of a menu bar popover: opening it -/// does not steal focus from the user's editor. -public final class PopoverPanel: NSPanel { - /// Invoked whenever the panel closes, however it was dismissed. - public var onDismiss: (() -> Void)? - - private var clickOutsideMonitor: Any? - - public init() { - super.init( - contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), - styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], - backing: .buffered, - defer: false - ) - isFloatingPanel = true - level = .statusBar - hidesOnDeactivate = false - becomesKeyOnlyIfNeeded = false - isOpaque = false - backgroundColor = .clear - hasShadow = true - isMovable = false - animationBehavior = .utilityWindow - } - - /// Wraps the content in a real popover material. - /// - /// A borderless panel has NO background of its own: without this the dashboard - /// composites straight onto whatever application is underneath, so labels collide - /// with the app behind it and contrast depends on that app's colours. `NSPopover` - /// supplies this surface automatically; a panel must build it. - public override var contentViewController: NSViewController? { - didSet { - guard let content = contentViewController?.view else { return } - contentView = PopoverSurface.make(content: content) - } - } - - /// Suspends resign-key dismissal, so presenting a modal sheet does not tear the - /// panel down behind it and strand a user who chose Cancel. - public var isPresentingModal = false - - public override var canBecomeKey: Bool { true } - /// Never main: this is chrome, not a document window. - public override var canBecomeMain: Bool { false } - - public var isShown: Bool { isVisible } - - /// Presents under a status item button, clamped to the visible screen. - public func present(from button: NSStatusBarButton) { - guard let buttonWindow = button.window else { return } - layoutContent() - - let size = contentViewController?.preferredContentSize ?? frame.size - setContentSize(size) - - let buttonRect = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) - var origin = NSPoint( - x: buttonRect.midX - size.width / 2, - y: buttonRect.minY - size.height - 6 - ) - - if let screen = buttonWindow.screen ?? NSScreen.main { - let visible = screen.visibleFrame - origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) - origin.y = max(origin.y, visible.minY + 8) - } - - setFrameOrigin(origin) - makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - installClickOutsideMonitor() - } - - public func dismiss() { - // Idempotent: a late monitor callback must not re-run teardown. - guard isVisible else { return } - removeClickOutsideMonitor() - orderOut(nil) - onDismiss?() - } - - /// Transient behaviour: clicking anywhere else dismisses, matching what a menu bar - /// popover trained the user to expect. - private func installClickOutsideMonitor() { - removeClickOutsideMonitor() - clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown] - ) { [weak self] _ in - self?.dismiss() - } - } - - private func removeClickOutsideMonitor() { - if let monitor = clickOutsideMonitor { NSEvent.removeMonitor(monitor) } - clickOutsideMonitor = nil - } - - public override func cancelOperation(_ sender: Any?) { dismiss() } - - public override func resignKey() { - super.resignKey() - // Losing key focus means the user moved on — unless we put the focus elsewhere - // ourselves by presenting a confirmation. - guard !isPresentingModal else { return } - if isVisible { dismiss() } - } - - private func layoutContent() { - contentViewController?.view.layoutSubtreeIfNeeded() - } -} - -private enum PopoverSurface { - static func make(content: NSView) -> NSView { - let surface: NSView -#if compiler(>=6.2) - if #available(macOS 26, *) { - let glass = NSGlassEffectView() - glass.cornerRadius = 16 - glass.style = .regular - glass.contentView = content - surface = glass - } else { - surface = makeMaterialSurface(content: content) - } -#else - surface = makeMaterialSurface(content: content) -#endif - - let host = NSView() - host.addSubview(surface) - surface.translatesAutoresizingMaskIntoConstraints = false - content.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - surface.topAnchor.constraint(equalTo: host.topAnchor), - surface.leadingAnchor.constraint(equalTo: host.leadingAnchor), - surface.trailingAnchor.constraint(equalTo: host.trailingAnchor), - surface.bottomAnchor.constraint(equalTo: host.bottomAnchor), - content.topAnchor.constraint(equalTo: surface.topAnchor), - content.leadingAnchor.constraint(equalTo: surface.leadingAnchor), - content.trailingAnchor.constraint(equalTo: surface.trailingAnchor), - content.bottomAnchor.constraint(equalTo: surface.bottomAnchor), - ]) - return host - } - - private static func makeMaterialSurface(content: NSView) -> NSView { - let effect = NSVisualEffectView() - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 10 - effect.layer?.masksToBounds = true - effect.addSubview(content) - return effect - } -} diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift deleted file mode 100644 index 1cc8dd51ae..0000000000 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ /dev/null @@ -1,384 +0,0 @@ -import AppKit -import MenuBarCore - -/// The popover body: one column ordered by urgency. -/// -/// Deliberately not a tab bar. A menu bar popover is a glance surface, and tabs would put -/// the answer to "is it fine?" one click away three times out of four. -/// -/// Fixed header and action row with a scrolling middle: the quota and provider sections -/// grow with the user's configuration, and an uncapped popover would eventually run off -/// the screen. -public final class PopoverViewController: NSViewController { - public override init(nibName: NSNib.Name?, bundle: Bundle?) { - super.init(nibName: nibName, bundle: bundle) - } - - public required init?(coder: NSCoder) { nil } - - /// The popover never grows past this; the variable middle scrolls instead. - private static let maxHeight: CGFloat = 480 - - // Fixed chrome - private let header = StatusHeaderView() - private let dashboardButton = NSButton() - private let stopButton = NSButton() - private let overflowButton = NSButton() - /// State-specific call to action: "Add key…" or "Retry". - private let primaryButton = NSButton() - - // Scrolling body - private let scrollView = NSScrollView() - private let body = NSStackView() - private let metrics = MetricsView() - private let timelineChart = TimelineChartView() - private let models = ModelsListView() - private let accounts = AccountsListView() - private let quotaStack = NSStackView() - private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) - private let providers = ProviderListView() - /// Transient result of the last write action. Actions that report nothing leave the - /// user guessing whether anything happened. - private let resultBanner = makeLabel("", font: Theme.caption, color: Theme.muted) - private let skeleton = SkeletonView() - private let guidanceLabel: NSTextField = { - let field = makeLabel("", font: Theme.caption, color: Theme.muted) - // Guidance is a sentence, not a stat: let it wrap instead of truncating away - // the half that explains what to do. - field.lineBreakMode = .byWordWrapping - field.maximumNumberOfLines = 3 - field.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - return field - }() - private let commandField = NSTextField(labelWithString: "") - private let metricsSeparator = makeSeparator() - private let quotaSeparator = makeSeparator() - - public var onDashboard: (() -> Void)? - public var onCompanionSettings: (() -> Void)? - public var onStop: (() -> Void)? - public var onQuit: (() -> Void)? - public var onRefresh: (() -> Void)? - /// `(provider, shouldDisable)`. - public var onToggleProvider: ((String, Bool) -> Void)? - /// Distinct callbacks: "Retry" must retry in place, while "Add key…" navigates to - /// the dashboard. Routing both through one handler made Retry open a browser. - public var onAddKey: (() -> Void)? - public var onRetry: (() -> Void)? - - private var snapshot: ProxySnapshot? - private var scrollHeight: NSLayoutConstraint? - /// Guards the banner's auto-hide so a newer result is not cleared by an older timer. - private var resultToken = 0 - - public override func loadView() { - configureControls() - resultBanner.isHidden = true - resultBanner.lineBreakMode = .byWordWrapping - resultBanner.maximumNumberOfLines = 3 - resultBanner.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 - providers.onToggle = { [weak self] name, disable in - self?.onToggleProvider?(name, disable) - } - - body.orientation = .vertical - body.alignment = .leading - body.spacing = Theme.rowGap - body.setViews( - [skeleton, metrics, timelineChart, metricsSeparator, models, quotaStack, quotaEmpty, - accounts, providers, quotaSeparator, resultBanner, guidanceLabel, commandField], - in: .top - ) - body.translatesAutoresizingMaskIntoConstraints = false - - // A flipped clip view puts the scroll origin at the TOP. Without this, content - // that overflows opens scrolled to the bottom, hiding the status and metrics the - // urgency order exists to surface first. - scrollView.contentView = FlippedClipView() - scrollView.documentView = body - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.translatesAutoresizingMaskIntoConstraints = false - - let actions = NSStackView(views: [dashboardButton, stopButton, primaryButton, NSView(), overflowButton]) - actions.orientation = .horizontal - actions.spacing = Theme.rowGap - actions.alignment = .centerY - - let column = NSStackView(views: [header, makeSeparator(), scrollView, actions]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = Theme.rowGap - column.edgeInsets = NSEdgeInsets( - top: Theme.gutter, left: Theme.gutter, - bottom: Theme.gutter, right: Theme.gutter - ) - column.translatesAutoresizingMaskIntoConstraints = false - - let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 300)) - root.addSubview(column) - - let contentWidth = Theme.width - Theme.gutter * 2 - NSLayoutConstraint.activate([ - column.topAnchor.constraint(equalTo: root.topAnchor), - column.leadingAnchor.constraint(equalTo: root.leadingAnchor), - column.trailingAnchor.constraint(equalTo: root.trailingAnchor), - column.bottomAnchor.constraint(equalTo: root.bottomAnchor), - root.widthAnchor.constraint(equalToConstant: Theme.width), - header.widthAnchor.constraint(equalToConstant: contentWidth), - actions.widthAnchor.constraint(equalToConstant: contentWidth), - scrollView.widthAnchor.constraint(equalToConstant: contentWidth), - body.widthAnchor.constraint(equalToConstant: contentWidth), - ]) - - let heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: 120) - heightConstraint.isActive = true - scrollHeight = heightConstraint - - view = root - } - - private func configureControls() { - for (button, title) in [(dashboardButton, "Dashboard"), (stopButton, "Stop proxy")] { - button.title = title - button.bezelStyle = .rounded - button.controlSize = .small - button.font = Theme.caption - button.target = self - } - dashboardButton.action = #selector(dashboardTapped) - stopButton.action = #selector(stopTapped) - - primaryButton.bezelStyle = .rounded - primaryButton.controlSize = .small - primaryButton.font = Theme.caption - primaryButton.target = self - primaryButton.action = #selector(primaryTapped) - primaryButton.isHidden = true - - overflowButton.title = "···" - overflowButton.bezelStyle = .rounded - overflowButton.controlSize = .small - overflowButton.font = Theme.caption - overflowButton.target = self - overflowButton.action = #selector(overflowTapped) - overflowButton.setAccessibilityLabel("More actions") - - quotaStack.orientation = .vertical - quotaStack.alignment = .leading - quotaStack.spacing = Theme.tightGap - - commandField.font = Theme.numericSmall - commandField.textColor = Theme.text - commandField.isSelectable = true - commandField.isBordered = false - commandField.drawsBackground = false - } - - public func apply(_ snapshot: ProxySnapshot) { - self.snapshot = snapshot - header.apply(snapshot) - - let showsData = snapshot.showsData - let isLoading = !snapshot.hasEverLoaded && snapshot.state == .loading - - // Loading shows structure, not empty copy: the shape of the answer is already - // known, only the values are missing. - skeleton.isHidden = !isLoading - - metrics.isHidden = !showsData - metricsSeparator.isHidden = !showsData - quotaSeparator.isHidden = !showsData - if showsData { - metrics.apply(snapshot) - timelineChart.apply(snapshot) - models.apply(snapshot) - accounts.apply(snapshot) - applyQuotas(snapshot) - providers.apply(snapshot) - } else { - timelineChart.isHidden = true - models.isHidden = true - accounts.isHidden = true - quotaStack.isHidden = true - quotaEmpty.isHidden = true - providers.isHidden = true - } - - applyGuidance(snapshot) - applyActions(snapshot, isLoading: isLoading) - resize() - } - - private func applyQuotas(_ snapshot: ProxySnapshot) { - for view in quotaStack.arrangedSubviews { - quotaStack.removeArrangedSubview(view) - view.removeFromSuperview() - } - let rows = snapshot.quotaRows - quotaStack.isHidden = rows.isEmpty - // "Not fetched yet" and "the proxy reported none" are different facts. - quotaEmpty.isHidden = !(rows.isEmpty && snapshot.quotasLoaded) - for quota in rows { - let row = QuotaRowView(quota: quota) - row.translatesAutoresizingMaskIntoConstraints = false - quotaStack.addArrangedSubview(row) - row.widthAnchor.constraint(equalTo: quotaStack.widthAnchor).isActive = true - } - } - - /// Shows the outcome of a write action, then clears itself. A banner that never - /// leaves would become permanent furniture. - public func showResult(_ text: String, isError: Bool) { - resultBanner.stringValue = text - resultBanner.textColor = isError ? Theme.red : Theme.muted - resultBanner.isHidden = false - refreshSize() - - resultToken &+= 1 - let token = resultToken - DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in - guard let self, self.resultToken == token else { return } - self.resultBanner.isHidden = true - self.refreshSize() - } - } - - public func revertProvider(_ name: String, to enabled: Bool) { - providers.revert(name, to: enabled) - } - - public func setProviderBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { - providers.setBusy(name, busy, intended: intended) - } - - /// Re-measures after content changes height (disclosure, banner). - public func refreshSize() { resize() } - - /// Guidance text plus any command the user should run. Commands are shown as - /// selectable text; the app never executes them. - private func applyGuidance(_ snapshot: ProxySnapshot) { - var guidance: String? - var command: String? - - switch snapshot.nextAction { - case .none: - // A running-but-at-risk proxy still has advice worth surfacing. - if case .running = snapshot.state, let recommended = snapshot.recommendedCommand { - guidance = "Recommended:" - command = recommended - } - case .runCommand(let value): - guidance = "Start it again with:" - command = value - case .addAPIKey: - guidance = "This proxy is bound to a non-loopback address and needs a key." - case .retry: - guidance = snapshot.dataAge.map { "Showing data from \(Format.age($0)). Retrying automatically." } - ?? "Retrying automatically." - } - - guidanceLabel.isHidden = guidance == nil - guidanceLabel.stringValue = guidance ?? "" - commandField.isHidden = command == nil - commandField.stringValue = command ?? "" - if let command { - commandField.setAccessibilityLabel("Command to run: \(command)") - } - } - - private func applyActions(_ snapshot: ProxySnapshot, isLoading: Bool) { - // Nothing is actionable before the first read completes. - dashboardButton.isEnabled = !isLoading - overflowButton.isEnabled = !isLoading - stopButton.isEnabled = snapshot.state.isRunning - stopButton.isHidden = !snapshot.state.isRunning - - switch snapshot.nextAction { - case .addAPIKey: - primaryButton.isHidden = false - primaryButton.title = "Add key…" - primaryButton.keyEquivalent = "\r" - case .retry: - primaryButton.isHidden = false - primaryButton.title = "Retry" - primaryButton.keyEquivalent = "\r" - case .none, .runCommand: - primaryButton.isHidden = true - primaryButton.keyEquivalent = "" - } - } - - private func resize() { - view.layoutSubtreeIfNeeded() - let bodyHeight = ceil(body.fittingSize.height) - // Chrome is the header, separator, action row, and insets. - let chrome = ceil(header.fittingSize.height) + Theme.gutter * 2 + Theme.rowGap * 3 + 28 - let natural = chrome + bodyHeight - let capped = min(Self.maxHeight, natural) - // Scrollers appear only when the content genuinely overflows; a scroll bar on a - // three-line loading state reads as a broken layout. - let overflowing = natural > Self.maxHeight - scrollView.hasVerticalScroller = overflowing - scrollHeight?.constant = max(0, capped - chrome) - preferredContentSize = NSSize(width: Theme.width, height: max(96, capped)) - } - - // MARK: - Actions - - @objc private func dashboardTapped() { onDashboard?() } - @objc private func stopTapped() { onStop?() } - @objc private func primaryTapped() { - switch snapshot?.nextAction { - case .addAPIKey: onAddKey?() - case .retry: onRetry?() - default: break - } - } - @objc private func refreshTapped() { onRefresh?() } - @objc private func quitTapped() { onQuit?() } - - @objc private func overflowTapped() { - let menu = NSMenu() - menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self - menu.addItem(withTitle: "Open dashboard", action: #selector(dashboardTapped), keyEquivalent: "").target = self - menu.addItem(withTitle: "Companion settings…", action: #selector(companionSettingsTapped), keyEquivalent: "").target = self - menu.addItem(.separator()) - menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) - } - @objc private func companionSettingsTapped() { onCompanionSettings?() } - - /// AppKit routes Escape here for the whole responder chain, which `keyDown` does not - /// reliably receive inside a popover. - public override func cancelOperation(_ sender: Any?) { - view.window?.performClose(nil) - } -} - -/// Top-anchored clip view. AppKit scroll views are bottom-origin by default. -final class FlippedClipView: NSClipView { - override var isFlipped: Bool { true } -} - -/// Loading structure: grey bars where values will appear, so the first paint shows the -/// shape of the answer instead of empty space or a spinner. -final class SkeletonView: NSView { - override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: 84) - } - - override func draw(_ dirtyRect: NSRect) { - Theme.raised.setFill() - let widths: [CGFloat] = [72, 0, 96, 140, 120, 110] - var y = bounds.maxY - 12 - for width in widths { - guard width > 0 else { y -= 8; continue } - let rect = NSRect(x: 0, y: y, width: width, height: 9) - NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() - y -= 15 - } - } -} diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift deleted file mode 100644 index c0d5b6dc0b..0000000000 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ /dev/null @@ -1,255 +0,0 @@ -import AppKit -import MenuBarCore - -/// Collapsed provider list with per-provider enable/disable switches. -/// -/// Collapsed by default: reading status is frequent, toggling a provider is rare, and -/// the urgency order in `003` puts actions below information. -public final class ProviderListView: NSView { - private let disclosure = NSButton() - private let summary = makeLabel("", font: Theme.caption, color: Theme.muted) - private let rows = NSStackView() - private var expanded = false - private var snapshot: ProxySnapshot? - /// Providers with a write in flight, mapped to the state the USER chose. A poll can - /// still be carrying pre-write data, so the intended value — not the snapshot — is - /// what a rebuilt row must show. - private var pending: [String: Bool] = [:] - - /// `(provider, shouldDisable)`. - public var onToggle: ((String, Bool) -> Void)? - - public override init(frame: NSRect) { - super.init(frame: frame) - - disclosure.bezelStyle = .disclosure - disclosure.setButtonType(.onOff) - disclosure.title = "" - disclosure.target = self - disclosure.action = #selector(toggleExpanded) - disclosure.setAccessibilityLabel("Show providers") - - rows.orientation = .vertical - rows.alignment = .leading - rows.spacing = Theme.tightGap - rows.isHidden = true - - let header = NSStackView(views: [disclosure, summary]) - header.orientation = .horizontal - header.spacing = Theme.tightGap - header.alignment = .centerY - - let column = NSStackView(views: [header, rows]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = Theme.tightGap - column.translatesAutoresizingMaskIntoConstraints = false - addSubview(column) - NSLayoutConstraint.activate([ - column.topAnchor.constraint(equalTo: topAnchor), - column.leadingAnchor.constraint(equalTo: leadingAnchor), - column.trailingAnchor.constraint(equalTo: trailingAnchor), - column.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - public convenience init() { self.init(frame: .zero) } - - public required init?(coder: NSCoder) { nil } - - public func apply(_ snapshot: ProxySnapshot) { - self.snapshot = snapshot - - guard snapshot.providersLoaded else { - isHidden = true - return - } - isHidden = false - - if snapshot.providers.isEmpty { - summary.stringValue = "No providers configured." - disclosure.isHidden = true - rows.isHidden = true - return - } - - disclosure.isHidden = false - let enabled = snapshot.providers.filter(\.isEnabled).count - summary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" - rebuildRows(snapshot) - rows.isHidden = !expanded - } - - private func rebuildRows(_ snapshot: ProxySnapshot) { - for view in rows.arrangedSubviews { - rows.removeArrangedSubview(view) - view.removeFromSuperview() - } - - for provider in snapshot.visibleProviders.sorted(by: { $0.name < $1.name }) { - let isDefault = provider.name == snapshot.defaultProvider - let row = ProviderRowView( - provider: provider, - isDefault: isDefault - ) { [weak self] shouldDisable in - self?.onToggle?(provider.name, shouldDisable) - } - // A refresh that lands mid-write must not undo the optimistic state: apply - // the intended value first, then mark the row busy. - if let intended = pending[provider.name] { - row.setEnabled(intended) - row.setBusy(true) - } - row.translatesAutoresizingMaskIntoConstraints = false - rows.addArrangedSubview(row) - row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true - } - } - - /// Shared by the disclosure button and the test hook. - func setExpanded(_ value: Bool) { - expanded = value - disclosure.state = value ? .on : .off - rows.isHidden = !expanded - disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") - (window?.contentViewController as? PopoverViewController)?.refreshSize() - } - - var providerRows: [NSView] { rows.arrangedSubviews } - - @objc private func toggleExpanded() { - expanded = disclosure.state == .on - rows.isHidden = !expanded - disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") - // The popover has to grow or shrink with the disclosure. - (window?.contentViewController as? PopoverViewController)?.refreshSize() - } - - /// Reverts a switch after the proxy rejected the change. - public func revert(_ name: String, to enabled: Bool) { - pending[name] = nil - for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { - row.setEnabled(enabled) - row.setBusy(false) - } - } - - /// Marks a provider as having a write in flight. Its switch stays inert until the - /// authoritative refresh lands, so a poll cannot resurrect the pre-toggle state and - /// a second click cannot race the first. - /// `intended` is the state the user selected, retained so a poll landing mid-write - /// cannot snap the switch back. - public func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { - if busy { - pending[name] = intended ?? pending[name] ?? true - } else { - pending[name] = nil - } - for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { - if busy, let value = pending[name] { row.setEnabled(value) } - row.setBusy(busy) - } - } -} - -public final class ProviderRowView: NSView { - public let providerName: String - private let toggle = NSSwitch() - private let onToggle: (Bool) -> Void - private var baseEnabled = true - private var isBusy = false - - init(provider: ProviderSummary, isDefault: Bool, onToggle: @escaping (Bool) -> Void) { - self.providerName = provider.name - self.onToggle = onToggle - super.init(frame: .zero) - - let name = makeLabel(provider.name, font: Theme.caption, color: Theme.text) - let detail = makeLabel( - isDefault ? "default" : (provider.authMode ?? ""), - font: Theme.micro, - color: Theme.faint - ) - - let labels = NSStackView(views: [name, detail]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 0 - - toggle.state = provider.isEnabled ? .on : .off - toggle.controlSize = .mini - toggle.target = self - toggle.action = #selector(switched) - - // The proxy rejects only DISABLING the default provider (`provider-routes.ts:178` - // guards on `rawBody.disabled && name === defaultProvider`). Enabling it is - // valid, so a default provider that is currently off must stay toggleable — - // otherwise the app strands the user in a state it cannot leave. - let wouldDisableDefault = isDefault && provider.isEnabled - toggle.isEnabled = !wouldDisableDefault - toggle.toolTip = wouldDisableDefault - ? "This is the default provider. Choose another default in the dashboard first." - : nil - baseEnabled = toggle.isEnabled - toggle.setAccessibilityLabel("\(provider.name) enabled") - - let row = NSStackView(views: [labels, NSView(), toggle]) - row.orientation = .horizontal - row.spacing = Theme.rowGap - row.alignment = .centerY - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) - NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } - - var toggleState: Bool { toggle.state == .on } - var toggleIsEnabled: Bool { toggle.isEnabled } - - /// Inert while its write is in flight, so a second click cannot race the first. - func setBusy(_ busy: Bool) { - isBusy = busy - toggle.isEnabled = busy ? false : baseEnabled - alphaValue = busy ? 0.6 : 1 - } - - @objc private func switched() { - // Optimistic: the switch has already moved. The caller reverts on failure. - onToggle(toggle.state == .off) - } -} - - -// MARK: - Test inspection - -/// Read-only hooks so the UI suite can assert on rendered control state rather than on -/// the view's private bookkeeping. -package extension ProviderListView { - /// Expands the list without going through a click, so tests do not depend on - /// NSButton action dispatch. - func expandForTesting() { setExpanded(true) } - - func isToggleOn(_ name: String) -> Bool? { row(name)?.isOn } - func isToggleEnabled(_ name: String) -> Bool? { row(name)?.isToggleEnabled } - func hasProviderForTesting(_ name: String) -> Bool { row(name) != nil } - - private func row(_ name: String) -> ProviderRowView? { - for case let row as ProviderRowView in providerRows where row.providerName == name { - return row - } - return nil - } -} - -package extension ProviderRowView { - var isOn: Bool { toggleState } - var isToggleEnabled: Bool { toggleIsEnabled } -} diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift deleted file mode 100644 index e7eb2951b1..0000000000 --- a/app/Sources/MenuBarUI/StatusIcon.swift +++ /dev/null @@ -1,73 +0,0 @@ -import AppKit -import MenuBarCore - -/// The menu bar glyph. -/// -/// Drawn as vector paths rather than shipped as PNGs, so it stays crisp at every scale -/// factor and inverts correctly as a template image. -/// -/// Colour is deliberately absent here. macOS menu bar items are monochrome by -/// convention, and a coloured dot up there is the tell of an app that does not respect -/// the platform. State is carried by fill and by a notch instead. The coloured dot lives -/// inside the popover, where it sits beside a word and so never encodes meaning by -/// colour alone. -public enum StatusIcon { - public static let size = NSSize(width: 17, height: 17) - - public static func image(for state: ProxyState) -> NSImage { - switch state { - case .running(let health) where health.isProtected: - return mark(filled: true, notched: false, alpha: 1) - case .running: - return mark(filled: true, notched: true, alpha: 1) - case .loading, .degraded: - return mark(filled: false, notched: false, alpha: 1) - case .unreachable, .unauthorized: - return mark(filled: false, notched: false, alpha: 0.4) - } - } - - /// A rounded mark reduced to menu bar scale. - /// - /// The notch is carved out of the geometry with an even-odd path rather than by - /// compositing. An earlier version stroked with `.clear` and `.clear` composite mode, - /// which silently did nothing — the rendered at-risk glyph was indistinguishable from - /// the protected one, so the state signal was invisible. - private static func mark(filled: Bool, notched: Bool, alpha: CGFloat) -> NSImage { - let image = NSImage(size: size, flipped: false) { rect in - let inset = rect.insetBy(dx: 2.5, dy: 2.5) - let path = NSBezierPath(roundedRect: inset, xRadius: 4, yRadius: 4) - - if notched { - // A slot carved out of the trailing edge, kept fully inside the mark so - // the silhouette stays clean. Even-odd winding turns the subpath into a - // hole rather than a second filled shape. - let notch = NSBezierPath( - roundedRect: NSRect( - x: inset.maxX - 4.2, - y: inset.midY - 1.1, - width: 3.0, - height: 2.2 - ), - xRadius: 1.1, - yRadius: 1.1 - ) - path.append(notch) - path.windingRule = .evenOdd - } - - NSColor.black.withAlphaComponent(alpha).setStroke() - NSColor.black.withAlphaComponent(alpha).setFill() - - if filled { - path.fill() - } else { - path.lineWidth = 1.6 - path.stroke() - } - return true - } - image.isTemplate = true - return image - } -} diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift deleted file mode 100644 index b1e8b4241f..0000000000 --- a/app/Sources/MenuBarUI/Theme.swift +++ /dev/null @@ -1,103 +0,0 @@ -import AppKit - -/// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on -/// what "healthy" looks like. -/// -/// For SURFACES, AppKit's semantic colours win over a hardcoded hex: they track -/// light/dark plus the increased-contrast and vibrancy accessibility settings, which a -/// literal cannot. -/// -/// The TEXT tiers are a deliberate exception. Measured against the popover material, -/// `tertiaryLabelColor` renders at 2.01:1 in light and 2.39:1 in dark — it is designed -/// for disabled affordances, not for information the user has to read. All four text and -/// mark tokens below are therefore calibrated against the rendered material and verified -/// numerically rather than trusted by name. -enum Theme { - // Surfaces - static let separator = NSColor.separatorColor - static let raised = NSColor.controlBackgroundColor - - // Text: --text / --muted / --faint - // - // `tertiaryLabelColor` measured 2.01:1 in light and 2.39:1 in dark against the - // popover material — well under the 4.5:1 required for normal text. AppKit's - // tertiary tier is intended for disabled affordances, not for information the user - // has to read, and every label using this tier here (range heading, metric captions, - // quota window labels) carries real meaning. Calibrated tokens replace it. - /// All three text tiers are calibrated against the RENDERED popover material, not - /// picked from AppKit's semantic palette. Measured backgrounds: light (220,219,218), - /// dark (102,101,101). - /// - /// The dark material constrains this hard — pure white measures only 5.81:1 against - /// it — so the tiers are packed into the band that remains while keeping every text - /// tier above 4.5:1 and preserving `text > muted > faint` in both appearances. - static let text = dynamic(light: 0x1A1A1A, dark: 0xFFFFFF) - static let muted = dynamic(light: 0x3D3D3D, dark: 0xF2F2F2) - /// Small supporting text that must still be legible: 10-11pt captions and labels. - static let faint = dynamic(light: 0x545454, dark: 0xEDEDED) - /// Graphical marks only, held to the 3:1 non-text threshold rather than 4.5:1. - static let graphMark = dynamic(light: 0x707070, dark: 0xD2D2D2) - - // State colours, taken verbatim from styles.css. - static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) - static let amber = dynamic(light: 0x9A4A08, dark: 0xFBBF24) - static let red = dynamic(light: 0xB91C1C, dark: 0xF87171) - - // Type ladder: --text-micro / --text-caption / --text-label / --text-control. - static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) - static let caption = NSFont.systemFont(ofSize: 11) - static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) - /// Monospaced digits are the AppKit equivalent of `font-variant-numeric: tabular-nums`. - /// Without this, polling makes every digit jitter. - static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) - static let numericSmall = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) - - // Geometry: --space-* and --radius-sm. - static let gutter: CGFloat = 12 - static let rowGap: CGFloat = 8 - static let tightGap: CGFloat = 4 - static let radius: CGFloat = 8 - static let width: CGFloat = 340 - - static func color(for tone: ProxyToneBridge) -> NSColor { - switch tone { - case .neutral: return muted - case .good: return green - case .warning: return amber - case .bad: return red - } - } - - /// Quota fill: green under 80, amber to 95, red above. The percentage is always - /// printed beside the bar, so colour is reinforcement rather than the only signal. - static func quotaColor(percent: Double?) -> NSColor { - guard let percent else { return faint } - if percent > 95 { return red } - if percent >= 80 { return amber } - return green - } - - /// `light-dark()` equivalent: resolves per appearance instead of at creation time. - private static func dynamic(light: Int, dark: Int) -> NSColor { - NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - return NSColor(hex: isDark ? dark : light) - } - } -} - -/// Mirrors `ProxyState.Tone` without importing AppKit into the core module. -enum ProxyToneBridge { - case neutral, good, warning, bad -} - -extension NSColor { - convenience init(hex: Int) { - self.init( - srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, - green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, - alpha: 1 - ) - } -} diff --git a/app/Sources/MenuBarUI/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift deleted file mode 100644 index 56a8a6288f..0000000000 --- a/app/Sources/MenuBarUI/TimelineChartView.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AppKit -import MenuBarCore - -public final class TimelineChartView: NSView { - private var timeline: UsageTimeline? - private var settings = CompanionSettings.defaults - private let colors = [0x0A84FF, 0xFF9F0A, 0x30D158, 0xBF5AF2, 0xFF453A, 0x64D2FF] - - public override var intrinsicContentSize: NSSize { - NSSize(width: NSView.noIntrinsicMetric, height: 104) - } - - public func apply(_ snapshot: ProxySnapshot) { - settings = snapshot.settings - timeline = snapshot.timeline - isHidden = !settings.showChart || timeline == nil - setAccessibilityLabel("Usage timeline") - needsDisplay = true - } - - public override func draw(_ dirtyRect: NSRect) { - guard let timeline, !timeline.isEmpty else { - if settings.showChart { - drawText("No token usage in this window.", in: NSRect(x: 0, y: 36, width: bounds.width, height: 16), font: Theme.caption, color: Theme.muted) - } - return - } - let chartHeight: CGFloat = 72 - let maxValue = settings.chartStyle == .stackedBar ? timeline.stackedMax : timeline.maxPoint - drawText(Format.tokens(Int(maxValue.rounded())), in: NSRect(x: 0, y: chartHeight + 8, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted, alignment: .right) - let window = timeline.buckets * timeline.bucketSeconds / 3600 - let windowLabel: String - if window < 48 { - windowLabel = "\(window)h" - } else { - windowLabel = "\(window / 24)d" - } - drawText(windowLabel, in: NSRect(x: 0, y: chartHeight + 8, width: 40, height: 14), font: Theme.micro, color: Theme.muted) - - let plot = NSRect(x: 0, y: 20, width: bounds.width, height: chartHeight) - Theme.muted.setStroke() - let baseline = NSBezierPath() - baseline.move(to: NSPoint(x: plot.minX, y: plot.minY)) - baseline.line(to: NSPoint(x: plot.maxX, y: plot.minY)) - baseline.lineWidth = 0.5 - baseline.stroke() - - if settings.chartStyle == .stackedBar { - drawBars(timeline, in: plot, maxValue: maxValue) - } else { - drawLines(timeline, in: plot, maxValue: maxValue) - } - - drawLegend(timeline, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14)) - } - - private func drawText( - _ text: String, in rect: NSRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left - ) { - let style = NSMutableParagraphStyle() - style.alignment = alignment - NSAttributedString( - string: text, - attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style] - ).draw(in: rect) - } - - private func drawLines(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { - guard timeline.buckets > 1, maxValue > 0 else { return } - for (seriesIndex, series) in timeline.series.enumerated() { - let path = NSBezierPath() - for (index, value) in series.points.enumerated() { - let x = plot.minX + plot.width * CGFloat(index) / CGFloat(max(timeline.buckets - 1, 1)) - let y = plot.minY + plot.height * CGFloat(value / maxValue) - if index == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) } - } - NSColor(hex: colors[seriesIndex % colors.count]).setStroke() - path.lineWidth = 1.5 - path.stroke() - } - } - - private func drawBars(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { - guard timeline.buckets > 0, maxValue > 0 else { return } - let width = max(1, plot.width / CGFloat(timeline.buckets) - 1) - for bucket in 0.. 0 { - let extra = entries.count - visible - let suffixWidth = extra > 0 - ? NSAttributedString(string: "+\(extra) more", attributes: attributes).size().width + separator - : 0 - let entryWidth = entries.prefix(visible).reduce(CGFloat.zero) { width, entry in - width + dotSize + 4 + entry.1.size().width + separator - } - if entryWidth + suffixWidth <= rect.width || visible == 0 { break } - visible -= 1 - } - let extra = entries.count - visible - var x = rect.minX - for (index, text) in entries.prefix(visible) { - let dot = NSRect(x: x, y: rect.midY - dotSize / 2, width: dotSize, height: dotSize) - NSColor(hex: colors[index % colors.count]).setFill() - NSBezierPath(ovalIn: dot).fill() - x += dotSize + 4 - text.draw(at: NSPoint(x: x, y: rect.minY)) - x += text.size().width + separator - } - if extra > 0 { - NSAttributedString(string: "+\(extra) more", attributes: attributes) - .draw(at: NSPoint(x: x, y: rect.minY)) - } - } -} diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift deleted file mode 100644 index 70c229b685..0000000000 --- a/app/Sources/MenuBarUI/Views.swift +++ /dev/null @@ -1,264 +0,0 @@ -import AppKit -import MenuBarCore - -// MARK: - Shared helpers - -func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { - let field = NSTextField(labelWithString: text) - field.font = font - field.textColor = color - field.lineBreakMode = .byTruncatingTail - return field -} - -func makeRow(_ views: [NSView], spacing: CGFloat = Theme.rowGap) -> NSStackView { - let stack = NSStackView(views: views) - stack.orientation = .horizontal - stack.spacing = spacing - stack.alignment = .firstBaseline - return stack -} - -func makeSeparator() -> NSView { - let line = NSView() - line.wantsLayer = true - line.layer?.backgroundColor = Theme.separator.cgColor - line.translatesAutoresizingMaskIntoConstraints = false - line.heightAnchor.constraint(equalToConstant: 1).isActive = true - return line -} - -// MARK: - Status header - -/// `● Running 127.0.0.1:10100` -/// -/// The dot never travels alone: the word beside it carries the same meaning, so the UI -/// stays readable without colour perception (WCAG 1.4.1). -final class StatusHeaderView: NSView { - private let dot = StatusDotView() - private let title = makeLabel("", font: Theme.label, color: Theme.text) - private let endpoint = makeLabel("", font: Theme.caption, color: Theme.muted) - private let detail = makeLabel("", font: Theme.caption, color: Theme.muted) - - init() { - super.init(frame: .zero) - let top = makeRow([dot, title, NSView(), endpoint], spacing: Theme.rowGap) - top.alignment = .centerY - top.distribution = .fill - endpoint.setContentHuggingPriority(.defaultHigh, for: .horizontal) - - let stack = NSStackView(views: [top, detail]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 2 - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - let state = snapshot.state - title.stringValue = state.title - endpoint.stringValue = snapshot.endpoint.display - dot.tone = bridge(state.tone) - - if let text = state.detail { - detail.stringValue = text - detail.isHidden = false - } else { - detail.isHidden = true - } - - setAccessibilityLabel("Proxy \(state.title) at \(snapshot.endpoint.display)") - } - - private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { - switch tone { - case .neutral: return .neutral - case .good: return .good - case .warning: return .warning - case .bad: return .bad - } - } -} - -final class StatusDotView: NSView { - var tone: ProxyToneBridge = .neutral { - didSet { needsDisplay = true } - } - - override var intrinsicContentSize: NSSize { NSSize(width: 8, height: 8) } - - override func draw(_ dirtyRect: NSRect) { - let rect = NSRect(x: 0, y: (bounds.height - 8) / 2, width: 8, height: 8) - Theme.color(for: tone).setFill() - NSBezierPath(ovalIn: rect).fill() - } -} - -// MARK: - Metrics - -/// Three columns plus a range header that echoes the response, never the request. -final class MetricsView: NSView { - private let rangeLabel = makeLabel("USAGE", font: Theme.micro, color: Theme.faint) - private let columns: [(caption: NSTextField, value: NSTextField)] - private let emptyLabel = makeLabel("", font: Theme.caption, color: Theme.muted) - private let stack: NSStackView - private let columnsRow: NSStackView - - init() { - let captions = ["TOKENS", "REQUESTS", "COST"] - columns = captions.map { caption in - (makeLabel(caption, font: Theme.micro, color: Theme.faint), - makeLabel(Format.unknown, font: Theme.numeric, color: Theme.text)) - } - - let columnViews: [NSView] = columns.map { pair in - let column = NSStackView(views: [pair.caption, pair.value]) - column.orientation = .vertical - column.alignment = .leading - column.spacing = 1 - return column - } - columnsRow = NSStackView(views: columnViews) - columnsRow.orientation = .horizontal - columnsRow.distribution = .fillEqually - columnsRow.alignment = .top - - stack = NSStackView(views: [rangeLabel, columnsRow, emptyLabel]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = Theme.tightGap - - super.init(frame: .zero) - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: topAnchor), - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { nil } - - func apply(_ snapshot: ProxySnapshot) { - let usage = snapshot.today ?? snapshot.usage - isHidden = !snapshot.settings.showToday - rangeLabel.stringValue = usage?.rangeLabel ?? "USAGE" - columnsRow.arrangedSubviews[2].isHidden = !snapshot.settings.showCost - - // Three states: known-empty gets copy, unknown gets em dashes, data gets values. - switch snapshot.usageIsEmpty { - case .some(true): - columnsRow.isHidden = true - emptyLabel.isHidden = false - emptyLabel.stringValue = "No requests in this period." - default: - columnsRow.isHidden = false - emptyLabel.isHidden = true - let summary = usage?.summary - let requests = Format.count(summary?.requests) - columns[0].value.stringValue = Format.tokens(summary?.totalTokens) - columns[1].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests - columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) - columns[0].value.setAccessibilityLabel( - "\(Format.tokens(summary?.totalTokens)) tokens" - ) - columns[1].value.setAccessibilityLabel( - (summary?.hasEstimates ?? false) - ? "\(requests) requests, partly estimated" - : "\(requests) requests" - ) - columns[2].value.setAccessibilityLabel( - "\(Format.cost(summary?.estimatedCostUsd)) estimated cost" - ) - } - } -} - -// MARK: - Quotas - -/// `OpenAI ▓▓▓▓▓░░░░░ 44%` -final class QuotaRowView: NSView { - init(quota: NormalizedQuota) { - super.init(frame: .zero) - - let name = makeLabel(quota.providerLabel, font: Theme.caption, color: Theme.text) - name.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - name.lineBreakMode = .byTruncatingTail - - // Which window a number belongs to is not decoration: 42% of an API-usage window - // and 42% of a month mean very different things. - let window = makeLabel( - quota.hasPercent ? quota.windowLabel : "", - font: Theme.micro, color: Theme.faint - ) - - let labels = NSStackView(views: [name, window]) - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 0 - - let bar = QuotaBarView() - bar.percent = quota.percent - - let value = makeLabel(Format.percent(quota.percent), font: Theme.numericSmall, color: Theme.muted) - value.alignment = .right - - let row = NSStackView(views: [labels, bar, value]) - row.orientation = .horizontal - row.spacing = Theme.rowGap - row.alignment = .centerY - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) - - NSLayoutConstraint.activate([ - row.topAnchor.constraint(equalTo: topAnchor), - row.leadingAnchor.constraint(equalTo: leadingAnchor), - row.trailingAnchor.constraint(equalTo: trailingAnchor), - row.bottomAnchor.constraint(equalTo: bottomAnchor), - labels.widthAnchor.constraint(equalToConstant: 132), - value.widthAnchor.constraint(equalToConstant: 36), - ]) - - // The percentage is spoken, not merely drawn as a filled width. - let reset = Format.resetsIn(quota.resetAt) - setAccessibilityLabel( - quota.hasPercent - ? "\(quota.providerLabel): \(Format.percent(quota.percent)) of \(quota.windowLabel) quota, resets in \(reset)" - : "\(quota.providerLabel): quota unknown" - ) - } - - required init?(coder: NSCoder) { nil } -} - -final class QuotaBarView: NSView { - var percent: Double? - - override var intrinsicContentSize: NSSize { NSSize(width: 110, height: 6) } - - override func draw(_ dirtyRect: NSRect) { - let track = NSRect(x: 0, y: (bounds.height - 6) / 2, width: bounds.width, height: 6) - Theme.raised.setFill() - NSBezierPath(roundedRect: track, xRadius: 3, yRadius: 3).fill() - - // A nil percent draws no fill at all — a zero-width bar would read as "0% used", - // which is a different fact from "unknown". - guard let percent else { return } - let clamped = max(0, min(100, percent)) - guard clamped > 0 else { return } - let fill = NSRect(x: 0, y: track.origin.y, width: track.width * CGFloat(clamped / 100), height: 6) - Theme.quotaColor(percent: percent).setFill() - NSBezierPath(roundedRect: fill, xRadius: 3, yRadius: 3).fill() - } -} diff --git a/app/Sources/MenuBarUITests/Harness.swift b/app/Sources/MenuBarUITests/Harness.swift deleted file mode 100644 index 0deb1d6ae4..0000000000 --- a/app/Sources/MenuBarUITests/Harness.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -/// A dependency-free assertion harness. -/// -/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line -/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles -/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to -/// run the unit tests of a menu bar companion would put the tests out of reach for most -/// contributors and for any CI runner without Xcode selected. -/// -/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that -/// both a human and CI can read. If the package ever gains a full-Xcode requirement for -/// other reasons, migrating these cases to swift-testing is mechanical. -public struct TestFailure { - let test: String - let message: String - let file: String - let line: Int -} - -public final class TestRunner { - private(set) var passed = 0 - private(set) var failures: [TestFailure] = [] - private var current = "" - - public init() {} - - public func test(_ name: String, _ body: () throws -> Void) { - current = name - let failuresBefore = failures.count - do { - try body() - } catch { - failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) - print("FAIL — \(name): threw \(error)") - return - } - // A case that recorded an expectation failure is not a pass, even though its - // body returned normally. - if failures.count == failuresBefore { - passed += 1 - print("ok — \(name)") - } - } - - public func expect( - _ condition: Bool, - _ message: @autoclosure () -> String, - file: String = #file, - line: Int = #line - ) { - guard !condition else { return } - let failure = TestFailure(test: current, message: message(), file: file, line: line) - failures.append(failure) - print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") - } - - public func equal( - _ actual: T, - _ expected: T, - _ label: String = "", - file: String = #file, - line: Int = #line - ) { - expect( - actual == expected, - "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", - file: file, - line: line - ) - } - - public func notNil( - _ value: T?, - _ label: String, - file: String = #file, - line: Int = #line - ) -> T? { - expect(value != nil, "\(label) should not be nil", file: file, line: line) - return value - } - - public func isNil( - _ value: T?, - _ label: String, - file: String = #file, - line: Int = #line - ) { - expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) - } - - /// Prints the summary and returns the process exit code. - public func summarize() -> Int32 { - print("") - if failures.isEmpty { - print("\(passed) passed, 0 failed") - return 0 - } - print("\(passed) passed, \(failures.count) FAILED") - for failure in failures { - print(" - \(failure.test): \(failure.message)") - } - return 1 - } -} diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift deleted file mode 100644 index daf7e872b2..0000000000 --- a/app/Sources/MenuBarUITests/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -import AppKit -import MenuBarCore -import MenuBarUI - -// UI-layer tests. Separate from MenuBarCoreTests because these need AppKit and an -// NSApplication; the core suite deliberately has no UI dependency. -// -// These cover the Phase 3 behaviours that were defects in earlier review rounds: -// optimistic rollback, pending state surviving a poll, and the direction-sensitive -// default-provider guard. - -let app = NSApplication.shared -app.setActivationPolicy(.prohibited) - -let runner = TestRunner() - -func provider(_ name: String, enabled: Bool = true) -> ProviderSummary { - let json = #"{"name":"\#(name)","disabled":\#(enabled ? "false" : "true")}"# - return try! JSONDecoder().decode(ProviderSummary.self, from: Data(json.utf8)) -} - -func snapshot( - providers: [ProviderSummary], - defaultProvider: String? = "openai" -) -> ProxySnapshot { - ProxySnapshot( - state: .running(StartupHealth(status: "protected")), - endpoint: .default, - providers: providers, - defaultProvider: defaultProvider, - lastUpdated: Date(), - providersLoaded: true - ) -} - -// MARK: - Default-provider guard direction - -runner.test("ui: an enabled default provider cannot be switched off") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("openai"), provider("anthropic")])) - list.expandForTesting() - - runner.equal(list.isToggleEnabled("openai"), false, "enabled default is inert") - runner.equal(list.isToggleEnabled("anthropic"), true, "non-default is toggleable") -} - -// The proxy guard is `disabled && name === defaultProvider`, so ENABLING the default is -// valid. Making the control inert whenever isDefault stranded the user. -runner.test("ui: a disabled default provider can still be switched back on") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("openai", enabled: false)])) - list.expandForTesting() - - runner.equal(list.isToggleEnabled("openai"), true, "disabled default must be recoverable") -} - -// MARK: - Optimistic update and rollback - -runner.test("ui: a rejected write restores the switch it moved") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - // User switches it off; the write is in flight. - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") - runner.equal(list.isToggleEnabled("anthropic"), false, "inert while in flight") - - // The proxy rejects it. - list.revert("anthropic", to: true) - runner.equal(list.isToggleOn("anthropic"), true, "reverted to the server's value") - runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") -} - -runner.test("ui: a successful write clears busy without reverting") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - // The authoritative refresh now reports it disabled. - list.apply(snapshot(providers: [provider("anthropic", enabled: false)])) - list.setBusy("anthropic", false) - - runner.equal(list.isToggleOn("anthropic"), false, "server state retained") - runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") -} - -// MARK: - Pending state versus a stale poll - -// This is the defect a reviewer caught: rebuildRows initialised each switch from the -// snapshot, so a poll carrying pre-write data snapped the switch back mid-write. -runner.test("ui: a stale poll cannot undo an in-flight optimistic change") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") - - // A poll that started before the write lands, still reporting the old value. - list.apply(snapshot(providers: [provider("anthropic", enabled: true)])) - - runner.equal(list.isToggleOn("anthropic"), false, "stale poll must not snap it back") - runner.equal(list.isToggleEnabled("anthropic"), false, "still inert while in flight") -} - -runner.test("ui: pending state is per provider and does not leak") { - let list = ProviderListView() - list.apply(snapshot(providers: [provider("anthropic"), provider("xai")])) - list.expandForTesting() - - list.setBusy("anthropic", true, intended: false) - runner.equal(list.isToggleEnabled("anthropic"), false, "target is inert") - runner.equal(list.isToggleEnabled("xai"), true, "sibling is unaffected") - runner.equal(list.isToggleOn("xai"), true, "sibling keeps its value") -} - -// MARK: - Empty and unloaded states - -runner.test("ui: providers are hidden until they have actually been read") { - let list = ProviderListView() - var unloaded = snapshot(providers: []) - unloaded.providersLoaded = false - list.apply(unloaded) - runner.equal(list.isHidden, true, "not fetched yet is not the same as none") - - list.apply(snapshot(providers: [])) - runner.equal(list.isHidden, false, "an empty result renders its own copy") -} - -runner.test("ui: hidden providers do not create rows") { - let list = ProviderListView() - var current = snapshot(providers: [provider("openai"), provider("anthropic")]) - current.settings = CompanionSettings(hiddenProviders: ["openai"]) - list.apply(current) - list.expandForTesting() - runner.equal(list.hasProviderForTesting("openai"), false) - runner.equal(list.hasProviderForTesting("anthropic"), true) -} - -runner.test("ui: chart setting hides the timeline view") { - let chart = TimelineChartView() - var current = snapshot(providers: []) - current.timeline = try! JSONDecoder().decode( - UsageTimeline.self, - from: Data(#"{"start":0,"end":1,"bucketSeconds":1,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"#.utf8) - ) - current.settings = CompanionSettings(showChart: false) - chart.apply(current) - runner.equal(chart.isHidden, true) -} - -runner.test("ui: menu title renders from a companion template") { - let report = try! JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":3}}"#.utf8) - ) - var current = snapshot(providers: []) - current.today = report - current.settings = CompanionSettings(menuBarTemplate: "req {requests}") - runner.equal(current.menuBarTitle, "req 3") -} - -exit(runner.summarize()) diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index b130d1fda5..edc91171f4 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -293,8 +293,8 @@ struct OpenCodexWidgetView: View { Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") .font(.title2) Text(failure == .missing - ? "Open the OpenCodex menu bar app to start sharing usage." - : "Snapshot unreadable — refresh from the menu bar app.") + ? "Open the OpenCodex desktop app to start sharing usage." + : "Snapshot unreadable — refresh from the desktop app.") .font(.caption) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift deleted file mode 100644 index 02eca1592b..0000000000 --- a/app/Sources/UIProbe/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -// Visual-QA harness (not shipped). -// -// Presents the real PopoverPanel over a deliberately loud backdrop and captures it with -// CGWindowListCreateImage, so every UI state can be inspected without depending on free -// menu bar space. -// -// Two harness decisions are load-bearing, both learned the hard way: -// * Present through the REAL panel. An earlier version used a plain NSWindow, which -// supplied its own background and hid the fact that the panel had none at all. -// * Capture through the window server. cacheDisplay(in:to:) skips text rendering and -// produced screenshots with no labels. -// -// PROBE_STATE: live | stopped | unauthorized | loading | degraded | empty | overflow -// PROBE_TAG: output filename suffix -// PROBE_APPEARANCE: light | dark (forces appearance without touching system settings) - -import AppKit -import MenuBarCore -import MenuBarUI - -// Presents the real PopoverPanel over a contrasting backdrop and captures it through the -// window server, so the UI can be inspected without depending on menu bar space. -final class ProbeDelegate: NSObject, NSApplicationDelegate { - let controller = PopoverViewController() - var window: NSWindow? - - func applicationDidFinishLaunching(_ n: Notification) { - // Force an appearance for contrast measurement without touching system settings. - if let name = ProcessInfo.processInfo.environment["PROBE_APPEARANCE"] { - NSApp.appearance = NSAppearance(named: name == "dark" ? .darkAqua : .aqua) - } - let endpoint = ProxyDiscovery.resolve() - let client = ProxyClient(endpoint: endpoint) - let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - - // A loud backdrop first: if the panel has no surface of its own, this shows - // straight through and the defect is unmissable. - let backdrop = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), - styleMask: [.titled], backing: .buffered, defer: false) - backdrop.title = "backdrop" - let strip = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 620)) - strip.wantsLayer = true - strip.layer?.backgroundColor = NSColor.systemRed.cgColor - for i in 0..<14 { - let bar = NSView(frame: NSRect(x: 0, y: CGFloat(i) * 44, width: 520, height: 22)) - bar.wantsLayer = true - bar.layer?.backgroundColor = NSColor.systemYellow.cgColor - strip.addSubview(bar) - } - backdrop.contentView = strip - backdrop.center() - backdrop.makeKeyAndOrderFront(nil) - - // Present through the real panel so its surface (or absence of one) is captured. - let realPanel = PopoverPanel() - realPanel.contentViewController = controller - controller.view.layoutSubtreeIfNeeded() - let size = controller.preferredContentSize - realPanel.setContentSize(NSSize(width: 340, height: max(size.height, 200))) - realPanel.setFrameOrigin(NSPoint(x: backdrop.frame.midX - 170, y: backdrop.frame.midY - 150)) - realPanel.makeKeyAndOrderFront(nil) - window = realPanel - NSApp.activate(ignoringOtherApps: true) - - Task { - var snap: ProxySnapshot - let mode = ProcessInfo.processInfo.environment["PROBE_STATE"] ?? "live" - switch mode { - case "stopped": - snap = ProxySnapshot(state: .unreachable, endpoint: endpoint, - lastKnownStartCommand: "ocx service start") - case "unauthorized": - snap = ProxySnapshot(state: .unauthorized, endpoint: endpoint) - case "loading": - snap = ProxySnapshot(state: .loading, endpoint: endpoint) - case "degraded": - snap = ProxySnapshot(state: .degraded("The proxy returned an unexpected status (503)."), - endpoint: endpoint, lastUpdated: Date().addingTimeInterval(-120)) - case "overflow": - let many = (1...24).map { i in - #"{"provider":"p\#(i)","label":"Provider \#(i)","quota":{"weeklyPercent":\#(i * 3)}}"# - }.joined(separator: ",") - let quotas = (try? JSONDecoder().decode([QuotaReport].self, from: Data("[\(many)]".utf8))) ?? [] - let usage = try? JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":100,"totalTokens":1200},"models":[{"provider":"p","model":"m","requests":100,"totalTokens":1200}]}"#.utf8)) - let timeline = try? JSONDecoder().decode( - UsageTimeline.self, - from: Data(#"{"start":0,"end":3600,"bucketSeconds":900,"buckets":4,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"p/m","provider":"p","model":"m","total":1200,"points":[100,200,300,600]}],"availableModels":["p/m"],"missingMeasurements":0}"#.utf8)) - snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, settings: CompanionSettings(menuBarMetric: .tokens), - today: usage, timeline: timeline, - quotas: quotas, - quotasLoaded: true) - case "empty": - let usage = try? JSONDecoder().decode( - UsageReport.self, - from: Data(#"{"range":"today","summary":{"requests":0},"models":[],"accounts":[]}"#.utf8)) - snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, today: usage, quotas: [], providers: [], - providersLoaded: true, quotasLoaded: true) - default: - await coordinator.setPopoverOpen(true) - snap = await coordinator.current - } - await MainActor.run { - self.controller.apply(snap) - // Expand the provider list so its toggles are visible in the capture. - if ProcessInfo.processInfo.environment["PROBE_EXPAND"] == "1" { - self.expandProviders(in: self.controller.view) - } - if ProcessInfo.processInfo.environment["PROBE_RESULT"] != nil { - self.controller.showResult( - ProcessInfo.processInfo.environment["PROBE_RESULT"]!, - isError: ProcessInfo.processInfo.environment["PROBE_RESULT_ERROR"] == "1") - } - self.controller.view.layoutSubtreeIfNeeded() - // Match the real popover: size to content instead of a fixed frame. - let h = self.controller.preferredContentSize.height - if h > 0, let w = self.window { - w.setContentSize(NSSize(width: 340, height: h)) - } - } - try? await Task.sleep(nanoseconds: 1_200_000_000) - await MainActor.run { self.capture() } - } - } - - @MainActor func expandProviders(in view: NSView) { - for sub in view.subviews { - if let button = sub as? NSButton, button.bezelStyle == .disclosure { - button.state = .on - if let target = button.target, let action = button.action { - _ = target.perform(action, with: button) - } - } - expandProviders(in: sub) - } - } - - @MainActor func capture() { - guard let w = window else { return } - let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" - // CGWindowListCreateImage rather than shelling out to screencapture: nothing - // under app/ may construct a Process (030 security rule). The bitmap-rep path - // is not an option either — it skips text rendering entirely. - let id = CGWindowID(w.windowNumber) - if let cg = CGWindowListCreateImage( - .null, .optionIncludingWindow, id, [.boundsIgnoreFraming, .bestResolution] - ) { - let rep = NSBitmapImageRep(cgImage: cg) - if let png = rep.representation(using: .png, properties: [:]) { - try? png.write(to: URL(fileURLWithPath: "/tmp/popover-\(tag).png")) - } - } - NSApp.terminate(nil) - } -} - -let app = NSApplication.shared -app.setActivationPolicy(.regular) -let d = ProbeDelegate() -app.delegate = d -app.run() diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist index 360a159103..568e5fce75 100644 --- a/app/Widget-Info.plist +++ b/app/Widget-Info.plist @@ -4,7 +4,7 @@ CFBundleDevelopmentRegionen CFBundleExecutableOpenCodexWidget - CFBundleIdentifiercom.opencodex.menubar.widget + CFBundleIdentifiercom.opencodex.desktop.widget CFBundleInfoDictionaryVersion6.0 CFBundleNameOpenCodex CFBundlePackageTypeXPC! diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index c134193f42..f59ebe9916 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -303,6 +303,7 @@ Utilisez `ocx service` pour maintenir un proxy d’arrière-plan toujours actif, ### `ocx tray [--json] [--no-start]` Installe et contrôle l’icône OpenCodex dans la zone de notification Windows. Elle démarre à l’ouverture de session et fournit des commandes du proxy accessibles en un clic. `start` et `stop` contrôlent uniquement l’icône ; utilisez son menu pour contrôler le proxy. `--no-start` s’applique à `install` et installe l’icône sans la lancer immédiatement. +Obsolète : l’application OpenCodex fournit la zone de notification sous Windows, macOS et Linux ; `ocx tray` reste disponible pour les installations sans l’application de bureau. ## Tableau de bord diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index a323e659a7..9591108e6c 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -111,7 +111,7 @@ Everything else — accounts, model configuration, storage — stays in the dash Add the widget from the desktop: right-click, choose **Edit Widgets**, then add **OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same -privacy-safe usage snapshot as the menu bar app. The widget refreshes when the app polls. +privacy-safe usage snapshot as the desktop app. The widget refreshes when the app polls. It requires macOS 14 or later and reads only the privacy-safe snapshot written by the OpenCodex app; it does not receive API keys or raw account data. @@ -145,11 +145,13 @@ Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun. ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script -directly: `bash scripts/build-macos-app.sh`. +The bundle appears in Tauri's release output, with the WidgetKit appex under +`OpenCodex.app/Contents/PlugIns/`. Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command Line Tools ships only current-architecture Swift compatibility libraries, and the build @@ -159,7 +161,7 @@ If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTIT sign with the hardened runtime instead of ad-hoc: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## Uninstall diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index ff67a25ccc..3561b02216 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -9,6 +9,12 @@ description: OpenCodex プロキシの状態、使用量、プロバイダーの プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは ローカルの管理 API に接続するクライアントとして動きます。 +## デスクトップアプリ (Tauri) + +同じダッシュボードを OpenCodex デスクトップアプリ内で実行できます。Usage コンパニオン +パネルは OS に合ったインストール手順を表示し、デスクトップシェル内では **ブラウザーで開く** +を選ぶと現在の画面を通常のブラウザーで開けます。 + ## インストール [リリースページ](https://github.com/lidge-jun/opencodex/releases)から @@ -134,11 +140,13 @@ macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接 -実行できます: `bash scripts/build-macos-app.sh`。 +バンドルは Tauri のリリース出力に生成され、WidgetKit 拡張は +`OpenCodex.app/Contents/PlugIns/` に含まれます。 ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には 現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは @@ -148,7 +156,7 @@ bun run build:macos ではなく hardened runtime で署名できます。 ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## アンインストール diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index bf37bc911b..877954e975 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -272,6 +272,7 @@ OpenCodex の更新後、既存の Windows シムにこの動作を適用する ### `ocx tray [--json] [--no-start]` Windows ステータス トレイ アイコンをインストールして制御します。 Windows ログイン時に開始され、ワンクリックでプロキシ コントロールを提供します。 `start` および `stop` はアイコンのみを制御します。そのメニューを使用してプロキシを制御します。 `--no-start` は `install` に適用され、トレイをすぐに起動せずにインストールします。 +非推奨: OpenCodex デスクトップアプリは Windows、macOS、Linux のトレイを提供します。`ocx tray` はデスクトップアプリを使わないインストール向けに残っています。 ## ダッシュボード diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index 53159b4589..d33c9d45ff 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -9,6 +9,12 @@ description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 API에 붙는 클라이언트입니다. +## 데스크톱 앱 (Tauri) + +같은 대시보드를 OpenCodex 데스크톱 앱에서 실행할 수 있습니다. 사용량 패널은 운영체제에 +맞는 설치 단계를 보여주며, 데스크톱 셸 안에서는 **브라우저에서 열기**를 선택해 현재 +대시보드 화면을 일반 브라우저로 열 수 있습니다. + ## 설치 [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 @@ -130,11 +136,13 @@ macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이 ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요: -`bash scripts/build-macos-app.sh`. +번들은 Tauri 릴리스 출력에 생성되며, WidgetKit 확장은 +`OpenCodex.app/Contents/PlugIns/` 아래에 포함됩니다. 유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools 에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 @@ -144,7 +152,7 @@ bun run build:macos 런타임으로 서명할 수 있습니다. ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## 삭제 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index e4f2913c6f..97048c3768 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -390,6 +390,8 @@ OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하 Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로그인 시 시작되며, 프록시를 원클릭으로 제어할 수 있습니다. `start`와 `stop`은 아이콘만 제어합니다. 프록시 제어는 메뉴를 사용하세요. `--no-start`는 `install`에 적용되며, 트레이를 바로 실행하지 않고 설치합니다. +지원 중단 예정: OpenCodex 데스크톱 앱이 Windows, macOS, Linux에서 트레이를 제공합니다. +`ocx tray`는 데스크톱 앱이 없는 설치를 위해 계속 사용할 수 있습니다. ## 대시보드 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e8380c73ca..07f6ca5853 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -611,6 +611,8 @@ file is not part of the injected `env_key` contract; the launching process must Install and control the Windows status tray icon. It starts at Windows login and provides one-click proxy controls. `start` and `stop` control the icon only; use its menu to control the proxy. `--no-start` applies to `install` and installs the tray without launching it immediately. +Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` +remains for installs without the desktop app. ## Dashboard diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index 30f54ad983..2519b08b29 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -9,6 +9,12 @@ description: Нативное приложение, показывающее с Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — клиент, который обращается к локальному management API. +## Настольное приложение (Tauri) + +Ту же панель можно открыть в настольном приложении OpenCodex. Панель компаньона показывает +шаги установки для выбранной ОС, а пункт **Открыть в браузере** открывает текущий экран +в обычном браузере, когда панель работает внутри desktop shell. + ## Установка Скачайте `OpenCodex-<версия>-macos-universal.zip` со @@ -136,11 +142,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую: -`bash scripts/build-macos-app.sh`. +Бандл появится в выходных файлах Tauri, а расширение WidgetKit будет включено в +`OpenCodex.app/Contents/PlugIns/`. Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом @@ -150,7 +158,7 @@ bun run build:macos подписать с hardened runtime вместо ad-hoc: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## Удаление diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index f338d75388..6719e9cf2c 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -395,6 +395,8 @@ ocx codex-shim uninstall one-click управление прокси. `start` и `stop` управляют только иконкой; самим прокси нужно управлять из её меню. `--no-start` применяется к `install` и устанавливает tray, не запуская её немедленно. +Устарело: приложение OpenCodex для рабочего стола предоставляет трей в Windows, macOS и Linux; +`ocx tray` остаётся для установок без приложения для рабочего стола. ## Дашборд diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index ffa5b19df5..f1c3039260 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -470,6 +470,8 @@ Windows durum tepsisi simgesini kurun ve kontrol edin. Windows oturum açılış başlar ve tek tıklamayla proxy kontrolleri sağlar. `start` ve `stop` yalnızca simgeyi kontrol eder; proxy'yi kontrol etmek için menüsünü kullanın. `--no-start`, `install` için geçerlidir ve tepsiyi hemen başlatmadan kurar. +Kullanımdan kaldırıldı: OpenCodex masaüstü uygulaması Windows, macOS ve Linux'ta tepsi sağlar; +`ocx tray`, masaüstü uygulaması olmayan kurulumlar için kullanılmaya devam eder. ## Kontrol Paneli diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 50058524bf..bc352dc779 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -7,6 +7,11 @@ description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供 它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 +## 桌面应用(Tauri) + +同一个仪表板也可以在 OpenCodex 桌面应用中运行。用量面板会显示匹配操作系统的安装步骤; +在桌面壳中选择**在浏览器中打开**,即可在普通浏览器中打开当前页面。 + ## 安装 从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 @@ -117,11 +122,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ```bash git clone https://github.com/lidge-jun/opencodex.git cd opencodex -bun run build:macos +bun run prepare-sidecar +bun run prepare-widget +bunx tauri build ``` -程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本: -`bash scripts/build-macos-app.sh`。 +程序包会生成在 Tauri 的发布输出中,WidgetKit 扩展位于 +`OpenCodex.app/Contents/PlugIns/`。 构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift 兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 @@ -130,7 +137,7 @@ bun run build:macos 替代 ad-hoc 签名: ```bash -MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget ``` ## 卸载 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 30687e2b81..b1fdef059a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -259,6 +259,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安装并控制 Windows 状态托盘图标。它会在 Windows 登录时启动,并提供一键代理控制。`start` 和 `stop` 只控制图标本身;要控制代理,请使用其菜单。`--no-start` 适用于 `install`,会安装托盘但不会立即启动。 +已弃用:OpenCodex 桌面应用在 Windows、macOS 和 Linux 上提供托盘;没有桌面应用的安装仍可使用 `ocx tray`。 ## 仪表盘 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index d9c104d3c9..4317ef37b3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -244,6 +244,7 @@ ocx codex-shim uninstall ### `ocx tray [--json] [--no-start]` 安裝並控制 Windows 狀態列圖示。它在 Windows 登入時啟動並提供一鍵代理控制。`start` 與 `stop` 僅控制圖示;請用其選單控制代理。`--no-start` 適用於 `install`,並在不立即啟動它的情況下安裝 tray。 +已淘汰:OpenCodex 桌面應用程式在 Windows、macOS 與 Linux 提供系統匣;沒有桌面應用程式的安裝仍可使用 `ocx tray`。 ## 儀表板 diff --git a/package.json b/package.json index 4b6912dfa6..b8671c59b0 100644 --- a/package.json +++ b/package.json @@ -58,10 +58,8 @@ "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", - "build:macos": "bash scripts/build-macos-app.sh", "build:standalone": "bun scripts/build-standalone.ts", - "package:macos": "bash scripts/package-macos-release.sh", - "test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests", + "test:macos": "swift run --package-path app MenuBarCoreTests", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/readme/README.fr.md b/readme/README.fr.md index 411a27e759..5a7f5d7955 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -98,7 +98,7 @@ Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fo le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance tierce). Téléchargez-le depuis la [page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec -`bun run build:macos`. +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) diff --git a/readme/README.ja.md b/readme/README.ja.md index ee8a7e6adc..4e65d37511 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -104,7 +104,7 @@ Codex 認証用の **ChatGPT アカウントプール**も管理できます。C ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 [リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 -`bun run build:macos` でローカルビルドできます。 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` でローカルビルドできます。 アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 diff --git a/readme/README.ko.md b/readme/README.ko.md index f93936bc5a..f1e8212a30 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -101,7 +101,7 @@ round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레 대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. 소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 -`bun run build:macos`로 직접 빌드할 수 있습니다. +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드할 수 있습니다. 앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. 자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. diff --git a/readme/README.ru.md b/readme/README.ru.md index d2df43cd07..c2e04bfa89 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -108,7 +108,7 @@ ocx start # прокси + панель управлен Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или -соберите локально командой `bun run build:macos`. +соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). diff --git a/readme/README.tr.md b/readme/README.tr.md index 511a42b0a1..b69a673761 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -104,7 +104,7 @@ bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). [Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya -`bun run build:macos` ile yerel olarak derleyin. +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` ile yerel olarak derleyin. Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index cc2e57271f..70030a04dc 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -101,7 +101,7 @@ ocx start # 代理 + 仪表板:localhost:10100 无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 [`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) -下载,或使用 `bun run build:macos` 在本地构建。 +下载,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 [macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index adb8bf5201..f48a03ac91 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -99,7 +99,7 @@ ocx start # 代理 + 儀表板位於 localhost:10100 無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 [`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) -下載,或使用 `bun run build:macos` 在本機建置。 +下載,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 [macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh deleted file mode 100755 index 8dcac03ee1..0000000000 --- a/scripts/build-macos-app.sh +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Assembles OpenCodex.app by hand. -# -# No Xcode project, so there is nothing to keep in sync with the package manifest. The -# bundle is staged in a temp directory and moved into place at the end, so an interrupted -# build never leaves a half-written .app that launches and misbehaves. - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" -package_dir="$repo_root/app" -output_root="${OUTPUT_DIR:-$repo_root/dist/macos}" -configuration="${CONFIGURATION:-release}" - -if [[ "$(uname -s)" != "Darwin" ]]; then - echo "build:macos requires macOS." >&2 - exit 1 -fi - -# Validate BEFORE creating anything, so the script cannot leave a directory behind at a -# path it then refuses to build into. -# -# `cd … && pwd` keeps LOGICAL paths on macOS, so a symlink inside the repository that -# points elsewhere would satisfy the prefix check below and then be deleted for real. -# Resolve physically: walk up to the nearest existing ancestor, resolve that, and -# re-append the parts that do not exist yet. -resolve_physical() { - local target="$1" part resolved - # Absolute-ise relative input against the caller's directory. - [[ "$target" = /* ]] || target="$PWD/$target" - - # ORDER MATTERS, and getting it wrong has been a bypass twice. - # - # 1. Normalise lexically FIRST. Resolving physically first and normalising afterwards - # lets `..` reveal a symlink that is then never physically resolved — so - # /.missing/../some-symlink passed containment while pointing elsewhere. - # 2. THEN walk up to the nearest existing ancestor of the normalised path and resolve - # that with `pwd -P`, which follows any symlinks that survived normalisation. - # - # Iteration is over a quoted array, never `for part in $tail`: word splitting there - # let a literal glob such as `rel*` expand against the filesystem. - local -a parts=() stack=() - local IFS=/ - read -r -a parts <<< "$target" - unset IFS - - for part in "${parts[@]}"; do - case "$part" in - "" | ".") continue ;; - "..") - # `unset 'stack[-1]'` is a bad subscript in bash 3.2 (what macOS ships), so it - # silently failed and `..` was never applied. Compute the index instead. - if [[ ${#stack[@]} -gt 0 ]]; then - unset "stack[$(( ${#stack[@]} - 1 ))]" - stack=("${stack[@]}") - fi - ;; - *) stack+=("$part") ;; - esac - done - - # Now resolve physically, component by component, so a symlink ANYWHERE along the - # surviving path is followed — including one that only became reachable because a - # `..` removed a non-existent parent above it. - # - # Resolving only the nearest existing ancestor is not enough: for - # /.missing/../outward-link the ancestor is , and the trailing - # `outward-link` symlink was re-appended unresolved and never followed. - resolved="/" - for part in "${stack[@]}"; do - local candidate="${resolved%/}/$part" - if [[ -L "$candidate" && ! -d "$candidate" ]]; then - # A symlink that is not a directory: dangling, or pointing at a file. Following it - # lexically was the third bypass here — a link to `../../outside` produced - # `/../../outside`, which satisfied the `/*` prefix check and then - # escaped during `mkdir -p`. There is no legitimate reason for OUTPUT_DIR to pass - # through such a link, so refuse instead of trying to be clever. - echo "Refusing to build through '$candidate': it is a symlink that does not" >&2 - echo "resolve to an existing directory." >&2 - exit 1 - fi - if [[ -d "$candidate" ]]; then - # `cd … && pwd -P` follows the symlink and any chain behind it. - resolved="$(cd "$candidate" && pwd -P)" - else - resolved="${resolved%/}/$part" - fi - done - printf '%s' "$resolved" -} - -output_root="$(resolve_physical "$output_root")" -app_bundle="$output_root/OpenCodex.app" - -# The build deletes whatever sits at $app_bundle, so the destination must be somewhere -# this project owns. Comparing $app_bundle against $output_root proves nothing — both -# come from the same variable, so pointing OUTPUT_DIR at /Applications would have passed -# and then recursively removed a real app. -allowed_root="$(cd "$repo_root" && pwd -P)" -if [[ -n "${TMPDIR:-}" ]]; then - allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd -P || echo "")" -else - allowed_tmp="" -fi -case "$output_root" in - "$allowed_root"/*) ;; - /private/tmp/*|/tmp/*) ;; - *) - if [[ -z "$allowed_tmp" || "$output_root" != "$allowed_tmp"/* ]]; then - echo "Refusing to build into '$output_root': it is outside the repository and the" >&2 - echo "temp directory. Set OUTPUT_DIR to a path under $repo_root." >&2 - exit 1 - fi - ;; -esac - -mkdir -p "$output_root" - -swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) -widget_swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexWidget) - -if [[ "${UNIVERSAL:-0}" == "1" ]]; then - developer_dir="$(xcode-select -p 2>/dev/null || true)" - if [[ "$developer_dir" == *"CommandLineTools"* ]]; then - echo "UNIVERSAL=1 requires the full Xcode toolchain; Command Line Tools ships only" >&2 - echo "current-architecture Swift compatibility libraries, so the x86_64 slice cannot" >&2 - echo "link. Install Xcode, then:" >&2 - echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 - exit 1 - fi - swift_args+=(--arch arm64 --arch x86_64) - widget_swift_args+=(--arch arm64 --arch x86_64) -fi - -echo "==> Building ($configuration)…" -swift build "${swift_args[@]}" -swift build "${widget_swift_args[@]}" -bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" -executable="$bin_dir/OpenCodexMenuBar" -widget_bin_dir="$(swift build "${widget_swift_args[@]}" --show-bin-path)" -widget_executable="$widget_bin_dir/OpenCodexWidget" - -if [[ ! -x "$executable" ]]; then - echo "Build did not produce an executable at $executable" >&2 - exit 1 -fi -if [[ ! -x "$widget_executable" ]]; then - echo "Build did not produce an executable at $widget_executable" >&2 - exit 1 -fi - -staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" -staged_app="$staging_root/OpenCodex.app" -iconset="$staging_root/OpenCodex.iconset" -cleanup() { rm -rf "$staging_root"; } -trap cleanup EXIT - -mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" -cp "$executable" "$staged_app/Contents/MacOS/OpenCodexMenuBar" -cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" -appex="$staged_app/Contents/PlugIns/OpenCodexWidget.appex" -mkdir -p "$appex/Contents/MacOS" -cp "$widget_executable" "$appex/Contents/MacOS/OpenCodexWidget" -cp "$package_dir/Widget-Info.plist" "$appex/Contents/Info.plist" - -# The app version comes from package.json, so it can never claim a version the release -# did not ship. -version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" -if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Could not read a valid version from package.json: '$version'" >&2 - exit 1 -fi - -# Apple constrains BOTH version fields, and differently from the npm version string: -# -# CFBundleShortVersionString - three period-separated integers. A prerelease suffix -# like "-preview.1" is not valid here. -# CFBundleVersion - ONE TO THREE period-separated integers. A fourth -# component is ignored, so appending a build number to a -# full semver produces no additional identity at all. -# -# So the short version is the numeric core, and when CI supplies a run number it becomes -# the CFBundleVersion outright — a monotonically increasing single integer is both valid -# and genuinely distinguishing, which "2.7.36." would not have been. -version_core="${version%%-*}" -if [[ ! "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Version core must be three integers for CFBundleShortVersionString: '$version_core'" >&2 - exit 1 -fi - -if [[ -n "${MACOS_BUILD_NUMBER:-}" ]]; then - if [[ ! "$MACOS_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then - echo "MACOS_BUILD_NUMBER must be a positive integer, got '$MACOS_BUILD_NUMBER'" >&2 - exit 1 - fi - build_version="$MACOS_BUILD_NUMBER" -else - build_version="$version_core" -fi -if [[ ! "$build_version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then - echo "CFBundleVersion must be one to three integers, got '$build_version'" >&2 - exit 1 -fi - -plutil -replace CFBundleShortVersionString -string "$version_core" "$staged_app/Contents/Info.plist" -plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" -plutil -replace CFBundleShortVersionString -string "$version_core" "$appex/Contents/Info.plist" -plutil -replace CFBundleVersion -string "$build_version" "$appex/Contents/Info.plist" - -# Icon: reuse the dashboard favicon rather than adding another binary asset to the repo. -icon_source="$repo_root/gui/public/favicon.png" -if [[ ! -f "$icon_source" ]]; then - echo "Missing icon source: $icon_source" >&2 - exit 1 -fi -mkdir -p "$iconset" -for size in 16 32 128 256 512; do - sips -z "$size" "$size" "$icon_source" \ - --out "$iconset/icon_${size}x${size}.png" >/dev/null - sips -z "$((size * 2))" "$((size * 2))" "$icon_source" \ - --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null -done -iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" - -# Signing. -# -# MACOS_SIGN_IDENTITY selects a Developer ID Application certificate already present in -# the caller's keychain and enables the hardened runtime, which is what notarization -# requires. It is a LOCAL hook: CI does not set it, because an identity name alone -# cannot sign on a hosted runner — nothing imports the certificate and private key, so -# codesign fails with "no identity found". Wiring CI signing properly means a protected -# P12 import, a temporary keychain, notarytool credentials, and stapling. -# -# Without it the bundle is ad-hoc signed: structurally valid, but `spctl --assess` -# rejects it and a downloaded copy shows "cannot be opened because the developer cannot -# be verified". The project has no Developer ID certificate today, so ad-hoc is what -# ships and the docs must carry the right-click-Open path rather than pretend -# otherwise. -# -# The widget reads the host snapshot through its own bundle container fallback path. -# App Groups require a team-ID-prefixed group and a Developer ID / team-signed extension; -# ad-hoc signatures cannot satisfy that requirement, so the widget uses its own container. -if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then - codesign --force --options runtime --timestamp \ - --entitlements "$package_dir/Widget.entitlements" \ - --sign "$MACOS_SIGN_IDENTITY" "$appex" - codesign --force --deep --options runtime --timestamp \ - --sign "$MACOS_SIGN_IDENTITY" "$staged_app" - echo "==> Signed with $MACOS_SIGN_IDENTITY (hardened runtime)" -else - codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ - --timestamp=none "$appex" - codesign --force --sign - --timestamp=none "$staged_app" - echo "==> Ad-hoc signed (no MACOS_SIGN_IDENTITY): Gatekeeper will require the" >&2 - echo " right-click-Open path on first launch." >&2 -fi - -if [[ -L "$app_bundle" ]]; then - echo "Refusing to replace '$app_bundle': it is a symlink." >&2 - exit 1 -fi -rm -rf "$app_bundle" -mv "$staged_app" "$app_bundle" - -echo "==> Built $app_bundle (release $version, short $version_core, build $build_version)" -lipo -archs "$app_bundle/Contents/MacOS/OpenCodexMenuBar" -lipo -archs "$app_bundle/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" diff --git a/scripts/package-macos-release.sh b/scripts/package-macos-release.sh deleted file mode 100755 index f9b0b1ebd8..0000000000 --- a/scripts/package-macos-release.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Wraps OpenCodex.app for distribution. -# -# Every step is an assertion rather than a hope: a release asset that is produced but -# empty, unsigned, or missing its executable is worse than no asset at all, because the -# failure surfaces on the user's machine instead of in CI. - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" -output_dir="${RELEASE_OUTPUT_DIR:-$repo_root/dist/release}" -universal="${UNIVERSAL:-1}" - -if [[ "$(uname -s)" != "Darwin" ]]; then - echo "package:macos requires macOS." >&2 - exit 1 -fi - -package_version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" -if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid package version for the macOS release asset: '$package_version'" >&2 - exit 1 -fi - -# A release dispatched for one version must never package a different one. -if [[ -n "${RELEASE_VERSION:-}" && "$RELEASE_VERSION" != "$package_version" ]]; then - echo "package.json ($package_version) does not match the requested release (${RELEASE_VERSION})" >&2 - exit 1 -fi - -if [[ "$universal" != "0" && "$universal" != "1" ]]; then - echo "UNIVERSAL must be 0 or 1." >&2 - exit 1 -fi - -mkdir -p "$output_dir" -output_dir="$(cd "$output_dir" && pwd)" - -build_root="$(mktemp -d "${TMPDIR:-/tmp}/OpenCodex-release.XXXXXX")" -cleanup() { rm -rf "$build_root"; } -trap cleanup EXIT - -OUTPUT_DIR="$build_root" UNIVERSAL="$universal" CONFIGURATION=release \ - bash "$script_dir/build-macos-app.sh" >&2 - -app_bundle="$build_root/OpenCodex.app" -executable="$app_bundle/Contents/MacOS/OpenCodexMenuBar" - -codesign --verify --deep --strict --verbose=2 "$app_bundle" - -# Report the Gatekeeper verdict rather than discovering it on a user's machine. An -# ad-hoc build is expected to be rejected; that is documented, not a packaging failure. -# A build that claimed a real identity and STILL fails assessment is a failure. -if spctl --assess --type execute "$app_bundle" >/dev/null 2>&1; then - echo "==> Gatekeeper: accepted" >&2 -else - if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then - echo "Signed with $MACOS_SIGN_IDENTITY but Gatekeeper still rejects the bundle." >&2 - echo "It likely needs notarization (notarytool) and a stapled ticket." >&2 - exit 1 - fi - echo "==> Gatekeeper: rejected (expected for an ad-hoc signature)." >&2 - echo " Users must right-click > Open on first launch; this is documented." >&2 -fi - -architectures="$(lipo -archs "$executable")" -if [[ "$universal" == "1" ]]; then - for required_arch in arm64 x86_64; do - if [[ " $architectures " != *" $required_arch "* ]]; then - echo "Universal build is missing $required_arch (got: $architectures)" >&2 - exit 1 - fi - done - architecture_label="universal" -else - architecture_label="${architectures// /-}" -fi - -archive_name="OpenCodex-${package_version}-macos-${architecture_label}.zip" -checksum_name="${archive_name}.sha256" -archive_path="$output_dir/$archive_name" -checksum_path="$output_dir/$checksum_name" -rm -f "$archive_path" "$checksum_path" - -# ditto rather than zip: it preserves extended attributes and symlinks, so the unpacked -# bundle stays launchable. Plain zip corrupts the code signature. -ditto -c -k --sequesterRsrc --keepParent "$app_bundle" "$archive_path" - -# An archive that exists but does not contain the executable is the failure mode this -# assertion exists to catch. -archive_entries="$(unzip -Z1 "$archive_path")" -if ! grep -Fqx 'OpenCodex.app/Contents/MacOS/OpenCodexMenuBar' <<< "$archive_entries"; then - echo "Packaged archive does not contain the OpenCodex executable." >&2 - echo "Archive entries were:" >&2 - printf '%s\n' "$archive_entries" | head -20 >&2 - exit 1 -fi - -( - cd "$output_dir" - shasum -a 256 "$archive_name" > "$checksum_name" -) - -if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - { - echo "archive_name=$archive_name" - echo "checksum_name=$checksum_name" - } >> "$GITHUB_OUTPUT" -fi - -echo "$archive_path" -echo "$checksum_path" diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6181c9b7f7..23631d963b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,7 +167,6 @@ } }, "explicit": { - "macos-build-script.test.ts": "gui", "gui-desktop-sidecar-script.test.ts": "gui", "standalone-build-script.test.ts": "gui", "standalone-service.test.ts": "service", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index c4fc115c6f..d689d7bcb6 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -17,8 +17,16 @@ Tauri. Generated files under desktop/src-tauri/binaries/ and desktop/src-tauri/resources/ remain ignored. The management API companion presence check in -`src/server/management/companion-routes.ts` accepts both -`OpenCodexMenuBar/` and `OpenCodexDesktop/` user agents. This is presence -telemetry only; management authentication remains in the shared API boundary. +`src/server/management/companion-routes.ts` recognizes the +`OpenCodexDesktop/` user agent. This is presence telemetry only; management +authentication remains in the shared API boundary. The desktop webview uses a Mozilla-compatible `OpenCodexDesktop/` user-agent marker, which the GUI detects to identify the shell without using IPC. + +## Widget snapshot + +The macOS desktop shell writes the WidgetKit snapshot to +`~/Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json`. +The schema version is `1`; the Rust writer refreshes it every five minutes after an +immediate first write. The WidgetKit appex reads this privacy-safe file and performs no +network access. diff --git a/structure/overview.md b/structure/overview.md index 4ead182379..cc5df8cf86 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -33,9 +33,11 @@ native Anthropic passthrough branch that forwards without translation. The Live/ different in kind — it resolves an OpenAI/ChatGPT relay and forwards to it directly, without the adapter bridge. -`app/` is a second, optional surface: a native macOS menu bar companion. It is a client -of the management API, not part of the proxy — it adds no endpoint and changes no -routing. Treat it the way you treat `gui/`: it may consume what `src/` already exposes, +`app/` contains the native macOS WidgetKit extension bundled into the Tauri desktop app. +`MenuBarCore` is its snapshot model/formatting layer; the desktop shell writes the +privacy-safe snapshot that the widget reads without network access. It is a client of +the management API, not part of the proxy — it adds no endpoint and changes no routing. +Treat it the way you treat `gui/`: it may consume what `src/` already exposes, and a change that requires a new endpoint is a change to the proxy first. Its persisted display contract is owned by `src/companion/`. diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index 0cda8de533..3e10aa82c8 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -67,17 +67,17 @@ test("the aggregate gate expects the job instead of ignoring it", () => { // job missing from `expected_for` reads as `undeclared`, not as skipped. const gate = workflow.jobs?.ci; expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("structure-gate"); - expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("macos-app"); + expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("widget"); const script = (gate?.steps ?? []).map(step => step.run ?? "").join("\n"); expect(script).toContain("structure-gate) echo \"$structure\" ;;"); - expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate macos-app\""); - expect(script).toContain("|macos-app)"); + expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate widget\""); + expect(script).toContain("|widget)"); expect(script).toContain("CHANGES_STRUCTURE"); }); -test("app changes select the macOS app job", () => { +test("app changes select the macOS widget job", () => { expect(filters.ci).toContain("app/**"); - const macosApp = workflow.jobs?.["macos-app"]; - expect(macosApp?.if).toContain("needs.changes.outputs.ci == 'true'"); - expect(Array.isArray(macosApp?.needs) ? macosApp?.needs : []).toContain("changes"); + const widget = workflow.jobs?.widget; + expect(widget?.if).toContain("needs.changes.outputs.ci == 'true'"); + expect(Array.isArray(widget?.needs) ? widget?.needs : []).toContain("changes"); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2349a61a26..fd66fd2923 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,4 @@ { - "macos-build-script.test.ts": "gui", "gui-desktop-sidecar-script.test.ts": "gui", "standalone-build-script.test.ts": "gui", "standalone-service.test.ts": "service", diff --git a/tests/gui/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts deleted file mode 100644 index 70d4a018e7..0000000000 --- a/tests/gui/macos-build-script.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { repoPath, repoRoot as findRepoRoot } from "../helpers/repo-root"; - -// The macOS build script deletes whatever sits at its destination, so its containment -// check is a safety boundary rather than a convenience. These run the real script. -// -// Every case here shipped as a defect at some point: -// - the original check compared two values derived from the same variable, so any -// OUTPUT_DIR passed; -// - resolving logical paths let a repository-local symlink point outside; -// - re-appending an unresolved tail let `.nope/../../outside` escape entirely; -// - resolving physically BEFORE normalising let `..` reveal a symlink that was then -// never followed. - -const repoRoot = findRepoRoot(); -const script = repoPath("scripts", "build-macos-app.sh"); -const isMacOS = process.platform === "darwin"; - -const scriptText = await Bun.file(script).text(); -const packageText = await Bun.file(repoPath("app", "Package.swift")).text(); - -async function runScript(outputDir: string, cwd: string = repoRoot) { - const proc = Bun.spawn(["bash", script], { - cwd, - env: { ...process.env, OUTPUT_DIR: outputDir }, - stdout: "pipe", - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([ - new Response(proc.stderr).text(), - proc.exited, - ]); - return { stderr, exitCode }; -} - -/// Runs `body` with a uniquely named sandbox that this test owns and always removes. -/// -/// An earlier version deleted FIXED paths such as `/ocx-escaped-probe`, -/// which would have destroyed unrelated data if anything already lived there. A test -/// for a safety boundary must not itself be destructive. -async function withSandbox(body: (sandbox: string) => Promise): Promise { - const sandbox = mkdtempSync(join(tmpdir(), "ocx-containment-")); - try { - return await body(sandbox); - } finally { - rmSync(sandbox, { recursive: true, force: true }); - } -} - -describe.skipIf(!isMacOS)("macOS build script containment", () => { - test("refuses a destination outside the repository and creates nothing", async () => { - // Deliberately NOT derived from process.env.HOME: other suites replace HOME with a - // temp directory, and temp is a permitted root — so this test built successfully and - // failed during a full-suite run. A sibling of the repository is stable and is - // outside every permitted root. - const target = resolve(repoRoot, "..", `.ocx-outside-${process.pid}-${Date.now()}`); - - const { stderr, exitCode } = await runScript(target); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Refusing to build"); - expect(existsSync(target)).toBe(false); - }, 120_000); - - test("refuses an unresolved .. traversal before creating any directory", async () => { - const intermediate = join(repoRoot, `.ocx-traversal-${process.pid}`); - const escapedName = `.ocx-escaped-${process.pid}-${Date.now()}`; - const escaped = resolve(repoRoot, "..", escapedName); - - // String concatenation, NOT path.join: join() normalises `..` itself, so the script - // would never receive the traversal that was the actual bypass. Written with join() - // this test passed against the broken resolver. - const traversal = `${intermediate}/../../${escapedName}`; - - const { stderr, exitCode } = await runScript(traversal); - - expect(exitCode).not.toBe(0); - // The message names the RESOLVED path, which is the proof normalisation happened. - expect(stderr).toContain(escapedName); - expect(stderr).toContain("Refusing to build into"); - expect(existsSync(escaped)).toBe(false); - expect(existsSync(intermediate)).toBe(false); - }, 120_000); - - test("follows a symlink revealed by a .. traversal instead of trusting the link path", async () => { - const link = join(repoRoot, `.ocx-link-${process.pid}`); - const missing = join(repoRoot, `.ocx-missing-${process.pid}`); - - const { stderr, exitCode } = await withSandbox(async (sandbox) => { - const outside = join(sandbox, "outside-target"); - rmSync(link, { recursive: true, force: true }); - symlinkSync(outside, link); - try { - // Nothing exists at the missing component, so `..` has to be applied lexically - // before the symlink can be resolved. - return await runScript(`${missing}/../${link.split("/").pop()}`); - } finally { - rmSync(link, { recursive: true, force: true }); - rmSync(missing, { recursive: true, force: true }); - } - }); - - // The link points at a directory that does not exist, so the script refuses to - // build THROUGH it rather than guessing where it leads. What must never happen is - // treating the unresolved link path as a destination inside the repository. - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Refusing to build"); - expect(stderr).not.toContain(`${missing}/`); - expect(existsSync(link)).toBe(false); - expect(existsSync(missing)).toBe(false); - }, 300_000); - - test("refuses a symlink that points outside the permitted roots", async () => { - const link = join(repoRoot, `.ocx-outward-${process.pid}`); - const outside = join( - process.env.HOME ?? "/Users/shared", - `.ocx-symtarget-${process.pid}-${Date.now()}`, - ); - - rmSync(link, { recursive: true, force: true }); - symlinkSync(outside, link); - try { - const { stderr, exitCode } = await runScript(link); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Refusing to build"); - expect(existsSync(outside)).toBe(false); - } finally { - rmSync(link, { recursive: true, force: true }); - } - }, 120_000); - - // The third bypass: a RELATIVE dangling target was joined onto the resolved prefix - // without normalising, so `link -> ../../outside` became `/../../outside`, - // satisfied the `/*` prefix check, and escaped during mkdir -p. - test("refuses a symlink whose relative target escapes the repository", async () => { - const link = join(repoRoot, `.ocx-rel-${process.pid}`); - const escaped = resolve(repoRoot, "..", "..", `ocx-rel-target-${process.pid}`); - - rmSync(link, { recursive: true, force: true }); - symlinkSync(`../../ocx-rel-target-${process.pid}`, link); - try { - const { stderr, exitCode } = await runScript(link); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Refusing to build"); - expect(existsSync(escaped)).toBe(false); - } finally { - rmSync(link, { recursive: true, force: true }); - } - }, 120_000); - - // Runs the child in a directory that CONTAINS a matching entry, so the old unquoted - // loop would have expanded the star. With cwd=repoRoot and the glob under dist/, the - // pattern matched nothing and the test passed against the broken implementation too. - test("treats glob characters as literal path components", async () => { - await withSandbox(async (sandbox) => { - const decoy = join(sandbox, "ocx-glob-decoy-probe"); - mkdirSync(decoy, { recursive: true }); - - const { stderr } = await runScript(join(sandbox, "ocx-glob-*-probe"), sandbox); - - expect(stderr).not.toContain("Refusing to build"); - // The literal-star path is the one that was used, not the decoy it could match. - expect(existsSync(join(sandbox, "ocx-glob-*-probe"))).toBe(true); - expect(existsSync(join(decoy, "OpenCodex.app"))).toBe(false); - }); - }, 300_000); - - test("allows a destination inside the repository", async () => { - const inside = join(repoRoot, "dist", `ocx-inside-${process.pid}`); - try { - const { stderr } = await runScript(inside); - expect(stderr).not.toContain("Refusing to build into"); - } finally { - rmSync(inside, { recursive: true, force: true }); - } - }, 300_000); - - test("allows a temp destination", async () => { - await withSandbox(async (sandbox) => { - const { stderr } = await runScript(join(sandbox, "build")); - expect(stderr).not.toContain("Refusing to build into"); - }); - }, 300_000); -}); - -describe("macOS widget packaging", () => { - test("stages, signs, and validates the WidgetKit appex", () => { - expect(scriptText).toContain("--product OpenCodexWidget"); - expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex"); - expect(scriptText).toContain("Widget-Info.plist"); - expect(scriptText).toContain("Widget.entitlements"); - expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget"); - expect(scriptText).toContain("container fallback path"); - expect(packageText).toContain("_NSExtensionMain"); - }); -}); From 092155df85898366a2a5342643136f4fdfb53f8d Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:22:02 -0700 Subject: [PATCH 4/6] docs(cli): deprecate tray command Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/cli/registry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 85c2d66031..7d4cac2803 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -94,9 +94,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "tray", usage: "ocx tray [--json] [--no-start]", - summary: "Install and control the Windows status tray icon.", + summary: "Install and control the Windows status tray icon. Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` remains for installs without the desktop app.", details: [ "The tray starts at Windows login and provides one-click proxy controls.", + "Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` remains for installs without the desktop app.", "Tray start/stop controls the icon only; use its menu to start or stop the proxy.", "--no-start (install only) installs the tray without launching it immediately.", ], From 95f6829c8ad4e56d9cf1cd3f6c9a3e0a9ecd6e1b Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:23:42 -0700 Subject: [PATCH 5/6] fix(release): restore standalone asset attachment Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 29 ++++++++++++++++++++ src/cli/registry.ts | 2 +- structure/desktop-shell.md | 5 ++-- tests/ci-workflows/ci-structure-gate.test.ts | 1 + 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6669d8e8fb..cb206c437e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,35 @@ jobs: if-no-files-found: error retention-days: 7 + attach-standalone: + runs-on: ubuntu-latest + needs: [publish, package-standalone] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download standalone packaged assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: standalone-* + merge-multiple: true + path: dist/release + + - name: Verify the checksum before uploading + run: | + cd dist/release + shasum -a 256 -c ./*.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + # Workflow inputs reach shell code through env, never by interpolation into + # run: source. tests/ci-workflows.test.ts enforces this repo-wide. + RELEASE_VERSION: ${{ inputs.version }} + run: | + gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber + publish: needs: validate-dispatch runs-on: ubuntu-latest diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 7d4cac2803..b6df952a15 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -94,7 +94,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "tray", usage: "ocx tray [--json] [--no-start]", - summary: "Install and control the Windows status tray icon. Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` remains for installs without the desktop app.", + summary: "Install and control the Windows status tray icon (deprecated in favor of the desktop app).", details: [ "The tray starts at Windows login and provides one-click proxy controls.", "Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` remains for installs without the desktop app.", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index d689d7bcb6..8440fe115e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -17,8 +17,9 @@ Tauri. Generated files under desktop/src-tauri/binaries/ and desktop/src-tauri/resources/ remain ignored. The management API companion presence check in -`src/server/management/companion-routes.ts` recognizes the -`OpenCodexDesktop/` user agent. This is presence telemetry only; management +`src/server/management/companion-routes.ts` accepts both +`OpenCodexMenuBar/` (legacy Swift companion) and `OpenCodexDesktop/` user agents. +This is presence telemetry only; management authentication remains in the shared API boundary. The desktop webview uses a Mozilla-compatible `OpenCodexDesktop/` user-agent marker, which the GUI detects to identify the shell without using IPC. diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index 3e10aa82c8..5081034b85 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -77,6 +77,7 @@ test("the aggregate gate expects the job instead of ignoring it", () => { test("app changes select the macOS widget job", () => { expect(filters.ci).toContain("app/**"); + expect(filters.ci).toContain("desktop/**"); const widget = workflow.jobs?.widget; expect(widget?.if).toContain("needs.changes.outputs.ci == 'true'"); expect(Array.isArray(widget?.needs) ? widget?.needs : []).toContain("changes"); From c44c37047e69c35a2b45f1da892b778c4bc95342 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:31:05 -0700 Subject: [PATCH 6/6] fix(desktop): encode widget timeline model ids and resync README manifest Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/src/widget.rs | 22 ++++++++++++++++++++++ readme/i18n-manifest.json | 14 +++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs index 30d5b5326f..cc2c78c0f6 100644 --- a/desktop/src-tauri/src/widget.rs +++ b/desktop/src-tauri/src/widget.rs @@ -231,6 +231,7 @@ mod macos { let models = models .iter() .filter_map(Value::as_str) + .map(percent_encode) .collect::>() .join(","); if !models.is_empty() { @@ -241,6 +242,19 @@ mod macos { query } + fn percent_encode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded + } + fn snapshot_path() -> PathBuf { let home = std::env::var_os("HOME") .map(PathBuf::from) @@ -488,6 +502,14 @@ mod macos { let _ = fs::remove_file(path); } + #[test] + fn timeline_query_encodes_model_ids() { + let query = timeline_query(&json!({ + "settings": { "models": ["openai/gpt-4.1", "claude 3,5"] } + })); + assert!(query.ends_with("&models=openai%2Fgpt-4.1,claude%203%2C5")); + } + #[test] fn chart_series_are_truncated_to_six() { let series = (0..8) diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 569d3612db..ffde56df11 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" + "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9" } } }