lightweight variant (#1)

Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
+90 -22
View File
@@ -18,6 +18,37 @@ Cloning without it succeeds but leaves text pointer files in place of the
images, and the build then fails on an unreadable icon. An existing clone is
repaired with `git lfs install` followed by `git lfs pull`.
## Running it while working on it
```powershell
dotnet run --project CursorLang.Agent
```
That is the whole application, not just the background half: building the agent puts
the settings window next to it, because that is where the agent looks for it — the
tray menu starts it by path, and an installation has the two in one folder. Build the
agent alone and the Settings item would silently do nothing.
Rider and Visual Studio pick the launch profiles up from
`CursorLang.Agent/Properties/launchSettings.json` and
`CursorLang.Settings/Properties/launchSettings.json`, so the run configuration
dropdown offers three:
| Profile | What it does |
|---|---|
| `Agent` | The user's launch: tray icon and the settings window straight away |
| `Agent (started by Windows)` | Adds `--startup`: tray only, no window — the sign-in case |
| `Settings` | The settings window on its own, with no agent behind it |
Both halves read `%APPDATA%\CursorLang\settings.json`, the same file the installed
application uses — there is one code path resolving it and both call it. Debugging
therefore edits the real settings; that is deliberate, since watching the agent pick
up a change made in the window is most of what there is to watch.
To step through both at once, turn on attaching to child processes in the debugger:
the settings window is started by the agent as a separate process, and without that
the debugger stays with the agent.
## Administrator rights
The app runs as the ordinary user. Elevation was dropped so that the app can be
@@ -31,20 +62,43 @@ neither delivers keystrokes to the low-level hook nor accepts the layout change
request, so the hotkey does nothing there. Reading the layout of a foreign window
is not restricted, so the tooltip itself keeps working everywhere.
## Two processes
The app is two executables sharing one folder.
`CursorLang.exe` is the agent: the keyboard hook, the layout polling, the tooltip
and the tray icon. It is what Windows starts at sign-in and what sits in the tray
all day, so it is built without WPF — a background process that carries the
rendering stack costs around a hundred megabytes for a tooltip it draws with Win32
in eight. It holds under 9 MB instead.
`CursorLang.Settings.exe` is the settings window, and nothing else. The agent
starts it from the tray menu; closing the window ends the process, and the memory
WPF took goes back to the system. The price is a cold start of half a second or so
the next time the window is asked for.
`CursorLang.Core.dll` is what both hold in common: the models, the settings file,
the layout tracking, the updates. No UI, and it must stay that way — whatever lands
there lands in the background process.
The connection between the two is `settings.json` and nothing else. The window
writes it — whole, into a temporary file moved into place in one step — and then
posts a registered window message to the agent, which re-reads. The message carries
no data: sending the changed values along would make the file stop being the only
source of truth. An agent that is not running is a normal case — the window works the
same. Nobody watches the file: it has one writer, and that writer speaks up.
## The tray
The app works in the background and lives in the notification area. The settings
window is a guest on the screen rather than the app itself: it shows up after the
installation and whenever the icon or its menu is asked for. Both buttons in its
title bar — minimise and close — put the window away into the tray and end nothing;
the way out of the app is the Exit item of the tray menu.
The settings window is a guest on the screen rather than the app itself: it shows
up after the installation and whenever the icon or its menu is asked for. Both
buttons in its title bar mean what they say — the window closes and its process
ends. The way out of the app itself is the Exit item of the tray menu.
The window is hidden rather than closed. Closing it would take its handle with it,
and everything hung on the window — the theme, the place it was left in, the
computed layout — would have to be built anew on every show.
The menu of the icon is a WPF one rather than a system one: that way it keeps to
the theme and the language chosen in the settings, like the rest of the interface.
The menu of the icon is a system one, drawn by Windows. Its captions still follow
the language chosen in the settings, but the theme no longer reaches it: a WPF
menu would mean the whole rendering stack in the background process, which is the
one thing that process is built to avoid.
Started by Windows itself, the app shows no window at all and goes straight to the
tray — the user asked for it to be there when they sign in, not for a window to
@@ -54,9 +108,8 @@ package has no say in its command line, and Windows is asked about the activatio
instead.
Should Windows refuse the icon — there is no notification area in a session without
a desktop — the window takes its usual duties back: it shows up whatever the launch
was, and closing it ends the app. The alternative would be an app the user can
neither see nor quit.
a desktop — the agent opens the settings window whatever the launch was. The
alternative would be an app the user can neither see nor quit.
## Startup
@@ -64,8 +117,9 @@ Startup is switched on from the app's settings by whichever means the build has.
A package declares it in the manifest as a `windows.startupTask`. A build unpacked
into a folder registers itself the way desktop programs always have — under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, which needs no administrator
rights. The command written there ends with `--startup`: that is how the app tells
a launch of Windows' own doing from a launch by the user.
rights. The command names `CursorLang.exe` — the agent, not the settings window
the checkbox was clicked in — and ends with `--startup`: that is how the agent
tells a launch of Windows' own doing from a launch by the user.
Either way Windows lists the app in Settings — Apps — Startup; if the user turns
it off there, the app can no longer turn it back on and says so instead of
@@ -86,6 +140,10 @@ does not own.
## Updates
The check runs when the settings window is opened, not when the machine is
switched on: the background half no longer goes to the network at all, and there
would be nothing in it to show the answer. The setting in the window says as much.
The app looks for new versions among the releases of its own repository. A
release counts when its tag is a plain version — `v1.2.3` or `1.2.3` — and an
MSIX package is attached to it. A tag with anything else in it, `v1.2.3-beta`
@@ -138,10 +196,18 @@ is a secret handed to everyone who got the app.
dotnet test
```
The suite lives in `CursorLang.Tests` and runs on xUnit. Half of the app —
windows, dispatcher timers, the keyboard hook — only works on an STA thread with
a message loop, so the tests keep one such thread for the whole run and drive
everything through it.
There is a suite per project — `CursorLang.Core.Tests`, `CursorLang.Agent.Tests`
and `CursorLang.Settings.Tests` — all on xUnit, with the fakes and the helpers in a
library of their own, `CursorLang.Tests.Shared`. That library knows nothing about
xUnit: the one thing that wanted an assertion says so with an exception instead.
Core's suite carries no WPF reference, which is a check in itself: anything that
would drag WPF into the background process fails to compile there.
Half of the app — windows, timers, the keyboard hook — only works on an STA thread
with a message loop, so the tests keep one such thread for the whole run and drive
everything through it. For Core and the agent that thread runs a plain Win32 loop
(`CursorLang.Tests.Shared/Pump.cs`); the settings window gets a WPF dispatcher
instead.
A few checks need a real foreground window: the caret position and the layout
switch request are only observable there. Windows does not always grant the
@@ -154,12 +220,14 @@ else's running instance is not their business.
Coverage is collected with:
```powershell
dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage.runsettings
dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
```
What stays uncovered is what a test process cannot reach: the code paths that
require an MSIX package identity — `StartupTask` and the package data folder —
and the composition root in `App.xaml.cs`, which is exercised end-to-end instead.
and the composition roots — `App.xaml.cs` and `Agent.cs` which are exercised
end-to-end instead. One of those end-to-end checks reads the module list of the
running agent and fails if any part of the WPF renderer is in it.
## Continuous integration