Pull request / build (pull_request) Successful in 47s
A bilingual (EN/RU) static page in site/, split into index.html, styles.css, boot.js and main.js, with a favicon.svg. Documents placement modes, per-mode look, the Caps Lock shortcut and a privacy policy — the last is meant to double as the Partner Center privacy policy URL. STORE_URL is still a placeholder in both download buttons until the Store listing exists.
417 lines
21 KiB
Markdown
417 lines
21 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.
|
|
|
|
## The site
|
|
|
|
`site/index.html` is the page the Store listing points at: what the application
|
|
does, and a privacy policy saying that it collects nothing. Partner Center leaves
|
|
the policy optional for an app that gathers no data, but a page saying so out
|
|
loud is what takes the question off the reviewer's desk.
|
|
|
|
The whole of it is five files in `site`, and nothing is fetched from anywhere
|
|
else — no framework, no build step, no font from a network:
|
|
|
|
| File | What is in it |
|
|
|---|---|
|
|
| `index.html` | The markup, both languages at once |
|
|
| `styles.css` | Every rule, the palette included |
|
|
| `boot.js` | The language and the theme of an earlier visit, before the first frame |
|
|
| `main.js` | The rest: the switches, the menu, the demo, the preview |
|
|
| `favicon.svg` | The same dark plate with "Aя" as the application icon |
|
|
|
|
`boot.js` is small and separate for one reason: it has to run before the body is
|
|
parsed. It reads what was chosen last time and puts it on the `<html>` element,
|
|
and without it the first frame would be English in the system theme and correct
|
|
itself a moment later, in plain sight. `main.js` works on markup that has to
|
|
exist by then, so it is deferred instead.
|
|
|
|
Both languages live in the markup at once, and a switch in the corner picks one.
|
|
Every translated piece is a `<span lang="en">` beside a `<span lang="ru">`, and
|
|
whichever does not match the language on the `<html>` element is hidden by a rule
|
|
of two lines. With the scripts switched off the page still reads, in English.
|
|
|
|
Publishing means copying the five files as they are. Before that, two things are
|
|
still waiting for an address.
|
|
|
|
The Store listing is the first: the two download buttons are
|
|
`<span class="btn disabled" role="link">` rather than links, because a
|
|
placeholder in `href` would be a relative address and anything following it would
|
|
land on a 404. The address turns each of them back into
|
|
`<a class="btn" href="…">` and takes the note beside it away.
|
|
|
|
The address of the site itself is the second, wanted by three tags the head does
|
|
not carry yet: `og:image`, `og:url` and the canonical link.
|
|
|
|
Support goes to `support@alrakis.kz`: it stands in the privacy section, under the
|
|
download buttons and in the footer.
|