diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..2d18494 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,133 @@ +# the name by which the project can be referenced within Serena +project_name: "Claude-Code-Usage-Monitor" + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- rust + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e8c9182 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A lightweight **Windows-only** native taskbar widget (Rust) that displays Claude Code, Codex, and Google Antigravity usage/quota directly in the Windows taskbar, plus system tray icon badges. No backend service; it reads local OAuth credentials and polls provider endpoints directly. + +The project targets Windows exclusively via the `windows` crate (Win32 APIs). It will not build or run on Linux/macOS. The development environment here is WSL, so builds and tests must target Windows (e.g. `cargo build --target x86_64-pc-windows-msvc` or build on a Windows host). + +## Commands + +```bash +cargo build # debug build (Windows target) +cargo build --release # optimized release (opt-level=z, LTO, stripped, panic=abort) +cargo test # run tests +cargo test # run a single test by name +cargo clippy # lint +``` + +Runtime entry points (the produced exe): +- `claude-code-usage-monitor` — normal run, shows widget + tray icons +- `claude-code-usage-monitor --diagnose` — writes a startup/visibility log to `%TEMP%\claude-code-usage-monitor.log` +- `updater::handle_cli_mode` intercepts update-related CLI args before the window launches (see `main.rs`) + +## Architecture + +`main.rs` wires modules and dispatches: diagnose mode → updater CLI mode → `window::run()` (the main message loop). Modules: + +- **`window.rs`** (largest, ~3300 lines) — the heart. Owns the Win32 window (a layered/child window embedded into the taskbar), the `wnd_proc` message handler, all painting, drag-to-move (restricted to the divider handle), DPI/display-change handling, the right-click context menu, settings persistence (`SettingsFile`), and the timers (`TIMER_POLL`, `TIMER_COUNTDOWN`, `TIMER_RESET_POLL`, `TIMER_UPDATE_CHECK`). Background work posts custom messages (`WM_APP_USAGE_UPDATED`, `WM_APP_UPDATE_CHECK_COMPLETE`, `WM_APP_TRAY`) back to the window thread. +- **`native_interop.rs`** — Win32 helpers and shared constants: enumerating taskbars (`find_taskbars`, multi-monitor), locating taskbar child windows, taskbar/window rects, window-style and timer/message constants. Multi-monitor placement is anchored by `taskbar_index` (settings) plus `tray_offset`. +- **`poller.rs`** (~1700 lines) — all network + credential logic. Reads credentials (Windows `~/.claude/.credentials.json`, WSL distro fallback, Codex `auth.json`, Antigravity token in Windows Credential Manager `gemini:antigravity`), polls Anthropic / ChatGPT Codex / Google Antigravity endpoints, refreshes expired tokens by shelling out to the provider CLI (`refresh_or_fallback`, `cli_refresh_*`), and falls back to rate-limit headers. `poll()` is the entry; `credential_watch_*` detects credential changes. +- **`models.rs`** — shared data types: `UsageSection`, `UsageData`, `AppUsageData` (per-provider results consumed by window/tray rendering). +- **`tray_icon.rs`** — system tray (notification area) icons, one per enabled provider, with percentage badges and hover tooltips; left-click toggles widget. +- **`theme.rs`** — colors and the per-provider usage color schemes (Claude warm, Codex black/white, Antigravity Google-blue). +- **`updater.rs`** — update check + self-update (WinGet path and portable self-download from GitHub releases). +- **`localization/`** — 10 languages. `mod.rs` defines `LanguageId`, the `Strings` struct, system-language detection, and language resolution; each `*.rs` returns that language's `Strings`. Add a language by adding a module, extending `LanguageId::ALL`, and the match arms in `mod.rs`. +- **`diagnose.rs`** — opt-in file logging used across modules when `--diagnose` is passed. +- **`build.rs`** — embeds the icon and PE version metadata (from `CARGO_PKG_VERSION`) via `winres`. + +## Persistence + +- Settings: `%APPDATA%\ClaudeCodeUsageMonitor\settings.json` — `SettingsFile` in `window.rs` (tray_offset, taskbar_index, poll_interval_ms, language, last_update_check_unix, widget_visible, show_claude_code, show_codex, show_antigravity). Fields use serde defaults so old files stay forward-compatible — preserve that when adding fields. +- Startup-with-Windows: registry Run key `ClaudeCodeUsageMonitor`. +- Single-instance: global mutex `Global\ClaudeCodeUsageMonitor`. + +## Conventions + +- Version lives only in `Cargo.toml`; `build.rs` packs it into the exe and `updater.rs` compares against it. Release commits are tagged like `v1.4.8`. +- The widget embeds into the real Windows taskbar window; there's a non-embedded fallback paint path (`WM_PAINT`) for when embedding fails. Multi-monitor moves happen by dragging the widget onto another taskbar, which updates `taskbar_index`. diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..22feff6 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Open Antigravity en meld je opnieuw aan. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", antigravity_window_title: "Antigravity-gebruiksmonitor", + monitor: "Monitor", + primary_monitor: "Primair", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..c945823 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Open Antigravity and sign in again. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", antigravity_window_title: "Antigravity Usage Monitor", + monitor: "Monitor", + primary_monitor: "Primary", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..ed85825 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Ouvrez Antigravity et reconnectez-vous. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", antigravity_window_title: "Moniteur d'utilisation Antigravity", + monitor: "Moniteur", + primary_monitor: "Principal", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..b264ef2 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Offnen Sie Antigravity und melden Sie sich erneut an. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", antigravity_window_title: "Antigravity-Nutzungsmonitor", + monitor: "Monitor", + primary_monitor: "Primär", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..e97a3ac 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", + monitor: "モニター", + primary_monitor: "プライマリ", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..a6d4b21 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", + monitor: "모니터", + primary_monitor: "기본", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 2a06b04..a40cb00 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -181,6 +181,8 @@ pub struct Strings { pub antigravity_token_expired_body: &'static str, pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, + pub monitor: &'static str, + pub primary_monitor: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..61d2918 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -47,4 +47,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Abra o Antigravity e entre novamente. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", antigravity_window_title: "Monitor de uso do Antigravity", + monitor: "Monitor", + primary_monitor: "Principal", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..118ec3e 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -47,4 +47,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", + monitor: "Монитор", + primary_monitor: "Основной", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..3a4a175 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Abre Antigravity e inicia sesion otra vez. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", antigravity_window_title: "Monitor de uso de Antigravity", + monitor: "Monitor", + primary_monitor: "Principal", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..e80700d 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", + monitor: "螢幕", + primary_monitor: "主要", second_suffix: "秒", }; diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..043052f 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,5 +1,12 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITORINFOEXW, MONITOR_DEFAULTTONULL, +}; + +/// `MONITORINFOF_PRIMARY` flag (not re-exported by the `windows` crate version +/// in use). Set in `MONITORINFO::dwFlags` for the primary monitor. +const MONITORINFOF_PRIMARY: u32 = 0x0000_0001; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; @@ -24,10 +31,48 @@ pub const WM_APP: u32 = 0x8000; pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1; pub const WM_APP_TRAY: u32 = WM_APP + 3; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct TaskbarWindow { pub hwnd: HWND, pub rect: RECT, + /// Stable display device name (e.g. `\\.\DISPLAY1`) of the monitor this + /// taskbar lives on. Empty if it could not be resolved. Unlike a positional + /// index, this survives explorer restarts and monitor reordering, so it is + /// used to re-attach to the same physical monitor across sessions. + pub monitor: String, + /// Whether this taskbar lives on the primary monitor. Used as the fallback + /// target when no remembered monitor matches, since a geometric index can + /// point at the wrong screen on multi-monitor setups (issue #43). + pub is_primary: bool, +} + +/// Resolve the stable display device name and primary-monitor flag for the +/// monitor a window sits on. +pub fn monitor_info_for_window(hwnd: HWND) -> Option<(String, bool)> { + unsafe { + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL); + if monitor.is_invalid() { + return None; + } + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + if GetMonitorInfoW(monitor, &mut info.monitorInfo as *mut MONITORINFO).as_bool() { + let len = info + .szDevice + .iter() + .position(|&c| c == 0) + .unwrap_or(info.szDevice.len()); + let name = String::from_utf16_lossy(&info.szDevice[..len]); + let is_primary = (info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY) != 0; + if name.is_empty() { + None + } else { + Some((name, is_primary)) + } + } else { + None + } + } } pub fn find_taskbars() -> Vec { @@ -39,7 +84,14 @@ pub fn find_taskbars() -> Vec { let class_name = String::from_utf16_lossy(&class_name[..len as usize]); if class_name == "Shell_TrayWnd" || class_name == "Shell_SecondaryTrayWnd" { if let Some(rect) = get_taskbar_rect(hwnd).or_else(|| get_window_rect_safe(hwnd)) { - taskbars.push(TaskbarWindow { hwnd, rect }); + let (monitor, is_primary) = + monitor_info_for_window(hwnd).unwrap_or_default(); + taskbars.push(TaskbarWindow { + hwnd, + rect, + monitor, + is_primary, + }); } } } @@ -169,6 +221,11 @@ pub fn get_window_thread_id(hwnd: HWND) -> u32 { unsafe { GetWindowThreadProcessId(hwnd, None) } } +/// Whether a window handle still refers to an existing window. +pub fn window_exists(hwnd: HWND) -> bool { + unsafe { IsWindow(hwnd).as_bool() } +} + /// Unhook a WinEvent hook pub fn unhook_win_event(hook: HWINEVENTHOOK) { unsafe { diff --git a/src/window.rs b/src/window.rs index f6d261e..b6e0be9 100644 --- a/src/window.rs +++ b/src/window.rs @@ -84,6 +84,7 @@ struct AppState { last_update_check_unix: Option, taskbar_index: usize, + taskbar_monitor: Option, tray_offset: i32, dragging: bool, drag_start_mouse_x: i32, @@ -131,6 +132,10 @@ const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +/// Base id for the dynamic per-monitor menu entries. Entry `IDM_MONITOR_BASE + i` +/// selects the `i`-th taskbar in `find_taskbars()` order. +const IDM_MONITOR_BASE: u16 = 200; +const IDM_MONITOR_MAX: u16 = 215; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -140,6 +145,10 @@ const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// recreates the taskbar and wipes our tray-icon registration). const TASKBAR_WATCH_INTERVAL_SECS: u64 = 2; +/// Consecutive watchdog ticks the stored taskbar handle must be gone before we +/// treat it as a real explorer restart (rather than a transient enumeration miss). +const TASKBAR_MISSING_TICKS: u32 = 2; + static SUPPRESS_TRAY_REPOSITION_UNTIL: Mutex> = Mutex::new(None); /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) @@ -232,21 +241,42 @@ fn relaunch_self() { /// dedicated thread (independent of the dead message loop) polls the taskbar /// handle and, when it changes, relaunches the widget as a fresh process. fn spawn_taskbar_watchdog() { - std::thread::spawn(move || loop { - std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); - let stored = { - let state = lock_state(); - state.as_ref().and_then(|s| s.taskbar_hwnd) - }; - // Only relevant once we have embedded into a taskbar at least once. - let Some(old) = stored else { - continue; - }; - let taskbars = native_interop::find_taskbars(); - if !taskbars.is_empty() && !taskbars.iter().any(|taskbar| taskbar.hwnd == old) { + std::thread::spawn(move || { + let mut missing_streak = 0u32; + loop { + std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); + let stored = { + let state = lock_state(); + state.as_ref().and_then(|s| s.taskbar_hwnd) + }; + // Only relevant once we have embedded into a taskbar at least once. + let Some(old) = stored else { + missing_streak = 0; + continue; + }; + // Opening the Start menu/search makes the taskbar briefly drop out of + // EnumWindows (~5s) even though its HWND is still valid. The old check + // read that as an explorer restart and relaunched, wiping the widget. + // If the handle still exists it is a transient blip, so skip. + if native_interop::window_exists(old) { + missing_streak = 0; + continue; + } + let taskbars = native_interop::find_taskbars(); + if taskbars.is_empty() || taskbars.iter().any(|taskbar| taskbar.hwnd == old) { + missing_streak = 0; + continue; + } + // Only relaunch once the handle is truly gone for several consecutive + // checks (a real explorer restart), not a one-off enumeration miss. + missing_streak += 1; + if missing_streak < TASKBAR_MISSING_TICKS { + continue; + } + missing_streak = 0; let new = taskbars[0].hwnd; diagnose::log(format!( - "watchdog: taskbar changed old={:?} new={:?} -> relaunching", + "watchdog: taskbar destroyed old={:?} new={:?} -> relaunching", old.0, new.0 )); relaunch_self(); @@ -302,6 +332,8 @@ struct SettingsFile { tray_offset: i32, #[serde(default)] taskbar_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + taskbar_monitor: Option, #[serde(default = "default_poll_interval")] poll_interval_ms: u32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -323,6 +355,7 @@ impl Default for SettingsFile { Self { tray_offset: 0, taskbar_index: 0, + taskbar_monitor: None, poll_interval_ms: default_poll_interval(), language: None, last_update_check_unix: None, @@ -382,6 +415,7 @@ fn save_state_settings() { save_settings(&SettingsFile { tray_offset: s.tray_offset, taskbar_index: s.taskbar_index, + taskbar_monitor: s.taskbar_monitor.clone(), poll_interval_ms: s.poll_interval_ms, language: s .language_override @@ -494,19 +528,67 @@ fn toggle_widget_visibility(hwnd: HWND) { } } -fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { - let taskbars = native_interop::find_taskbars(); +/// Pick which enumerated taskbar to attach to. A previously chosen monitor +/// (by stable device name) wins over the positional index, so the widget stays +/// on the same physical monitor even when the taskbar enumeration order changes +/// (multi-monitor setups, explorer restarts, DPI/display changes). +/// +/// When no remembered monitor matches, prefer the primary monitor's taskbar +/// rather than a geometric index. The sorted index 0 is the taskbar with the +/// smallest `top`, which on a setup with a vertical secondary monitor is often +/// that secondary screen — the exact source of the widget landing on the wrong +/// monitor (issue #43). +fn resolve_taskbar_index( + taskbars: &[native_interop::TaskbarWindow], + preferred_monitor: Option<&str>, + fallback_index: usize, +) -> usize { + if let Some(monitor) = preferred_monitor { + if let Some(index) = taskbars + .iter() + .position(|taskbar| taskbar.monitor == monitor) + { + return index; + } + } + if let Some(index) = taskbars.iter().position(|taskbar| taskbar.is_primary) { + return index; + } + fallback_index.min(taskbars.len().saturating_sub(1)) +} + +fn attach_to_taskbar(hwnd: HWND, requested_index: usize, preferred_monitor: Option<&str>) -> bool { + let mut taskbars = native_interop::find_taskbars(); + + // When we already know which monitor the widget belongs on but its taskbar + // is not present yet (e.g. a secondary taskbar still coming up after an + // explorer restart or display change), briefly wait for it instead of + // falling back to the wrong monitor's taskbar. + if let Some(monitor) = preferred_monitor { + let mut attempts = 0; + while attempts < 10 + && !taskbars + .iter() + .any(|taskbar| taskbar.monitor == monitor) + { + std::thread::sleep(Duration::from_millis(200)); + taskbars = native_interop::find_taskbars(); + attempts += 1; + } + } + if taskbars.is_empty() { diagnose::log("taskbar not found; using fallback popup window"); return false; } - let index = requested_index.min(taskbars.len().saturating_sub(1)); - let taskbar = taskbars[index]; + let index = resolve_taskbar_index(&taskbars, preferred_monitor, requested_index); + let taskbar = taskbars[index].clone(); diagnose::log(format!( - "taskbar selected index={index} count={} hwnd={:?} rect=({}, {}, {}, {})", + "taskbar selected index={index} count={} hwnd={:?} monitor={} rect=({}, {}, {}, {})", taskbars.len(), taskbar.hwnd, + taskbar.monitor, taskbar.rect.left, taskbar.rect.top, taskbar.rect.right, @@ -546,6 +628,9 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { s.tray_notify_hwnd = tray_notify; s.win_event_hook = hook; s.taskbar_index = index; + if !taskbar.monitor.is_empty() { + s.taskbar_monitor = Some(taskbar.monitor.clone()); + } s.embedded = true; } true @@ -1321,6 +1406,7 @@ pub fn run() { update_status: UpdateStatus::Idle, last_update_check_unix: settings.last_update_check_unix, taskbar_index: settings.taskbar_index, + taskbar_monitor: settings.taskbar_monitor.clone(), tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, @@ -1331,7 +1417,11 @@ pub fn run() { } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { + if attach_to_taskbar( + hwnd, + settings.taskbar_index, + settings.taskbar_monitor.as_deref(), + ) { embedded = true; } @@ -2487,7 +2577,14 @@ unsafe extern "system" fn wnd_proc( s.tray_offset = new_offset; } } - if attach_to_taskbar(hwnd, target_index) { + // Honor the dropped monitor explicitly; otherwise the + // primary-monitor fallback would override the user's drop. + let preferred = if target_taskbar.monitor.is_empty() { + None + } else { + Some(target_taskbar.monitor.clone()) + }; + if attach_to_taskbar(hwnd, target_index, preferred.as_deref()) { position_at_taskbar(); render_layered(); } @@ -2568,10 +2665,40 @@ unsafe extern "system" fn wnd_proc( let mut state = lock_state(); if let Some(s) = state.as_mut() { s.tray_offset = 0; + // Forget the remembered monitor so the widget falls + // back to the primary monitor's taskbar. + s.taskbar_monitor = None; } } + // Re-attach with no monitor preference: this resolves to the + // primary monitor and re-stores it as the remembered monitor. + attach_to_taskbar(hwnd, 0, None); save_state_settings(); position_at_taskbar(); + render_layered(); + } + id if (IDM_MONITOR_BASE..=IDM_MONITOR_MAX).contains(&id) => { + let target = (id - IDM_MONITOR_BASE) as usize; + let taskbars = native_interop::find_taskbars(); + if let Some(taskbar) = taskbars.get(target) { + let preferred = if taskbar.monitor.is_empty() { + None + } else { + Some(taskbar.monitor.clone()) + }; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.taskbar_monitor = preferred.clone(); + s.tray_offset = 0; + } + } + if attach_to_taskbar(hwnd, target, preferred.as_deref()) { + save_state_settings(); + position_at_taskbar(); + render_layered(); + } + } } IDM_START_WITH_WINDOWS => { set_startup_enabled(!is_startup_enabled()); @@ -2715,6 +2842,7 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + taskbar_monitor, ) = { let state = lock_state(); match state.as_ref() { @@ -2729,6 +2857,7 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.taskbar_monitor.clone(), ), None => ( POLL_15_MIN, @@ -2741,6 +2870,7 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + None, ), } }; @@ -2859,6 +2989,46 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + // Monitor submenu: one entry per detected taskbar. Only shown when more + // than one taskbar exists (i.e. a real multi-monitor setup). + let taskbars = native_interop::find_taskbars(); + if taskbars.len() > 1 { + let monitor_menu = CreatePopupMenu().unwrap(); + for (index, taskbar) in taskbars.iter().enumerate() { + if index >= (IDM_MONITOR_MAX - IDM_MONITOR_BASE) as usize { + break; + } + let selected = match taskbar_monitor.as_deref() { + Some(name) => !name.is_empty() && name == taskbar.monitor, + // No explicit choice yet: reflect the primary-monitor default. + None => taskbar.is_primary, + }; + let mut label = format!("{} {}", strings.monitor, index + 1); + if taskbar.is_primary { + label.push_str(&format!(" ({})", strings.primary_monitor)); + } + let label_wide = native_interop::wide_str(&label); + let flags = if selected { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + monitor_menu, + flags, + (IDM_MONITOR_BASE as usize) + index, + PCWSTR::from_raw(label_wide.as_ptr()), + ); + } + let monitor_label = native_interop::wide_str(strings.monitor); + let _ = AppendMenuW( + settings_menu, + MF_POPUP, + monitor_menu.0 as usize, + PCWSTR::from_raw(monitor_label.as_ptr()), + ); + } + let language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() {