Skip to content

Stop CodexBar from stealing focus - #713

Draft
Finesssee wants to merge 1 commit into
mainfrom
fix/no-focus-stealing
Draft

Finesssee wants to merge 1 commit into
mainfrom
fix/no-focus-stealing

Conversation

@Finesssee

@Finesssee Finesssee commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

CodexBar should take the foreground only when the user acts on it directly: a tray click, a tray menu item, the global hotkey, or a click in one of its own windows. Startup, single-instance handoff, refreshes, auto-resume, hooks and proof automation now show things without activating them.

Why it happened: WebviewWindow::set_focus isn't a polite request on Windows.

  • tao 0.34.8 src/platform_impl/windows/window.rs, Window::set_focus (lines 175-186), calls force_window_active (lines 1500-1527).
  • When SetForegroundWindow is refused, that function injects a synthetic Alt down and up through SendInput, then retries. The source comment calls it "a little hack which can "steal" the foreground window permission".
  • This gets past the Windows foreground lock, and the injected Alt can open the menu bar of the app the user is typing in.
  • On top of that, every window was shown with SW_SHOW (tao window_state.rs, WindowFlags::apply_diff, lines 325-337), which activates on its own.

Changes

New shell/activation.rs: one decision point with three levels

  • UserAction calls set_focus (tray click, menu item, hotkey, a click in CodexBar's own UI).
  • IfAllowed makes one plain SetForegroundWindow and accepts a refusal (launch from Start or Explorer, single-instance handoff).
  • Never leaves the foreground alone (proof automation, hide-to-tray and failed-transition recovery).

Showing without activating

  • main ("focus": false in tauri.conf.json), the flyout, Settings and the float bar are built with focused(false), so tao shows them with SW_SHOWNOACTIVATE.
  • set_focus is only called from activation::apply.

Proof mode

  • With CODEXBAR_PROOF_MODE set, activation::suppress_all() turns every request into Never.
  • Surfaces are shown for automation with no set_focus and no foreground change.

Single-instance handoff

  • Before handing off, the second process finds the running instance's {id}-sic/{id}-siw window (tauri-plugin-single-instance 2.4.1 src/platform_impl/windows.rs).
  • It calls AllowSetForegroundWindow for that instance's pid, so the running instance's IfAllowed request succeeds without the Alt hack.

DWM caption refresh

  • SetWindowPos(SWP_FRAMECHANGED …) in shell/dwm.rs now also passes SWP_NOACTIVATE. It runs on every surface transition.

Float bar recovery

  • Un-minimizing used SW_RESTORE, which activates (tao window_state.rs lines 390-402).
  • It now restores with SW_SHOWNOACTIVATE on the main thread. unminimize then only refreshes tao's cached flag (tao window.rs lines 584-598 re-read IsIconic).

Auto-resume

  • Live session: this runs after a background refresh, not a user action. It now calls FlashWindowEx(FLASHW_TRAY | FLASHW_TIMERNOFG) on the session's window, where it used to call ShowWindow(SW_RESTORE) plus SetForegroundWindow. The user-initiated focus_session is unchanged.
  • New console: the new codexbar::host::console_launch calls CreateProcessW with STARTF_USESHOWWINDOW + SW_SHOWMINNOACTIVE, the same request start /min makes. This is needed because CommandExt::show_window is unstable (Tracking issue for CommandExt::show_window rust-lang/rust#127544).
  • Batch-shim bug fix: shims (npm's claude.cmd) now run as "<System32>\cmd.exe" /d /v:off /s /c ""<script>" "<arg>" …", with ", % and control characters rejected.
    • The old wrapper passed that line through std's MSVC quoting, which turns inner quotes into \". cmd.exe can't parse that.

Hooks, Bedrock (aws configure export-credentials) and Doubao (arkcli usage plan)

  • These spawn with CREATE_NO_WINDOW, so background refreshes and events don't pop a console window.

Checked and left unchanged, since they don't activate or only run on a user action: toasts, rfd dialogs, open URL/path, the updater, codex_desktop, login_runner, credentials_store.

Upstream reference

None. This is a Windows-only shell fix (maintainer item "no focus stealing"), with no upstream CodexBar counterpart.

Ported / Deferred

Nothing is ported. Out of scope and unchanged here:

  • Windows Terminal as the default console host: it may ignore SW_SHOWMINNOACTIVE.
  • Flash with Windows Terminal hosting the session: the flash can't find the window, because the window's pid is Windows Terminal's, not the CLI's. The same pid lookup already limited focus_session before this change.
  • Ctrl+C in resumed consoles: CREATE_NEW_PROCESS_GROUP disables Ctrl+C there. This predates this branch.
  • Doubao's piped stdout/stderr with a try_wait loop: pipe-drain risk, predates this branch.

Validation

All commands ran in the worktree with a dedicated CARGO_TARGET_DIR.

Command Result
cargo +1.98.0 fmt --all --check pass
cargo +1.98.0 clippy --workspace --all-targets -- -D warnings pass
cargo test -p codexbar 2165 passed, 0 failed, 1 ignored
cargo test -p codexbar-desktop-tauri 464 passed, 1 failed: commands::tests::bootstrap_payload_exposes_every_provider_variant (see below)

The one failure is #684 and doesn't come from this branch:

New tests:

  • shell::activation: user actions force focus; launches only ask; background never touches the foreground; proof mode never activates anything.
  • state: the pending flyout reveal carries its activation.
  • auto_resume_tests::batch_shims_keep_exact_resume_arguments.
  • host::console_launch (Windows):
    • MSVC quoting for executables.
    • The system cmd.exe line for .cmd/.bat, case-insensitive, with & ( ) kept literal.
    • Rejection of ", % and control characters.
    • A GetSystemDirectoryW lookup.
  • agent_sessions: request_session_attention reports Unsupported for remote and file-only sessions.

Affected areas

  • apps/desktop-tauri/src-tauri/src/: shell/ (activation, window, transition, flyout, Settings, DWM), floatbar/window.rs, commands/surface.rs, tray_bridge.rs, main.rs, proof_harness.rs, state.rs, auto_resume.rs, tauri.conf.json.
  • rust/src/: host/console_launch.rs (new), agent_sessions/focus.rs, core/hooks.rs, providers/bedrock, providers/doubao, managed_process.rs (build_command_line is now pub(crate)).

UI proof

PASS with browser-use over WebView2 CDP on d38bce7d: #713 (comment)

  • Three proof-mode runs (tray panel, flyout, Settings → General) and one second-instance launch.
  • No CodexBar process became the foreground in 22,657 samples taken every 15 ms, and the user's foreground app never changed.
  • Every webview, including the float bar and the flyout opened by the handoff, reported document.hasFocus() false while visible. Dark under theme auto; no host email or account visible.
  • Not covered (native, browser-use per maintainer): tray click and hotkey (UserAction), IfAllowed outside proof mode, the auto-resume flash and minimized console, and CREATE_NO_WINDOW spawns. The unit tests above cover them.

CodexBar now takes the foreground only on a direct user action (tray
click, tray menu item, global hotkey, a click in its own UI).

- Route every focus request through shell::activation (UserAction,
  IfAllowed, Never). Only UserAction calls set_focus, whose tao
  implementation injects a synthetic Alt through SendInput.
- Build main, flyout, Settings and the float bar unfocused so show()
  uses SW_SHOWNOACTIVATE; add SWP_NOACTIVATE to the DWM frame refresh.
- Startup and single-instance handoff ask Windows once
  (IfAllowed); the second instance passes its foreground permission on
  with AllowSetForegroundWindow before handing off.
- Proof mode (CODEXBAR_PROOF_MODE) shows surfaces without activating
  them.
- Float bar recovery restores with SW_SHOWNOACTIVATE instead of
  SW_RESTORE.
- Auto-resume flashes an already running session's taskbar button
  instead of restoring and activating it, and starts new resume consoles
  minimized and inactive (SW_SHOWMINNOACTIVE). Batch shims now run
  through the system cmd.exe with quoting cmd.exe parses; the old
  wrapper produced \" escapes that cmd.exe cannot read.
- Hooks, the Bedrock AWS CLI call and Doubao's arkcli run with
  CREATE_NO_WINDOW, so background refreshes open no console windows.
@coderabbitai

coderabbitai Bot commented Sep 30, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Finesssee

Copy link
Copy Markdown
Collaborator Author

UI proof (browser-use)

Result: PASS on build d38bce7dc9b07682125e4074d3ba08d0b668c4e7, the current PR head "Stop CodexBar from stealing focus". In three proof-mode runs (tray panel, flyout, Settings → General) and one second-instance launch, no CodexBar process ever became the foreground window. The user's own foreground app stayed in front for all 22,657 samples, taken every 15 ms. Every webview, including the float bar and the flyout opened by the handoff, reported document.hasFocus() false while visible.

At the maintainer's direction, this proof drove the app's WebView2 over CDP with the browser-use CLI instead of CUA. It used only DOM reads: no activate_tab, keyboard, mouse or focus. The proof windows stayed on the second display, and the kit settings turned the global shortcut off.

Setup

  • Build: pnpm run tauri:build:debug at d38bce7d in the worker worktree. The exe was copied to the proof kit.
  • Proof-only patch: never committed, and reverted after the build. It is only the workspace Cargo.toml [patch.crates-io] dirs shim for an isolated home, plus the resulting Cargo.lock change. The focus behavior under test is this PR's own code, with no overlay.
  • Data: isolated USERPROFILE, HOME, APPDATA, LOCALAPPDATA, XDG_CONFIG_HOME and provider homes under the kit, with the provider API key variables unset. Only Codex is enabled. The theme is auto, the float bar is on, start minimized is off and the global shortcut is empty, so no hotkey is registered.
  • Seed: CODEXBAR_SEED_USAGE_JSON holds one bridge-shaped Codex snapshot: Session 61% (5-hour window, resets 3 hours after launch), Weekly 74%, an informational reset-credits row 7 days out, and errorState: "ready". With the seed active, non-forced refreshes skip the fetch, so nothing is fetched and no account is read. Every reset is under 24.8 days away, because of the setTimeout overflow on main described in Not blocking.
  • Monitors (read-only):
    • fg-monitor.ps1 samples GetForegroundWindow every 15 ms. It logs each foreground change with the pid, the process name and whether the process is in the proof exe's tree. It never logs window titles.
    • proof-window-guard.ps1 and a descendant-window guard move proof windows to the second display with SWP_NOACTIVATE.
  • Commands:
    • bash launch.sh trayPanel, popOut and settings:general, one app at a time. Each exports CODEXBAR_PROOF_MODE and WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9335 ....
    • Each run was driven with BU_CDP_URL=http://127.0.0.1:9335 BU_NAME=worker-713 BH_TAB_MARKER=0 browser-use < <script>.
    • Before each attach, curl /json/version showed Edg WebView2, and the port 9335 listener was a msedgewebview2 child of this kit's exe.

Results

# Assertion Result
B0 No real email or account from the host is visible. DOM scans of every app page in all runs (main webview, flyout, float bar) found no @ addresses and no host user name, before any screenshot. PASS
B1 No CodexBar process takes the foreground. Foreground samples per run: tray panel 2,548, flyout 2,419, Settings plus handoff 17,690. Each log's only foreground record is its initial sample (the user's browser), and 0 samples were in the proof exe's tree. tree-windows.ps1 after run 1 and after the handoff: every proof window was on the second display, and the foreground pid was outside the proof tree. PASS
B2 The surface renders with the seed. The tray panel (run 1, main webview) and the flyout (run 2, index.html?window=flyout) both show "Codex / Updated just now / Session 61% used / Weekly 74% used". In every run, get_cached_providers returns Codex with errorState ready, 61% and 74%, and no error. PASS
B3 Dark under theme auto. In the tray panel, the flyout and Settings: data-theme=dark, color-scheme: dark, body rgb(28, 28, 30) with text rgb(245, 245, 247). The float bar is also data-theme=dark, with its transparent background and text rgba(255, 255, 255, 0.95). Settings → General shows Theme "Auto (system)". PASS
B4 No webview holds focus. Every app page in every run reports document.hasFocus() false, with visibilityState visible and activeElement BODY. PASS
B5 The float bar shows without activation. Its target is present in all three runs, showing "74%", with hasFocus() false. B1 holds over its creation. PASS
B6 The second-instance handoff activates nothing. With run 3's instance on Settings → General, a second launch of the kit exe with the kit environment ran from 00:24:34.804Z and exited 0 at 00:24:35.026Z. The running instance then opened the flyout: a new index.html?window=flyout target that wasn't there before the launch, with its DWM setup logged at 00:24:35.032Z. The flyout rendered the seeded card with hasFocus() false. The foreground didn't change, and only the first instance kept running. In proof mode, suppress_all() turns the handoff's IfAllowed into Never, so this run checks the show-without-activating path. PASS
— Tray click, tray menu items and the global hotkey (UserAction → set_focus) Not covered (native, browser-use per maintainer). Unit tests: shell::activation.
— IfAllowed outside proof mode: a launch from Start or Explorer, and a handoff with AllowSetForegroundWindow Not covered (native, browser-use per maintainer). Unit tests: shell::activation, state.
— Auto-resume flash (FlashWindowEx) and the minimized console (SW_SHOWMINNOACTIVE) Not covered (native, browser-use per maintainer). Unit tests: auto_resume_tests, host::console_launch, agent_sessions.
— CREATE_NO_WINDOW spawns (hooks, Bedrock, Doubao) and float bar un-minimize with SW_SHOWNOACTIVATE Not covered (native, browser-use per maintainer).

Validation at d38bce7d

Run in the worker worktree on Rust 1.98.0. This PR doesn't change the frontend.

Command Result
cargo +1.98.0 fmt --all --check pass
cargo +1.98.0 clippy --workspace --all-targets -- -D warnings pass
cargo +1.98.0 test -p codexbar 2165 passed, 0 failed, 1 ignored
cargo +1.98.0 test -p codexbar-desktop-tauri 464 passed, 1 failed: bootstrap_payload_exposes_every_provider_variant, the non-hermetic #684 test that #711 fixes
ci/circleci: pr-check SUCCESS
CodeRabbit SUCCESS

Screenshots

All paths are under C:\Users\FSOS\AppData\Local\Win-CodexBar\port-audit\proof\713\shots\.

  • bu-trayPanel-traypanel.png: run 1, the tray panel with the seeded Codex card.
  • bu-popOut-flyout.png: run 2, the flyout window.
  • bu-settings-general-main.png: run 3, Settings → General in dark, with Theme "Auto (system)".
  • bu-settings-handoff-flyout.png: the flyout that the second launch opened.

The three 328×434 tray panel captures are byte-identical. That's expected: the seed and window size are the same, and the panel shows no countdown.

Not blocking (found on main, not caused by this PR)

  • The reset-refresh timer in useProviders overflows for resets more than about 24.8 days away. WebView2 stores the setTimeout delay as a 32-bit signed integer, so the timer fires at once and forces a refresh. The Port upstream 0.70.0: refresh 16 provider brand accents #719 proof comment has the details. It's tracked as a separate fix, and this kit's seed keeps every reset under that limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant