diff --git a/README.RU.md b/README.RU.md new file mode 100644 index 0000000..0697727 --- /dev/null +++ b/README.RU.md @@ -0,0 +1,170 @@ +# cursor-lang + +[English version](README.md) + +Приложение WPF показывает раскладку клавиатуры у курсора. + +## Клонирование + +Двоичные ресурсы — иконка exe и логотипы MSIX — хранятся в Git LFS, поэтому +перед клонированием нужно установить [git-lfs](https://git-lfs.com): + +```powershell +git lfs install +git clone git@git.alrakis.kz:alrakis/cursor-lang.git +``` + +Клонирование без него проходит успешно, но вместо изображений остаются +текстовые файлы-указатели, и сборка затем падает на нечитаемой иконке. +Существующий клон чинится командой `git lfs install`, а затем `git lfs pull`. + +## Права администратора + +Приложение работает от имени обычного пользователя. От повышения прав +отказались, чтобы приложение можно было опубликовать в Microsoft Store: пакеты +MSIX всегда выполняются в контексте вошедшего пользователя, а политика Store +отклоняет приложения, которым права администратора нужны для любой части +функциональности. + +Цена этого — необязательная горячая клавиша Caps Lock. Пока фокусом владеет +окно с более высоким уровнем целостности — Диспетчер задач, Редактор реестра, +запросы UAC, — Windows не доставляет нажатия низкоуровневому хуку и не принимает +запрос на смену раскладки, так что горячая клавиша там ничего не делает. Чтение +раскладки чужого окна не ограничено, поэтому сама подсказка продолжает работать +везде. + +## Автозапуск + +Автозапуск включается из настроек приложения тем способом, который доступен +сборке. Пакет объявляет его в манифесте как `windows.startupTask`. Сборка, +распакованная в папку, прописывается так, как это всегда делали программы для +рабочего стола, — в `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, и прав +администратора для этого не нужно. + +И в том, и в другом случае Windows показывает приложение в разделе Параметры — +Приложения — Автозагрузка; если пользователь выключит его там, приложение уже не +сможет включить его обратно и скажет об этом вместо молчаливого отказа. Запись в +реестре при этом остаётся на месте: решение пользователя Windows хранит отдельно +от неё, в `StartupApproved`, и приложение с ним считается. + +## Настройки + +Расположение `settings.json` зависит от способа установки приложения. Отдельная +установка хранит его в `%APPDATA%\CursorLang`. Пакетная сборка хранит его в +собственной папке данных пакета, которую Windows удаляет вместе с приложением — +предполагается, что приложения из Store не оставляют после себя ничего. + +При первом запуске пакетная сборка подхватывает настройки, оставленные отдельной +установкой, и копирует их себе. Исходный файл остаётся на месте: обе сборки могут +быть установлены рядом, и приложение не вправе удалять настройки, которые ему не +принадлежат. + +## Обновления + +Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск +годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему +приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta` +— пропускается: предварительную версию берут намеренно, приложение её не +предлагает. + +Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры. +Если его нет, берётся пакет, в имени которого стоит архитектура этой машины: +`CursorLang-1.2.3.0-x64.msix`. Такие имена даёт `build-msix.ps1`, так что выпуск +делается прикладыванием того, что он собрал. + +Пакет скачивается во временную папку и передаётся установщику приложений +Windows: тот показывает издателя, спрашивает подтверждение и заменяет +установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску +пакет должен быть подписан — неподписанный установится только на машине в режиме +разработчика. Работающее приложение до перезапуска продолжает жить на старых +файлах. + +У приложения, установленного из Store, раздела обновлений нет вовсе: его +обновляет Store, а пакет со стороны Windows поверх него всё равно не примет. + +Само по себе приложение спрашивает о выпусках раз в сутки, при запуске, и хранит +дату последней удачной проверки в настройках. Там же её можно выключить — тогда +остаётся кнопка в окне настроек, делающая то же самое по требованию. + +Где искать выпуски, задаётся в `UpdateOptions`: репозиторий принадлежит тому, кто +выпускает приложение, а не пользователю, поэтому значения живут в сборке, а не в +`settings.json`: + +```csharp +services.AddSingleton(new UpdateOptions +{ + ServiceUri = new Uri("https://git.alrakis.kz/"), // сам сервер Gitea + Project = "alrakis/cursor-lang", +}); +``` + +Выпуски берутся из Gitea, а её API живёт на самом сервере: адрес — тот же, по +которому репозиторий открывают в браузере, и под ним приложение спрашивает +`/api/v1/repos/{владелец}/{репозиторий}/releases`. + +Закрытому репозиторию нужен токен. Он читается из переменной окружения +`CURSORLANG_UPDATE_TOKEN`, а не хранится в исходниках: секрет, встроенный в +сборку, — это секрет, отданный всем, кто эту сборку получил. + +## Тесты + +```powershell +dotnet test +``` + +Тесты лежат в `CursorLang.Tests` и работают на xUnit. Половина приложения — +окна, таймеры диспетчера, перехват клавиатуры — живёт только на потоке STA +с очередью сообщений, поэтому тесты держат один такой поток на весь прогон +и выполняют на нём всё, что этого требует. + +Части проверок нужно настоящее окно переднего плана: положение каретки и +просьбу сменить раскладку видно только там. Право вывести окно вперёд Windows +даёт не всегда, и такие проверки сообщают о себе как о пропущенных, а не как +о провалившихся — без окна переднего плана проверять нечего. Сквозные проверки +запускают собранное приложение отдельным процессом и пропускают себя, если +приложение уже работает: вмешиваться в чужой запущенный экземпляр они не вправе. + +Покрытие снимается так: + +```powershell +dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage.runsettings +``` + +Непокрытым остаётся то, до чего тестовому процессу не дотянуться: пути, которым +нужен установленный пакет MSIX — `StartupTask` и папка данных пакета, — и +композиционный корень в `App.xaml.cs`, который вместо этого проверяется сквозным +запуском приложения. + +## Сборка пакета MSIX + +Ни Visual Studio, ни Windows SDK не требуются — `makeappx` и `signtool` +поставляются из пакета NuGet. Порядок публикации целиком — от регистрации +разработчика до отправки на проверку — описан в +[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md). + +```powershell +# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок) +powershell -File Packaging\New-Assets.ps1 + +# Сборка и подпись для локальной проверки +powershell -File Packaging\build-msix.ps1 -Architectures x64 -Sign + +# Сборка для Store — identity берётся из Partner Center +powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` + -IdentityName 12345Alrakis.CursorLang -Publisher "CN=ABCD1234-..." +``` + +Результат — `artifacts\packages\CursorLang-.msixbundle`, покрывающий x64 +и arm64. Загружать в Partner Center его нужно неподписанным — Store подписывает +пакет собственным сертификатом. + +Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а +MSIX не может установить среду выполнения как зависимость пакета. + +Чтобы попробовать пакет без установки, зарегистрируйте опубликованный layout — +для этого нужен режим разработчика и не нужна подпись вовсе: + +```powershell +Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml +Remove-AppxPackage (Get-AppxPackage -Name Alrakis.CursorLang).PackageFullName +``` diff --git a/README.md b/README.md index 459b03f..78b9947 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,168 @@ # cursor-lang -The WPF application displays the keyboard layout at the cursor +[Русская версия](README.RU.md) -The app requests administrator rights on startup. Windows does not deliver -keyboard input to a low-level hook while an app of a higher integrity level -holds the focus, so without elevation the Caps Lock hotkey would silently stop -working in Task Manager, Registry Editor and anything else started as -administrator. Note that a plain shortcut in the startup folder cannot launch an -elevated app — use a Task Scheduler task with the "highest privileges" flag. \ No newline at end of file +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`. + +## 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. + +## 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. + +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. + +## Updates + +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. + +By itself the app asks about releases once a day, at startup, and remembers the +date of the last successful check in the settings. The check can be turned off +there, which leaves the button in the settings window doing the same 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 +``` + +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. + +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 CursorLang.Tests\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. + +## Building the MSIX package + +Neither Visual Studio nor the Windows SDK is required — `makeappx` and +`signtool` come 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 + +# Build and sign for local testing +powershell -File Packaging\build-msix.ps1 -Architectures x64 -Sign + +# Build for the Store — identity comes from Partner Center +powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` + -IdentityName 12345Alrakis.CursorLang -Publisher "CN=ABCD1234-..." +``` + +The result is `artifacts\packages\CursorLang-.msixbundle` covering x64 +and arm64. Upload it to Partner Center unsigned — the Store signs it with its own +certificate. + +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 try the package without installing it, register the published layout — this +needs Developer Mode and no signature at all: + +```powershell +Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml +Remove-AppxPackage (Get-AppxPackage -Name Alrakis.CursorLang).PackageFullName +```