From 7aa4c850d7deba1cd20663bbb6b936f5ef5678c2 Mon Sep 17 00:00:00 2001 From: Oksion Date: Sat, 24 Oct 2026 21:23:44 +0500 Subject: [PATCH] =?UTF-8?q?feat(shell):=20=D1=8F=D1=80=D0=BB=D1=8B=D0=BA?= =?UTF-8?q?=20=D0=B2=20=D0=BC=D0=B5=D0=BD=D1=8E=20=C2=AB=D0=9F=D1=83=D1=81?= =?UTF-8?q?=D0=BA=C2=BB=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B1=D1=83=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B5=D0=B9=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8?= =?UTF-8?q?=20(XIC-106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пакет winget у нас portable и ярлыков не создаёт, поэтому ярлык кладёт само приложение — только по желанию: тумблер в Настройки → Общие или клик по приветствию первого запуска. Свой .lnk в Start Menu\Programs профиля через IShellLinkW, состояние — сам файл; аргумент --show сразу открывает настройки. Самопочинка на старте по правилам автозапуска: пропавший или более старый exe — переписать, другую папку той же версии не перехватывать. Вторая копия раньше молча выходила, и клик по exe давал только UAC. Теперь она сигналит сеансовое событие Local\XiControlShow (создаётся сразу за мьютексом — ранний сигнал не теряется), делится правом на передний план, и работающая копия открывает настройки или выводит вперёд уже открытое окно. Не нашла будильника — копия другого пользователя или старая версия: сообщение «уже запущен». Запуск с --autostart не будит: задача срабатывает и на возврат в сеанс. Тосты теперь ставят действие по клику на каждом показе — иначе клик по постороннему тосту исполнил бы чужое действие (создал бы ярлык без спроса). Проверено вживую на portable-копии: сообщение при старой версии, ярлык по клику на приветствие, пробуждение с окном на переднем плане, тишина на --autostart, старт ярлыком с окном, удаление тумблером. Тест на живом .lnk ловит сдвиг слотов vtable. Сборка 0/0, тесты 923/923. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++ CLAUDE.md | 18 ++- README.md | 15 ++- README.ru.md | 15 ++- src/Localization/lang/en.json | 6 + src/Localization/lang/ru.json | 6 + src/Localization/lang/zh.json | 6 + src/Program.cs | 34 ++++- src/SystemIntegration/InstanceWake.cs | 63 +++++++++ src/SystemIntegration/StartMenuShortcut.cs | 125 ++++++++++++++++++ src/Ui/AppController.cs | 14 ++ src/Ui/Settings/GeneralTab.cs | 3 + src/Ui/SettingsActions.cs | 2 + src/Ui/SettingsForm.cs | 7 + src/Ui/TrayApp.cs | 65 +++++++-- .../XiControl.Tests/StartMenuShortcutTests.cs | 59 +++++++++ 16 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 src/SystemIntegration/InstanceWake.cs create mode 100644 src/SystemIntegration/StartMenuShortcut.cs create mode 100644 tests/XiControl.Tests/StartMenuShortcutTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1147a17..a32b067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ ## [Unreleased] +### Добавлено +- **Ярлык в меню «Пуск».** Пакет winget ставит XiControl как portable-программу, а такая ярлыков + не создаёт — найти приложение можно было только по exe. Теперь ярлык добавляется по желанию: + тумблер «Ярлык в меню „Пуск“» в Настройки → Общие или клик по приветствию первого запуска. Запуск + из «Пуска» сразу открывает окно настроек. +- **Повторный запуск поднимает уже работающий XiControl.** Раньше вторая копия молча закрывалась, и + клик по exe не давал ничего, кроме запроса UAC. Теперь открывается окно настроек работающей копии, + а если оно уже открыто — выходит на передний план. Если работающая копия принадлежит другому + пользователю этого компьютера или это старая версия, XiControl так и скажет. Автозапуск по-прежнему + ничего не открывает (XIC-106). + ## [0.18.0] — 2026-10-18 ### Добавлено diff --git a/CLAUDE.md b/CLAUDE.md index a29d4ed..1e987cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,8 @@ Mi-кнопка и «мёртвые» клавиши (включая мульт вкл/выкл, мёртвая зона у нижнего края тачпада, сила вибрации и усилие нажатия тачпада, сильное нажатие на тачпад как назначаемое действие, виджет «Монитор», индикатор-метрика в трее (Вт/CPU/GPU/NPU/RAM/°C цифрой на втором значке), -окно настроек в стиле Win11, проверка обновлений (оповещение, без самообновления), проверка +окно настроек в стиле Win11, ярлык в меню «Пуск» по желанию (повторный запуск поднимает +работающую копию), проверка обновлений (оповещение, без самообновления), проверка драйверов и BIOS у Xiaomi по кнопке (сверка, без скачивания и установки), опциональный HTTP API для управления из локальной сети (телефон/Home Assistant). Всё управление железом — через штатный WMI-интерфейс прошивки `MiCommonInterface` (протокол MIFS, ODM Bitland). @@ -128,7 +129,8 @@ config.json рядом с exe, XIC-34) — папка программы. Фак ## Архитектура Одно трей-приложение, **без службы** (проверено: admin-процесса достаточно и для SET/GET, и для -WMI-событий). `Program.cs`: single-instance mutex → DI-контейнер +WMI-событий). `Program.cs`: single-instance mutex (вторая копия не стартует, а будит первую +через `InstanceWake` — кроме запуска с `--autostart`, XIC-106) → DI-контейнер (`Microsoft.Extensions.DependencyInjection`, все синглтоны; провайдер владеет Dispose в обратном порядке создания — компоненты инжектированное не диспоузят) → `TrayApp.Start()` → `Application.Run()`. @@ -265,7 +267,17 @@ WMI-событий). `Program.cs`: single-instance mutex → DI-контейне другой источник — на ближайшей смене питания. Таблица и решение — чистые `OverlayFor`/`Decide` под тестами), `AutoStart` (задача планировщика с SID в имени — своя на каждого пользователя, задача старого образца `XiControl` мигрирует при следующем переключении; самопочинка на старте чинит и пропавший - путь, и устаревшую версию exe, дев-сборки `0.0.0` в сверке не участвуют), `AwakeMode` («режим совы»: подписанный + путь, и устаревшую версию exe, дев-сборки `0.0.0` в сверке не участвуют), `StartMenuShortcut` + (XIC-106: winget-пакет portable и ярлыков не создаёт — свой `.lnk` в Start Menu\Programs профиля + через `IShellLinkW`, только по тумблеру или клику по приветствию первого запуска; состояние — сам + файл, без поля в конфиге; аргумент `--show` открывает настройки; самопочинка на старте — по тем же + правилам, что у `AutoStart`, ярлык на другую папку той же версии не перехватывается; слоты vtable + проверяет тест на живом `.lnk` во временной папке), `InstanceWake` (будильник второй копии: + auto-reset событие `Local\XiControlShow` создаётся сразу за мьютексом, чтобы ранний сигнал не + потерялся; вторая копия делает `AllowSetForegroundWindow` и сигналит, первая открывает настройки или + выводит вперёд уже открытое окно без пересборки. Событие сеансовое при глобальном мьютексе: + копия другого пользователя или старая версия будильника не найдёт — тогда сообщение «уже запущен»), + `AwakeMode` («режим совы»: подписанный запрос питания `PowerCreateRequest` — всегда SystemRequired + крышка на AC, экран держится DisplayRequired — кроме `OwlIgnoreDisplay: true` в config.json, тогда только «не спать»; не SetThreadExecutionState — тот привязан к потоку и в `powercfg /requests` без причины, XIC-93; исходное действие крышки diff --git a/README.md b/README.md index d65a4cd..3157e6a 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ where there are no watts to show, you can watch load or degrees instead.* (Settings → General). When off, no icon, no timer and no data sources are created: zero extra load. - 🌐 UI language: Russian / English / Chinese (中文). - 🚀 Autostart via Task Scheduler (no UAC prompt at logon, works on battery). +- 📌 Start menu shortcut on request — launching from Start opens Settings, and if Xi Control is + already running, it brings that copy up instead of starting a second one. - 🛰️ **HTTP API for the local network** (optional, **off by default**) — control it from a phone or from Home Assistant automations: read status and charge (`GET /status`), switch mode, charge protection, "travel" and owl. A command allowlist, token authorization @@ -217,6 +219,8 @@ moved to another drive or a flash stick, and survives a Windows reinstall with n protected by "administrators only" permissions, and that's precisely why the API cannot be enabled — or its token swapped — by editing the user config. - Autostart is a Windows Task Scheduler entry; by its nature it lives in the system, not in the folder. + The same goes for the Start menu shortcut, if you turn it on: it sits in your profile's Start menu + folder and points at this exe. ### Antivirus false positives @@ -271,7 +275,7 @@ Launch `XiControl.exe` (confirm the UAC prompt) — a tray icon appears. | Keyboard backlight key | OSD with the level (off / 50% / 100% / auto) | All options live in the **Settings…** window (tray menu item): tabs General (language, -autostart, panel theme, update check, logging, tray indicator), Features (owl, touchpad, +autostart, Start menu shortcut, panel theme, update check, logging, tray indicator), Features (owl, touchpad, touchscreen, refresh-rate control), Battery (charge threshold with a hint, "travel" sound and file, lock-screen sound and toast, charger-wattage OSD, "weak PSU" threshold, battery health), Display (brightness limit and memory, auto-brightness by sensor with the curve graph; auto refresh rate, "Keep refresh rate" and the rates — the section is @@ -301,6 +305,15 @@ next toggle. Self-repair on launch covers not only a missing exe but also a task **outdated build** — the typical case when the portable version is unpacked into a new folder. Besides logon, the task also fires when you return to the session via fast user switching. +The winget package is portable and creates no Start menu shortcut, so Xi Control adds its own — only +when you ask: the "Start menu shortcut" toggle (Settings → General) or a click on the first-run +notification. The shortcut opens Settings right away. If Xi Control is already running, launching it +again (by the shortcut or the exe) brings up the running copy's Settings window instead of a second +copy. The UAC prompt on that click stays: the app needs administrator rights. If the running copy +belongs to another user on this PC, or is an older version, you get a message saying so. A +shortcut pointing at a removed or older build is fixed on launch, like the autostart task. +`winget uninstall` can't remove it — turn the toggle off before uninstalling. + ### Travel mode (a one-off charge to 100%) You usually keep battery care on (say, 80%), but before a trip you want a full charge. Press the diff --git a/README.ru.md b/README.ru.md index eec22b4..cda5d53 100644 --- a/README.ru.md +++ b/README.ru.md @@ -163,6 +163,8 @@ ни источники данных: ноль дополнительной нагрузки. - 🌐 Язык интерфейса: русский / английский / китайский (中文). - 🚀 Автозапуск через Планировщик заданий (без UAC-запроса при входе, работает на батарее). +- 📌 Ярлык в меню «Пуск» по желанию — запуск из «Пуска» открывает настройки, а если Xi Control уже + работает, поднимает его, а не вторую копию. - 🛰️ **HTTP API для локальной сети** (опционально, по умолчанию **выключен**) — управляй с телефона или из автоматизаций Home Assistant: чтение статуса и заряда (`GET /status`), переключение режима, защиты заряда, «В дорогу» и совы. Белый список команд, авторизация по @@ -220,6 +222,8 @@ winget install Oksion.XiControl Файл защищён правами «изменять может только администратор», и именно поэтому API нельзя включить или подменить его токен правкой пользовательского конфига. - Автозапуск — задача Планировщика Windows, она по своей природе живёт в системе, а не в папке. + Как и ярлык в «Пуске», если его включить: он лежит в папке меню «Пуск» вашего профиля и ведёт на + этот exe. ### Антивирусы и ложные срабатывания @@ -275,7 +279,7 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu | Клавиша подсветки клавиатуры | OSD с уровнем (выкл / 50% / 100% / авто) | Все опции собраны в окне **«Настройки…»** (пункт меню трея): вкладки Общие (язык, -автозапуск, тема панелей, проверка обновлений, логирование, индикатор в трее), Функции +автозапуск, ярлык в «Пуске», тема панелей, проверка обновлений, логирование, индикатор в трее), Функции (сова, тачпад, сенсорный экран, управление частотой), Батарея (порог заряда с пояснением, звук и файл «В дорогу», звук и уведомление на локскрине, OSD мощности зарядника, порог «слабого блока», здоровье батареи), Экран (лимит и запоминание яркости, авто-яркость по датчику с графиком кривой; авто-герцовка, @@ -307,6 +311,15 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu Кроме входа в систему задача срабатывает на возврат в сеанс при быстром переключении пользователей. +Пакет winget — portable и ярлыков в «Пуск» не создаёт, поэтому Xi Control кладёт свой — только +по вашему желанию: тумблер «Ярлык в меню „Пуск“» (Настройки → Общие) или клик по приветствию +первого запуска. Ярлык сразу открывает настройки. Если Xi Control уже работает, повторный запуск +(ярлыком или exe) открывает окно настроек у работающей копии, а не поднимает вторую. Запрос UAC +на этот клик остаётся: приложению нужны права администратора. Если работающая копия принадлежит +другому пользователю этого компьютера или это старая версия, появится сообщение об этом. Ярлык +на удалённую или старую сборку чинится при запуске, как задача автозапуска. `winget uninstall` +его не уберёт — выключите тумблер перед удалением. + ### Режим «В дорогу» (разовый заряд до 100%) Обычно держишь «беречь батарею» (скажем, 80%), но перед поездкой хочется полный заряд. Нажми diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index 05301b7..242278c 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -34,6 +34,8 @@ "settings.language.desc": "Language of the menu, panel and pop-ups.", "settings.autostart": "Start with Windows", "settings.autostart.desc": "Via Task Scheduler, without a UAC prompt.", + "settings.startmenu": "Start menu shortcut", + "settings.startmenu.desc": "Launching from Start opens the settings window — or brings up the running XiControl instead of a second copy.", "settings.general.comfort": "Comfort", "settings.brightness.desc": "Separately for AC and battery; no Power profiles.", "settings.battery.sub": "Charge threshold, travel mode and the charge-ready sound.", @@ -524,6 +526,10 @@ "theme.system": "Follow Windows", "toast.firstrun.title": "XiControl is running in the tray", "toast.firstrun.text": "Click the tray icon for the quick panel, right-click for the menu. Holding the Mi button also opens the panel.", + "toast.firstrun.startmenu": "Click this notification to add XiControl to the Start menu.", + "toast.startmenu.added": "XiControl has been added to the Start menu. You can remove the shortcut in Settings → General.", + "toast.startmenu.failed": "Couldn't add the Start menu shortcut — see the log for details.", "err.title": "Error", + "err.running.elsewhere": "XiControl is already running — for another user on this PC, or as an older version that can't be brought up from here. To start this copy, close that one: tray menu → Exit.", "err.noiface": "MIFS interface not found. Requires administrator rights and a compatible Xiaomi/Redmi laptop." } diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index 8f79af7..d0ec508 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -34,6 +34,8 @@ "settings.language.desc": "Язык меню, панели и всплывашек.", "settings.autostart": "Запускать при входе в Windows", "settings.autostart.desc": "Через Планировщик заданий, без запроса UAC.", + "settings.startmenu": "Ярлык в меню «Пуск»", + "settings.startmenu.desc": "Запуск из «Пуска» открывает окно настроек, а если XiControl уже работает — поднимает его, а не вторую копию.", "settings.general.comfort": "Удобства", "settings.brightness.desc": "Отдельно для сети и батареи; без «Профилей».", "settings.battery.sub": "Порог заряда, режим «В дорогу» и звук готовности.", @@ -524,6 +526,10 @@ "theme.system": "Как в Windows", "toast.firstrun.title": "XiControl работает в трее", "toast.firstrun.text": "Клик по значку — быстрая панель, правый клик — меню. Удержание кнопки Mi тоже открывает панель.", + "toast.firstrun.startmenu": "Нажмите на это уведомление — и XiControl появится в меню «Пуск».", + "toast.startmenu.added": "XiControl добавлен в меню «Пуск». Убрать ярлык можно в Настройки → Общие.", + "toast.startmenu.failed": "Не получилось добавить ярлык в меню «Пуск» — подробности в журнале.", "err.title": "Ошибка", + "err.running.elsewhere": "XiControl уже запущен — у другого пользователя этого компьютера или в старой версии, которую отсюда не вызвать. Чтобы запустить эту копию, закройте ту: меню в трее → «Выход».", "err.noiface": "Интерфейс MIFS не найден. Нужны права администратора и совместимый ноутбук Xiaomi/Redmi." } diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index a131b19..80c7233 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -34,6 +34,8 @@ "settings.language.desc": "菜单、面板和提示的语言。", "settings.autostart": "开机自启动", "settings.autostart.desc": "通过任务计划程序,无需 UAC 提示。", + "settings.startmenu": "“开始”菜单快捷方式", + "settings.startmenu.desc": "从“开始”菜单启动会打开设置窗口;若 XiControl 已在运行,则唤出它,而不会再启动一个副本。", "settings.general.comfort": "便捷功能", "settings.brightness.desc": "分别用于电源和电池,无需电源配置。", "settings.battery.sub": "充电阈值、出行模式与充电完成提示音。", @@ -524,6 +526,10 @@ "theme.system": "跟随 Windows", "toast.firstrun.title": "XiControl 正在系统托盘中运行", "toast.firstrun.text": "点击托盘图标打开快捷面板,右键打开菜单。长按 Mi 键也可打开面板。", + "toast.firstrun.startmenu": "点击此通知,即可将 XiControl 添加到“开始”菜单。", + "toast.startmenu.added": "已将 XiControl 添加到“开始”菜单。可在 设置 → 常规 中移除快捷方式。", + "toast.startmenu.failed": "未能添加“开始”菜单快捷方式,详情请查看日志。", "err.title": "错误", + "err.running.elsewhere": "XiControl 已在运行:可能属于本机的其他用户,或是无法从此处唤出的旧版本。如需启动此副本,请先关闭那个:托盘菜单 → 退出。", "err.noiface": "未找到 MIFS 接口。需要管理员权限和兼容的小米/红米(Redmi)笔记本电脑。" } diff --git a/src/Program.cs b/src/Program.cs index bce08a5..9886324 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -10,11 +10,18 @@ namespace XiControl; internal static class Program { [STAThread] - private static void Main() + private static void Main(string[] args) { - // единственный экземпляр + // единственный экземпляр; вторая копия не стартует, а будит первую (XIC-106) using var mutex = new Mutex(true, @"Global\XiControlMutex", out bool created); - if (!created) return; + if (!created) + { + // автозапуск будить не должен: задача срабатывает и на возврат в сеанс, когда мы + // уже работаем, — окно настроек выскакивало бы на каждом переключении пользователей + if (!HasArg(args, "--autostart")) WakeRunning(); + return; + } + using var wake = new InstanceWake(); // сразу за мьютексом: ранний вызов не теряется ApplicationConfiguration.Initialize(); WatchForCrashes(); @@ -86,10 +93,29 @@ private static void Main() return; } - provider.GetRequiredService().Start(); + var tray = provider.GetRequiredService(); + tray.Start(); + // запуск ярлыком из «Пуска» — человек ждёт окно, а не значок в трее + tray.AttachWake(wake, showNow: HasArg(args, StartMenuShortcut.ShowArg)); Application.Run(); } + private static bool HasArg(string[] args, string arg) => args.Contains(arg, StringComparer.OrdinalIgnoreCase); + + /// + /// Мьютекс занят. Свою копию в этом сеансе будим — она откроет окно настроек. Не нашли — + /// работает копия у другого пользователя или старая версия без будильника: говорим прямо, + /// иначе клик по ярлыку снова не дал бы ничего, кроме UAC. + /// + private static void WakeRunning() + { + if (InstanceWake.Signal()) return; + ApplicationConfiguration.Initialize(); + Loc.Current = new JsonConfigStore().Load().Language ?? ""; // только чтение, конфиг не пишем + MessageBox.Show(Loc.T("err.running.elsewhere"), Loc.T("app.name"), + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + /// /// Последняя сеть перед смертью процесса. Сама по себе она ничего не чинит — исключение, /// дошедшее сюда, приложение уже не переживёт, — но превращает «зависла и закрылась» в diff --git a/src/SystemIntegration/InstanceWake.cs b/src/SystemIntegration/InstanceWake.cs new file mode 100644 index 0000000..e089a35 --- /dev/null +++ b/src/SystemIntegration/InstanceWake.cs @@ -0,0 +1,63 @@ +using System.Runtime.InteropServices; + +namespace XiControl.SystemIntegration; + +/// +/// Будильник уже запущенного экземпляра (XIC-106). Вторая копия (мьютекс в Program занят) раньше +/// молча выходила — клик по ярлыку в «Пуске» не давал ничего, кроме UAC. Теперь она сигналит +/// именованное событие, и работающий экземпляр открывает окно настроек. Именованного события +/// хватает: передавать нечего, а pipe или широковещательное сообщение — лишняя механика. +/// +/// Мьютекс глобальный, а событие — в пространстве сеанса (Local\): копия, запущенная у +/// другого пользователя при быстром переключении, события не найдёт и честно скажет, что XiControl +/// уже работает, — вместо того чтобы открыть окно на рабочем столе, которого она не видит. +/// +/// Создаётся сразу за мьютексом, задолго до готовности UI: auto-reset событие держит сигнал, пока +/// его не заберут, поэтому ранний вызов не теряется — окно откроется, как только TrayApp подпишется. +/// +public sealed class InstanceWake : IDisposable +{ + private const string EventName = @"Local\XiControlShow"; + private const uint ASFW_ANY = uint.MaxValue; + + private readonly EventWaitHandle _event = new(false, EventResetMode.AutoReset, EventName); + private RegisteredWaitHandle? _wait; + + [DllImport("user32.dll")] + private static extern bool AllowSetForegroundWindow(uint processId); + + /// Подписка на будильник. Колбэк приходит с пула — маршалинг в UI на вызывающем. + public void Listen(Action onWake) + { + _wait = ThreadPool.RegisterWaitForSingleObject(_event, (_, _) => + { + try { onWake(); } + catch (Exception ex) { Log.Ex("InstanceWake", ex); } + }, null, Timeout.Infinite, executeOnlyOnce: false); + } + + /// + /// Вторая копия: разбудить первую. false — в этом сеансе будить некого: работает копия у + /// другого пользователя или старая версия без будильника. + /// + public static bool Signal() + { + try + { + using var ev = EventWaitHandle.OpenExisting(EventName); + // окно поднимет ДРУГОЙ процесс, а на передний план Windows пускает только того, кто + // получил последний ввод, — это мы: нас только что запустили кликом. Делимся правом, + // иначе окно откроется позади активного (та же грабля, что в XIC-90) + _ = AllowSetForegroundWindow(ASFW_ANY); + return ev.Set(); + } + catch (WaitHandleCannotBeOpenedException) { return false; } + catch (Exception ex) { Log.Ex("InstanceWake.Signal", ex); return false; } + } + + public void Dispose() + { + _wait?.Unregister(null); + _event.Dispose(); + } +} diff --git a/src/SystemIntegration/StartMenuShortcut.cs b/src/SystemIntegration/StartMenuShortcut.cs new file mode 100644 index 0000000..b375e52 --- /dev/null +++ b/src/SystemIntegration/StartMenuShortcut.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; + +namespace XiControl.SystemIntegration; + +/// +/// Ярлык в меню «Пуск» (XIC-106). Пакет winget у нас portable — такой ярлыков не создаёт, а +/// инсталлятора нет, поэтому ярлык кладёт само приложение и только по явному действию человека +/// (тумблер в «Общих» или клик по приветствию первого запуска). Ярлык свой у каждого +/// пользователя — Start Menu\Programs в профиле, как задача автозапуска (XIC-23). Состояние — +/// сам файл, поля в конфиге нет: удалили ярлык руками — тумблер это увидит. +/// Запуск по ярлыку передаёт : окно настроек открывается сразу, а уже +/// работающий экземпляр поднимает своё (). +/// +public static class StartMenuShortcut +{ + /// Аргумент запуска по ярлыку: сразу открыть окно настроек. + public const string ShowArg = "--show"; + + // имя файла не зависит от языка интерфейса: иначе смена языка осиротила бы ярлык + private static string LinkPath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), "XiControl.lnk"); + + private static readonly Guid CLSID_ShellLink = new("00021401-0000-0000-C000-000000000046"); + private const int MaxPath = 1024; + + public static bool Exists() => File.Exists(LinkPath); + + /// Создать ярлык на текущий exe или убрать его. false — не вышло, причина в журнале. + public static bool Set(bool enabled) + { + try + { + if (enabled) Write(Environment.ProcessPath!, LinkPath); // у обычного exe-процесса путь есть всегда + else File.Delete(LinkPath); // файла нет — Delete молча проходит + return true; + } + catch (Exception ex) { Log.Ex("StartMenuShortcut.Set", ex); return false; } + } + + /// + /// Самопочинка, как у автозапуска: ярлык есть, но ведёт на пропавший exe или на сборку старее + /// текущей — переписываем на себя. Ярлык на ДРУГУЮ папку той же версии не трогаем: две + /// portable-копии иначе перехватывали бы его друг у друга на каждом старте. Ярлыка нет — + /// не создаём: без спроса в профиль не пишем. Вызывается один раз на старте. + /// + public static void RepairIfBroken() + { + if (Environment.ProcessPath is not { } exe || !Exists()) return; + if (ReadLink(LinkPath)?.Target is not { Length: > 0 } target) return; // не разобрали — чужое не переписываем + + bool alive = File.Exists(target); + string? version = null; + if (alive) + { + try { version = FileVersionInfo.GetVersionInfo(target).FileVersion; } + catch (Exception ex) { Log.Ex("StartMenuShortcut.FileVersion", ex); return; } + } + if (!NeedsRepair(alive, version, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version)) + return; + + Log.Write($"StartMenuShortcut: ярлык ведёт на {(alive ? $"старую сборку ({version})" : "пропавший exe")} " + + $"{target} — переписываю на текущий"); + try { Write(exe, LinkPath); } + catch (Exception ex) { Log.Ex("StartMenuShortcut.Repair", ex); } + } + + /// Пора ли переписать ярлык: цель пропала или старее нас. Дев-сборки в сверке версий + /// не участвуют — правило общее с задачей автозапуска (). + internal static bool NeedsRepair(bool targetExists, string? targetVersion, Version? current) + => !targetExists || AutoStart.IsOutdated(targetVersion, current); + + // путь ярлыка — параметром ради теста: он пишет и читает .lnk во временной папке и так ловит + // сдвиг слотов vtable, которого компилятор не видит (COM-вызов просто попал бы не в тот метод) + internal static void Write(string exe, string linkPath) + { + var link = NewLink(); + try + { + link.SetPath(exe); + link.SetArguments(ShowArg); + link.SetWorkingDirectory(Path.GetDirectoryName(exe)!); + Directory.CreateDirectory(Path.GetDirectoryName(linkPath)!); + ((IPersistFile)link).Save(linkPath, true); + } + finally { Marshal.ReleaseComObject(link); } + } + + /// Цель и аргументы ярлыка; null — не прочитали (причина в журнале). + internal static (string Target, string Args)? ReadLink(string linkPath) + { + var link = NewLink(); + try + { + ((IPersistFile)link).Load(linkPath, 0); // STGM_READ + var buf = new char[MaxPath]; + link.GetPath(buf, buf.Length, 0, 0); + string target = Text(buf); + link.GetArguments(buf, buf.Length); + return (target, Text(buf)); + } + catch (Exception ex) { Log.Ex("StartMenuShortcut.ReadLink", ex); return null; } + finally { Marshal.ReleaseComObject(link); } + + static string Text(char[] b) => new string(b, 0, Math.Max(0, Array.IndexOf(b, '\0'))); + } + + private static IShellLinkW NewLink() => + (IShellLinkW)Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_ShellLink, throwOnError: true)!)!; + + [ComImport, Guid("000214F9-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellLinkW + { + void GetPath([Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] char[] pszFile, int cch, + nint pfd, uint fFlags); // слот 0 + void f1(); void f2(); void f3(); void f4(); void f5(); // слоты 1..5 (не используем) + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); // слот 6 + void GetArguments([Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] char[] pszArgs, + int cch); // слот 7 + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); // слот 8 + void f9(); void f10(); void f11(); void f12(); void f13(); void f14(); void f15(); void f16(); // 9..16 + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); // слот 17 + } +} diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index 63ab657..6b9ef88 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -112,6 +112,8 @@ public void Startup() // самопочинка: после обновления/переноса exe задача указывает на пропавший путь // и молча не стартует — пересоздаём на текущий exe if (_autoStart) Safe(() => { AutoStart.RepairIfBroken(); return true; }, true); + // то же для ярлыка в «Пуске» (XIC-106): нет ярлыка — ничего не создаём + Safe(() => { StartMenuShortcut.RepairIfBroken(); return true; }, true); }); // Тактильные настройки тачпада живут в нём самом — только прочитать для вкладки @@ -835,6 +837,18 @@ public void ToggleAutoStart(bool on) }); } + /// Ярлык в меню «Пуск» есть (XIC-106). Без кэша: проверка — один File.Exists, а + /// удалить ярлык могут и руками. + public static bool StartMenuEnabled => StartMenuShortcut.Exists(); + + /// Ярлык в «Пуск»: создать на текущий exe или убрать. Файл и COM — миллисекунды, + /// воркер не нужен. Возвращает итоговое состояние. + public static bool ToggleStartMenu(bool on) + { + StartMenuShortcut.Set(on); + return StartMenuEnabled; + } + /// Доступные языки (культура + родное название) — для комбо настроек. public IReadOnlyList Languages => _loc.Available; diff --git a/src/Ui/Settings/GeneralTab.cs b/src/Ui/Settings/GeneralTab.cs index 41350b6..28773e8 100644 --- a/src/Ui/Settings/GeneralTab.cs +++ b/src/Ui/Settings/GeneralTab.cs @@ -20,6 +20,9 @@ public GeneralTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act, Action }, ui.Sc(150))); ui.AddRow(this, "settings.autostart", "settings.autostart.desc", ui.Toggle(act.GetAutoStart(), act.SetAutoStart)); + // winget-пакет portable и сам ярлыков не создаёт — кладём свой, только по тумблеру (XIC-106) + ui.AddRow(this, "settings.startmenu", "settings.startmenu.desc", + ui.Toggle(act.GetStartMenu(), act.SetStartMenu)); // тема панелей/OSD/«Монитора»: значения соответствуют AppConfig.FlyoutTheme string?[] themeValues = [null, "light", "system"]; diff --git a/src/Ui/SettingsActions.cs b/src/Ui/SettingsActions.cs index de4181e..45de480 100644 --- a/src/Ui/SettingsActions.cs +++ b/src/Ui/SettingsActions.cs @@ -20,6 +20,8 @@ public sealed class SettingsActions { public required Func GetAutoStart; public required Action SetAutoStart; + public required Func GetStartMenu; // ярлык в меню «Пуск» (XIC-106) + public required Action SetStartMenu; public required Func> Languages; // доступные языки (data-driven) public required Func CurrentLanguage; // текущий культурный код public required Action SetLanguage; // сменить язык по культурному коду diff --git a/src/Ui/SettingsForm.cs b/src/Ui/SettingsForm.cs index d9f3074..4214ede 100644 --- a/src/Ui/SettingsForm.cs +++ b/src/Ui/SettingsForm.cs @@ -95,6 +95,13 @@ public void Popup() BuildAll(); FitToScreen(Screen.FromPoint(Cursor.Position)); // открываемся по центру экрана с курсором Show(); + Reveal(); + } + + /// Вывести уже показанное окно вперёд без пересборки (XIC-106: повторный запуск + /// ярлыком не должен сбрасывать открытую вкладку). + public void Reveal() + { if (WindowState == FormWindowState.Minimized) WindowState = FormWindowState.Normal; Activate(); BringToFront(); diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs index 460690a..076d21e 100644 --- a/src/Ui/TrayApp.cs +++ b/src/Ui/TrayApp.cs @@ -21,7 +21,9 @@ public sealed class TrayApp : IDisposable private readonly QuickPanelForm _panel; private bool _lastOnline; // прошлое состояние питания (для OSD только на реальном переходе) private bool _locked; // сессия заблокирована (Win+L): OSD не виден — обратная связь звуком/тостом (XIC-11) - private string? _pendingUpdateUrl; // куда вести по клику на тост об обновлении + // Действие по клику на тост. Ставится при КАЖДОМ показе (у большинства — null): иначе клик по + // следующему, постороннему тосту исполнил бы чужое — с XIC-106 это создание ярлыка без спроса + private Action? _balloonClick; // Кэш отчёта о батарее (BatteryTab + /status API): износ/циклы/ёмкость меняются раз в // сутки-недели, а окно настроек пересобирается на каждый Popup/смену языка — держим короткий @@ -138,7 +140,7 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, IPowerEv // зарегистрированным AppUserModelID — а он требует ярлыка в «Пуске», то есть // следа в системе, которого у портативного exe быть не должно. // Напоминания тостами не дублируем: три штуки подряд — уже спам. - if (!reminder) Safe(() => { _tray.ShowBalloonTip(10_000, Loc.T("app.name"), + if (!reminder) Safe(() => { Balloon(10_000, Loc.T("app.name"), $"{Loc.T("osd.softcharge", pct)} — {Loc.T("osd.softcharge.sub")}", ToolTipIcon.Warning); return true; }, false); } @@ -192,7 +194,7 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, IPowerEv if (_locked) { if (_cfg.TravelLockSound) Sound.PlayToggle(on); - if (_cfg.TravelLockToast) Safe(() => { _tray.ShowBalloonTip(4000, Loc.T("app.name"), + if (_cfg.TravelLockToast) Safe(() => { Balloon(4000, Loc.T("app.name"), on ? $"{Loc.T("osd.travel")} — {Loc.T("osd.travel.sub")}" : Loc.T("osd.travel.off"), ToolTipIcon.Info); return true; }, false); return; @@ -249,9 +251,9 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, IPowerEv _controller.UpdateFound = OnUpdateFound; _tray.BalloonTipClicked += (_, _) => { - if (_pendingUpdateUrl is not string url) return; // тост мог быть и не про обновление - _pendingUpdateUrl = null; - ShellOpen.OpenUrl(url); + var click = _balloonClick; + _balloonClick = null; + click?.Invoke(); }; // тачпад/экран: колбэк приходит с фонового потока — маршалим в UI _controller.TouchpadToggled = b => _osd.BeginInvoke(new Action(() => @@ -366,12 +368,17 @@ public void Start() _osd.BeginInvoke(new Action(_menu.Prime)); // Одноразовая подсказка первого запуска: как открыть панель и меню — иначе - // самая богатая поверхность (QuickPanel) остаётся спрятанной за Mi-кнопкой + // самая богатая поверхность (QuickPanel) остаётся спрятанной за Mi-кнопкой. + // Заодно предлагаем ярлык в «Пуск» (XIC-106): сами без спроса не создаём, клик по тосту — + // и есть спрос; дальше тот же тумблер в «Общих» if (!_cfg.FirstRunShown) { _cfg.FirstRunShown = true; _cfg.Save(); - _tray.ShowBalloonTip(10000, Loc.T("toast.firstrun.title"), Loc.T("toast.firstrun.text"), ToolTipIcon.None); + bool offer = !AppController.StartMenuEnabled; + Balloon(10000, Loc.T("toast.firstrun.title"), + offer ? $"{Loc.T("toast.firstrun.text")}\n{Loc.T("toast.firstrun.startmenu")}" : Loc.T("toast.firstrun.text"), + onClick: offer ? AddStartMenuFromToast : null); } // Проверка обновления — с задержкой и в фоне: старт не замедляем, сеть в UI-поток не тащим @@ -392,14 +399,48 @@ private void OnUpdateFound(ReleaseInfo release) _osd.BeginInvoke(new Action(() => { UpdateCheck.MarkNotified(_cfg, release); - _pendingUpdateUrl = release.Url; - _tray.ShowBalloonTip(10000, Loc.T("toast.update.title"), - Loc.T("toast.update.text", release.Tag), ToolTipIcon.None); + Balloon(10000, Loc.T("toast.update.title"), Loc.T("toast.update.text", release.Tag), + onClick: () => ShellOpen.OpenUrl(release.Url)); })); return true; }, false); } + // Все тосты — через это: действие по клику ставится заново на каждом показе (см. _balloonClick) + private void Balloon(int ms, string title, string text, ToolTipIcon icon = ToolTipIcon.None, Action? onClick = null) + { + _balloonClick = onClick; + _tray.ShowBalloonTip(ms, title, text, icon); + } + + // Клик по приветствию первого запуска — согласие на ярлык в «Пуске» (XIC-106). Итог говорим + // тем же тостом: человек кликнул по уведомлению и ждёт ответа там же + private void AddStartMenuFromToast() + { + bool ok = AppController.ToggleStartMenu(true); + Balloon(6000, Loc.T("app.name"), Loc.T(ok ? "toast.startmenu.added" : "toast.startmenu.failed"), + ok ? ToolTipIcon.None : ToolTipIcon.Warning); + } + + /// + /// Будильник второй копии (XIC-106): запуск ярлыком при работающем приложении открывает окно + /// настроек здесь. showNow — нас самих только что запустили ярлыком. + /// + public void AttachWake(InstanceWake wake, bool showNow) + { + // колбэк — с пула; NotifyIcon не Control, маршалим через форму OSD, как остальные + wake.Listen(() => _osd.BeginInvoke(new Action(ShowSettingsWindow))); + if (showNow) _osd.BeginInvoke(new Action(ShowSettingsWindow)); + } + + // Окно уже открыто — только вывести вперёд: Popup пересобирает вкладки, и человек потерял бы, + // где был. Закрыто — открыть как обычно. + private void ShowSettingsWindow() + { + if (_settings is { IsDisposed: false, Visible: true } s) s.Reveal(); + else OpenSettings(); + } + // Тултип трея: имя • режим • заряд (NotifyIcon.Text ограничен 127 символами). private static string TrayText(PerfMode? mode) { @@ -768,6 +809,8 @@ private void OpenSettings() { GetAutoStart = () => _controller.AutoStartEnabled, SetAutoStart = _controller.ToggleAutoStart, + GetStartMenu = () => AppController.StartMenuEnabled, + SetStartMenu = on => AppController.ToggleStartMenu(on), Languages = () => _controller.Languages, CurrentLanguage = () => _controller.CurrentLanguage, SetLanguage = _controller.SetLanguage, diff --git a/tests/XiControl.Tests/StartMenuShortcutTests.cs b/tests/XiControl.Tests/StartMenuShortcutTests.cs new file mode 100644 index 0000000..28fade5 --- /dev/null +++ b/tests/XiControl.Tests/StartMenuShortcutTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using XiControl.SystemIntegration; +using Xunit; + +namespace XiControl.Tests; + +/// +/// Ярлык в «Пуске» (XIC-106) — чистая логика самопочинки: когда переписывать ярлык на себя. +/// Сам ярлык (IShellLink, профиль пользователя) юнитами не трогаем. +/// +public sealed class StartMenuShortcutTests +{ + // Живой IShellLink, но во временной папке, не в «Пуске»: интерфейс объявлен вручную по слотам + // vtable, и ошибка в номере слота компилятором не ловится — вызов ушёл бы в соседний метод. + // Запись и чтение одним и тем же объявлением сами по себе ничего не доказали бы, поэтому + // проверяем ещё и то, что видит в ярлыке сама оболочка. + [Fact] + public void Write_ThenReadLink_RoundTripsTargetAndArgs() + { + string dir = Path.Combine(Path.GetTempPath(), "XiControlTests_" + Guid.NewGuid().ToString("N")); + string link = Path.Combine(dir, "XiControl.lnk"); + string exe = Path.Combine(dir, "Папка с пробелом", "XiControl.exe"); + try + { + StartMenuShortcut.Write(exe, link); + + StartMenuShortcut.ReadLink(link).Should().Be((exe, StartMenuShortcut.ShowArg)); + // рабочая папка и аргумент лежат в .lnk строками UTF-16 (секция StringData), но не + // обязательно с чётного смещения — читаем обе раскладки. Сам путь exe оболочка хранит + // элементами IDList, не одной строкой, — его подтверждает ReadLink выше + byte[] bytes = File.ReadAllBytes(link); + string raw = System.Text.Encoding.Unicode.GetString(bytes) + + System.Text.Encoding.Unicode.GetString(bytes, 1, bytes.Length - 1); + raw.Should().Contain(Path.GetDirectoryName(exe)).And.Contain(StartMenuShortcut.ShowArg); + } + finally { try { Directory.Delete(dir, recursive: true); } catch (IOException) { /* временная папка — не критично */ } } + } + + [Fact] + public void NeedsRepair_WhenTargetMissing() + { + // распаковали новую версию в другую папку, старую удалили — ярлык ведёт в никуда + StartMenuShortcut.NeedsRepair(targetExists: false, targetVersion: null, Version.Parse("0.19.0")) + .Should().BeTrue(); + } + + [Theory] + [InlineData("0.18.0.0", "0.19.0", true)] // ярлык поднимает прошлую версию — чиним + [InlineData("0.19.0.0", "0.19.0", false)] // та же версия (в т.ч. другая portable-папка) — не перехватываем + [InlineData("0.20.0.0", "0.19.0", false)] // в ярлыке новее: мы старая копия, не откатываем + [InlineData("0.0.0.0", "0.19.0", false)] // дев-сборки в сверке не участвуют — как у автозапуска + [InlineData("0.18.0.0", "0.0.0", false)] + [InlineData(null, "0.19.0", false)] // версию не прочитали — чужое не переписываем + public void NeedsRepair_WhenTargetAlive_ComparesVersions(string? targetVersion, string current, bool expected) + { + StartMenuShortcut.NeedsRepair(targetExists: true, targetVersion, Version.Parse(current)) + .Should().Be(expected); + } +}