Skip to content

feat(windows): indicate device battery in the tray - #964

Open
yuzi-co wants to merge 4 commits into
AprilNEA:masterfrom
yuzi-co:feat/tray-battery-indicator
Open

feat(windows): indicate device battery in the tray#964
yuzi-co wants to merge 4 commits into
AprilNEA:masterfrom
yuzi-co:feat/tray-battery-indicator

Conversation

@yuzi-co

@yuzi-co yuzi-co commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

The Windows tray now reports per-device battery three ways: rows in the right-click menu, an optional battery glyph in place of the brand mark, and hover text that mirrors the menu wording. A low-battery balloon fires once per drained device rather than on every inventory tick.

Two new [app_settings] keys drive it, both read live (no agent restart): battery_alerts (default true) and tray_icon_style (brand | battery, default brand). Both are exposed as switches under Settings → General, Windows-gated for this milestone.

This is the Windows milestone; the macOS menu-bar item gets the same surfaces next, which is why the shared modules (tray_battery, tray_glyph, battery_alert) are platform-neutral with only the Windows callers wired up. The rationale for each design choice lives in those modules' own docs.

Fixes #959

Related, and deliberately not closed by this PR: #669 asks for a macOS desktop widget showing battery — a different surface, out of scope here.

Changes

  • openlogi-core — device display names; the battery_alerts / tray_icon_style settings on AppSettings, with TrayIconStyle exported from config; BatteryInfo::needs_attention, the one rule for "this battery is low".
  • openlogi-agent-core — new battery_alert module: the low-battery state machine. Fires on → Low and Low → Critical, never twice for the same severity, never on a level the firmware reports as Unknown, never for a device on power. Charging, recovering, or vanishing re-arms a device. Orchestrator gains live battery_alerts() / tray_icon_style() reads.
  • openlogi-agenttray_battery (menu-row snapshot + tooltip, a pull surface read when the menu opens, so no idle wakeups), tray_glyph (icon selection, warning on exactly the readings the GUI device card warns on), and notify (one-shot alerts, raised by the agent because the GUI is normally closed). tray_windows grows balloon / tooltip / glyph message arms; the glyph and tooltip are both replayed when Explorer restarts and drops the icon. Battery glyphs render from Lucide SVGs vendored under assets/lucide (0BSD).
  • openlogi-desktop — two switches under General, Windows-gated.
  • openlogi-ui — four new strings across all 22 locales.
  • docs — the two settings in docs/CONFIGURATION.md and docs/config.example.toml; a README feature bullet.

Rebased past #983, which redesigned the GUI battery indicator. That redesign moved the card's low-battery decision to a percentage while the tray still read the firmware's level bucket, so the two could disagree about the same device — a Good bucket at 15% drew an attention tone on the card and a healthy glyph in the tray. The second commit lifts the rule into openlogi-core and points the card, the tray, and the alert threshold at it, which is what the drift test now checks. The tray keeps its finer-grained glyphs above the warning line, where the card has nothing to disagree with.

One dependency edge is added: resvg, pinned to the version gpui already resolves to, so Cargo.lock grows exactly one line and no gpui pin moves.

run() in the agent crossed the 100-line budget with the new bookkeeping, so its autostart-and-permissions preamble is extracted as reconcile_startup().

Testing

Run on the final tree (Windows 11, RUSTFLAGS="-D warnings"):

cargo fmt --all -- --check                                    pass
cargo clippy --workspace --all-targets -- -D warnings         pass
cargo test --workspace                                        pass (see note)
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps \
  --document-private-items --exclude openlogi-ui \
  --exclude openlogi-desktop --exclude openlogi-overlay \
  --exclude openlogi-agent                                    pass (see note)
cargo test -p openlogi-desktop i18n                           pass
cargo test -p openlogi-ui locale                              pass
cargo deny check                                              unchanged vs master

36 unit tests cover the alert state machine, row/tooltip derivation, and glyph selection, including two that hold the tray's low state to the same predicate the device card uses, so the two cannot drift apart silently.

Two notes, both pre-existing on master and verified against a clean origin/master worktree, not introduced here:

Not runtime-tested on hardware. The tray glyph, balloon, and tooltip need a Windows desktop with a battery-reporting device. To verify: enable both switches in Settings → General, then check the right-click menu lists each online device, hover text matches those rows, the icon shows the lowest-charge device's glyph, and a balloon fires once as a device crosses 20% and again at 10%. Restarting Explorer should leave both the glyph and the tooltip intact.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds Windows tray battery rows, hover text, selectable battery glyphs, and one-shot low-battery notifications, backed by live configuration settings and a shared battery-attention predicate.

  • Adds battery alert state tracking and tray snapshot/glyph derivation.
  • Extends the Windows tray message pump with process-local payload mailboxes, shell-update retries, and Explorer-restart restoration.
  • Adds Windows-gated desktop settings, localized labels, documentation, and vendored Lucide battery assets.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the prior alert rearming, shell-update retry, and unauthenticated pointer-decoding issues are addressed in the current code.

Important Files Changed

Filename Overview
crates/openlogi-agent/src/tray_windows.rs Adds Windows tray battery surfaces and replaces externally forgeable pointer payloads with synchronized process-local mailboxes while retrying rejected shell updates.
crates/openlogi-agent/src/main.rs Integrates tray battery reconciliation and continuously evaluates alert state even while notifications are disabled.
crates/openlogi-agent-core/src/battery_alert.rs Implements per-device low and critical battery transition tracking with recovery, charging, disappearance, and unknown-reading handling.
crates/openlogi-agent/src/tray_battery.rs Derives menu rows and bounded tooltip text while supporting cache invalidation after failed shell handoffs.
crates/openlogi-agent/src/tray_glyph.rs Selects the desired brand or battery glyph and makes failed handoffs retryable through cache invalidation.
crates/openlogi-core/src/config/settings.rs Adds persisted application settings for battery alerts and tray icon style with backward-compatible defaults.

Sequence Diagram

sequenceDiagram
  participant Inventory as Inventory watcher
  participant Agent as Agent core
  participant State as Battery/tray state
  participant Tray as Windows tray thread
  participant Shell as Windows Shell
  Inventory->>Agent: Device inventory snapshot
  Agent->>State: Derive rows, tooltip, glyph, and alerts
  State-->>Agent: Changed tray state and new alerts
  Agent->>Tray: Post payload-free wakeup
  Tray->>State: Read process-local mailbox
  Tray->>Shell: NIM_MODIFY icon, tooltip, or balloon
  alt Shell update fails
    Tray->>State: Invalidate deduplication cache
    Inventory->>Agent: Next reconciliation snapshot
  end
Loading

Reviews (10): Last reviewed commit: "fix(windows): stop trusting the tray win..." | Re-trigger Greptile

Comment thread crates/openlogi-agent/src/tray_windows.rs Outdated
Comment thread crates/openlogi-agent/src/main.rs
Comment thread crates/openlogi-agent/src/tray_windows.rs Outdated
@yuzi-co
yuzi-co force-pushed the feat/tray-battery-indicator branch 2 times, most recently from 0c36f29 to 36ede56 Compare August 25, 2026 08:04
Comment thread crates/openlogi-agent/src/tray_windows.rs
@yuzi-co
yuzi-co force-pushed the feat/tray-battery-indicator branch 3 times, most recently from d658198 to 7829e36 Compare August 25, 2026 08:34
@yuzi-co

yuzi-co commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master (`8c80c6e`) after #960/#961 landed — one README conflict where the button-remapping bullet was reworded next to the new battery bullet; kept master's wording.

Also dropped docs/plans/2026-08-19-tray-battery-indicator-m1-windows.md from the PR (−2,003 lines, now 2,964 insertions instead of 4,847). It was a step-by-step build script whose value expired when the work landed, and every path in it predated the openlogi-guiopenlogi-desktop split, so it would have shipped as misleading documentation. The design spec stays: it explains why the code is shaped this way (lowest-charge-wins selection, pull-not-push menu rows, alerts raised by the agent rather than the GUI), which the code comments reference and which outlives the milestone.

Worth flagging for review: this PR still introduces docs/specs/ as a new top-level convention — master has neither docs/specs/ nor docs/plans/ today. Happy to drop the spec too and fold its rationale into the module docs if you would rather not start that directory here.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Start a greploop in Codex and it will work through the open comments and keep going until this PR reviews clean.

@yuzi-co
yuzi-co force-pushed the feat/tray-battery-indicator branch from 7829e36 to 8fe71d1 Compare August 25, 2026 10:40
@yuzi-co

yuzi-co commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Dropped docs/specs/ — the design spec is out, so this PR no longer introduces any new top-level docs directory (the implementation plan went earlier). The diff is 48 files / 2,604 insertions, down from 4,847 when both documents were in.

Before deleting it I checked what the spec held that the code did not. Two of its three recorded risks were already documented better in the modules themselves — battery_alert.rs explains the level-versus-percentage trigger choice in more depth than the spec did, and tray_glyph::state_for covers lowest-charge-wins and why the menu rows make it safe. One was genuinely missing, so it moved into show_balloon:

Focus Assist / Do Not Disturb can swallow a NIF_INFO balloon with no error to report, so a successful call here is not proof anything appeared on screen.

That is the kind of thing worth having in the file someone opens when a user reports that alerts never fire.

Also rebased onto fcd95b2: #974 moved the live Config behind ConfigState, so the two tray-battery setters now go through ConfigState::edit like set_show_in_menu_bar beside them.

Gate green — fmt, clippy --workspace --all-targets, rustdoc, locale parity, and the three-target cross-lint (Windows / Linux / macOS).

@yuzi-co
yuzi-co force-pushed the feat/tray-battery-indicator branch 3 times, most recently from b123284 to 1cf4390 Compare August 25, 2026 15:11
@davidbudnick davidbudnick added type: feature New feature request platform: windows Windows-specific issue labels Aug 25, 2026
The tray icon, menu, and tooltip now report per-device battery, and a
low-battery balloon fires once per drained device rather than on every
inventory tick.

- openlogi-core gains device display names, the low-battery state machine,
  and the `battery_alerts` / `tray_icon_style` settings behind the existing
  app-settings schema.
- The agent publishes a tray battery row snapshot and picks the glyph
  battery, filtering states that did not change so the shell is not asked to
  redraw an identical icon.
- The Windows tray draws the battery glyph over the brand mark from vendored
  lucide icons (0BSD, checked in under assets/lucide), restoring the plain
  mark when the glyph is switched off.
- The GUI exposes both settings under General, in all 22 locales.
- Alerts ignore a level OpenLogi derived itself, so a computed estimate
  cannot raise a balloon the device never reported.

Squashed from 18 commits to port across the openlogi-gui -> openlogi-desktop
split; the pre-squash history is on backup/tray-battery-pre-squash.
The tray glyph, the GUI device card, and the low-battery alert each
carried their own rule for "this battery is low". They agreed when the
tray was written, and stopped agreeing as soon as the card was redesigned
to decide by percentage while the tray still read the firmware's level
bucket: an MX Master reporting `Good` at 15% drew an attention tone on
the card and a healthy glyph in the tray, for the same device, at the
same moment.

Move the rule to `BatteryInfo::needs_attention` in openlogi-core, which
all three now call. The alert's `LOW_PERCENTAGE` becomes an alias of the
same constant — an alert for a device neither surface is flagging would
read as a bug.

Above the threshold the tray keeps its finer-grained glyphs, which the
card has no equivalent for; a firmware `Low` up there is the 0x1000
bucket being eager (it calls everything from 20% to 49% low) and now
draws as the comfortable charge it is.
`update_tray_battery` skipped `BatteryAlerts::evaluate` entirely when the
setting was off, which froze the fold rather than silencing it. Turning
alerts back on then replayed a world that no longer existed: a device that
drained past the threshold while muted fired immediately, and one that had
already alerted and since recovered stayed marked as alerted, so its next
real crossing was swallowed.

Fold on every tick and gate only the notification — the same reasoning the
glyph beside it already documents for publishing unconditionally.

Also drops a line from `evaluate`'s docs claiming it never fires above
`LOW_PERCENTAGE`. `alert_level` honours a firmware `Critical` at any
reading, which `a_firmware_critical_marker_fires_above_the_low_threshold`
has always asserted; the module header explains why. Only the summary was
wrong.
@yuzi-co
yuzi-co force-pushed the feat/tray-battery-indicator branch from 1cf4390 to b6a8085 Compare August 25, 2026 16:04
@yuzi-co

yuzi-co commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Self-review pass on the rebased branch turned up two defects; both fixed in b6a8085.

The alert bookkeeping froze while alerts were muted. update_tray_battery skipped BatteryAlerts::evaluate entirely when the setting was off, rather than silencing its output. Turning alerts back on then replayed a stale world: a device that drained past the threshold while muted alerted immediately, and one that had alerted and since recovered stayed marked as alerted, so its next real crossing was swallowed. It now folds every tick and gates only the notification — the same reasoning the tray glyph beside it already documents for publishing unconditionally.

evaluate's docs contradicted its code. They claimed it never fires above LOW_PERCENTAGE, but alert_level honours a firmware Critical at any reading — which a_firmware_critical_marker_fires_above_the_low_threshold has asserted all along, and the module header explains why. Only the summary was wrong; the behaviour is unchanged.

Rebased onto master (77a4a9b, v0.8.0) and re-ran the full local gate on the final tree: fmt, workspace clippy, workspace tests, rustdoc, i18n parity, plus Linux and macOS cross-lints of the non-GUI crates. The only failures are the two Windows-only defects that live on master and are fixed by #970.

Comment thread crates/openlogi-agent/src/tray_windows.rs
`PostMessageW` targets a window any same-integrity process on the desktop
can find, and UIPI filters nothing between equals, so a `WPARAM` arriving
at `wnd_proc` is attacker-supplied data. The balloon and tooltip arms took
theirs straight to `Box::from_raw`: a forged `WM_APP+2` was a free of an
arbitrary integer, and the safety comment's argument — "we posted it
exactly once" — was a description of our own callers rather than anything
the code enforced.

The payloads move to a process-local mailbox and the messages become bare
wakeups, so a forged post now drains an empty queue and does nothing. The
glyph message already carried a scalar, validated through
`BatteryGlyph::from_index`; only its doc needed correcting.

Balloons queue and the tooltip replaces, which the new test pins along
with the mailbox being where payloads live.

Also invalidates the dedup caches when `Shell_NotifyIconW(NIM_MODIFY)`
rejects an update. The post-failure and no-window paths already did this;
the shell-rejection path did not, so an icon or hover text the shell
refused stayed refused — every later identical snapshot was filtered as a
repeat, leaving the stale value up until the next real change or an
Explorer restart.
@yuzi-co

yuzi-co commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Worked through Greptile's open findings; all three are addressed in 353fd96.

The two unresolved P1s were both right and both the same shape: the tray's dedup caches were invalidated when there was no window and when PostMessageW failed, but not when Shell_NotifyIconW(NIM_MODIFY) itself rejected the update. That left the one case where the request reached the tray thread and the shell refused it — every later identical snapshot then filtered as a repeat, so the stale glyph or hover text stayed up until the next real change or an Explorer restart. Both paths now invalidate.

The security finding was also right, and the fix goes further than its thread suggested. PostMessageW targets a window any same-integrity process on the desktop can find, and UIPI filters nothing between equals — so a WPARAM arriving at wnd_proc is attacker-supplied data. The balloon and tooltip arms took theirs straight to Box::from_raw. Worth being precise about the blast radius: this is not a privilege boundary, since a same-integrity process can already OpenProcess the agent outright. What it is, is an unsound unsafe block — the safety comment argued we post it exactly once, which describes our callers rather than anything the code enforces.

Rather than validate the pointer (which cannot be done), the payloads move to a process-local mailbox and the messages become bare wakeups. A forged post now drains an empty queue and does nothing, and there is no Box::from_raw left in the file. Balloons queue in order and the tooltip keeps only the newest — both pinned by a new test, which also fails if a pointer is ever put back on the wire.

The glyph message already carried a plain scalar validated through BatteryGlyph::from_index, so a forged one can at worst draw the wrong icon. Only its doc comment needed correcting, since it described itself in contrast to the balloon message that no longer works that way.

Gate green on the final tree: fmt, workspace clippy, workspace tests, rustdoc, and Linux/macOS cross-lints. Merges clean against afb10a4.

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

Labels

platform: windows Windows-specific issue type: feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Show battery level on tray icon

2 participants