Files
cursor-lang/README.RU.md
T
alex 11d9bd223e
Pull request / build (pull_request) Successful in 52s
modified caret mode
2026-08-15 04:04:42 +05:00

29 KiB

cursor-lang

English version

Приложение 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 не оставляют после себя ничего.

При первом запуске пакетная сборка подхватывает настройки, оставленные отдельной установкой, и копирует их себе. Исходный файл остаётся на месте: обе сборки могут быть установлены рядом, и приложение не вправе удалять настройки, которые ему не принадлежат.

Всё, что относится к всплывающей подсказке, принадлежит режиму размещения, а не приложению, и файл хранит по разделу на каждый режим — AtCursor, AtCaret и FixedPoint. В каждом лежат сторона, отступ, размер шрифта, прозрачность и оба цвета. Подсказка рядом с курсором ввода находится внутри читаемого текста, и её хотят видеть небольшой и незаметной; ту, что в углу монитора, ищут намеренно, и её хотят видеть крупной. Общее для всех режимов оформление приходилось настраивать заново после каждого переключения, поэтому окно настроек показывает оформление выбранного выше режима и записывает только в него.

Сторона означает в каждом режиме своё, и у каждого для неё собственный тип. Рядом с курсором мыши это любой из шести углов и сторон. Рядом с курсором ввода — только слева или справа: сверху и снизу от каретки находится следующая строка текста, и подсказка закрывала бы читаемое. Для фиксированной точки это место на мониторе, а отступ — расстояние от его края: в середине края нет, поэтому выбор середины сбрасывает отступ в нуль, а окно настроек показывает его неактивным. Отступ остаётся на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы всё, что ниже, при каждой смене места.

Положение каретки сообщает не всякое приложение, и тогда рядом с курсором ввода — нигде. Подсказка уходит на фиксированную точку: место, отступ и оформление берутся оттуда, из режима каретки не берётся ничего. Курсор мыши был бы запасным вариантом поближе — и неверным: он там, где его оставили, сбоку или на другом мониторе, тогда как смотрят на каретку. Фиксированная точка всегда на одном и том же месте, и потому известно, куда смотреть, — а вместе с местом должно приходить и оформление: подсказка, настроенная мелкой и незаметной для середины текста, в углу монитора останется незамеченной.

К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после переключения режима, и это вопрос, который задают один раз.

Обновления

Приложение обновляет Store, а само приложение об этом не заботится: раздела обновлений в окне нет, запросов в сеть нет и кода для них тоже нет.

Дело не во вкусе, а в цене подписи. MSIX Windows установит только тогда, когда доверяет подписи на нём, а публично доверенный сертификат для подписи кода оказался недосягаем: удостоверяющие центры, которые их продают, здесь его не выдают, а те, что держали бы ключ в облачном HSM, — тем более, а сертификат на USB-токене сюда не привезти. Без подписи пакет установится только на машине в режиме разработчика, значит выкладывать в выпуск нечего и искать обновления негде. Store подписывает пакет своим сертификатом и обновляет приложение сам — на этом вопрос и закрыт.

Версия работающего приложения стоит в заголовке окна настроек: CursorLang 1.2.3 — Настройки. Больше её в интерфейсе нигде нет, и стоит она там для того, чтобы её можно было назвать в сообщении об ошибке.

Тесты

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’е, живущем службой в нулевом сеансе, пропускают себя: показать окно там негде.

Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам Partner Center. Поэтому прогон оставляет его в артефактах под именем msix-1.2.3.0, откуда его забирают и загружают руками; к релизу не прикладывается ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный пакет, висящий в релизе, выглядел бы как то, что можно установить, и не устанавливался бы нигде — см. раздел об обновлениях.

Identity берётся из переменных репозитория, а если те не заданы — из значений по умолчанию в скрипте: MSIX_IDENTITY_NAME, MSIX_PUBLISHER и MSIX_PUBLISHER_DISPLAY_NAME. Вместе identity и publisher задают family name пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store примет следующую за другое приложение.

Сборка пакета MSIX

Ни Visual Studio, ни Windows SDK не требуются — makeappx поставляется из пакета NuGet. Порядок публикации целиком — от регистрации разработчика до отправки на проверку — описан в Packaging/PUBLISHING.RU.md.

# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
powershell -File Packaging\New-Assets.ps1

# Проверка на своей машине
powershell -File Packaging\build-msix.ps1

# Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
    -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."

Результат — artifacts\packages\CursorLang-<версия>-x64.msix. Он загружается в Partner Center как есть.

Собирается только x64. Сборка под arm64 удвоила бы вес каждого релиза ради машин, которые и так выполняют x64 через эмуляцию.

Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки на эту машину вместо пакета регистрируется layout — см. -Install ниже.

Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а MSIX не может установить среду выполнения как зависимость пакета.

Чтобы посмотреть, как пакет работает на этой машине, соберите его с -Install:

pwsh -File Packaging\build-msix.ps1 -Install

Приложение появится в меню «Пуск» как любое установленное. Регистрируется не сам файл пакета, а layout, из которого пакет собирается, поэтому подпись не нужна — достаточно режима разработчика. Обратная сторона в том, что приложение работает прямо из artifacts\layout, и пересборка вытащила бы файлы у него из-под ног: такую регистрацию скрипт снимает перед очисткой папки независимо от того, просили ли -Install. Копию, установленную из Store, он не трогает, хотя называется она так же.

Удалить вручную:

Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName

Сборка установщика

То же приложение собирается и обычным MSI — чтобы раздавать помимо Store: пока Store не вынес решение или если так его и не вынесет. Ставить ничего, кроме .NET SDK, не нужно: WiX приезжает пакетом NuGet, как и makeappx.

# Собрать и запустить — посмотреть глазами пользователя
pwsh -File Packaging\build-installer.ps1 -Install

# Всё, что нужно релизу
pwsh -File Packaging\build-installer.ps1 -Version 1.0.1

Результат — artifacts\installers\CursorLang-<версия>-x64.msi. Всё лежит внутри .msi — отдельного архива рядом с ним нет.

Устанавливается только для текущего пользователя, в %LOCALAPPDATA%\Programs\CursorLang, поэтому не просит ни прав администратора, ни подтверждения. Удаление идёт через «Параметры» — «Приложения», как у любой программы, и уносит с собой запись автозапуска: иначе Windows продолжала бы показывать в автозагрузке приложение, которого уже нет.

Установщик никто не подписывает, поэтому Windows предупреждает о неизвестном издателе и пользователю приходится настоять. Покупка сертификата это сразу не снимет: SmartScreen смотрит на репутацию, а у нового сертификата её нет, пока приложение не наберёт установок.

Про проект WiX стоит знать две вещи, прежде чем его править.

Он закреплён на WiX 5, а не на нынешней 7: начиная с шестой версии инструмент требует принимать лицензию Open Source Maintenance Fee — бесплатную при доходе меньше $10 000 в год, но принимать её должен человек, а не сборочный скрипт.

И он собирается без проверки MSI. Проверки ICE выполняются службой установщика Windows, до которой сборочному агенту не дотянуться: каждая возвращается ошибкой WIX0217, и сборка умирает на без малого сотне таких. На обычной машине служба отвечает, и проверка включается одним ключом:

dotnet build Packaging\Installer\CursorLang.wixproj -p:SuppressValidation=false

Три правила остаются подавленными и тогда. MSI исходит из установки на всю машину, а установка в профиль пользователя нарушает правила, которые описывают ровно то, что здесь и задумано.