# 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-.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 ```