Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ver>.exe`. Setup is a custom-drawn
Expand Down Expand Up @@ -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 ->
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
30 changes: 30 additions & 0 deletions build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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) -
Expand All @@ -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. ----
Expand Down Expand Up @@ -89,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) ---------------------
Expand Down
22 changes: 17 additions & 5 deletions docs/architecture/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 5 additions & 4 deletions docs/architecture/11-build-test-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ver>.exe` | compiles `installer\wind.nsi` with makensis `/WX` and runs `tools\installer_check.ps1` |

Expand All @@ -27,7 +28,7 @@ Wind's core rule is that anything with real logic in it compiles without `<windo

On top of those, a number of pure header-only modules ride into the tests through their test files: `src/engine_pick.h` (the hybrid model's engine decision), `src/drag_follow.h`, `src/hdr_scale.h`, `src/inspect_focus.h`, `src/config_ui/wind_watchdog.h`, and others. The `tests/` directory has one file per module (`test_transform.cpp`, `test_engine_pick.cpp`, `test_profiles.cpp`, ...), so when you add a pure module you add its test file and, if it is a `.cpp`, append it to the `:test` source list in `build.bat`.

Why this split matters: the magnifier itself cannot be driven headlessly (it needs a desktop, a GPU, and a real cursor), so the unit tests are the only verification loop that runs everywhere, including CI. Anything testable therefore has to live on the pure side. The Win32 half (`src/render_engine.cpp`, `src/input_router.cpp`, `src/transform_model.cpp`, `src/tray.cpp`, `src/main.cpp`) is kept as thin as the OS allows and is verified by deploying and using it (see the deploy section below).
Why this split matters: the magnifier itself cannot be driven headlessly (it needs a desktop, a GPU, and a real cursor), so the unit tests are the only verification loop that runs everywhere, including CI. Anything testable therefore has to live on the pure side. The Win32 half (`src/render_engine.cpp`, `src/input_router.cpp`, `src/transform_model.cpp`, `src/tray_app/`, `src/main.cpp`) is kept as thin as the OS allows and is verified by deploying and using it (see the deploy section below).

Some files straddle the line, and the pattern for those is an `#ifndef WIND_TESTS` block. `src/config.cpp` is the canonical example: the parsing half (`ParseConfig`, `IsForbiddenBindVk`, `StripUiOnlyKeys`) is pure and fully tested in `tests/test_config.cpp`, while `LoadConfig` and the default-ini writer live below `#ifndef WIND_TESTS`, which is where the file's only `#include <windows.h>` 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.

Expand All @@ -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:

Expand Down
Loading
Loading