Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a30e67f
chore(release): open dev at 2.74.0 before releasing 2.73.0 (#6267)
github-actions[bot] Sep 29, 2026
11782ee
fix(release): sign the packaged macOS keyring addons before notarizat…
lidge-jun Sep 30, 2026
d4480b7
ci: run the Linux package E2E on pull requests only for packaging inp…
lidge-jun Sep 30, 2026
1b97c6f
test: isolate server admission lifecycle and synchronize quota switch…
lidge-jun Sep 30, 2026
fdf4177
test: stabilize Windows catalog, picker, and log-guard fixtures (#6277)
lidge-jun Sep 30, 2026
7904af5
fix(codex): support autostart shims from standalone ocx (#6284)
lidge-jun Sep 30, 2026
f69918c
fix(codex): retire main short lock on explicit absent primary (#6287)
lidge-jun Sep 30, 2026
e570586
fix: packaged macOS startup probes and desktop ownership safety (#6286)
lidge-jun Sep 30, 2026
db6e266
fix(kiro): display one final answer across completion retry (#6283)
lidge-jun Sep 30, 2026
6538001
fix(catalog): discover new native models from the live Codex roster a…
lidge-jun Sep 30, 2026
6944cc3
fix(desktop): open dashboard links in the default browser (#6303)
lidge-jun Sep 30, 2026
2405624
feat(minimax): add MiniMax-M3.1-Flash-Preview with its always-on thin…
lidge-jun Sep 30, 2026
cda8ff4
test: keep the suite runnable from Codex-managed worktrees and beside…
lidge-jun Sep 30, 2026
961a4b5
fix(oauth): report browser launch failures and harden device login UX…
lidge-jun Sep 30, 2026
04731c1
fix(xai): one Grok 4.7 row; OAuth Fast uses the faster build-fast lan…
lidge-jun Sep 30, 2026
26acb40
feat(codex): show Codex credits under the Week bar behind a Codex Aut…
lidge-jun Sep 30, 2026
3008bae
chore(release): open dev at 2.75.0 before releasing 2.74.0 (#6317)
github-actions[bot] Sep 30, 2026
592c5cf
test: budget reserve catalog lifecycle children for cold Windows runn…
lidge-jun Sep 30, 2026
943d67b
Merge main into 2.74.0 promotion
lidge-jun Sep 30, 2026
de5c672
chore(release): 2.74.0
lidge-jun Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -270,15 +270,20 @@ jobs:
- '.github/workflows/ci.yml'
gui:
- 'gui/**'
# Building both Linux package formats and booting their real payloads is
# substantially heavier than the Rust-only desktop-shell check. Keep it
# scoped to inputs that can change the packaged shell, dashboard or
# standalone sidecar. The workflow names itself so edits to this lane
# cannot skip their own E2E.
# Building both Linux package formats and booting their real payloads takes
# about 15 minutes, far more than the Rust-only desktop-shell check. On a
# pull request it runs only for inputs that change how the package is
# assembled or launched: the shell itself, the standalone sidecar build
# and its runtime locator, the native keyring staging, and the dependency
# set. Ordinary src/** and gui/** changes no longer select it on a pull
# request; they are still covered on every promotion push to main and
# preview and by workflow_dispatch, which always request this job.
# The workflow names itself so edits to this lane cannot skip their own E2E.
desktop:
- 'desktop/**'
- 'gui/**'
- 'src/**'
- 'src/lib/standalone.ts'
- 'src/lib/keyring-native.ts'
- 'src/lib/bun-runtime.ts'
- 'scripts/build-standalone.ts'
- 'scripts/standalone-keyring.ts'
- 'scripts/standalone-targets.ts'
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "opencodex-desktop"
version = "2.73.0"
version = "2.74.0"
description = "OpenCodex desktop shell"
authors = ["OpenCodex contributors"]
license = "MIT"
Expand Down
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,14 @@ pub fn run() {
popup::hide(app);
startup::open_dashboard(app);
}))
.plugin(tauri_plugin_opener::init())
// Links are opened by the webviews' own new-window handler (`window.rs`), not by the
// plugin's injected click interceptor, which calls an IPC command the loopback dashboard
// is not granted and so swallowed every `target="_blank"` click.
.plugin(
tauri_plugin_opener::Builder::new()
.open_js_links_on_click(false)
.build(),
)
.plugin(tauri_plugin_process::init())
// The argument is what makes a login launch recognisable. Nothing else in a bare launch
// distinguishes it from a person opening the app, and D7 needs the difference.
Expand Down Expand Up @@ -316,6 +323,7 @@ pub fn run() {
// to the loopback dashboard by `capabilities/dashboard-zoom.json`.
.zoom_hotkeys_enabled(true)
.on_navigation(window::navigation_allowed(app.handle().clone()))
.on_new_window(window::open_new_windows_in_default_browser())
// A hidden window still loads pages: wry builds this one with WebView2
// IsVisible=false, and the bootstrap page navigates to the dashboard URL
// afterwards, so the eval that a later show or hide would rely on has nowhere
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ fn ensure(app: &AppHandle, endpoint: ProxyEndpoint) -> tauri::Result<WebviewWind
.user_agent(&window::webview_user_agent())
.initialization_script(initialization_script())
.on_navigation(popup_navigation_allowed(endpoint, app_handle.clone()))
.on_new_window(window::open_new_windows_in_default_browser())
.on_page_load(|popup, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
set_visibility(&popup, popup.is_visible().unwrap_or(false));
Expand Down Expand Up @@ -247,6 +248,9 @@ fn popup_navigation_allowed(
) -> impl Fn(&Url) -> bool + Send + 'static {
move |url| {
if !same_origin(url, endpoint) {
// WKWebView asks this policy before it would create a window for a `_blank` link, so
// refusing here without opening is what left the popup's external links dead on macOS.
window::open_in_default_browser(url);
return false;
}
if is_close_url(url, endpoint) {
Expand Down
64 changes: 59 additions & 5 deletions desktop/src-tauri/src/window.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{auth::Auth, exit, AppState};
use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent};
use tauri::webview::{NewWindowFeatures, NewWindowResponse};
use tauri::{AppHandle, Manager, Runtime, Url, WebviewWindow, WindowEvent};

pub fn webview_user_agent() -> String {
let platform = if cfg!(target_os = "macos") {
Expand Down Expand Up @@ -50,14 +51,47 @@ pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool {
if url.scheme() == "http" && url.host_str() == Some(endpoint.host) {
return url.port_or_known_default() == Some(endpoint.port);
}
if matches!(url.scheme(), "http" | "https") {
let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>);
}
open_in_default_browser(url);
}
false
}
}

/// Whether a URL the dashboard asked for belongs in the user's default browser.
///
/// Only web addresses leave the app. Anything else a page could name (`file:`, `javascript:`,
/// a custom scheme) has no business being handed to the OS launcher from a webview.
fn opens_in_default_browser(url: &Url) -> bool {
matches!(url.scheme(), "http" | "https")
}

pub fn open_in_default_browser(url: &Url) {
if opens_in_default_browser(url) {
let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>);
}
}

/// What a `window.open` or `target="_blank"` link from a shell webview does.
///
/// The shell never grows a second webview: every such request is answered in the default
/// browser and the in-app window is denied. Without this handler the pinned wry answers the
/// request itself, and on no platform does that reach a browser: WebView2 marks it handled and
/// drops it, WebKitGTK creates nothing, and WKWebView only gets there when its navigation policy
/// happens to see the URL first. That is how the dashboard's "didn't open? open the login page"
/// link, and the device-code logins that rely on it, did nothing in the app.
///
/// It is also why the opener plugin's click interceptor is switched off in `lib.rs`: that script
/// cancels a `_blank` click and asks for `plugin:opener|open_url` over IPC, which the loopback
/// dashboard is not granted, so the click was consumed and nothing opened. Routing links here
/// instead keeps the decision in one Rust function and adds no IPC grant to a remote origin.
pub fn open_new_windows_in_default_browser<R: Runtime>(
) -> impl Fn(Url, NewWindowFeatures) -> NewWindowResponse<R> + Send + 'static {
|url, _features| {
open_in_default_browser(&url);
NewWindowResponse::Deny
}
}

/// The bundled `frontendDist` origin.
///
/// Tauri serves it as `tauri://localhost` on macOS and Linux, and as `http://tauri.localhost` on
Expand Down Expand Up @@ -150,13 +184,33 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) {

#[cfg(test)]
mod tests {
use super::{is_app_origin, is_update_page_url, webview_user_agent};
use super::{is_app_origin, is_update_page_url, opens_in_default_browser, webview_user_agent};
use tauri::Url;

fn url(value: &str) -> Url {
Url::parse(value).expect("a url")
}

#[test]
fn only_web_addresses_are_handed_to_the_default_browser() {
for value in [
"https://auth.openai.com/oauth/authorize?client_id=x",
"https://github.com/login/device",
"http://127.0.0.1:1455/auth/callback",
] {
assert!(opens_in_default_browser(&url(value)), "{value}");
}
for value in [
"about:blank",
"file:///etc/passwd",
"javascript:alert(1)",
"tauri://localhost/index.html",
"mailto:someone@example.com",
] {
assert!(!opens_in_default_browser(&url(value)), "{value}");
}
}

#[test]
fn the_app_origin_is_allowed_by_both_spellings_on_every_platform() {
// The custom scheme everywhere, and the http spelling WebView2 needs on Windows. The
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCodex",
"version": "2.73.0",
"version": "2.74.0",
"identifier": "com.opencodex.desktop",
"build": {
"frontendDist": "../ui",
Expand Down
69 changes: 69 additions & 0 deletions devlog/_fin/260930_desktop_external_links/010_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 260930 desktop external links — plan

## Conclusion

Links the loopback dashboard asks to open in a new window (OAuth "didn't open?" fallback,
device-code verification links, `window.open`) now leave the desktop app through one Rust
handler that hands http/https URLs to the OS default browser. Nothing new is granted over IPC.

## Problem

User report: pressing a login button in the desktop app often does not open the default browser.

Two read-only gpt-6.1-sol lanes and direct reads of the pinned crates (tauri 2.11.6, wry 0.55.1,
tauri-plugin-opener 2.5.3) agree:

1. The server-side launch (`/api/oauth/login` -> `openUrl`) is intact for browser flows, but its
result is discarded, and device-code flows (Copilot, Kimi, Nous, Meta Muse, Kiro, Codex device)
never launch server-side by design. Both depend on the GUI's `target="_blank"` link.
2. `tauri_plugin_opener::init()` injects a click listener that `preventDefault()`s every `_blank`
click and invokes `plugin:opener|open_url`. The dashboard is the remote origin
`http://127.0.0.1:*`; its capabilities (`dashboard-titlebar.json`, `dashboard-zoom.json`) grant
no opener permission, so the IPC is denied after the click was already cancelled: nothing opens.
3. No webview installs `on_new_window`. Without it wry drops `window.open` on WebView2
(`SetHandled(true)`) and WebKitGTK (no `create` handler); WKWebView only reaches the browser
because its navigation policy sees the URL first.

## Options considered

- A. Grant `opener:allow-open-url` to the remote dashboard origin. Fixes anchors only, leaves
`window.open` broken on Windows/Linux, and widens the IPC surface of a remote origin.
- B (chosen). Disable the plugin's JS interceptor (`open_js_links_on_click(false)`) and install an
`on_new_window` handler on the main window and the tray popup that opens http/https in the default
browser and returns `NewWindowResponse::Deny`. One decision point in Rust, no new grant, covers
both anchors and `window.open` on all three platforms.

## Diff-level plan

- `desktop/src-tauri/src/window.rs`: extract `opens_in_default_browser(&Url)` (http/https only)
and `open_in_default_browser`; reuse it in `navigation_allowed`; add
`open_new_windows_in_default_browser<R>()` returning the handler; unit test for the scheme filter.
- `desktop/src-tauri/src/lib.rs`: build the opener plugin with `open_js_links_on_click(false)`;
add `.on_new_window(...)` to the main window builder.
- `desktop/src-tauri/src/popup.rs`: add `.on_new_window(...)` to the tray popup builder, and open
external http/https URLs in `popup_navigation_allowed` before refusing them. Audit round 1 (FAIL)
found that WKWebView consults this policy before it would create a window for a `_blank` link, so
a refusal without opening kept the popup's external links dead on macOS; round 2 passed.

Bundled pages (`desktop/ui`) contain no `_blank` anchors or opener calls, so disabling the
interceptor removes nothing they relied on. `mailto:`/`tel:` were never reachable from the
dashboard (same denied IPC) and stay out of the external-open filter.

## Verification

Same steps as the CI `desktop` job: placeholder sidecar and resource files (gitignored), then
`cargo fmt --check`, `cargo clippy --all-targets -D warnings`, `cargo test` for
`desktop/src-tauri`. Exact-head PR CI is the merge gate. A packaged-app click test is not
available locally; the behavior claim rests on the pinned wry/opener sources cited above.

## Outcome

Implemented as planned plus the audit fold in `popup.rs`. Local proof on macOS arm64:
`cargo fmt --check`, `cargo clippy --all-targets -D warnings` and `cargo test` (190 passed,
including `only_web_addresses_are_handed_to_the_default_browser`) exit 0; the desktop,
release-contract and repo-hygiene Bun suites (24 files, 334 pass, 2 platform skips),
`privacy:scan` and `structure:check` pass. Windows and Linux behavior is source-reviewed
against wry 0.55.1 and covered by the hosted desktop CI job, not by a packaged click test.

What this does not change: server-side `openUrl` still discards its launch result, and
device-code logins still do not auto-open a browser; both remain separate follow-ups.
84 changes: 84 additions & 0 deletions devlog/_plan/260930_codex_credits_bar/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 000 — Codex credits balance on Codex Set account cards

## Objective

Show each Codex login's credits balance ("Credits remaining 62,500" on chatgpt.com Codex
usage settings) as a compact row directly under the existing Week quota row on the
Codex Set → Multi-auth main card and every pool card, behind one persisted page-wide
switch, the way the retired Codex Spark quota switch worked (#2649, bf73afee50).

Scope change recorded 2026-09-30: the GPT-5.5 retirement originally bundled with this
unit was removed by the owner ("5.5는 내가 나중에 패치할께 그냥 크레딧만 진행"). For
that later patch: the live `/backend-api/codex/models?client_version=0.170.0` roster still
lists `gpt-5.5` with `visibility: list` and
`upgrade: { model: "gpt-5.6-sol", retirement_at: "2026-10-14T19:00:00Z" }`.

## Evidence (000-range research)

- `GET https://chatgpt.com/backend-api/wham/usage` (Bearer Codex access token +
`ChatGPT-Account-Id`) returns top-level keys `account_id, additional_rate_limits,
chatpass, code_review_rate_limit, credits, email, model_usage, plan_type, promo,
rate_limit, rate_limit_reached_type, rate_limit_reset_credits, spend_control, user_id`.
Probed 2026-09-30 with the main login; only key names and value types were printed.
- `credits` = `{ has_credits: boolean, unlimited: boolean, overage_limit_reached: boolean,
balance: string, approx_local_messages: [number, number], approx_cloud_messages:
[number, number] }`. `balance` is a decimal STRING and is fractional on pool accounts
(e.g. "62498.725", "62479.806261").
- The official Codex usage page (read through Aside, chatgpt.com/codex/cloud/settings/usage)
renders "Credits remaining 62,500 — Credits extend usage beyond your plan limits." as a
plain number with no bar and no denominator.
- opencodex already fetches this exact response: main in
`src/codex/auth-api/main-account-probe.ts` (`fetchMainAccountInfoWhileOwned`, publish block
after `credentialIsCurrent()`), pool in `src/codex/auth-api/pool-quota-probe.ts`
(`publishPoolQuotaResponse`-style block that parses `WhamUsageResponse`). The response's
`credits` object is currently ignored.
- The closest analogue is `rate_limit_reset_credits.available_count`: main keeps it
memory-only and bound to the physical ChatGPT account id
(`rememberMainResetCredits` / `mainResetCreditsForCurrentIdentity`) because the
`__main__` alias can change identity while the proxy is down.

## Decisions

- D1 Storage: new sibling module `src/codex/credits.ts` with a process-local map
keyed by opencodex account id (`__main__` or pool id) and tagged with the identity it
was read from. Never persisted, never logged, never folded into `StoredAccountQuota`
(quota participates in routing, recovery and persistence; credits are display only).
No TTL: the row shows the last observation for the same identity, like reset credits.
Explicit `credits: null` clears; an absent field keeps the previous observation.
- D2 Exposure: optional `credits` on `CodexAuthAccountDto`, emitted only when
`config.showCodexCredits === true`. `/api/provider-quotas` stays unchanged (its
projection is an allowlist). The switch controls exposure only, not probing.
- D3 Switch: `showCodexCredits?: boolean`, default off (absent = off), following the
Spark precedent and the `oauthOpenBrowser` settings chain (type, zod degrade-not-reject
schema, diagnostics, GET/PUT `/api/settings` with validate-mutate-persist-rollback).
Toggle sits in the Codex Auth page head beside Pause exhausted / Refresh quotas.
- D4 Row: same `.quota-row` grid as Week: label "Credits", reset columns reused for
"remaining", a bar, and the formatted balance in the value column. There is no
denominator, so the bar is a STATUS bar, not a percentage: full (ok tone) when a
positive balance or `unlimited`; empty when the balance is zero or
`overage_limit_reached`. Value column: locale-formatted balance (max 2 fraction
digits), "Unlimited", or balance plus "· Overage limit reached". The title tooltip
carries the approx local/cloud message ranges. No `%`, no `role=progressbar`.
Architect D5 proposed a number-only row; the owner explicitly asked for "비슷한 바"
(a bar like the Week one), so the status bar is kept and documented.

## Work-phase map (dependency order)

| Work-phase | Doc | Delivers |
|---|---|---|
| wp0 | 000 (this), 010, 020 | Locked roadmap |
| wp2 | 010_phase1_credits_implementation.md | Parser + store + DTO + setting + GUI row + toggle + tests + docs |
| wp3 | 020_phase2_pr_ci_merge.md | PR from template with screenshot, exact-head CI, merge into `dev` |

## Constraints

- File-size ratchet: `gui/src/styles.css` has one line of headroom → new CSS lives in a
new stylesheet. Keep additions in the large files minimal; put logic in siblings.
- New test files must be registered in `scripts/test-layout/layout.json` `explicit` and
`tests/fixtures/test-layout-expected.json`.
- i18n: every key lands in all ten locales (en, ko, ja, zh, zh-TW, de, fr, ru, tr, vi).
- Privacy: never log WHAM bodies, balances, tokens, or account ids.
- SoT sync: `structure/providers/openai-accounts.md` (credits projection) and
`structure/config.md` (new setting) if they enumerate settings/DTO fields;
`bun run structure:check` must pass.

Loading
Loading