Files
cursor-lang/README.md
T
alex 743974a0c1
Release / release (push) Successful in 3m47s
added comments to readme (#12)
Reviewed-on: #12
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-15 02:56:58 +00:00

373 lines
19 KiB
Markdown

# cursor-lang
[Русская версия](README.RU.md)
The WPF application displays the keyboard layout at the cursor.
## Cloning
Binary assets — the exe icon and the MSIX logos — are stored in Git LFS, so
[git-lfs](https://git-lfs.com) has to be installed before cloning:
```powershell
git lfs install
git clone git@git.alrakis.kz:alrakis/cursor-lang.git
```
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
published in the Microsoft Store: MSIX packages always run in the context of the
signed-in user, and Store policy rejects apps that need administrator rights for
any part of their functionality.
The cost is the optional Caps Lock hotkey. While a window of a higher integrity
level holds the focus — Task Manager, Registry Editor, UAC prompts — Windows
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. No UI, and it must stay that way — whatever lands there lands
in the background process.
Everything the window has to say to the agent goes through `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 one word in the other direction is the last one: Exit in the tray menu asks an
open settings window to close before the agent goes, so that no window is left
belonging to nothing. It travels as a named event of the session rather than a
window message — the agent starts that process and deliberately keeps no handle to
it. Nobody listening is the usual case, and it is a normal answer.
## The tray
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 — and it
closes an open settings window along with the agent.
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
greet them every morning. The two builds tell such a launch apart differently: the
registry entry of an unpacked build carries the `--startup` argument, while a
package has no say in its command line, and Windows is asked about the activation
instead.
Should Windows refuse the icon — there is no notification area in a session without
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
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 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
silently failing. The registry entry stays where it is in that case: Windows keeps
the user's verdict apart from it, under `StartupApproved`, and the app obeys it.
## Settings
Where `settings.json` lives depends on how the app was installed. A separate
install keeps it in `%APPDATA%\CursorLang`. The packaged build keeps it in the
package's own data folder, which Windows removes together with the app — Store
apps are expected to leave nothing behind.
On its first run the packaged build picks up the settings left by a separate
install and copies them over. The original file stays where it is: both builds
may be installed side by side, and the app has no business deleting settings it
does not own.
Everything about the popup belongs to a placement mode rather than to the app, and
the file keeps a section per mode — `AtCursor`, `AtCaret` and `FixedPoint`. Each of
them holds the side, the offset, the font size, the opacity and both colours. The
popup next to the caret sits inside a text being read and is wanted small and quiet;
the one in the corner of the monitor is looked for on purpose and is wanted large. A
look shared by the modes meant setting it up again after every switch, so the settings
window shows the look of the mode chosen above it and writes into that mode alone.
What the side means differs by mode, and each of them has a type saying so. Next to
the cursor it is any of the six corners and sides. Next to the caret it is left or
right and nothing else — above or below the caret is exactly where the next line of
the text is, so the popup would cover what is being read. For the fixed point it is a
place on the monitor, and the offset is the distance from its edge: in the middle
there is no edge to stand off from, so choosing the middle puts the offset back to
zero and the settings window shows it greyed out. It stays in place rather than
leaving the section — a row that comes and goes would move everything below it on
every switch of the place.
Not every application reports where its caret is, and next to the caret is then
nowhere. The popup goes to the fixed point instead — the place, the offset and the
look all from that mode, none of them from the caret one. The mouse cursor would be
the nearer fallback and is the wrong one: it is wherever it was last left, off to a
side or on another monitor, while the eyes are on the caret. The fixed point is
always in the same place, so it is known where to look — and the look must come with
it, since a popup set small and quiet for the middle of a text goes unnoticed in the
corner of a monitor.
Some applications those are is decided by what a window exposes rather than by
what it is written in. A Win32 control creates a system caret and is read at once;
Chromium and Electron draw their own and report it over the accessibility
interfaces. A window that paints its text into a bare canvas has nothing to report
at all — and both kinds live in the same application: the editors of a JetBrains
IDE answer, since the runtime keeps a caret for them, while a tool window whose
interface is painted by Compose holds no text component for UI Automation or the
Java Access Bridge to find. That window is where the fixed point earns its keep.
Which mode the look belongs to is explained in a tooltip next to the mode itself
rather than by a line of text in the section: the sliders below show other numbers
after a switch of the mode, and that is a question asked once.
## Updates
The Store updates the app, and the app itself does nothing about it: there is no
updates section in the window, no request to the network and no code for either.
That is not a matter of taste but of what a signature costs. Windows installs an
MSIX only when it trusts the signature on it, and a publicly trusted code signing
certificate has turned out to be beyond reach — the certificate authorities that
sell them will not issue one here, and those that would keep the key in a cloud
HSM will not either, while a certificate on a USB token cannot be shipped in.
Without a signature a package installs nowhere but a machine in developer mode,
so there is nothing to hand out from a release and nothing for an updater to
find. The Store signs the package with its own certificate and updates the app
by itself, which leaves the whole question to it.
The version of the running app is in the title of the settings window:
`CursorLang 1.2.3 — Settings`. It is nowhere else in the interface, and it is
there so that a bug report can name it.
## Tests
```powershell
dotnet test
```
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
right to bring a window forward, and those checks report themselves as skipped
rather than as failures — there is nothing to verify without a foreground
window. The end-to-end checks start the built application as a separate process
and skip themselves if the app is already running: interfering with someone
else's running instance is not their business.
Coverage is collected with:
```powershell
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 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
The pipelines live in `.gitea/workflows` and run on Gitea Actions. A pull
request into `master` is built and tested; a tag of the form `v1.2.3` is built,
tested and packed into an MSIX, which is left in the artifacts of the run. The
version is taken from the tag alone — a tag shaped any other way stops the run
right at the start. The package version ends up as `1.2.3.0`: the Store takes
four numbers and keeps the last one for itself, so the tag has no say in it.
Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10
SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a
text pointer and the build fails on it. The runner is better off working in an
interactive desktop session: the tests raise real windows, and the checks that
need a desktop of their own — the end-to-end ones, and those that ask for the
foreground window or the caret — skip themselves on a runner that lives as a
service in session 0, where there is no desktop to show a window on.
The package goes to the Store and nowhere else: it is unsigned, and Partner
Center puts its own signature on it. So the run leaves it in the artifacts under
the name `msix-1.2.3.0`, where whoever uploads it picks it up by hand; nothing
is attached to the release, which Gitea makes for the tag by itself and which
carries the tag alone. An unsigned package hanging off a release would look like
something to install and install nowhere — see the section on updates.
The identity comes from repository variables and falls back to the defaults of
the script when unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and
`MSIX_PUBLISHER_DISPLAY_NAME`. Together the identity and the publisher decide
the family name of the package, so both have to stay as they are from version to
version, or the Store takes the next one for a different app.
## Building the MSIX package
Neither Visual Studio nor the Windows SDK is required — `makeappx` comes from a
NuGet package. The full publishing procedure — from opening a developer account
to submitting for certification — is written up in
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md) (Russian only).
```powershell
# One-off: draw the logos and the exe icon (already committed, rerun after edits)
powershell -File Packaging\New-Assets.ps1
# A check on your own machine
powershell -File Packaging\build-msix.ps1
# For Partner Center — the identity is the one reserved there
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
```
The result is `artifacts\packages\CursorLang-<version>-x64.msix`. Upload it to
Partner Center as it is.
Only x64 is built. An arm64 build would double the size of every release for the
sake of machines that run the x64 one under emulation anyway.
Nothing here is signed: the Store signs the package itself, and for installing it
on this machine the layout is registered instead — see `-Install` below.
The app ships with its own copy of .NET: Windows does not include .NET 10, and
MSIX cannot install a runtime as a package dependency.
To see the package working on this machine, build it with `-Install`:
```powershell
pwsh -File Packaging\build-msix.ps1 -Install
```
The application then shows up in the Start menu like any installed one. What
gets registered is the layout the package is made of rather than the package
file, so no signature is needed — only Developer Mode. The flip side is that the
application runs straight out of `artifacts\layout`, and a rebuild would pull
the files out from under it; the script removes such a registration before it
wipes the folder, whether or not `-Install` was asked for. A copy installed from
the Store answers to the same name and is left alone.
To remove it by hand:
```powershell
Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName
```
## Building the installer
The same application also comes as an ordinary MSI, for handing round outside the
Store — to try it out before the Store has passed judgement, or if it never does.
Nothing has to be installed beyond the .NET SDK: WiX comes from a NuGet package,
the same way `makeappx` does.
```powershell
# Build it and run it afterwards, to see what a user sees
pwsh -File Packaging\build-installer.ps1 -Install
# Everything a release needs
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1
```
The result is `artifacts\installers\CursorLang-<version>-x64.msi`. Everything
travels inside the .msi; there is no cabinet to send alongside it.
It installs for the current user alone, into `%LOCALAPPDATA%\Programs\CursorLang`,
and so asks for no administrator rights and no consent dialog. Uninstalling goes
through Apps in Settings like any other program and takes the startup entry with
it — otherwise Windows would go on listing an application that is no longer there.
Nobody signs the installer, so Windows warns about an unknown publisher and the
user has to insist. Buying a certificate would not silence it at once either:
SmartScreen goes by reputation, and a fresh certificate has none until enough
people have installed the application.
Two things about the WiX project are worth knowing before touching it.
It pins WiX 5 rather than the current 7: from version 6 the toolset asks every
build to accept the Open Source Maintenance Fee licence — free below $10,000 of
yearly revenue, but a decision for a person rather than for a build script.
And it builds without MSI validation. The ICE checks run inside the Windows
Installer service, which a build agent cannot reach: every check comes back as
`WIX0217` and the build dies on close to a hundred of them. On an ordinary
desktop machine the service does answer, and validation is one switch away:
```powershell
dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false
```
Three of its rules stay suppressed even then. MSI assumes an installation for the
whole machine, and installing into the user's own profile trips rules that
describe exactly what was intended here.