From b4cdf8d005b7f543c321b64e6db07272e3388072 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:47:50 +0200 Subject: [PATCH 1/7] docs(tray): tray process spec + plan (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- .../plans/2026-09-29-tray-process.md | 63 +++++++++++ .../specs/2026-09-29-tray-process-design.md | 101 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-tray-process.md create mode 100644 docs/superpowers/specs/2026-09-29-tray-process-design.md diff --git a/docs/superpowers/plans/2026-09-29-tray-process.md b/docs/superpowers/plans/2026-09-29-tray-process.md new file mode 100644 index 0000000..8745d6f --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-tray-process.md @@ -0,0 +1,63 @@ +# Tray Process Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The tray icon and menu live in a non-UIAccess WindTray.exe so the menu stacks like any app's menu (below the cursor and the Snipping Tool overlay). + +**Architecture:** Wind.exe creates a named shared-memory block with its live status and starts WindTray.exe; the helper owns the icon and menu, reads the block, writes `menuOpen`, and acts through files, ShellExecute and the existing quit event. + +**Tech Stack:** C++17 / MSVC, Win32 (Shell_NotifyIcon, file mapping), doctest, NSIS. + +**Spec:** `docs/superpowers/specs/2026-09-29-tray-process-design.md` + +## Global Constraints + +- No em-dashes anywhere. Pure headers do not include ``. +- WindTray.exe must NOT have a uiAccess manifest; Wind must start it with ShellExecuteW (verified: children do not inherit UIAccess). +- The user-visible tray (icon, tooltip, menu, header, balloons) stays identical. +- Quit goes through `Local\Wind_QuitRequest`, never a kill. +- Branch `feat/291-tray-process`, commits `type(scope): subject` + trailer, patch version bump. + +## Review Focus + +1. Explorer restart while Wind runs: the icon comes back once, never twice. +2. Wind killed (Task Manager): the tray icon disappears (no ghost icon) and WindTray exits. +3. WindTray killed: Wind restarts it; a helper that crashes on start does not spin. +4. Two Wind instances (the eviction handshake on a model relaunch): exactly one tray icon at the end. +5. The menu open while Wind's tick reads `menuOpen`: the weld stays suspended exactly as today. + +--- + +### Task 1: Shared block and status transport + +**Files:** Create `src/tray_ipc.h` (pure layout + version check), `tests/test_tray_ipc.cpp`; modify `src/tray_status.h` (keep labels; add conversion to/from the block), `src/main.cpp` (create the mapping at startup, publish into it each tick instead of the atomics). +- [ ] Tests for the layout check (magic/version mismatch -> "no live data") and the status round trip. +- [ ] Wind creates `Local\Wind_TrayState_v1`, writes `windPid`, publishes level/engine/panning. +- [ ] Decide the diagnostics-export balloon path (shared notify seq/text vs WindConfig-side); implement the smaller. +- [ ] Commit `feat(tray): shared status block (#291)`. + +### Task 2: WindTray.exe + +**Files:** Create `src/tray_app/main.cpp`, `src/tray_app/WindTray.manifest` (asInvoker, PMv2); move `src/tray.cpp` owner-draw/menu/profile code into the helper (shared sources compiled into both where needed); `build.bat` target. +- [ ] Single instance mutex, icon add/remove, `TaskbarCreated` re-add, wait on Wind's process handle -> remove icon and exit. +- [ ] Menu thread as today, header reads the block; `menuOpen` written around TrackPopupMenu. +- [ ] Actions: Settings (WindConfig from own folder), Quit (quit event), Profiles (SwitchToProfile; engine change relaunches Wind.exe from own folder). +- [ ] Commit `feat(tray): WindTray.exe helper owns the tray icon and menu (#291)`. + +### Task 3: Wind.exe side + +**Files:** `src/main.cpp`, `src/tray.cpp/.h` (removed or reduced). +- [ ] Remove the tray icon/menu from Wind; start WindTray with ShellExecuteW + PID; restart it if it exits (<= 3 per minute); read `menuOpen` for `suppressCursorSync`. +- [ ] Commit `refactor(tray): Wind.exe no longer owns the tray (#291)`. + +### Task 4: Packaging + +**Files:** `tools/uiaccess_setup.ps1`, `installer/wind.nsi`, `tools/installer_check.ps1`, `.github/workflows/release.yml` / `alpha.yml` if they list files. +- [ ] Build, deploy, install, uninstall and upgrade include WindTray.exe; installer_check asserts it. +- [ ] Commit `build(tray): ship WindTray.exe (#291)`. + +### Task 5: Verify, docs, ship + +- [ ] Unit tests, build, deploy; owner field test per spec section 6 (owner runs every UI check). +- [ ] Docs: CLAUDE.md (three binaries; the UIAccess stacking reason), `docs/architecture/` process section, installer README. +- [ ] Review workflow, owner approves fixes, PR, owner says merge. diff --git a/docs/superpowers/specs/2026-09-29-tray-process-design.md b/docs/superpowers/specs/2026-09-29-tray-process-design.md new file mode 100644 index 0000000..11d70be --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-tray-process-design.md @@ -0,0 +1,101 @@ +# Tray icon and menu in their own process (issue #291) + +Date: 2026-09-29. Owner: Max. Status: awaiting approval (spec + plan together). + +## 1. Problem + +Wind.exe is a UIAccess process. Windows stacks a UIAccess process's topmost windows, its tray popup +menu included, above almost everything (measured 2026-09-29: `EnumWindows` put the menu at z=1, above +the band-16 cursor sprite at z=7). Consequences seen in the field: + +1. The magnified cursor goes UNDER Wind's own tray menu (#290). +2. The tray menu stays ABOVE the Snipping Tool overlay when a snip starts with the menu open. +3. A workaround that switched the cursor sprite into the menu's band (#290 branch) coincided with + RTSS's hook crashing WebView2 when Settings was opened from that menu (intermittent; the #290 + branch is dropped). + +Settings (WindConfig.exe) is already a separate, non-UIAccess process (verified: a WindConfig started +by Wind has `TokenUIAccess=0`). The tray icon and its menu are the last UI Wind.exe owns. + +## 2. Decision (owner, 2026-09-29) + +Move the tray icon and menu into a small helper process, **WindTray.exe**, that runs WITHOUT +UIAccess. Its menu is then an ordinary app menu: below the cursor sprite, below the Snipping Tool +overlay, like any other program's menu. No cursor band switching, no delay. + +## 3. Behaviour (unchanged for the user) + +The tray icon, tooltip and menu look and act exactly as today: live status header (zoom level, +engine, panning), Profiles submenu, Settings, Quit, and the profile-switch balloons. + +## 4. Design + +### 4.1 Processes + +- **Wind.exe** (UIAccess, unchanged core) no longer creates a tray icon. At startup it creates the + shared status block (4.2) and starts WindTray.exe with `ShellExecuteW` (children of Wind do not + inherit UIAccess). It passes its own PID on the command line. If WindTray exits while Wind runs + (crash, killed), Wind restarts it (checked about once a second from the existing idle path, at most + 3 restarts per minute so a crashing helper cannot spin). +- **WindTray.exe** (normal manifest, `asInvoker`, Per-Monitor-V2 DPI, no UIAccess): single instance + (`Local\Wind_Tray` mutex), owns `Shell_NotifyIcon`, re-adds the icon on `TaskbarCreated` (Explorer + restart), and exits when Wind.exe exits (it waits on Wind's process handle). Removes its icon on + every exit path. + +### 4.2 Shared status block + +A named file mapping `Local\Wind_TrayState_v1` (pure layout in `src/tray_ipc.h`, doctested): + +| Field | Writer | Meaning | +|---|---|---| +| `magic`, `version` | Wind | layout check; a mismatch makes the tray show "Wind" without live values | +| `level`, `engine`, `panning` | Wind (tick) | what `PublishTrayStatus` writes today | +| `menuOpen` | tray | the menu's modal loop is live | +| `windPid` | Wind | the running core | + +`level`/`engine`/`panning` replace today's in-process atomics (`tray_status.h` keeps its pure label +logic; only the transport changes). Wind reads `menuOpen` where it reads `Tray::MenuOpen()` today: +while the user aims at menu items the weld must not re-park the pointer (existing behaviour, +`ex.suppressCursorSync`). + +### 4.3 Menu actions (all in WindTray) + +- **Settings:** `ShellExecuteW` WindConfig.exe from the install folder (the tray's own folder). +- **Quit:** set the existing `Local\Wind_QuitRequest` event (the clean-exit path the installer + already uses), so Wind restores the cursor/clip/Magnifier state as on any quit. +- **Profiles:** today's `SwitchToProfile` moves as is (file I/O on the shared ini, the same way + WindConfig switches profiles). A profile that changes the engine relaunches **Wind.exe** from the + tray's folder (not the tray's own exe path, which is what `GetModuleFileNameW` returns today). +- **Balloons:** shown by the tray (it owns the icon). + +### 4.4 What Wind.exe drops + +`Tray::Add/Remove/Notify/HandleMessage`, the tray menu thread and owner-draw code (moved), the +WM_TRAY handling. The diagnostics-export completion balloon (a Settings-origin path) is shown by the +tray: Wind sets a `notify` sequence + text in the shared block, or the export path moves to WindConfig +(checked in Task 1; whichever is smaller). + +### 4.5 Packaging + +- `build.bat` builds WindTray.exe with the app (and in `uiaccess` builds; it is NOT uiAccess). +- `tools/uiaccess_setup.ps1` copies it next to Wind.exe (signing optional; it needs no UIAccess). +- `installer/wind.nsi` installs and removes it; `tools/installer_check.ps1` checks it is present. +- Upgrade: the installer's quit event stops Wind, and WindTray exits with Wind. + +## 5. Scope + +In: the helper, the shared block, moving the tray code, packaging, restart/exit handling. Out: any +change to what the menu shows or does. + +## 6. Testing + +- Doctests: the shared-block layout/version check and the tray status labels (existing tests move). +- Owner field test on the signed build: menu below the cursor while zoomed; Snipping Tool opened with + the menu open covers the menu; Settings from the tray (WebView2 starts, no crash); Profiles switch + (hot and engine-changing); Quit; Explorer restart re-adds the icon; kill WindTray (Wind restarts + it); quit or kill Wind (the icon disappears, no ghost icon); status header live while zoomed. +- The #290 branch is closed unmerged; its findings stay in the issue. + +## 7. Delivery + +One PR on `feat/291-tray-process`, patch version bump. From 316ceefd806b644e29b1d1ff9c720cc53d6d19df Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:57:44 +0200 Subject: [PATCH 2/7] feat(tray): shared status block (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/tick_stats.h | 6 +- src/tray.h | 11 --- src/{ => tray_app}/tray_draw.h | 0 src/{tray.cpp => tray_app/tray_menu.cpp} | 0 src/tray_ipc.h | 92 ++++++++++++++++++++++++ src/tray_status.h | 27 +------ tests/test_tray_ipc.cpp | 80 +++++++++++++++++++++ tests/test_tray_status.cpp | 9 --- 8 files changed, 177 insertions(+), 48 deletions(-) delete mode 100644 src/tray.h rename src/{ => tray_app}/tray_draw.h (100%) rename src/{tray.cpp => tray_app/tray_menu.cpp} (100%) create mode 100644 src/tray_ipc.h create mode 100644 tests/test_tray_ipc.cpp diff --git a/src/tick_stats.h b/src/tick_stats.h index 0c1c001..1c6483e 100644 --- a/src/tick_stats.h +++ b/src/tick_stats.h @@ -7,7 +7,8 @@ // no lock, no branch worth measuring. That matters: this is the magnifier's hot path, and a status // readout must never be the reason a frame is late. // -// Single producer (the tick loop), single consumer (the tray, when the menu opens). The ring is +// Single producer (the tick loop), single consumer (WindTray.exe, through the shared block in +// tray_ipc.h; a zero-filled ring is a valid empty one). The ring is // deliberately not synchronised beyond a relaxed head counter: a torn read costs one wrong pixel in // a sparkline and nothing else, which is not worth a lock on the tick path. #include @@ -44,9 +45,6 @@ class TickStats { std::atomic head_{0}; }; -// Process-wide instance. Defined inline so the header is self-contained (C++17). -inline TickStats& Ticks() { static TickStats s; return s; } - // --- pure statistics, unit-tested --------------------------------------------------------- // Median of the sample window. Median, not mean: one 25ms stall must not drag the headline figure diff --git a/src/tray.h b/src/tray.h deleted file mode 100644 index b87e66b..0000000 --- a/src/tray.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once -#include -namespace wind { -namespace Tray { - void Add(HWND hwnd, HINSTANCE hInst); // add icon - void Remove(); // delete icon - void Notify(const wchar_t* title, const wchar_t* text); // balloon - bool HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); // true if handled - bool MenuOpen(); // the tray menu's modal loop is live -} -} diff --git a/src/tray_draw.h b/src/tray_app/tray_draw.h similarity index 100% rename from src/tray_draw.h rename to src/tray_app/tray_draw.h diff --git a/src/tray.cpp b/src/tray_app/tray_menu.cpp similarity index 100% rename from src/tray.cpp rename to src/tray_app/tray_menu.cpp diff --git a/src/tray_ipc.h b/src/tray_ipc.h new file mode 100644 index 0000000..fd47cf6 --- /dev/null +++ b/src/tray_ipc.h @@ -0,0 +1,92 @@ +#pragma once +// The status block Wind.exe shares with WindTray.exe (issue #291). PURE (no ): the +// layout and the validity check are unit-tested; the named file mapping itself is opened by each +// side with a few Win32 calls. +// +// Why a second process at all: Wind.exe is UIAccess, and Windows stacks a UIAccess process's +// popup menu above the cursor sprite and above the Snipping Tool overlay (measured 2026-09-29). +// The tray icon and its menu live in WindTray.exe, which runs WITHOUT UIAccess, so its menu is an +// ordinary app menu. This block is the only coupling between the two besides the quit event. +// +// Writers: Wind writes everything except `menuOpen`; the tray writes only `menuOpen`. Every field +// is a lock-free atomic, so a reader in the other process never needs a lock, and a torn pairing +// (last tick's level with this tick's engine) is invisible in a menu. A freshly created mapping is +// zero-filled, which is a valid "no live data" state (magic 0 fails the check). +#include +#include +#include "tick_stats.h" +#include "tray_status.h" + +namespace wind { + +inline constexpr const wchar_t* kTrayBlockName = L"Local\\Wind_TrayState_v1"; +inline constexpr const wchar_t* kTrayMutexName = L"Local\\Wind_Tray"; + +struct TrayShared { + static constexpr uint32_t kMagic = 0x59525457u; // "WTRY" + static constexpr uint32_t kVersion = 1; + + std::atomic magic; + std::atomic version; + std::atomic windPid; // the running core + std::atomic level; // 1.0 = not zoomed + std::atomic engine; // TrayEngine + std::atomic panning; + std::atomic menuOpen; // tray -> Wind: the menu's modal loop is live + TickStats ticks; // frame-pacing ring for the header's fps and sparkline +}; + +// Cross-process atomics must not hide a lock inside the object (a lock would live in one +// process's memory only). +static_assert(std::atomic::is_always_lock_free, "shared block needs lock-free u32"); +static_assert(std::atomic::is_always_lock_free, "shared block needs lock-free i32"); +static_assert(std::atomic::is_always_lock_free, "shared block needs lock-free double"); +static_assert(std::atomic::is_always_lock_free, "TickStats head must be lock-free"); + +// Writer side (Wind): stamp the layout once after mapping. Fields first, magic last, so a reader +// that sees the magic also sees initialised values. +inline void InitTrayBlock(TrayShared& b, uint32_t pid) { + b.windPid.store(pid, std::memory_order_relaxed); + b.level.store(1.0, std::memory_order_relaxed); + b.engine.store(0, std::memory_order_relaxed); + b.panning.store(0, std::memory_order_relaxed); + b.version.store(TrayShared::kVersion, std::memory_order_relaxed); + b.magic.store(TrayShared::kMagic, std::memory_order_release); +} + +// A block written by a different layout (an old Wind next to a new tray, mid-upgrade) must never +// be read as live values: the tray then shows "Wind" with no numbers instead of garbage. +inline bool TrayBlockValid(const TrayShared* b) { + return b && b->magic.load(std::memory_order_acquire) == TrayShared::kMagic && + b->version.load(std::memory_order_relaxed) == TrayShared::kVersion; +} + +inline void PublishTrayStatus(TrayShared* b, const TrayStatus& s) { + if (!b) return; + b->level.store(s.level, std::memory_order_relaxed); + b->engine.store((int32_t)s.engine, std::memory_order_relaxed); + b->panning.store(s.panning ? 1u : 0u, std::memory_order_relaxed); +} + +// Default status (Idle, Advanced) when the block is missing or foreign. +inline TrayStatus ReadTrayStatus(const TrayShared* b) { + TrayStatus s; + if (!TrayBlockValid(b)) return s; + s.level = b->level.load(std::memory_order_relaxed); + const int32_t e = b->engine.load(std::memory_order_relaxed); + s.engine = (e >= 0 && e <= (int32_t)TrayEngine::System) ? (TrayEngine)e : TrayEngine::Advanced; + s.panning = b->panning.load(std::memory_order_relaxed) != 0; + return s; +} + +inline void SetTrayMenuOpen(TrayShared* b, bool open) { + if (b) b->menuOpen.store(open ? 1u : 0u, std::memory_order_relaxed); +} + +// Wind side. A foreign or missing block reads as closed: the only effect of `menuOpen` is to +// suspend the cursor re-park, and a stale "open" would freeze that for good. +inline bool TrayMenuOpen(const TrayShared* b) { + return TrayBlockValid(b) && b->menuOpen.load(std::memory_order_relaxed) != 0; +} + +} // namespace wind diff --git a/src/tray_status.h b/src/tray_status.h index f6170c9..aa5c47f 100644 --- a/src/tray_status.h +++ b/src/tray_status.h @@ -2,10 +2,8 @@ // What the tray shows about the running magnifier. PURE (no ): the label decisions are // where the bugs live, so they are unit-tested rather than eyeballed in a menu. // -// The tick loop publishes a snapshot; the tray reads it when the menu opens. There is no live -// update while the menu is open and that is deliberate (docs/superpowers/specs/2026-08-28-tray-menu-design.md): -// the values are current at the moment you open it, which is all a menu needs to be truthful. -#include +// The tick loop publishes a snapshot into the shared block (tray_ipc.h); WindTray.exe reads it +// while its menu is open. namespace wind { @@ -19,26 +17,7 @@ struct TrayStatus { bool panning = false; }; -// One writer (the tick loop), one reader (the tray). Three plain scalars behind a seqlock-free -// relaxed publish: the worst a torn read can do is pair last tick's level with this tick's engine, -// which is invisible in a menu that opens in 30ms. -inline std::atomic& TrayLevelSlot() { static std::atomic v{1.0}; return v; } -inline std::atomic& TrayEngineSlot() { static std::atomic v{0}; return v; } -inline std::atomic& TrayPanningSlot() { static std::atomic v{false}; return v; } - -inline void PublishTrayStatus(const TrayStatus& s) { - TrayLevelSlot().store(s.level, std::memory_order_relaxed); - TrayEngineSlot().store((int)s.engine, std::memory_order_relaxed); - TrayPanningSlot().store(s.panning, std::memory_order_relaxed); -} - -inline TrayStatus ReadTrayStatus() { - TrayStatus s; - s.level = TrayLevelSlot().load(std::memory_order_relaxed); - s.engine = (TrayEngine)TrayEngineSlot().load(std::memory_order_relaxed); - s.panning = TrayPanningSlot().load(std::memory_order_relaxed); - return s; -} +// The transport (Wind.exe -> WindTray.exe) is the shared block in tray_ipc.h. // --- pure label logic, unit-tested --------------------------------------------------------- diff --git a/tests/test_tray_ipc.cpp b/tests/test_tray_ipc.cpp new file mode 100644 index 0000000..9a772fd --- /dev/null +++ b/tests/test_tray_ipc.cpp @@ -0,0 +1,80 @@ +#include "../third_party/doctest.h" +#include "../src/tray_ipc.h" +#include + +using namespace wind; + +// The block is created zero-filled by the OS; value-initialisation gives tests the same state. +static std::unique_ptr Fresh() { return std::make_unique(); } + +TEST_CASE("a zero-filled block is not live data") { + auto b = Fresh(); + CHECK_FALSE(TrayBlockValid(b.get())); + const TrayStatus s = ReadTrayStatus(b.get()); + CHECK(s.level == doctest::Approx(1.0)); + CHECK(s.engine == TrayEngine::Advanced); + CHECK(s.panning == false); +} + +TEST_CASE("a missing block reads as defaults, never crashes") { + CHECK_FALSE(TrayBlockValid(nullptr)); + CHECK(ReadTrayStatus(nullptr).level == doctest::Approx(1.0)); + CHECK_FALSE(TrayMenuOpen(nullptr)); + PublishTrayStatus(nullptr, TrayStatus{}); // no-op + SetTrayMenuOpen(nullptr, true); // no-op +} + +TEST_CASE("status survives a publish/read round trip through the block") { + auto b = Fresh(); + InitTrayBlock(*b, 4242); + REQUIRE(TrayBlockValid(b.get())); + CHECK(b->windPid.load() == 4242u); + TrayStatus s; s.level = 7.4; s.engine = TrayEngine::Transform; s.panning = true; + PublishTrayStatus(b.get(), s); + const TrayStatus r = ReadTrayStatus(b.get()); + CHECK(r.level == doctest::Approx(7.4)); + CHECK(r.engine == TrayEngine::Transform); + CHECK(r.panning == true); +} + +TEST_CASE("a different layout version is not read as live values") { + auto b = Fresh(); + InitTrayBlock(*b, 1); + PublishTrayStatus(b.get(), TrayStatus{ 5.0, TrayEngine::Render, true }); + b->version.store(TrayShared::kVersion + 1); + CHECK_FALSE(TrayBlockValid(b.get())); + CHECK(ReadTrayStatus(b.get()).level == doctest::Approx(1.0)); + b->version.store(TrayShared::kVersion); + b->magic.store(0xDEADBEEFu); + CHECK_FALSE(TrayBlockValid(b.get())); +} + +TEST_CASE("an out-of-range engine value falls back to Advanced") { + auto b = Fresh(); + InitTrayBlock(*b, 1); + b->engine.store(99); + CHECK(ReadTrayStatus(b.get()).engine == TrayEngine::Advanced); + b->engine.store(-1); + CHECK(ReadTrayStatus(b.get()).engine == TrayEngine::Advanced); +} + +TEST_CASE("menuOpen round-trips, and a foreign block reads as closed") { + auto b = Fresh(); + InitTrayBlock(*b, 1); + CHECK_FALSE(TrayMenuOpen(b.get())); + SetTrayMenuOpen(b.get(), true); + CHECK(TrayMenuOpen(b.get())); + b->magic.store(0); + CHECK_FALSE(TrayMenuOpen(b.get())); // a stale "open" must never pin the weld off +} + +TEST_CASE("the tick ring inside the block works from zero-filled memory") { + auto b = Fresh(); + CHECK(b->ticks.empty()); + b->ticks.push(6.9f); + b->ticks.push(7.0f); + float out[4]; + REQUIRE(b->ticks.snapshot(out, 4) == 2); + CHECK(out[0] == doctest::Approx(6.9f)); + CHECK(out[1] == doctest::Approx(7.0f)); +} diff --git a/tests/test_tray_status.cpp b/tests/test_tray_status.cpp index ade81d4..e7de6f4 100644 --- a/tests/test_tray_status.cpp +++ b/tests/test_tray_status.cpp @@ -44,15 +44,6 @@ TEST_CASE("hybrid reads Advanced, not Auto") { CHECK(std::wstring(EngineLabel(TrayEngine::System)) == L"SYSTEM"); } -TEST_CASE("status survives a publish/read round trip") { - TrayStatus s; s.level = 7.4; s.engine = TrayEngine::Transform; s.panning = true; - PublishTrayStatus(s); - const TrayStatus r = ReadTrayStatus(); - CHECK(r.level == doctest::Approx(7.4)); - CHECK(r.engine == TrayEngine::Transform); - CHECK(r.panning == true); -} - // --- tick statistics ------------------------------------------------------------------- TEST_CASE("the ring returns the newest samples oldest-first") { From 50782f0a226e39b1e5bb8c339da87b2e2a7f186d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:57:55 +0200 Subject: [PATCH 3/7] feat(tray): WindTray.exe helper owns the tray icon and menu (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- build.bat | 27 +++++++ src/tray_app/main.cpp | 140 ++++++++++++++++++++++++++++++++++ src/tray_app/tray_app.h | 31 ++++++++ src/tray_app/tray_draw.h | 11 +-- src/tray_app/tray_icon.cpp | 42 +++++++++++ src/tray_app/tray_menu.cpp | 151 ++++++++++++------------------------- src/tray_app/wind_tray.rc | 28 +++++++ 7 files changed, 321 insertions(+), 109 deletions(-) create mode 100644 src/tray_app/main.cpp create mode 100644 src/tray_app/tray_app.h create mode 100644 src/tray_app/tray_icon.cpp create mode 100644 src/tray_app/wind_tray.rc diff --git a/build.bat b/build.bat index 4ad8c99..93b30bc 100644 --- a/build.bat +++ b/build.bat @@ -26,6 +26,7 @@ if /i "%1"=="check" goto :check if /i "%1"=="uiaccess" goto :uiaccess if /i "%1"=="config" goto :config if /i "%1"=="installer" goto :installer +if /i "%1"=="tray" goto :tray rem --- App build (normal: uiAccess=false, runs from anywhere) ---------------- rem Compile the app-icon resource (rc.exe ships with the Windows SDK, on PATH via vcvars). @@ -38,6 +39,8 @@ cl /nologo /std:c++17 /EHsc /O2 /W4 /Zi /DUNICODE /D_UNICODE ^ d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib windowscodecs.lib ole32.lib oleaut32.lib uuid.lib advapi32.lib ^ /MANIFEST:EMBED /MANIFESTUAC:NO /MANIFESTINPUT:Wind.manifest /SUBSYSTEM:WINDOWS ^ /DEBUG /OPT:REF /OPT:ICF +if errorlevel 1 exit /b 1 +call :tray_exe exit /b %errorlevel% rem --- UIAccess build (uiAccess=true: must be signed + run from Program Files) - @@ -54,6 +57,30 @@ cl /nologo /std:c++17 /EHsc /O2 /W4 /Zi /DUNICODE /D_UNICODE /DWIND_UIACCESS ^ d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib windowscodecs.lib ole32.lib oleaut32.lib uuid.lib advapi32.lib ^ /MANIFEST:EMBED /MANIFESTUAC:NO /MANIFESTINPUT:Wind.uiaccess.manifest /SUBSYSTEM:WINDOWS ^ /DEBUG /OPT:REF /OPT:ICF +if errorlevel 1 exit /b 1 +call :tray_exe +exit /b %errorlevel% + +rem --- Tray helper (WindTray.exe, issue #291). ALWAYS the plain manifest (asInvoker, NO uiAccess): +rem a UIAccess process's menu stacks above the cursor and the Snipping Tool overlay, which is the +rem whole reason the tray lives in its own process. Built by the app and uiaccess targets too. +:tray +call :tray_exe +exit /b %errorlevel% + +:tray_exe +rc /nologo /fo "%ROOT%src\tray_app\wind_tray.res" "%ROOT%src\tray_app\wind_tray.rc" +if errorlevel 1 (echo [build] rc.exe failed for WindTray & exit /b 1) +rem Objects go to src\tray_app\ so the shared sources never overwrite Wind.exe's .obj files. +cl /nologo /std:c++17 /EHsc /O2 /W4 /Zi /DUNICODE /D_UNICODE ^ + /Fo"%ROOT%src\tray_app\\" /Fd"%ROOT%WindTray.pdb" ^ + src\tray_app\*.cpp src\profiles.cpp src\config.cpp src\logging.cpp src\config_ui\ini_edit.cpp ^ + src\tray_app\wind_tray.res ^ + /Fe:WindTray.exe ^ + /link user32.lib shell32.lib gdi32.lib Dwmapi.lib Dbghelp.lib shlwapi.lib ole32.lib version.lib ^ + advapi32.lib ntdll.lib ^ + /MANIFEST:EMBED /MANIFESTUAC:NO /MANIFESTINPUT:Wind.manifest /SUBSYSTEM:WINDOWS ^ + /DEBUG /OPT:REF /OPT:ICF exit /b %errorlevel% rem --- Config UI host (WindConfig.exe). Builds the Svelte UI first if it exists. ---- diff --git a/src/tray_app/main.cpp b/src/tray_app/main.cpp new file mode 100644 index 0000000..3dd302c --- /dev/null +++ b/src/tray_app/main.cpp @@ -0,0 +1,140 @@ +// WindTray.exe (issue #291): owns Wind's notification-area icon and menu, WITHOUT UIAccess. +// +// Lifecycle: Wind.exe starts us with `--wind-pid ` (ShellExecute, so we never inherit its +// UIAccess token). We serve exactly that process and exit when it exits, removing the icon on the +// way out, so a Wind killed from Task Manager leaves no ghost icon. Single instance per session. +#include "tray_app.h" +#include "../logging.h" +#include "../tray_ipc.h" +#include +#include +#include + +namespace wind { namespace TrayApp { + +static TrayShared* g_block = nullptr; +static std::wstring g_appDir; + +TrayShared* Block() { return g_block; } +std::wstring AppDir() { return g_appDir; } + +void RequestWindQuit() { + // The same clean-exit path the installer and Settings use: Wind restores cursor, clip and + // Magnifier state as on any quit. A window message could not reach Wind anyway (UIPI). + HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, L"Local\\Wind_QuitRequest"); + if (ev) { SetEvent(ev); CloseHandle(ev); } + else wind::Log(wind::LogLevel::Warn, "tray", "quit: event open failed (err=%lu)", GetLastError()); +} + +}} // namespace wind::TrayApp + +using namespace wind; + +static UINT g_taskbarCreated = 0; + +static LRESULT CALLBACK TrayWndProc(HWND h, UINT m, WPARAM w, LPARAM l) { + if (m == TrayApp::WM_TRAY && (l == WM_RBUTTONUP || l == WM_LBUTTONUP)) { + POINT pt; GetCursorPos(&pt); + TrayApp::OpenMenu(pt); + return 0; + } + if (g_taskbarCreated && m == g_taskbarCreated) { + // Explorer restarted: the shell forgot every icon. AddIcon deletes before adding, so this + // can never leave two. + TrayApp::AddIcon(h, GetModuleHandleW(nullptr)); + return 0; + } + if (m == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(h, m, w, l); +} + +static DWORD ParseWindPid(const wchar_t* cmd) { + const wchar_t* p = cmd ? wcsstr(cmd, L"--wind-pid") : nullptr; + if (!p) return 0; + p += wcslen(L"--wind-pid"); + while (*p == L' ' || *p == L'=') ++p; + return (DWORD)wcstoul(p, nullptr, 10); +} + +int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR cmdLine, int) { + wind::LogInit(L"tray"); + const DWORD windPid = ParseWindPid(cmdLine); + + wchar_t exe[MAX_PATH]; + if (GetModuleFileNameW(nullptr, exe, MAX_PATH)) { + wchar_t* slash = wcsrchr(exe, L'\\'); + if (slash) { *slash = L'\0'; TrayApp::g_appDir = exe; SetCurrentDirectoryW(exe); } + } + + // The Wind we serve. No PID or a dead PID: there is nothing to show a tray for. + HANDLE hWind = windPid ? OpenProcess(SYNCHRONIZE, FALSE, windPid) : nullptr; + if (!hWind) { + wind::Log(wind::LogLevel::Warn, "tray", "no Wind process (pid=%lu err=%lu); exiting", + windPid, GetLastError()); + wind::LogShutdown(); + return 0; + } + + // Single instance. On a model relaunch the old Wind's tray is still exiting when the new Wind + // starts us, so wait for it rather than giving up (it exits as soon as its Wind is gone). + HANDLE mtx = CreateMutexW(nullptr, FALSE, kTrayMutexName); + if (mtx) { + HANDLE waits[2] = { mtx, hWind }; + const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 10000); + if (w != WAIT_OBJECT_0 && w != WAIT_ABANDONED_0) { + wind::Log(wind::LogLevel::Warn, "tray", "another tray is live (w=%lu); exiting", w); + CloseHandle(mtx); CloseHandle(hWind); + wind::LogShutdown(); + return 0; + } + } + + // The status block. Missing (an older Wind) is survivable: the header shows Idle with no fps. + HANDLE map = OpenFileMappingW(FILE_MAP_READ | FILE_MAP_WRITE, FALSE, kTrayBlockName); + if (map) TrayApp::g_block = static_cast( + MapViewOfFile(map, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, sizeof(TrayShared))); + if (!TrayApp::g_block) + wind::Log(wind::LogLevel::Warn, "tray", "status block unavailable (err=%lu)", GetLastError()); + + WNDCLASSW wc{}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = hInst; + wc.lpszClassName = L"WindTrayWnd"; + RegisterClassW(&wc); + // A hidden top-level window (not message-only): TaskbarCreated is broadcast to top-level + // windows, and a message-only window never receives it. + HWND hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, L"WindTrayWnd", L"Wind tray", WS_POPUP, + 0, 0, 0, 0, nullptr, nullptr, hInst, nullptr); + if (!hwnd) { + wind::Log(wind::LogLevel::Error, "tray", "window create failed (err=%lu)", GetLastError()); + return 1; + } + g_taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated"); + // Explorer runs at the same integrity, but allow the broadcast explicitly in case it does not. + ChangeWindowMessageFilterEx(hwnd, g_taskbarCreated, MSGFLT_ALLOW, nullptr); + TrayApp::AddIcon(hwnd, hInst); + wind::Log(wind::LogLevel::Info, "tray", "serving Wind pid=%lu", windPid); + + bool running = true; + while (running) { + const DWORD w = MsgWaitForMultipleObjects(1, &hWind, FALSE, INFINITE, QS_ALLINPUT); + if (w == WAIT_OBJECT_0) { + wind::Log(wind::LogLevel::Info, "tray", "Wind exited; removing the icon"); + break; + } + MSG msg; + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { + if (msg.message == WM_QUIT) { running = false; break; } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + // Every exit path removes the icon. A menu may still be open on its thread; ExitProcess ends + // it, and `menuOpen` is cleared so the next Wind (or this one, restarting us) never inherits a + // stale "open". + TrayApp::RemoveIcon(); + SetTrayMenuOpen(TrayApp::g_block, false); + wind::LogShutdown(); + ExitProcess(0); +} diff --git a/src/tray_app/tray_app.h b/src/tray_app/tray_app.h new file mode 100644 index 0000000..8826536 --- /dev/null +++ b/src/tray_app/tray_app.h @@ -0,0 +1,31 @@ +#pragma once +// WindTray.exe (issue #291): the tray icon and its menu, in a process WITHOUT UIAccess so the menu +// stacks like any app's menu (below the magnified cursor and the Snipping Tool overlay). Wind.exe +// starts it with its PID; the helper exits when that process does. See +// docs/superpowers/specs/2026-09-29-tray-process-design.md. +#include +#include + +namespace wind { +struct TrayShared; +namespace TrayApp { + +inline constexpr UINT WM_TRAY = WM_APP + 1; // the icon's callback message + +// tray_icon.cpp - the notification-area icon. The main thread adds/removes; Notify is callable +// from the menu thread (Shell_NotifyIcon is process-global). +void AddIcon(HWND hwnd, HINSTANCE hInst); +void RemoveIcon(); +void Notify(const wchar_t* title, const wchar_t* text); + +// tray_menu.cpp - opens the owner-drawn menu on its own thread at `pt`. False if one is already +// open (a second click while it is open is just a re-click). +bool OpenMenu(POINT pt); + +// main.cpp +TrayShared* Block(); // the shared status block, or nullptr when Wind did not create one +std::wstring AppDir(); // the folder holding WindTray.exe, Wind.exe and WindConfig.exe +void RequestWindQuit(); // sets Local\Wind_QuitRequest: Wind's clean-exit path + +} // namespace TrayApp +} // namespace wind diff --git a/src/tray_app/tray_draw.h b/src/tray_app/tray_draw.h index 4f4a604..095babd 100644 --- a/src/tray_app/tray_draw.h +++ b/src/tray_app/tray_draw.h @@ -1,12 +1,12 @@ #pragma once -// Owner-drawn tray menu: the drawing half, kept out of tray.cpp so the menu logic there stays +// Owner-drawn tray menu: the drawing half, kept out of tray_menu.cpp so the menu logic there stays // readable. See docs/superpowers/specs/2026-08-28-tray-menu-design.md for the design and for why // this is an owner-drawn HMENU rather than a custom popup window. #include #include #include -#include "tick_stats.h" -#include "tray_status.h" +#include "../tick_stats.h" +#include "../tray_status.h" namespace wind { namespace TrayDraw { @@ -111,12 +111,13 @@ inline void FillRoundRectC(HDC dc, const RECT& r, COLORREF c, int rad) { // a bright top line - the original 1px accent hairline on the dark well was field-rejected as // invisible. The dotted midline IS the median (the scale is 2x median), so a healthy trace hugs // it and a stall spikes visibly above it. -inline void DrawSparkline(HDC dc, const RECT& box, const Palette& pal, const Metrics& mt) { +inline void DrawSparkline(HDC dc, const RECT& box, const Palette& pal, const Metrics& mt, + const TickStats& ticks) { const COLORREF well = pal.dark ? RGB(0x19,0x19,0x1c) : RGB(0xef,0xef,0xf3); FillRoundRectC(dc, box, well, mt.scale(4)); float buf[TickStats::kCap]; - const int n = Ticks().snapshot(buf, TickStats::kCap); + const int n = ticks.snapshot(buf, TickStats::kCap); const int w = box.right - box.left, h = box.bottom - box.top; const int midY = (box.top + box.bottom) / 2; if (n < 8 || w <= 4 || h <= 4) { diff --git a/src/tray_app/tray_icon.cpp b/src/tray_app/tray_icon.cpp new file mode 100644 index 0000000..cc239b4 --- /dev/null +++ b/src/tray_app/tray_icon.cpp @@ -0,0 +1,42 @@ +#include "tray_app.h" +#include "../resource.h" +#include + +namespace wind { namespace TrayApp { + +static NOTIFYICONDATAW g_nid{}; + +void AddIcon(HWND hwnd, HINSTANCE hInst) { + g_nid.cbSize = sizeof(g_nid); + g_nid.hWnd = hwnd; + g_nid.uID = 1; + g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + g_nid.uCallbackMessage = WM_TRAY; + // Our logo badge at the shell's small-icon size (picks the 16px frame from the multi-size .ico + // for a crisp tray render). Fall back to the generic app icon if the resource can't be loaded. + if (!hInst) hInst = GetModuleHandleW(nullptr); + if (!g_nid.hIcon) + g_nid.hIcon = (HICON)LoadImageW(hInst, MAKEINTRESOURCEW(IDI_WIND), IMAGE_ICON, + GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), + LR_DEFAULTCOLOR); + if (!g_nid.hIcon) g_nid.hIcon = LoadIconW(nullptr, IDI_APPLICATION); + lstrcpyW(g_nid.szTip, L"Wind magnifier"); + // TaskbarCreated (Explorer restart) re-adds through here: delete first so a re-add that races + // a still-registered icon can never leave two. + Shell_NotifyIconW(NIM_DELETE, &g_nid); + Shell_NotifyIconW(NIM_ADD, &g_nid); +} + +void RemoveIcon() { if (g_nid.hWnd) Shell_NotifyIconW(NIM_DELETE, &g_nid); } + +void Notify(const wchar_t* title, const wchar_t* text) { + NOTIFYICONDATAW n = g_nid; // a copy: the flags below must not leak into a later re-add + n.uFlags = NIF_INFO; + // Bounded copies: szInfoTitle is 64 wchars, szInfo 256; profile names travel through here, + // so an unbounded lstrcpyW was a caller-controlled overflow of the fixed NOTIFYICONDATA. + lstrcpynW(n.szInfoTitle, title, ARRAYSIZE(n.szInfoTitle)); + lstrcpynW(n.szInfo, text, ARRAYSIZE(n.szInfo)); + Shell_NotifyIconW(NIM_MODIFY, &n); +} + +}} // namespace wind::TrayApp diff --git a/src/tray_app/tray_menu.cpp b/src/tray_app/tray_menu.cpp index 3ad6b81..d573c97 100644 --- a/src/tray_app/tray_menu.cpp +++ b/src/tray_app/tray_menu.cpp @@ -1,32 +1,23 @@ -#include "tray.h" -#include "resource.h" -#include "logging.h" -#include "config_path.h" -#include "profiles_io.h" -#include "config.h" -#include "config_ui/ini_edit.h" +// The tray menu (WindTray.exe, issue #291): owner-drawn, on its own thread, reading Wind's live +// status from the shared block (tray_ipc.h). +#include "tray_app.h" #include "tray_draw.h" -#include "tray_status.h" -#include "tick_stats.h" +#include "../logging.h" +#include "../config_path.h" +#include "../profiles_io.h" +#include "../config.h" +#include "../config_ui/ini_edit.h" +#include "../tray_ipc.h" #include #include #include -#include -#include #include -namespace wind { namespace Tray { -static NOTIFYICONDATAW g_nid{}; -static const UINT WM_TRAY = WM_APP + 1; +namespace wind { namespace TrayApp { static const UINT ID_SETTINGS = 1003, ID_QUIT = 1002; static const UINT ID_HEADER = 1005; // owner-drawn, disabled: the status readout static const UINT ID_PROFILE_BASE = 1100; // 1100..1131: one per profile menu item static const UINT kMaxProfileMenuItems = 32; -static UINT DiagDoneMsg() { static UINT m = RegisterWindowMessageW(L"Wind.DiagnosticsExportDone.v1"); return m; } -static std::mutex g_diagMx; -static std::wstring g_diagZip; -static bool g_diagOk = false, g_diagReady = false, g_diagRunning = false; - // Menu thread (2026-08-28, third design - the history matters here): // v1 SetTimer(8ms) kept the tick alive through TrackPopupMenu's modal loop, but SetTimer's // real floor is ~15.6ms, so the whole magnifier dropped to ~64Hz while any menu was open @@ -37,16 +28,20 @@ static bool g_diagOk = false, g_diagReady = false, g_diagRunning = false; // v3 (this) the menu runs on ITS OWN THREAD with its own zero-size popup window. The main // thread never blocks, so the tick keeps its normal full-rate pacing with no keep-alive // trick at all, and the menu's input is handled by its own loop - both sides at full speed. -// One menu at a time (g_menuOpen); the weld is suspended while it is open (main.cpp reads -// MenuOpen()) so the pointer belongs to the user while they aim at menu items. +// v4 (issue #291) the whole tray moved out of Wind.exe into WindTray.exe, so the menu is no +// longer a UIAccess window that stacks above the cursor and the Snipping Tool overlay. The +// menu thread stays: the header's live timer must keep ticking during the modal loop. +// One menu at a time (g_menuOpen); the weld is suspended while it is open (Wind reads `menuOpen` +// from the shared block) so the pointer belongs to the user while they aim at menu items. static void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW); static volatile LONG g_menuOpen = 0; -struct MenuCtx { HWND mainHwnd; POINT pt; }; +struct MenuCtx { POINT pt; }; +static const TickStats g_noTicks{}; // header input when Wind shared no block // Owner-draw state for ONE menu open. All of it lives on the menu thread: items, fonts, // palette, metrics are created per open and destroyed with the host window, and the status they -// render is read cross-thread from the tick loop's relaxed-atomic snapshot (tray_status.h) plus -// the frame-pacing ring (tick_stats.h) - both designed for exactly this reader. +// render is read cross-process from Wind's shared block (tray_ipc.h): the status snapshot plus the +// frame-pacing ring, both relaxed atomics designed for exactly this reader. struct MenuDrawState { std::vector items; TrayDraw::Palette pal; @@ -73,7 +68,9 @@ static void MeasureItem(const MenuDrawState& st, MEASUREITEMSTRUCT* mis) { static void DrawHeaderBody(const MenuDrawState& st, HDC dc, const RECT& r) { const auto& pal = st.pal; const int pad = st.mt.padX(); - const TrayStatus ts = ReadTrayStatus(); + TrayShared* blk = Block(); + const TrayStatus ts = ReadTrayStatus(blk); + const TickStats& ticks = TrayBlockValid(blk) ? blk->ticks : g_noTicks; wchar_t zoom[16]; const bool zoomed = FormatZoom(ts.level, zoom, 16); @@ -91,7 +88,7 @@ static void DrawHeaderBody(const MenuDrawState& st, HDC dc, const RECT& r) { } float buf[TickStats::kCap]; - const int n = Ticks().snapshot(buf, TickStats::kCap); + const int n = ticks.snapshot(buf, TickStats::kCap); if (n >= 8) { const double fps = FpsFromMs(MeanMs(buf, n)); // mean: jitter pairs cancel, see tick_stats.h wchar_t f[32]; wsprintfW(f, L"%d fps", (int)(fps + 0.5)); @@ -122,7 +119,7 @@ static void DrawHeaderBody(const MenuDrawState& st, HDC dc, const RECT& r) { DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); RECT sp{ r.left + pad, r.top + st.mt.scale(74), r.right - pad, r.top + st.mt.scale(98) }; - TrayDraw::DrawSparkline(dc, sp, pal, st.mt); + TrayDraw::DrawSparkline(dc, sp, pal, st.mt, ticks); SelectObject(dc, of); } @@ -330,10 +327,12 @@ static DWORD WINAPI MenuThread(LPVOID param) { st.menu = m; SetTimer(host, 1, 100, nullptr); // the live-header tick; see MenuHostProc + SetTrayMenuOpen(Block(), true); // Wind suspends the cursor re-park while this is set SetForegroundWindow(host); // we own the last input (the tray click), so this is permitted int cmd = TrackPopupMenu(m, TPM_RETURNCMD | TPM_RIGHTBUTTON, ctx.pt.x, ctx.pt.y, 0, host, nullptr); KillTimer(host, 1); + SetTrayMenuOpen(Block(), false); PostMessageW(host, WM_NULL, 0, 0); // the documented dismiss fix SetWindowLongPtrW(host, GWLP_USERDATA, 0); DestroyMenu(m); @@ -341,15 +340,14 @@ static DWORD WINAPI MenuThread(LPVOID param) { st.fonts.destroy(); DestroyWindow(host); - // Actions - all safe off the main thread: ShellExecute is thread-agnostic, Quit is a - // PostMessage to the main window, and the profile switch is file I/O whose UI feedback - // (Notify) is a process-global Shell_NotifyIcon call. Export diagnostics left the tray with - // this design (it is a button in the Settings UI); its worker plumbing stays for the - // Settings-origin path. + // Actions - all safe off the main thread: ShellExecute is thread-agnostic, Quit is a named + // event, and the profile switch is file I/O whose feedback (Notify) is a process-global + // Shell_NotifyIcon call. Full paths from our own folder: the tray's cwd is not trusted. if (cmd == ID_SETTINGS) - ShellExecuteW(nullptr, L"open", L"WindConfig.exe", nullptr, nullptr, SW_SHOW); + ShellExecuteW(nullptr, L"open", (AppDir() + L"\\WindConfig.exe").c_str(), nullptr, + AppDir().c_str(), SW_SHOW); else if (cmd == ID_QUIT) - PostMessageW(ctx.mainHwnd, WM_CLOSE, 0, 0); + RequestWindQuit(); else if (cmd >= (int)ID_PROFILE_BASE && cmd < (int)(ID_PROFILE_BASE + profNames.size())) SwitchToProfile(ini, profNames[cmd - ID_PROFILE_BASE]); @@ -357,42 +355,10 @@ static DWORD WINAPI MenuThread(LPVOID param) { return 0; } -// Diagnostics-export completion signal. The worker thread does NOT smuggle a heap pointer through the -// window message (any local process could PostMessage a forged LPARAM -> controlled deref/free). Instead -// it parks the result in this mutex-guarded slot and posts a bare wake-up; the handler reads the slot -// under the lock and never dereferences the message params. The message id is registered (process-unique, -// >= 0xC000) so it isn't a guessable WM_APP+n, and the handler ignores any wake-up with no result ready. - -void Add(HWND hwnd, HINSTANCE hInst) { - g_nid.cbSize = sizeof(g_nid); - g_nid.hWnd = hwnd; - g_nid.uID = 1; - g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; - g_nid.uCallbackMessage = WM_TRAY; - // Our logo badge at the shell's small-icon size (picks the 16px frame from the multi-size .ico - // for a crisp tray render). Fall back to the generic app icon if the resource can't be loaded. - if (!hInst) hInst = GetModuleHandleW(nullptr); - g_nid.hIcon = (HICON)LoadImageW(hInst, MAKEINTRESOURCEW(IDI_WIND), IMAGE_ICON, - GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), - LR_DEFAULTCOLOR); - if (!g_nid.hIcon) g_nid.hIcon = LoadIconW(nullptr, IDI_APPLICATION); - lstrcpyW(g_nid.szTip, L"Wind magnifier"); - Shell_NotifyIconW(NIM_ADD, &g_nid); -} -void Remove() { Shell_NotifyIconW(NIM_DELETE, &g_nid); } -void Notify(const wchar_t* title, const wchar_t* text) { - g_nid.uFlags = NIF_INFO; - // Bounded copies: szInfoTitle is 64 wchars, szInfo 256; profile names travel through here, - // so an unbounded lstrcpyW was a caller-controlled overflow of the fixed NOTIFYICONDATA. - lstrcpynW(g_nid.szInfoTitle, title, ARRAYSIZE(g_nid.szInfoTitle)); - lstrcpynW(g_nid.szInfo, text, ARRAYSIZE(g_nid.szInfo)); - Shell_NotifyIconW(NIM_MODIFY, &g_nid); - g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; -} // Switch the active profile from the tray: rewrite the live ini from the profile file (globals // preserved); the core's dir-watch hot-reloads everything except `model`, which is read once at -// launch - a model change relaunches Wind.exe (the new instance evicts us via the single-instance -// handshake, same as the swap-model path in main.cpp). +// launch - a model change relaunches Wind.exe from our folder (the new instance evicts the running +// one via the single-instance handshake, same as the swap-model path in Wind's main.cpp). static void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW) { // Every step logs (issue #184: a field switch failed with no trace - the balloon is // transient, the log is not). @@ -433,11 +399,12 @@ static void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW) oldModel.c_str(), newModel.c_str(), oldModel != newModel ? " (relaunching)" : " (hot)"); if (oldModel != newModel) { - wchar_t exe[MAX_PATH]; - const bool haveExe = GetModuleFileNameW(nullptr, exe, MAX_PATH) != 0; + // Wind.exe, NOT our own exe (GetModuleFileNameW here is WindTray.exe). + const std::wstring exe = AppDir() + L"\\Wind.exe"; + const bool haveExe = GetFileAttributesW(exe.c_str()) != INVALID_FILE_ATTRIBUTES; INT_PTR rc = haveExe - ? reinterpret_cast(ShellExecuteW(nullptr, L"open", exe, nullptr, nullptr, - SW_SHOWNORMAL)) + ? reinterpret_cast(ShellExecuteW(nullptr, L"open", exe.c_str(), nullptr, + AppDir().c_str(), SW_SHOWNORMAL)) : 0; if (rc > 32) { Notify(L"Wind", (L"Switched to \"" + nameW + L"\" (restarting for its model).").c_str()); @@ -453,38 +420,14 @@ static void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW) } } -bool MenuOpen() { return InterlockedCompareExchange(&g_menuOpen, 0, 0) != 0; } - -bool HandleMessage(HWND hwnd, UINT msg, WPARAM /*wp*/, LPARAM lp) { - if (msg == DiagDoneMsg()) { - // Export worker finished (off-thread). Read the result from the guarded slot - the message params - // are NOT trusted/dereferenced, so a forged wake-up from another process can't deref a pointer. - std::wstring zip; bool ok = false, ready = false; - { std::lock_guard lk(g_diagMx); - if (g_diagReady) { zip = std::move(g_diagZip); ok = g_diagOk; g_diagReady = false; g_diagZip.clear(); ready = true; } } - if (!ready) return true; // no export result pending (spurious/foreign wake-up): ignore - if (ok && !zip.empty()) { // reveal + notify here, on the message thread (tray state stays single-threaded) - std::wstring args = L"/select,\"" + zip + L"\""; - ShellExecuteW(nullptr, L"open", L"explorer.exe", args.c_str(), nullptr, SW_SHOWNORMAL); - Notify(L"Wind", L"Diagnostics exported to your Desktop."); - } else { - Notify(L"Wind", L"Could not export diagnostics."); - } - return true; - } - if (msg == WM_TRAY && (lp == WM_RBUTTONUP || lp == WM_LBUTTONUP)) { - // One menu at a time; a second click while it is open is just a re-click, ignored. - if (InterlockedCompareExchange(&g_menuOpen, 1, 0) != 0) return true; - auto* ctx = new MenuCtx{}; - ctx->mainHwnd = hwnd; - GetCursorPos(&ctx->pt); - HANDLE t = CreateThread(nullptr, 0, MenuThread, ctx, 0, nullptr); - if (t) CloseHandle(t); - else { delete ctx; InterlockedExchange(&g_menuOpen, 0); } - return true; - } - if (msg == WM_CLOSE) { DestroyWindow(hwnd); return true; } - if (msg == WM_DESTROY) { PostQuitMessage(0); return true; } +bool OpenMenu(POINT pt) { + // One menu at a time; a second click while it is open is just a re-click, ignored. + if (InterlockedCompareExchange(&g_menuOpen, 1, 0) != 0) return false; + auto* ctx = new MenuCtx{ pt }; + HANDLE t = CreateThread(nullptr, 0, MenuThread, ctx, 0, nullptr); + if (t) { CloseHandle(t); return true; } + delete ctx; + InterlockedExchange(&g_menuOpen, 0); return false; } }} diff --git a/src/tray_app/wind_tray.rc b/src/tray_app/wind_tray.rc new file mode 100644 index 0000000..0d02d09 --- /dev/null +++ b/src/tray_app/wind_tray.rc @@ -0,0 +1,28 @@ +#include "../resource.h" +#include "../version.h" +#include +// WindTray.exe resources (issue #291): the same Wind icon as Wind.exe, its own description. +IDI_WIND ICON "..\\..\\assets\\wind.ico" + +VS_VERSION_INFO VERSIONINFO + FILEVERSION WIND_VER_MAJOR,WIND_VER_MINOR,WIND_VER_PATCH,0 + PRODUCTVERSION WIND_VER_MAJOR,WIND_VER_MINOR,WIND_VER_PATCH,0 + FILEOS 0x40004L + FILETYPE 0x1L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Wind" + VALUE "FileDescription", "Wind tray icon" + VALUE "FileVersion", WIND_VERSION_STR + VALUE "ProductName", "Wind" + VALUE "ProductVersion", WIND_VERSION_STR + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END From 531a9e717d230e501c2b194ccef0a9115ebde735 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:57:55 +0200 Subject: [PATCH 4/7] refactor(tray): Wind.exe no longer owns the tray (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- src/main.cpp | 35 +++++++++------- src/tray_host.cpp | 104 ++++++++++++++++++++++++++++++++++++++++++++++ src/tray_host.h | 21 ++++++++++ 3 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 src/tray_host.cpp create mode 100644 src/tray_host.h diff --git a/src/main.cpp b/src/main.cpp index 2f1243b..1853761 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -37,10 +37,9 @@ #include "edge_pan.h" // mouse edge mode (issue #276 phase 2) #include "cursor_decode.h" // edge mode measures the cursor body #include "focus_track.h" // tracking: caret/focus watcher thread -#include "tray.h" +#include "tray_host.h" // WindTray.exe owns the icon and menu (#291) #include "gain_learner.h" // learned pointer ballistics: locked pan at TRUE desktop speed -#include "tray_status.h" // the tray menu's status snapshot -#include "tick_stats.h" // frame-pacing ring the tray sparkline draws from +#include "tray_ipc.h" // the status block shared with WindTray.exe // txPace=2 composite signal (see config.h). One thread blocks in DwmFlush forever and pulses an // auto-reset event per real composite; the pacing loop waits on the event WITH A TIMEOUT, so a @@ -791,6 +790,10 @@ static void EndPanelFreeze(TickState& t) { t.panelFreeze = false; } +// The status block WindTray.exe reads (tray_ipc.h). Null if the mapping failed: every accessor +// treats null as "no tray data", so the tick path needs no extra branch beyond the pointer test. +static wind::TrayShared* g_trayBlock = nullptr; + static void RunTick(TickState& t) { LARGE_INTEGER now; QueryPerformanceCounter(&now); @@ -798,7 +801,7 @@ static void RunTick(TickState& t) { t.prev = now; // One float store per tick, for the tray's frame-pacing readout. Deliberately the cheapest // possible coupling to the hot path: no lock, no allocation, and nothing reads it here. - wind::Ticks().push((float)(dt * 1000.0)); + if (g_trayBlock) g_trayBlock->ticks.push((float)(dt * 1000.0)); // Config hot-reload. A directory-change notification tells us WHEN to re-check magnifier.ini, // so the idle render thread does NO per-second filesystem stat (the old 1 Hz GetFileAttributesExW @@ -1099,8 +1102,8 @@ static void RunTick(TickState& t) { } double lvl = t.zoom.level(); { - // Snapshot for the tray menu, published every tick and read (cross-thread, relaxed - // atomics) when the menu opens - always current at the moment it is shown. "Advanced" is + // Snapshot for the tray menu, published every tick into the shared block and read by + // WindTray.exe (relaxed atomics) while its menu is open. "Advanced" is // the hybrid model: the mode that picks an engine per window type; renamed from "Auto" // because Auto undersold what it does. wind::TrayStatus ts_; @@ -1111,7 +1114,7 @@ static void RunTick(TickState& t) { : mdl == "magnify" ? wind::TrayEngine::System : wind::TrayEngine::Advanced; ts_.panning = lvl > 1.001; - wind::PublishTrayStatus(ts_); + wind::PublishTrayStatus(g_trayBlock, ts_); } int rawDx, rawDy; g_input.drainRaw(rawDx, rawDy); @@ -1885,11 +1888,12 @@ static void RunTick(TickState& t) { const bool quiesceHold = QuiesceHoldActive(t); ex.pauseWrites = t.clickPauseTicks > 0 || quiesceHold; if (quiesceHold) ex.suppressCursorSync = true; - // Our own menu is open: the pointer belongs to the USER (they are aiming at menu items), + // Our tray menu is open (in WindTray.exe, flagged through the shared block): the pointer + // belongs to the USER (they are aiming at menu items), // so the weld must not re-park it - at full tick rate it pins the cursor outright // (field-reported as a frozen cursor the moment the tray opened). The view keeps panning; // only the cursor re-park is suspended, exactly like drag-follow during a button hold. - if (wind::Tray::MenuOpen()) ex.suppressCursorSync = true; + if (wind::TrayMenuOpen(g_trayBlock)) ex.suppressCursorSync = true; if (t.clickPauseTicks > 0) --t.clickPauseTicks; if (inspect) { if (t.clickReleaseTicks > 0) { @@ -2394,7 +2398,8 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } return 0; } - if (Tray::HandleMessage(hwnd, msg, wp, lp)) return 0; + if (msg == WM_CLOSE) { DestroyWindow(hwnd); return 0; } + if (msg == WM_DESTROY) { PostQuitMessage(0); return 0; } return DefWindowProcW(hwnd, msg, wp, lp); } @@ -2655,7 +2660,9 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // only on Inspect entry). The LOCKED path no longer models ballistics at all - it replays the // learned desktop gain instead (gain_learner.h). g_input.setBallistics(ReadMouseBallistics()); - Tray::Add(hwnd, hInst); + // The tray icon and menu live in WindTray.exe (issue #291): a UIAccess process's menu stacks + // above the cursor and the Snipping Tool overlay, an ordinary process's menu does not. + g_trayBlock = wind::TrayHost::Start(exePath); TickState ts(model.get(), startupMon, cfg); ts.mRender = model.get(); @@ -2702,7 +2709,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { if (model2) model2->shutdown(); g_input.stop(); g_track.stop(); - Tray::Remove(); + wind::TrayHost::Stop(); ReleaseMutex(mtx); return 0; } @@ -2749,7 +2756,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { if (model2) model2->shutdown(); g_input.stop(); g_track.stop(); - Tray::Remove(); + wind::TrayHost::Stop(); ReleaseMutex(mtx); return 0; } @@ -3057,7 +3064,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { ts.gainLearner.serialize(buf, (int)sizeof(buf)); wind::WriteTextFileAtomic(wind::ResolveLogDir() + L"/learned_gain.txt", buf); // Win32 accepts '/' } - Tray::Remove(); + wind::TrayHost::Stop(); if (mtx) { ReleaseMutex(mtx); CloseHandle(mtx); } wind::LogShutdown(); return 0; diff --git a/src/tray_host.cpp b/src/tray_host.cpp new file mode 100644 index 0000000..b34a2c1 --- /dev/null +++ b/src/tray_host.cpp @@ -0,0 +1,104 @@ +#include "tray_host.h" +#include "tray_ipc.h" +#include "logging.h" +#include +#include +#include + +namespace wind { namespace TrayHost { + +static HANDLE g_map = nullptr; +static TrayShared* g_block = nullptr; +static HANDLE g_stop = nullptr; +static HANDLE g_thread = nullptr; +static std::wstring g_trayExe; + +// ShellExecuteEx, never CreateProcess with our token: the helper must NOT run with UIAccess (its +// whole purpose is an ordinary menu), and a shell launch gives it a plain token (verified +// 2026-09-29 with WindConfig: TokenUIAccess=0). +static HANDLE LaunchTray() { + wchar_t params[48]; + wsprintfW(params, L"--wind-pid %lu", GetCurrentProcessId()); + SHELLEXECUTEINFOW sei{}; + sei.cbSize = sizeof(sei); + sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC; + sei.lpVerb = L"open"; + sei.lpFile = g_trayExe.c_str(); + sei.lpParameters = params; + sei.nShow = SW_HIDE; + if (!ShellExecuteExW(&sei) || !sei.hProcess) { + wind::Log(wind::LogLevel::Warn, "tray", "WindTray launch failed (err=%lu)", GetLastError()); + return nullptr; + } + wind::Log(wind::LogLevel::Info, "tray", "WindTray started pid=%lu", GetProcessId(sei.hProcess)); + return sei.hProcess; +} + +static DWORD WINAPI Supervisor(LPVOID) { + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); // ShellExecuteEx's documented need + ULONGLONG starts[3] = { 0, 0, 0 }; // the last three launch times, oldest first + for (;;) { + // Rate limit: a helper that dies on start must not spin. At most three launches in any + // minute: a fourth waits until the oldest of the last three is a minute old. + const ULONGLONG now = GetTickCount64(); + if (starts[0] && now - starts[0] < 60000) { + const DWORD wait = (DWORD)(60000 - (now - starts[0])); + wind::Log(wind::LogLevel::Warn, "tray", "WindTray restarting too often; waiting %lu ms", wait); + if (WaitForSingleObject(g_stop, wait) == WAIT_OBJECT_0) break; + } + starts[0] = starts[1]; starts[1] = starts[2]; starts[2] = GetTickCount64(); + + HANDLE proc = LaunchTray(); + if (!proc) { + if (WaitForSingleObject(g_stop, 5000) == WAIT_OBJECT_0) break; + continue; + } + HANDLE waits[2] = { g_stop, proc }; + const DWORD w = WaitForMultipleObjects(2, waits, FALSE, INFINITE); + if (w == WAIT_OBJECT_0) { CloseHandle(proc); break; } + DWORD code = 0; GetExitCodeProcess(proc, &code); + CloseHandle(proc); + // A tray that died with its menu open must not leave the cursor re-park suspended. + SetTrayMenuOpen(g_block, false); + wind::Log(wind::LogLevel::Warn, "tray", "WindTray exited (code=%lu); restarting", code); + } + CoUninitialize(); + return 0; +} + +TrayShared* Start(const std::wstring& appDir) { + g_map = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + (DWORD)sizeof(TrayShared), kTrayBlockName); + if (g_map) g_block = static_cast( + MapViewOfFile(g_map, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, sizeof(TrayShared))); + if (g_block) { + // A mapping left open by the previous Wind (a model relaunch overlaps the two) keeps its + // old values: clear the tray-written flag, then stamp ours. + SetTrayMenuOpen(g_block, false); + InitTrayBlock(*g_block, GetCurrentProcessId()); + } else { + wind::Log(wind::LogLevel::Warn, "tray", "status block create failed (err=%lu)", GetLastError()); + } + + g_trayExe = appDir + L"\\WindTray.exe"; + if (GetFileAttributesW(g_trayExe.c_str()) == INVALID_FILE_ATTRIBUTES) { + wind::Log(wind::LogLevel::Error, "tray", "WindTray.exe missing next to Wind.exe; no tray icon"); + return g_block; + } + g_stop = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (g_stop) g_thread = CreateThread(nullptr, 0, Supervisor, nullptr, 0, nullptr); + return g_block; +} + +void Stop() { + if (g_thread) { + SetEvent(g_stop); + WaitForSingleObject(g_thread, 2000); + CloseHandle(g_thread); + g_thread = nullptr; + } + if (g_stop) { CloseHandle(g_stop); g_stop = nullptr; } + // The block stays mapped until the process exits: the tick loop may still publish into it. +} + +}} // namespace wind::TrayHost diff --git a/src/tray_host.h b/src/tray_host.h new file mode 100644 index 0000000..9476e0b --- /dev/null +++ b/src/tray_host.h @@ -0,0 +1,21 @@ +#pragma once +// Wind.exe's side of the tray split (issue #291). Wind no longer owns a tray icon: it creates the +// shared status block (tray_ipc.h) and keeps WindTray.exe running next to it. See +// docs/superpowers/specs/2026-09-29-tray-process-design.md. +#include + +namespace wind { +struct TrayShared; +namespace TrayHost { + +// Creates Local\Wind_TrayState_v1, stamps it with our PID, and starts the supervisor thread that +// launches WindTray.exe from appDir (restarting it if it exits, at most 3 times a minute). Returns +// the mapped block, or nullptr if the mapping failed (Wind still runs; the tray just has no data). +TrayShared* Start(const std::wstring& appDir); + +// Stops the supervisor. WindTray is NOT killed: it waits on our process handle and removes its +// icon the moment we exit. +void Stop(); + +} // namespace TrayHost +} // namespace wind From ba0886abb28e235f3b55c35f8f85de065490a9db Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:59:00 +0200 Subject: [PATCH 5/7] build(tray): ship WindTray.exe (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- installer/app.nsh | 18 ++++++++++++++++++ installer/wind.nsi | 3 +++ src/version.h | 4 ++-- tools/installer_check.ps1 | 2 ++ tools/release.ps1 | 3 ++- tools/uiaccess_setup.ps1 | 17 +++++++++++------ 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/installer/app.nsh b/installer/app.nsh index 214ee68..fffb272 100644 --- a/installer/app.nsh +++ b/installer/app.nsh @@ -94,6 +94,24 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then nsExec::Exec 'taskkill /IM Wind.exe /F' Pop $0 ${EndIf} + + ; WindTray.exe (issue #291) exits by itself when Wind does, removing its tray icon. Give it + ; up to 2 s, and only then force it: a forced kill leaves the icon behind until the mouse + ; passes over it, and its exe must not be held open while setup replaces it. + StrCpy $3 0 + ${Do} + nsExec::Exec 'cmd /c tasklist /FI "IMAGENAME eq WindTray.exe" /NH | find /I "WindTray.exe"' + Pop $4 + ${If} $4 != 0 + ${Break} + ${EndIf} + Sleep 100 + IntOp $3 $3 + 1 + ${LoopUntil} $3 >= 20 + ${If} $4 == 0 + nsExec::Exec 'taskkill /IM WindTray.exe /F' + Pop $0 + ${EndIf} Sleep 300 !macroend diff --git a/installer/wind.nsi b/installer/wind.nsi index a29aace..b1a1c95 100644 --- a/installer/wind.nsi +++ b/installer/wind.nsi @@ -104,6 +104,7 @@ Section "Wind" SEC_WIND ; at any point leaves a Wind that runs. File "..\Wind.exe" File "..\WindConfig.exe" + File "..\WindTray.exe" InitPluginsDir CreateDirectory "$PLUGINSDIR\ua" File "/oname=$PLUGINSDIR\ua\Wind.exe" "..\WindUA.exe" @@ -126,6 +127,7 @@ Section "Wind" SEC_WIND !else File "..\Wind.exe" File "..\WindConfig.exe" + File "..\WindTray.exe" !endif ; the licence the user accepted, kept next to the app so the terms are always findable File "/oname=LICENSE.txt" "..\LICENSE" @@ -186,6 +188,7 @@ Section "Uninstall" Delete "$INSTDIR\Wind.exe" Delete "$INSTDIR\WindConfig.exe" + Delete "$INSTDIR\WindTray.exe" Delete "$INSTDIR\LICENSE.txt" Delete "$INSTDIR\Uninstall.exe" RMDir /r "$INSTDIR\ui" diff --git a/src/version.h b/src/version.h index 5b1c5af..d391b12 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 11 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.11.1" +#define WIND_VERSION_STR "0.11.2" diff --git a/tools/installer_check.ps1 b/tools/installer_check.ps1 index 153b88f..545fb23 100644 --- a/tools/installer_check.ps1 +++ b/tools/installer_check.ps1 @@ -150,6 +150,7 @@ if (-not (Test-Path $setup)) { Start-Process $setup -ArgumentList '/S', "/D=$scratch" -Wait Check "silent install placed Wind.exe" { Test-Path (Join-Path $scratch 'Wind.exe') } Check "silent install placed WindConfig.exe" { Test-Path (Join-Path $scratch 'WindConfig.exe') } + Check "silent install placed WindTray.exe" { Test-Path (Join-Path $scratch 'WindTray.exe') } Check "silent install placed ui\dist" { Test-Path (Join-Path $scratch 'ui\dist\index.html') } # Local signing (issue #261): only when this build packed the uiAccess variant. if (Test-Path (Join-Path $root 'WindUA.exe')) { @@ -178,6 +179,7 @@ if (-not (Test-Path $setup)) { Start-Process $uninst -ArgumentList '/S', ('_?=' + $scratch) -Wait Start-Sleep -Milliseconds 1200 Check "uninstall removed Wind.exe" { -not (Test-Path (Join-Path $scratch 'Wind.exe')) } + Check "uninstall removed WindTray.exe" { -not (Test-Path (Join-Path $scratch 'WindTray.exe')) } Check "uninstall removed ui\dist" { -not (Test-Path (Join-Path $scratch 'ui')) } Check "uninstall removed the ARP key" { $null -eq (Get-ItemProperty $ARP -ErrorAction SilentlyContinue) } Check "uninstall removed every Wind Local Signing root" { diff --git a/tools/release.ps1 b/tools/release.ps1 index 9894aba..de3b7b0 100644 --- a/tools/release.ps1 +++ b/tools/release.ps1 @@ -111,7 +111,7 @@ if (-not $SkipBuild) { Invoke-Build 'config' } -foreach ($f in 'Wind.exe', 'WindConfig.exe') { +foreach ($f in 'Wind.exe', 'WindTray.exe', 'WindConfig.exe') { if (-not (Test-Path "$root\$f")) { throw "missing build output: $f" } } if (-not (Test-Path "$root\ui\dist\index.html")) { throw "missing ui\dist (build.bat config)" } @@ -121,6 +121,7 @@ if (-not (Test-Path "$root\ui\dist\index.html")) { throw "missing ui\dist (build if ($cert) { Invoke-Sign "$root\Wind.exe" $cert Invoke-Sign "$root\WindConfig.exe" $cert + Invoke-Sign "$root\WindTray.exe" $cert } Write-Host "=== compiling the installer ===" diff --git a/tools/uiaccess_setup.ps1 b/tools/uiaccess_setup.ps1 index 05d7026..0c3753f 100644 --- a/tools/uiaccess_setup.ps1 +++ b/tools/uiaccess_setup.ps1 @@ -1,5 +1,5 @@ # Wind UIAccess setup (run elevated). Creates a local self-signed code-signing cert, -# trusts it, signs Wind.exe, and deploys Wind.exe + WindConfig.exe + ui/dist to +# trusts it, signs Wind.exe, and deploys Wind.exe + WindTray.exe + WindConfig.exe + ui/dist to # C:\Program Files\Wind so UIAccess activates and the config UI is reachable. $ErrorActionPreference = 'Stop' # Derive paths from the script's own location ($PSScriptRoot = the tools\ dir) so this runs @@ -10,15 +10,16 @@ try { $root = Split-Path -Parent $PSScriptRoot $src = "$root\Wind.exe" $cfgSrc = "$root\WindConfig.exe" + $traySrc = "$root\WindTray.exe" # built by build.bat uiaccess; plain manifest, NO uiAccess (#291) $uiSrc = "$root\ui\dist" Write-Output "=== 0a. stop any running Wind / WindConfig (dev or deployed) so the exes are not locked ===" - Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force + Get-Process Wind,WindTray,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Milliseconds 400 Write-Output "=== 0b. build the UIAccess variant (uiAccess=true manifest) ===" & cmd /c "`"$root\build.bat`" uiaccess" - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $src)) { throw "build.bat uiaccess failed." } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $src) -or -not (Test-Path $traySrc)) { throw "build.bat uiaccess failed." } Write-Output "=== 0c. build the config UI host (npm build of ui + WindConfig.exe) ===" & cmd /c "`"$root\build.bat`" config" @@ -63,19 +64,23 @@ try { # quarantining it out of Program Files, which broke the tray's "Open Settings" (issue #86). $sigCfg = Set-AuthenticodeSignature -FilePath $cfgSrc -Certificate $cert -HashAlgorithm SHA256 Write-Output "WindConfig.exe sign status=$($sigCfg.Status)" + # WindTray.exe likewise needs no UIAccess (it must NOT have it, #291); signed for the same reason. + $sigTray = Set-AuthenticodeSignature -FilePath $traySrc -Certificate $cert -HashAlgorithm SHA256 + Write-Output "WindTray.exe sign status=$($sigTray.Status)" - Write-Output "=== 4. deploy Wind.exe, WindConfig.exe and ui\dist to C:\Program Files\Wind ===" - Get-Process Wind,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force + Write-Output "=== 4. deploy Wind.exe, WindTray.exe, WindConfig.exe and ui\dist to C:\Program Files\Wind ===" + Get-Process Wind,WindTray,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Milliseconds 300 $dst = "C:\Program Files\Wind" New-Item -ItemType Directory -Force $dst | Out-Null Copy-Item $src "$dst\Wind.exe" -Force Copy-Item $cfgSrc "$dst\WindConfig.exe" -Force + Copy-Item $traySrc "$dst\WindTray.exe" -Force $uiDst = "$dst\ui\dist" if (Test-Path $uiDst) { Remove-Item $uiDst -Recurse -Force } New-Item -ItemType Directory -Force "$dst\ui" | Out-Null Copy-Item $uiSrc $uiDst -Recurse -Force - Write-Output "deployed: Wind.exe, WindConfig.exe, ui\dist\" + Write-Output "deployed: Wind.exe, WindTray.exe, WindConfig.exe, ui\dist\" Write-Output "=== 5. magnifier.ini: NOT deployed - the app owns the single copy in %LOCALAPPDATA% ===" # We intentionally do NOT write a magnifier.ini into Program Files. Wind.exe resolves its ini to From 166f4d7aac9b6dc162845367f1fd0462360263f2 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:01:46 +0200 Subject: [PATCH 6/7] docs(tray): three binaries, the tray process and why (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- CLAUDE.md | 12 ++++++++++-- README.md | 2 +- docs/architecture/01-overview.md | 22 +++++++++++++++++----- docs/architecture/02-tick-loop.md | 2 +- docs/architecture/08-config-profiles.md | 6 +++--- docs/architecture/11-build-test-release.md | 9 +++++---- 6 files changed, 37 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d68c0b1..26f203b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ Developer book (readable, canonical): `docs/architecture/` - keep it in step wit compiles `src/config_ui/*.cpp` against the vendored WebView2 SDK -> `WindConfig.exe` next to `Wind.exe`). Also run by `tools\uiaccess_setup.ps1`, which deploys `WindConfig.exe` + `ui/dist` alongside the signed `Wind.exe`. +- The app and `uiaccess` targets also build `WindTray.exe` (`build.bat tray` alone), ALWAYS with the + plain manifest: it must never be uiAccess (see "Three binaries"). - Build the installer: `build.bat installer` (needs NSIS: `winget install NSIS.NSIS`; compiles `installer\wind.nsi` then runs `tools\installer_check.ps1`). Release artifact: `pwsh -File tools\release.ps1` -> `dist\Wind-Setup-x64-.exe`. Setup is a custom-drawn @@ -168,8 +170,14 @@ every host `setConfig` mirrors the profile-scoped snapshot back into its file. F duplicate/delete; bridge messages `listProfiles`/`switchProfile`/`createProfile`/`renameProfile`/ `duplicateProfile`/`deleteProfile`, each replying the refreshed list). -**Two binaries.** `Wind.exe` is the always-running tray magnifier (the perf-critical core -described above). `WindConfig.exe` is an on-demand settings GUI: a thin C++ WebView2 host +**Three binaries.** `WindTray.exe` (`src/tray_app/`, issue #291) owns the tray icon and menu +WITHOUT UIAccess: a UIAccess process's popup menu stacks above the cursor sprite and the Snipping +Tool overlay, an ordinary process's menu does not. Wind starts it with `ShellExecuteExW` (never +CreateProcess: no inherited UIAccess token) passing `--wind-pid`, restarts it if it dies (at most 3 +launches a minute, `src/tray_host.cpp`), and it exits when that Wind exits. The only coupling is the +shared block `Local\Wind_TrayState_v1` (`src/tray_ipc.h`: status, frame-pacing ring, `menuOpen`) +plus `Local\Wind_QuitRequest` for Quit. `Wind.exe` is the always-running magnifier (the +perf-critical core described above). `WindConfig.exe` is an on-demand settings GUI: a thin C++ WebView2 host (`src/config_ui/main.cpp`) that loads a built Svelte app from `ui/dist/` and talks to the core only by writing `magnifier.ini` (the core dir-watches and hot-reloads it - no IPC). First launch also runs a short guided onboarding (wind-trails-into-logo intro -> set zoom keys -> diff --git a/README.md b/README.md index d1d3873..c2b38ab 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ step entirely. `src\version.h` is the only place the version is declared. ## Build Requires Visual Studio 2022+ Build Tools (Desktop development with C++). From any shell: -- `build.bat` - builds `Wind.exe` (runs from anywhere). +- `build.bat` - builds `Wind.exe` and its tray helper `WindTray.exe` (runs from anywhere). - `build.bat test` - builds and runs the unit tests. - `build.bat uiaccess` - builds the UIAccess variant (signed-install prerequisite). - `build.bat config` - builds the Settings app (`WindConfig.exe` + the Svelte UI). diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index ae11cf8..1fd1267 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -3,8 +3,9 @@ Wind is a lightweight standalone fullscreen magnifier for Windows: a replacement for the built-in Magnify.exe that keeps zoom smooth and sub-pixel, keeps the screen fully interactive while zoomed, and keeps tracking the mouse even when a game hides, clips, or center-locks the cursor. It ships as -two cooperating binaries, `Wind.exe` (the always-running tray magnifier) and `WindConfig.exe` (an -on-demand settings app), whose only communication channel is the `magnifier.ini` file. This chapter +three cooperating binaries: `Wind.exe` (the always-running magnifier), `WindTray.exe` (its tray +icon and menu, a separate process since issue #291) and `WindConfig.exe` (an on-demand settings +app). Settings travel only through the `magnifier.ini` file. This chapter covers what Wind promises the user, why the code is split the way it is, and what every file in `src/` does. @@ -93,7 +94,7 @@ flowchart LR C --> P ``` -`Wind.exe` is the perf-critical core: the tick loop, the input hooks, the engines, the tray icon. +`Wind.exe` is the perf-critical core: the tick loop, the input hooks, the engines. It runs from login to logout and must never hitch. `WindConfig.exe` is a thin C++ WebView2 host (`src/config_ui/main.cpp`) that loads a built Svelte app from `ui/dist/` and talks to the core only by writing `magnifier.ini`. The core dir-watches the ini and hot-reloads it; there is no pipe, no @@ -102,6 +103,16 @@ crash, restart, or be rewritten without touching the magnifier loop; the config non-admin and non-elevated by design; and the ini stays a plain, hand-editable text file that is also the profile snapshot format (`src/profiles.h`, [Config and profiles](08-config-profiles.md)). +The tray is the one exception to "no shared memory", and it carries no settings. Wind.exe is a +signed UIAccess process, and Windows stacks a UIAccess process's popup menu above almost +everything, including the magnified cursor and the Snipping Tool overlay (measured 2026-09-29). +So the tray icon and menu live in `WindTray.exe`, which runs without UIAccess (`src/tray_app/`). +Wind starts it with `ShellExecuteExW` and its PID (`src/tray_host.cpp`), restarts it if it dies +(at most three launches a minute), and publishes its live status into a small named block +(`src/tray_ipc.h`). The tray writes one flag back, `menuOpen`, which suspends the cursor re-park +while the user aims at menu items; Quit sets `Local\Wind_QuitRequest`, the same clean-exit event +the installer uses. The tray exits when its Wind exits, taking the icon with it. + Two refinements keep this simple channel honest. First, the ini path is never hardcoded: `wind::ResolveIniPath()` (`src/config_path.h`) probes whether the exe directory is writable, so a dev build keeps the ini next to the exe while a Program Files install transparently falls back to @@ -206,8 +217,9 @@ large fleet of PowerShell measurement probes, see | `tick_stats.h` | Pure ring buffer of recent tick intervals backing the tray's frame-pacing readout | | `transform.cpp/.h` | Pure transform math: anchored offsets, TDR-safe clamps, input-transform rects, foreign-writer detection | | `transform_model.cpp/.h` | The transform engine: sessions, the weld, keep-alive, `txMaxStepPct` rate limit (default 25, i.e. 2.5% per tick) | -| `tray.cpp/.h` | Tray icon, balloon, and menu handling | -| `tray_draw.h` | Owner-drawn tray menu: the drawing half, kept out of `tray.cpp` | +| `tray_app/` | `WindTray.exe` (issue #291): `main.cpp` lifecycle (serves one Wind PID, single instance, TaskbarCreated), `tray_icon.cpp` icon and balloons, `tray_menu.cpp` the owner-drawn menu and profile switch, `tray_draw.h` its drawing half | +| `tray_host.cpp/.h` | Wind.exe side of the tray split: creates the shared block and supervises `WindTray.exe` | +| `tray_ipc.h` | Pure layout of the block shared with `WindTray.exe` (`Local\Wind_TrayState_v1`): status, frame-pacing ring, `menuOpen` | | `tray_status.h` | Pure decisions for what the tray menu shows (engine label, status text) from a published tick-loop snapshot | | `tx_cadence.h` | Pure transform write-cadence gates, traced against native Magnifier (issue #204) | | `tx_warm.h` | Pure transform warm-keeping: the pulsed rest-tick displacement (`txWarmHz`/`txWarmMode`) that keeps DWM's magnification re-render from going cold between pans | diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index e3a9115..ea9e149 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -53,7 +53,7 @@ ramp always eases at a steady rate regardless of frame-time spikes. ### Config hot-reload -Wind has no IPC with the settings app. `WindConfig.exe` writes `magnifier.ini` and the core +Wind has no IPC with the settings app (the tray helper's status block, `src/tray_ipc.h`, carries no settings). `WindConfig.exe` writes `magnifier.ini` and the core notices. The noticing is deliberately cheap: - At startup, `wWinMain` arms a `FindFirstChangeNotificationW` on the ini's parent directory diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index ed93096..06abad9 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -38,7 +38,7 @@ flowchart LR subgraph core [Wind.exe] WATCH[dir-change watch\n~4 Hz check] --> RELOAD[StripUiOnlyKeys fingerprint\nthen LoadConfig] RELOAD --> TICK[RunTick uses new Config] - TRAY[tray Profiles submenu\ntray.cpp SwitchToProfile] + TRAY[tray Profiles submenu\ntray_app/tray_menu.cpp SwitchToProfile] end WM -->|UpdateIniText + atomic write| INI WM -->|mirror: MakeProfileText| PROF @@ -168,7 +168,7 @@ exactly like the live ini. ### Switching: `MakeLiveText` and the model restart -A switch, whether from the tray (`SwitchToProfile`, `src/tray.cpp`) or the settings-UI titlebar +A switch, whether from the tray (`SwitchToProfile`, `src/tray_app/tray_menu.cpp`, in `WindTray.exe`) or the settings-UI titlebar dropdown (`DoSwitchProfile`, `src/config_ui/main.cpp`), is the same sequence: 1. Validate the profile file. `wind::ProfileTextError` rejects binary content, absurd size, and @@ -267,7 +267,7 @@ generates "Name copy", "Name copy 2", ... for duplication, truncating to fit the - `src/profiles_io.h`: profile file I/O, `WriteTextFileAtomic`, `MirrorLiveToActiveProfile`, `EnsureProfilesSeeded`. - `src/config_ui/main.cpp`: the bridge (`HandleWebMessage`), `DoSwitchProfile`, the setConfig - mirror; `src/tray.cpp`: the tray switch surface. + mirror; `src/tray_app/tray_menu.cpp`: the tray switch surface. - Spec: [2026-08-12-profiles-design.md](../superpowers/specs/2026-08-12-profiles-design.md). - Related chapters: [The tick loop](02-tick-loop.md) (the watch/reload mechanics), [The settings UI](09-settings-ui.md) (the other side of the bridge), diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index d4be42d..da53e8b 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -8,10 +8,11 @@ Everything native goes through `build.bat` at the repo root. It locates MSVC via | target | output | what it is | |---|---|---| -| (none) | `Wind.exe` | the normal app: `uiAccess=false` manifest (`Wind.manifest`), runs from anywhere | +| (none) | `Wind.exe` + `WindTray.exe` | the normal app: `uiAccess=false` manifest (`Wind.manifest`), runs from anywhere | | `test` | `wind_tests.exe` | the doctest binary over the pure-logic sources; runs it and returns its exit code | | `check` | (none) | compile-only pass over `src\*.cpp`, no link; catches type errors fast | -| `uiaccess` | `Wind.exe` | same app with `Wind.uiaccess.manifest` (`uiAccess=true`) and `/DWIND_UIACCESS`; only useful signed and in Program Files | +| `uiaccess` | `Wind.exe` + `WindTray.exe` | same app with `Wind.uiaccess.manifest` (`uiAccess=true`) and `/DWIND_UIACCESS`; only useful signed and in Program Files. `WindTray.exe` keeps the plain manifest (issue #291) | +| `tray` | `WindTray.exe` | the tray helper alone (`src/tray_app/` plus the shared profile/config/logging sources), objects in `src\tray_app\` so they never collide with Wind.exe's | | `config` | `WindConfig.exe` | npm-builds the Svelte app under `ui/` to `ui/dist/`, then compiles `src/config_ui/main.cpp` against the vendored WebView2 SDK (`third_party/webview2`) | | `installer` | `dist\Wind-Setup-x64-.exe` | compiles `installer\wind.nsi` with makensis `/WX` and runs `tools\installer_check.ps1` | @@ -27,7 +28,7 @@ Wind's core rule is that anything with real logic in it compiles without `` sits. `src/logging.cpp` uses the same split (pure formatting helpers above, the Win32 file backend below, both halves labeled in `src/logging.h`). If you add file or OS access to a pure file, put it under the guard or the test build stops compiling desktop-free, which is the point of the guard. @@ -45,7 +46,7 @@ One trap documented in the mock itself: rows marked `advanced: true` or carrying The `uiaccess` build exists because a few features need the UIAccess privilege: the opt-in band-16 overlay z-order (issue #162), keybinds over elevated windows, and the transform model's `MagSetInputTransform` publish that fixes desktop hover dead zones (see [Engines](03-engines.md) and ../POINTER-HITTEST-FINDINGS.md). Windows only grants UIAccess to a binary that is Authenticode-signed with a locally trusted certificate AND runs from a secure location, in practice `C:\Program Files\Wind`. An unsigned `uiaccess` build, or a signed one launched from the repo, silently gets no privilege, and `transform_model.cpp` then probes `TokenUIAccess` at init and disables the desktop-transform pick, so the app degrades rather than breaks. -`tools/uiaccess_setup.ps1` is the whole local flow in one elevated script: it stops any running Wind/WindConfig, runs `build.bat uiaccess` and `build.bat config`, finds or creates a self-signed "Wind Dev Test Cert" in `Cert:\LocalMachine\My`, trusts it (Root + TrustedPublisher), signs both exes (WindConfig.exe needs no UIAccess but an unsigned fresh build trips a Defender Wacatac false positive and gets quarantined, issue #86), and copies `Wind.exe`, `WindConfig.exe`, and `ui\dist` to Program Files. It deliberately does NOT deploy a `magnifier.ini`: the app resolves its ini to `%LOCALAPPDATA%\Wind\magnifier.ini` via `wind::ResolveIniPath` (src/config_path.h) because Program Files is read-only for the non-admin processes, and the script removes any stale Program Files copy. It transcript-logs to `tools\uiaccess_setup.log`; verify a deploy by checking that log for `status=Valid` and `DONE`. +`tools/uiaccess_setup.ps1` is the whole local flow in one elevated script: it stops any running Wind/WindConfig, runs `build.bat uiaccess` and `build.bat config`, finds or creates a self-signed "Wind Dev Test Cert" in `Cert:\LocalMachine\My`, trusts it (Root + TrustedPublisher), signs the exes (WindConfig.exe and WindTray.exe need no UIAccess but an unsigned fresh build trips a Defender Wacatac false positive and gets quarantined, issue #86), and copies `Wind.exe`, `WindTray.exe`, `WindConfig.exe`, and `ui\dist` to Program Files. It deliberately does NOT deploy a `magnifier.ini`: the app resolves its ini to `%LOCALAPPDATA%\Wind\magnifier.ini` via `wind::ResolveIniPath` (src/config_path.h) because Program Files is read-only for the non-admin processes, and the script removes any stale Program Files copy. It transcript-logs to `tools\uiaccess_setup.log`; verify a deploy by checking that log for `status=Valid` and `DONE`. Two rules around the script: From d2ebdd79f16a5974c0e8ec2ddde4ae655c8371e3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:44:45 +0200 Subject: [PATCH 7/7] fix(tray): review fixes: sign WindTray locally, safe supervisor stop, honest log, check target (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- build.bat | 3 +++ installer/local-sign.ps1 | 2 +- src/tray_app/main.cpp | 6 +++++- src/tray_host.cpp | 9 ++++++++- tools/installer_check.ps1 | 3 +++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/build.bat b/build.bat index 93b30bc..c43441d 100644 --- a/build.bat +++ b/build.bat @@ -116,6 +116,9 @@ exit /b %errorlevel% rem --- Compile-only check (no link; verifies all sources compile) ----------- :check cl /nologo /std:c++17 /EHsc /W4 /DUNICODE /D_UNICODE /c src\*.cpp +if errorlevel 1 exit /b 1 +rem WindTray.exe sources (issue #291); own object dir, its main.cpp would overwrite Wind's main.obj. +cl /nologo /std:c++17 /EHsc /W4 /DUNICODE /D_UNICODE /c /Fo"%ROOT%src\tray_app\\" src\tray_app\*.cpp exit /b %errorlevel% rem --- Installer (needs NSIS; winget install NSIS.NSIS) --------------------- diff --git a/installer/local-sign.ps1 b/installer/local-sign.ps1 index e93a7bd..a6415da 100644 --- a/installer/local-sign.ps1 +++ b/installer/local-sign.ps1 @@ -44,7 +44,7 @@ $env:PSModulePath = "$env:SystemRoot\system32\WindowsPowerShell\v1.0\Modules;$en Import-Module Microsoft.PowerShell.Security, PKI $subject = 'CN=Wind Local Signing' -$targets = @($Stage, "$Dir\WindConfig.exe") +$targets = @($Stage, "$Dir\WindConfig.exe", "$Dir\WindTray.exe") # WindTray: issue #291, same Defender reason as WindConfig $trustStores = 'Root', 'TrustedPublisher' $new = $null diff --git a/src/tray_app/main.cpp b/src/tray_app/main.cpp index 3dd302c..344b48d 100644 --- a/src/tray_app/main.cpp +++ b/src/tray_app/main.cpp @@ -82,7 +82,11 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR cmdLine, int) { HANDLE waits[2] = { mtx, hWind }; const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 10000); if (w != WAIT_OBJECT_0 && w != WAIT_ABANDONED_0) { - wind::Log(wind::LogLevel::Warn, "tray", "another tray is live (w=%lu); exiting", w); + if (w == WAIT_OBJECT_0 + 1) + wind::Log(wind::LogLevel::Warn, "tray", + "Wind pid=%lu exited before the tray mutex came free; exiting", windPid); + else + wind::Log(wind::LogLevel::Warn, "tray", "another tray is live (w=%lu); exiting", w); CloseHandle(mtx); CloseHandle(hWind); wind::LogShutdown(); return 0; diff --git a/src/tray_host.cpp b/src/tray_host.cpp index b34a2c1..bb14867 100644 --- a/src/tray_host.cpp +++ b/src/tray_host.cpp @@ -93,7 +93,14 @@ TrayShared* Start(const std::wstring& appDir) { void Stop() { if (g_thread) { SetEvent(g_stop); - WaitForSingleObject(g_thread, 2000); + // Close only once the thread is really gone. If it is still inside ShellExecuteEx (an AV + // scan can hold that for seconds), it reads g_stop next: closing it here would turn its + // waits into instant WAIT_FAILED returns, defeating the rate limit. On a timeout the two + // handles are left to process exit, which follows every Stop() call. + if (WaitForSingleObject(g_thread, 2000) != WAIT_OBJECT_0) { + wind::Log(wind::LogLevel::Warn, "tray", "supervisor still busy at stop; leaving it to exit"); + return; + } CloseHandle(g_thread); g_thread = nullptr; } diff --git a/tools/installer_check.ps1 b/tools/installer_check.ps1 index 545fb23..741cfb8 100644 --- a/tools/installer_check.ps1 +++ b/tools/installer_check.ps1 @@ -157,6 +157,9 @@ if (-not (Test-Path $setup)) { $sig = Get-AuthenticodeSignature (Join-Path $scratch 'Wind.exe') Check "local signing: Wind.exe signature is Valid" { $sig.Status -eq 'Valid' } Check "local signing: signed by CN=Wind Local Signing" { $sig.SignerCertificate.Subject -eq 'CN=Wind Local Signing' } + Check "local signing: WindTray.exe signature is Valid" { + (Get-AuthenticodeSignature (Join-Path $scratch 'WindTray.exe')).Status -eq 'Valid' + } Check "local signing: the signing key is gone" { -not (Get-ChildItem Cert:\LocalMachine\My | Where-Object Subject -eq 'CN=Wind Local Signing') }