323 lines
16 KiB
Markdown
323 lines
16 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, 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 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 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.
|
|
|
|
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 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`
|
|
among them, is passed over: a pre-release version is asked for on purpose, not
|
|
offered by the app.
|
|
|
|
Out of the attached files the `.msixbundle` is preferred — it carries both
|
|
architectures. Failing that, the package whose name holds the architecture of
|
|
this machine is taken: `CursorLang-1.2.3.0-x64.msix`. Those are the names
|
|
`build-msix.ps1` produces, so a release is made by attaching what it built.
|
|
|
|
The package is downloaded to the temp folder and handed to the Windows app
|
|
installer: it shows the publisher, asks for a confirmation and replaces the
|
|
installed version. Windows checks the signature, so the package attached to a
|
|
release has to be signed — an unsigned one installs nowhere but a machine in
|
|
developer mode. The running app keeps working off the old files until it is
|
|
restarted.
|
|
|
|
An app installed from the Store has no updates section at all: the Store
|
|
updates it, and a package from the side is something Windows would not accept
|
|
over it anyway.
|
|
|
|
The app asks about releases every time the settings window appears, and the
|
|
answer stays in the section as it is — an unreachable network included. Opening
|
|
that window is a deliberate act, rare enough for a request to cost nothing, so
|
|
nothing is remembered between openings and there is nothing to turn off. The
|
|
button next to the version asks the same question again on demand.
|
|
|
|
Where the releases are looked for is set in `UpdateOptions` — the repository
|
|
belongs to whoever publishes the app, not to the user, so the values live in the
|
|
build rather than in `settings.json`:
|
|
|
|
```csharp
|
|
services.AddSingleton(new UpdateOptions
|
|
{
|
|
ServiceUri = new Uri("https://git.alrakis.kz/"), // the Gitea server itself
|
|
Project = "alrakis/cursor-lang",
|
|
});
|
|
```
|
|
|
|
The releases come from Gitea, and its API sits on the server itself: the address
|
|
is the one the repository is opened at in a browser, and the app asks
|
|
`/api/v1/repos/{owner}/{repo}/releases` under it.
|
|
|
|
A closed repository needs a token. It is read from the `CURSORLANG_UPDATE_TOKEN`
|
|
environment variable rather than kept in the source: a secret built into the app
|
|
is a secret handed to everyone who got the app.
|
|
|
|
## 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, packed into an MSIX and published as a release with the packages
|
|
attached. 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 the release carries goes to Partner Center as it is. 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`.
|
|
|
|
## 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: your architecture alone
|
|
powershell -File Packaging\build-msix.ps1 -Architectures x64
|
|
|
|
# For Partner Center — the identity is the one reserved there
|
|
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
|
|
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
|
|
```
|
|
|
|
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
|
|
and arm64; next to it lie the packages of single architectures. Upload the bundle
|
|
to Partner Center as it is.
|
|
|
|
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 -Architectures x64 -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 AleksandrNeychev.CursorLang).PackageFullName
|
|
```
|