22 KiB
cursor-lang
Приложение WPF показывает раскладку клавиатуры у курсора.
Клонирование
Двоичные ресурсы — иконка exe и логотипы MSIX — хранятся в Git LFS, поэтому перед клонированием нужно установить git-lfs:
git lfs install
git clone git@git.alrakis.kz:alrakis/cursor-lang.git
Клонирование без него проходит успешно, но вместо изображений остаются
текстовые файлы-указатели, и сборка затем падает на нечитаемой иконке.
Существующий клон чинится командой git lfs install, а затем git lfs pull.
Запуск во время работы над приложением
dotnet run --project CursorLang.Agent
Это целое приложение, а не одна фоновая половина: сборка агента кладёт окно настроек рядом с ним, потому что именно там агент его и ищет — меню трея запускает окно по пути, а в установленном приложении они лежат в одной папке. Собери агента отдельно — и пункт «Настройки» молча ничего бы не делал.
Rider и Visual Studio берут профили запуска из
CursorLang.Agent/Properties/launchSettings.json и
CursorLang.Settings/Properties/launchSettings.json, так что в списке конфигураций
появляются три:
| Профиль | Что делает |
|---|---|
Agent |
Запуск как у пользователя: иконка в трее и сразу окно настроек |
Agent (started by Windows) |
Добавляет --startup: только трей, без окна — случай входа в систему |
Settings |
Окно настроек само по себе, без агента за спиной |
Обе половины читают %APPDATA%\CursorLang\settings.json — тот же файл, что и у
установленного приложения: путь вычисляется в одном месте, и обе половины зовут
именно его. Значит, отладка правит настоящие настройки; так и задумано — смотреть,
как агент подхватывает правку из окна, это и есть большая часть того, на что тут
стоит смотреть.
Чтобы шагать по обеим сразу, включи в отладчике присоединение к дочерним процессам: окно настроек агент запускает отдельным процессом, и без этого отладчик останется на агенте.
Права администратора
Приложение работает от имени обычного пользователя. От повышения прав отказались, чтобы приложение можно было опубликовать в Microsoft Store: пакеты MSIX всегда выполняются в контексте вошедшего пользователя, а политика Store отклоняет приложения, которым права администратора нужны для любой части функциональности.
Цена этого — необязательная горячая клавиша Caps Lock. Пока фокусом владеет окно с более высоким уровнем целостности — Диспетчер задач, Редактор реестра, запросы UAC, — Windows не доставляет нажатия низкоуровневому хуку и не принимает запрос на смену раскладки, так что горячая клавиша там ничего не делает. Чтение раскладки чужого окна не ограничено, поэтому сама подсказка продолжает работать везде.
Два процесса
Приложение — это два исполняемых файла в одной папке.
CursorLang.exe — агент: перехват клавиатуры, опрос раскладки, подсказка и
иконка в трее. Именно его Windows запускает при входе в систему и именно он
целый день сидит в трее, поэтому он собран без WPF: фоновый процесс,
таскающий за собой движок отрисовки, стоит около ста мегабайт ради подсказки,
которую средствами Win32 рисуют за восемь. Так он держится ниже 9 МБ.
CursorLang.Settings.exe — окно настроек и ничего больше. Агент запускает его
из меню трея; закрытие окна завершает процесс, и занятая WPF память целиком
возвращается системе. Плата — холодный старт около полусекунды при следующем
открытии окна.
CursorLang.Core.dll — общее для обоих: модели, файл настроек, слежение за
раскладкой, обновления. Без UI, и так должно остаться: всё, что попадёт туда,
попадёт и в фоновый процесс.
Связь между ними — только settings.json. Окно пишет его целиком, во временный
файл, который одним движением встаёт на место, и посылает агенту
зарегистрированное оконное сообщение, по которому тот перечитывает файл.
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
единственного источника правды. Работающий агент необязателен — без него окно
работает так же. За файлом никто не следит: писатель у него один, и он сам
сообщает о записи.
Трей
Окно настроек — гость на экране, а не само приложение: оно показывается после установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в заголовке окна означают ровно то, что написано: окно закрывается, а его процесс завершается. Выход из самого приложения — пункт «Выход» в меню трея.
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку, выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF означало бы весь движок отрисовки в фоновом процессе — ровно то, чего этот процесс и создан избегать.
Запущенное самой Windows, приложение не показывает окна вовсе и сразу уходит в
трей — пользователь просил, чтобы оно было на месте при входе в систему, а не
чтобы окно встречало его каждое утро. Такой запуск две сборки распознают
по-разному: запись в реестре у распакованной сборки несёт аргумент --startup, а
пакет своей командной строкой не распоряжается, и вместо неё Windows
спрашивают об активации.
Если Windows откажет иконке — в сеансе без рабочего стола области уведомлений нет, — агент открывает окно настроек при любом запуске. Иначе у пользователя осталось бы приложение, которое он не может ни увидеть, ни закрыть.
Автозапуск
Автозапуск включается из настроек приложения тем способом, который доступен
сборке. Пакет объявляет его в манифесте как windows.startupTask. Сборка,
распакованная в папку, прописывается так, как это всегда делали программы для
рабочего стола, — в HKCU\Software\Microsoft\Windows\CurrentVersion\Run, и прав
администратора для этого не нужно. Записанная там команда заканчивается на
--startup: так приложение отличает запуск, устроенный самой Windows, от
запуска пользователем.
И в том, и в другом случае 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:
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, а не хранится в исходниках: секрет, встроенный в
сборку, — это секрет, отданный всем, кто эту сборку получил.
Тесты
dotnet test
На каждый проект свой набор — CursorLang.Core.Tests, CursorLang.Agent.Tests
и CursorLang.Settings.Tests, — все на xUnit, а подделки и вспомогательный код
общие и вынесены в отдельную библиотеку CursorLang.Tests.Shared. Сама библиотека
про xUnit не знает: единственному месту, которому нужно было утверждение, хватило
исключения. У набора для Core нет ссылки на WPF, и это само по себе проверка: то,
что затянуло бы WPF в фоновый процесс, там попросту не скомпилируется.
Половина приложения — окна, таймеры, перехват клавиатуры — живёт только на
потоке STA с очередью сообщений, поэтому тесты держат один такой поток на весь
прогон и выполняют на нём всё, что этого требует. Для Core и агента на этом
потоке крутится обычный цикл Win32 (CursorLang.Tests.Shared/Pump.cs), для окна
настроек —
диспетчер WPF.
Части проверок нужно настоящее окно переднего плана: положение каретки и просьбу сменить раскладку видно только там. Право вывести окно вперёд Windows даёт не всегда, и такие проверки сообщают о себе как о пропущенных, а не как о провалившихся — без окна переднего плана проверять нечего. Сквозные проверки запускают собранное приложение отдельным процессом и пропускают себя, если приложение уже работает: вмешиваться в чужой запущенный экземпляр они не вправе.
Покрытие снимается так:
dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
Непокрытым остаётся то, до чего тестовому процессу не дотянуться: пути, которым
нужен установленный пакет MSIX — StartupTask и папка данных пакета, — и
композиционные корни App.xaml.cs и Agent.cs, которые вместо этого
проверяются сквозным запуском приложения. Одна из таких проверок читает список
модулей работающего агента и падает, если среди них окажется хоть что-то от
движка отрисовки WPF.
Непрерывная сборка
Пайплайны лежат в .gitea/workflows и работают на Gitea Actions. Запрос
на слияние в master собирается и проверяется тестами; по тегу вида v1.2.3
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
с приложенными пакетами. Номер версии берётся только из тега — тег любого
другого вида останавливает прогон в самом начале. Версия пакета получается
1.2.3.0: Store принимает четыре числа и последнее оставляет себе, так что тег
на него не влияет.
Обоим пайплайнам нужен runner под Windows с меткой windows-x64, на нём —
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
остаётся текстовой заглушкой и сборка на ней падает. Работать runner лучше
в интерактивном сеансе рабочего стола: тесты поднимают настоящие окна, и те
проверки, которым нужен свой рабочий стол — сквозные, а также те, что просят
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде.
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
берётся из переменных репозитория, а если те не заданы — из значений по
умолчанию в скрипте: MSIX_IDENTITY_NAME, MSIX_PUBLISHER
и MSIX_PUBLISHER_DISPLAY_NAME.
Сборка пакета MSIX
Ни Visual Studio, ни Windows SDK не требуются — makeappx поставляется из
пакета NuGet. Порядок публикации целиком — от регистрации разработчика
до отправки на проверку — описан в
Packaging/PUBLISHING.RU.md.
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
powershell -File Packaging\New-Assets.ps1
# Проверка на своей машине: только своя архитектура
powershell -File Packaging\build-msix.ps1 -Architectures x64
# Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
Результат — artifacts\packages\CursorLang-<версия>.msixbundle, покрывающий x64
и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
Center как есть.
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а MSIX не может установить среду выполнения как зависимость пакета.
Чтобы попробовать пакет без установки, зарегистрируйте опубликованный layout — для этого нужен режим разработчика и не нужна подпись вовсе:
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName