diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 36e6a00..f3fcb47 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -61,10 +61,10 @@ jobs: uses: gittools/actions/gitversion/execute@v0 - name: Execute unit tests - run: dotnet test + run: dotnet test YMouseButtonControl.sln - name: Restore the application - run: dotnet restore -r ${{ matrix.rid }} + run: dotnet restore ${{ env.PROJECT_FOLDER }}/${{ env.PROJECT_NAME }} -r ${{ matrix.rid }} - name: Publish run: | diff --git a/.gitignore b/.gitignore index 2d33069..51e2d42 100644 --- a/.gitignore +++ b/.gitignore @@ -451,3 +451,9 @@ $RECYCLE.BIN/ ## Visual Studio Code ## .vscode/* + +## +## Claude Code / local AI assistant settings (machine-specific, never commit) +## +.claude/ +CLAUDE.local.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e1c2314 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to YMouseButtonControl + +Thanks for your interest in improving YMouseButtonControl! This document explains how to +build, run, and test the project, and the conventions the codebase follows. + +## Prerequisites + +* [.NET SDK 8.0](https://dotnet.microsoft.com/en-us/download/visual-studio-sdks) +* A working internet connection for the first `dotnet restore` (NuGet packages) + +## Getting started + +```bash +git clone https://github.com/FaithBeam/YMouseButtonControl +cd YMouseButtonControl +dotnet restore YMouseButtonControl.sln +dotnet build YMouseButtonControl.sln +``` + +> **Note:** `YMouseButtonControl.sln` is the solution that matches this source tree. +> The `YMouseButtonControl.slnx` file describes a different, in-progress project layout +> (split per-platform projects) and is not the active solution. + +## Running the app + +```bash +dotnet run --project YMouseButtonControl/YMouseButtonControl.csproj +``` + +> **Important for contributors:** In **Debug** builds the global mouse/keyboard hook is +> replaced with a SharpHook `TestProvider` (see +> `YMouseButtonControl/DependencyInjection/KeyboardAndMouseBootstrapper.cs`). This means a +> Debug build does **not** capture real mouse buttons — it is meant for UI work and tests. +> To exercise real input handling, build/run in **Release**: +> +> ```bash +> dotnet run -c Release --project YMouseButtonControl/YMouseButtonControl.csproj +> ``` + +## Running the tests + +```bash +dotnet test YMouseButtonControl.sln +``` + +Tests live in `YMouseButtonControl.Tests`. They use xUnit and run the real data-access layer +against an in-memory SQLite database (a shared, open connection), so persistence behaviour is +exercised exactly as it is in production. When adding a feature that touches the database or a +command/query handler, please add a test alongside it. + +## Project layout + +| Project | Responsibility | +|---------|----------------| +| `YMouseButtonControl.Domain` | Plain entity/enum models (`Profile`, `ButtonMapping`, `Setting`, …). No dependencies. | +| `YMouseButtonControl.DataAccess` (`YMouseButtonControl.Infrastructure`) | EF Core `DbContext`, SQLite, migrations, and the seeded "Default" profile. | +| `YMouseButtonControl.Core` | ViewModels (ReactiveUI), services, mappers, and command/query handlers. The bulk of the logic. | +| `YMouseButtonControl` | Avalonia app: views (`.axaml`), platform entry point, and dependency-injection bootstrappers. | +| `YMouseButtonControl.Tests` | xUnit tests. | + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for a deeper tour. + +## Coding conventions + +* **Formatting** is enforced with [CSharpier](https://csharpier.com/). Run it before committing: + ```bash + dotnet tool restore # if a local tool manifest is present + dotnet csharpier . + ``` + Files listed in `.csharpierignore` are excluded. +* **Warnings are errors.** `YMouseButtonControl.Core` builds with `TreatWarningsAsErrors`, + so keep the build clean. +* **Async:** never use `List.ForEach(async …)` or any other pattern that produces a + fire-and-forget `async void`. Use an awaited `foreach`. (A real persistence bug was caused + by exactly this — see `ApplyProfiles` and its regression tests.) +* **DbContext is not thread-safe.** Do not start overlapping operations on the same context + instance. + +## Submitting changes + +1. Create a branch off `master`. +2. Make your change and add/adjust tests. +3. Run `dotnet csharpier .`, `dotnet build`, and `dotnet test`. +4. Open a pull request describing the change and linking any related issue. diff --git a/README.md b/README.md index 3e1151a..3a72dd5 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,17 @@ This is an attempt at a cross-platform clone of X-Mouse-Button-Control. 2. Extract the archive 3. Run YMouseButtonControl +## Language + +YMouseButtonControl is localized into **English, Russian, German, Spanish and French**. +By default it follows your operating system's UI language; you can override it in +**Settings → Language**. Changing the language requires a restart (like the theme setting). + ## Requirements * [.NET 8.0 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) * On windows if you run YMouseButtonControl.exe, it will take you to the download automatically + * Self-contained release archives bundle the runtime, so nothing extra is required for those ### Linux @@ -68,3 +75,27 @@ Anything that can install .NET 8 should be able to run YMouseButtonControl ``` * YOUR_PLATFORM: win-x64, linux-x64, osx-x64, [more runtimes here](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog) 4. YMouseButtonControl executable is located in bin folder + +### Helper script + +`scripts/build-release.sh` builds self-contained, single-file releases and strips files that +aren't needed to run (debug symbols, `LICENSE`): + +``` +scripts/build-release.sh # win-x64, linux-x64, osx-x64 +scripts/build-release.sh linux-x64 # a single runtime +scripts/build-release.sh win-x64 osx-arm64 +``` + +Output goes to `bin/publish-/`. Each folder contains the executable plus `appsettings.json` +(required at startup); the macOS build additionally ships the native `.dylib` files it needs. + +## Troubleshooting + +Having a problem (profiles not saving, Wayland limitations, per-app profiles, more than 5 +buttons)? See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for build/test instructions and +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for a tour of the codebase. diff --git a/YMouseButtonControl.Core/Localization/Localizer.cs b/YMouseButtonControl.Core/Localization/Localizer.cs new file mode 100644 index 0000000..c2995b7 --- /dev/null +++ b/YMouseButtonControl.Core/Localization/Localizer.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ReactiveUI; + +namespace YMouseButtonControl.Core.Localization; + +/// +/// Tiny in-process translation table. Strings are looked up by key against the dictionary for the +/// active language, falling back to English (the source language) for any missing key. +/// +/// Language is chosen once at startup (see App startup) and a change requires a restart, so the +/// markup extension resolves strings at XAML load time rather than via live bindings. +/// +public sealed class Localizer : ReactiveObject +{ + public static Localizer Instance { get; } = new(); + + /// Language codes the UI offers explicitly, besides the implicit "system" option. + public static readonly string[] SupportedLanguages = ["en", "ru", "de", "es", "fr"]; + + private IReadOnlyDictionary _current = Translations.En; + + private Localizer() { } + + /// + /// Sets the active language. may be one of , + /// or "system"/null/empty to follow the OS UI culture. Anything unsupported falls back to English. + /// + public void SetLanguage(string? code) + { + _current = Resolve(code) switch + { + "ru" => Translations.Ru, + "de" => Translations.De, + "es" => Translations.Es, + "fr" => Translations.Fr, + _ => Translations.En, + }; + } + + /// Resolves a stored language code to one of . + public static string Resolve(string? code) + { + if (string.IsNullOrWhiteSpace(code) || code == "system") + { + code = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + } + + return SupportedLanguages.Contains(code) ? code : "en"; + } + + public string this[string key] => Get(key); + + public string Get(string key) + { + if (_current.TryGetValue(key, out var value)) + { + return value; + } + + return Translations.En.TryGetValue(key, out var english) ? english : key; + } + + /// Looks up a composite-format string and applies . + public string Format(string key, params object?[] args) => + string.Format(CultureInfo.CurrentCulture, Get(key), args); +} diff --git a/YMouseButtonControl.Core/Localization/TrExtension.cs b/YMouseButtonControl.Core/Localization/TrExtension.cs new file mode 100644 index 0000000..f4d536a --- /dev/null +++ b/YMouseButtonControl.Core/Localization/TrExtension.cs @@ -0,0 +1,21 @@ +using System; +using Avalonia.Markup.Xaml; + +namespace YMouseButtonControl.Core.Localization; + +/// +/// XAML markup extension that resolves a translation key to its localized string, e.g. +/// Content="{i18n:Tr Btn_Apply}". The value is resolved once at load time, which is why a +/// language change requires an application restart (consistent with the Theme setting). +/// +public sealed class TrExtension : MarkupExtension +{ + public TrExtension() { } + + public TrExtension(string key) => Key = key; + + public string Key { get; set; } = string.Empty; + + public override object ProvideValue(IServiceProvider serviceProvider) => + Localizer.Instance[Key]; +} diff --git a/YMouseButtonControl.Core/Localization/Translations.cs b/YMouseButtonControl.Core/Localization/Translations.cs new file mode 100644 index 0000000..3612803 --- /dev/null +++ b/YMouseButtonControl.Core/Localization/Translations.cs @@ -0,0 +1,670 @@ +using System.Collections.Generic; + +namespace YMouseButtonControl.Core.Localization; + +/// +/// Translation tables keyed by a stable identifier. English () is the source of +/// truth and defines every key; the other languages may omit keys and fall back to English. +/// +public static class Translations +{ + public static readonly IReadOnlyDictionary En = new Dictionary + { + // Shared / buttons + ["Btn_Apply"] = "Apply", + ["Btn_Cancel"] = "Cancel", + ["Btn_Ok"] = "OK", + ["Btn_Close"] = "Close", + ["Btn_Copy"] = "Copy", + ["Btn_Add"] = "Add", + ["Btn_Edit"] = "Edit", + ["Btn_Remove"] = "Remove", + ["Btn_Import"] = "Import", + ["Btn_Export"] = "Export", + ["Btn_Up"] = "Up", + ["Btn_Down"] = "Down", + ["Btn_Refresh"] = "Refresh", + + // Main window + ["Main_AppWindowProfiles"] = "Application / Window Profiles", + ["Main_ProfileInformation"] = "Profile Information", + ["Main_Settings"] = "Settings", + ["Main_SaveProfile"] = "Save Profile", + ["Main_LoadProfile"] = "Load Profile", + + // Profile information + ["Info_Description"] = "Description", + ["Info_WindowCaption"] = "Window Caption", + ["Info_Process"] = "Process", + ["Info_WindowClass"] = "Window Class", + ["Info_ParentClass"] = "Parent Class", + ["Info_MatchType"] = "Match Type", + + // Layer view + ["Layer_Tab1"] = "Layer 1", + ["Layer_Tab2"] = "Layer 2", + ["Layer_TabScrolling"] = "Scrolling", + ["Layer_TabOptions"] = "Options", + ["Layer_Name"] = "Layer Name", + ["Layer_1Default"] = "Layer 1 (Default)", + ["Layer_Swap"] = "Swap", + ["Layer_Reset"] = "Reset", + + // Mouse button labels + ["Mb_Left"] = "Left Button", + ["Mb_Right"] = "Right Button", + ["Mb_Middle"] = "Middle Button", + ["Mb_Button4"] = "Mouse Button 4", + ["Mb_Button5"] = "Mouse Button 5", + ["Mb_WheelUp"] = "Wheel Up", + ["Mb_WheelDown"] = "Wheel Down", + ["Mb_WheelLeft"] = "Wheel Left", + ["Mb_WheelRight"] = "Wheel Right", + + // Button-mapping dropdown entries + ["Map_Disabled"] = "Disabled", + ["Map_NoChange"] = "** No Change (Don't Intercept) **", + ["Map_SimulatedUndefined"] = "Simulated Keys (undefined)", + ["Map_RightClick"] = "Right Click", + ["Map_SimulatedKeysFmt"] = "Simulated Keys: ({0})", + + // Simulated keystroke action types (long descriptions) + ["Skt_AsPressedReleased"] = "As mouse button is pressed & when released", + ["Skt_During"] = "During (press on down, release on up)", + ["Skt_ThreadPressed"] = "In another thread as mouse button is pressed", + ["Skt_ThreadReleased"] = "In another thread as mouse button is released", + ["Skt_AsPressed"] = "As mouse button is pressed", + ["Skt_AsReleased"] = "As mouse button is released", + ["Skt_Repeat"] = "Repeatedly while the button is down", + ["Skt_StickyHold"] = "Sticky (held down until button is pressed again)", + ["Skt_StickyRepeat"] = "Sticky (repeatedly until button is pressed again)", + + // Simulated keystroke action types (short descriptions) + ["SktShort_AsPressedReleased"] = "pressed & released", + ["SktShort_During"] = "during", + ["SktShort_ThreadPressed"] = "thread-down", + ["SktShort_ThreadReleased"] = "thread-up", + ["SktShort_AsPressed"] = "pressed", + ["SktShort_AsReleased"] = "released", + ["SktShort_Repeat"] = "repeat", + ["SktShort_StickyHold"] = "sticky hold", + ["SktShort_StickyRepeat"] = "sticky repeat", + + // Process selector dialog + ["Proc_Title"] = "Choose Application", + ["Proc_SelectRunning"] = "Select from the list of running applications:", + ["Proc_FilterWatermark"] = "Process Filter", + ["Proc_ColProcess"] = "Process", + ["Proc_ColProcessName"] = "ProcessName", + ["Proc_ColWindowTitle"] = "Window Title", + ["Proc_ColFileName"] = "File Name", + ["Proc_OrBrowse"] = "Or type in/browse to the application executable (.EXE) file", + ["Proc_Application"] = "Application", + ["Proc_SpecificWindow"] = "Specific Window", + + // Simulated keystrokes dialog + ["Sk_TitleFmt"] = "Simulated Keystrokes - {0}", + ["Sk_EnterCustomKeys"] = "Enter the custom key(s)", + ["Sk_MenuModifier"] = "Modifier Keys", + ["Sk_MenuStandard"] = "Standard Keys", + ["Sk_MenuDirection"] = "Direction Keys", + ["Sk_MenuFunction"] = "Function Keys", + ["Sk_MenuNumeric"] = "Numeric Keypad", + ["Sk_MenuMedia"] = "Media Keys", + ["Sk_MenuBrowser"] = "Browser Keys", + ["Sk_MenuMouse"] = "Mouse Buttons", + ["Sk_HowToSend"] = "How to send the simulated key strokes:", + ["Sk_Mode6Tip"] = "Mode 6 (repeat while mouse down) does not work on Linux.", + ["Sk_BlockInput"] = "Block original mouse input", + ["Sk_BlockInputTip"] = "Suppress the original mouse click or not.\nWindows and macOS only.", + ["Sk_AutoRepeatDelay"] = "Auto Repeat Delay", + ["Sk_AutoRepeatDelayTip"] = + "The delay in milliseconds before repeating. 33 is the default.\nThe lower the number, the quicker the repeat. The higher the number, the slower the repeat.", + ["Sk_RandomizeDelay"] = "Randomize auto repeat delay 0%-10%", + ["Sk_DescriptionDropdown"] = "Description (to show in the button drop-down)", + ["Sk_CursorPosition"] = "Cursor Position: X,Y", + + // Global settings dialog + ["Set_Title"] = "Global Settings", + ["Set_StartMinimized"] = "Start Minimized", + ["Set_StartMenu"] = "Start Menu", + ["Set_StartMenuTip"] = "Add YMouseButtonControl to the start menu.\nDisabled for macOS.", + ["Set_Logging"] = "Logging", + ["Set_LoggingTip"] = + "Whether or not logging to file YMouseButtonControl.log is performed. Requires a restart.", + ["Set_Theme"] = "Theme", + ["Set_ThemeTip"] = + "The application theme. Requires a restart.\nDefault: follow the theme of your OS. Doesn't work on Linux.\nLight: light theme.\nDark: dark theme.", + ["Set_Language"] = "Language", + ["Set_LanguageTip"] = "The application language. Requires a restart.", + ["Set_LanguageSystem"] = "System default", + }; + + public static readonly IReadOnlyDictionary Ru = new Dictionary + { + // Shared / buttons + ["Btn_Apply"] = "Применить", + ["Btn_Cancel"] = "Отмена", + ["Btn_Ok"] = "OK", + ["Btn_Close"] = "Закрыть", + ["Btn_Copy"] = "Копировать", + ["Btn_Add"] = "Добавить", + ["Btn_Edit"] = "Изменить", + ["Btn_Remove"] = "Удалить", + ["Btn_Import"] = "Импорт", + ["Btn_Export"] = "Экспорт", + ["Btn_Up"] = "Вверх", + ["Btn_Down"] = "Вниз", + ["Btn_Refresh"] = "Обновить", + + // Main window + ["Main_AppWindowProfiles"] = "Профили приложений / окон", + ["Main_ProfileInformation"] = "Информация о профиле", + ["Main_Settings"] = "Настройки", + ["Main_SaveProfile"] = "Сохранить профиль", + ["Main_LoadProfile"] = "Загрузить профиль", + + // Profile information + ["Info_Description"] = "Описание", + ["Info_WindowCaption"] = "Заголовок окна", + ["Info_Process"] = "Процесс", + ["Info_WindowClass"] = "Класс окна", + ["Info_ParentClass"] = "Родительский класс", + ["Info_MatchType"] = "Тип совпадения", + + // Layer view + ["Layer_Tab1"] = "Слой 1", + ["Layer_Tab2"] = "Слой 2", + ["Layer_TabScrolling"] = "Прокрутка", + ["Layer_TabOptions"] = "Параметры", + ["Layer_Name"] = "Имя слоя", + ["Layer_1Default"] = "Слой 1 (По умолчанию)", + ["Layer_Swap"] = "Поменять", + ["Layer_Reset"] = "Сбросить", + + // Mouse button labels + ["Mb_Left"] = "Левая кнопка", + ["Mb_Right"] = "Правая кнопка", + ["Mb_Middle"] = "Средняя кнопка", + ["Mb_Button4"] = "Mouse Button 4", + ["Mb_Button5"] = "Mouse Button 5", + ["Mb_WheelUp"] = "Колесо вверх", + ["Mb_WheelDown"] = "Колесо вниз", + ["Mb_WheelLeft"] = "Колесо влево", + ["Mb_WheelRight"] = "Колесо вправо", + + // Button-mapping dropdown entries + ["Map_Disabled"] = "Отключено", + ["Map_NoChange"] = "** Без изменений (не перехватывать) **", + ["Map_SimulatedUndefined"] = "Симулируемые клавиши (не определено)", + ["Map_RightClick"] = "Правый клик", + ["Map_SimulatedKeysFmt"] = "Симулируемые клавиши: ({0})", + + // Simulated keystroke action types (long descriptions) + ["Skt_AsPressedReleased"] = "При нажатии и отпускании кнопки мыши", + ["Skt_During"] = "В течение (нажать при нажатии, отпустить при отпускании)", + ["Skt_ThreadPressed"] = "В отдельном потоке при нажатии кнопки мыши", + ["Skt_ThreadReleased"] = "В отдельном потоке при отпускании кнопки мыши", + ["Skt_AsPressed"] = "При нажатии кнопки мыши", + ["Skt_AsReleased"] = "При отпускании кнопки мыши", + ["Skt_Repeat"] = "Повторять, пока кнопка удерживается", + ["Skt_StickyHold"] = "Залипание (удерживается до повторного нажатия кнопки)", + ["Skt_StickyRepeat"] = "Залипание (повторяется до повторного нажатия кнопки)", + + // Simulated keystroke action types (short descriptions) + ["SktShort_AsPressedReleased"] = "нажать и отпустить", + ["SktShort_During"] = "в течение", + ["SktShort_ThreadPressed"] = "поток-нажатие", + ["SktShort_ThreadReleased"] = "поток-отпускание", + ["SktShort_AsPressed"] = "нажато", + ["SktShort_AsReleased"] = "отпущено", + ["SktShort_Repeat"] = "повтор", + ["SktShort_StickyHold"] = "залипание-удержание", + ["SktShort_StickyRepeat"] = "залипание-повтор", + + // Process selector dialog + ["Proc_Title"] = "Выбор приложения", + ["Proc_SelectRunning"] = "Выберите из списка запущенных приложений:", + ["Proc_FilterWatermark"] = "Фильтр процессов", + ["Proc_ColProcess"] = "Процесс", + ["Proc_ColProcessName"] = "Имя процесса", + ["Proc_ColWindowTitle"] = "Заголовок окна", + ["Proc_ColFileName"] = "Имя файла", + ["Proc_OrBrowse"] = "Или введите / укажите путь к исполняемому файлу приложения (.EXE)", + ["Proc_Application"] = "Приложение", + ["Proc_SpecificWindow"] = "Конкретное окно", + + // Simulated keystrokes dialog + ["Sk_TitleFmt"] = "Симулируемые нажатия клавиш — {0}", + ["Sk_EnterCustomKeys"] = "Введите пользовательскую клавишу (или клавиши)", + ["Sk_MenuModifier"] = "Клавиши-модификаторы", + ["Sk_MenuStandard"] = "Стандартные клавиши", + ["Sk_MenuDirection"] = "Клавиши направления", + ["Sk_MenuFunction"] = "Функциональные клавиши", + ["Sk_MenuNumeric"] = "Цифровая клавиатура", + ["Sk_MenuMedia"] = "Мультимедийные клавиши", + ["Sk_MenuBrowser"] = "Клавиши браузера", + ["Sk_MenuMouse"] = "Кнопки мыши", + ["Sk_HowToSend"] = "Способ отправки симулируемых нажатий клавиш:", + ["Sk_Mode6Tip"] = "Режим 6 (повтор при удержании мыши) не работает в Linux.", + ["Sk_BlockInput"] = "Блокировать исходный ввод мыши", + ["Sk_BlockInputTip"] = "Подавлять или нет исходный щелчок мыши.\nТолько Windows и macOS.", + ["Sk_AutoRepeatDelay"] = "Задержка автоповтора", + ["Sk_AutoRepeatDelayTip"] = + "Задержка в миллисекундах перед повтором. По умолчанию 33.\nЧем меньше значение, тем быстрее повтор. Чем больше значение, тем медленнее повтор.", + ["Sk_RandomizeDelay"] = "Случайная задержка автоповтора 0%-10%", + ["Sk_DescriptionDropdown"] = "Описание (для отображения в выпадающем списке кнопки)", + ["Sk_CursorPosition"] = "Положение курсора: X,Y", + + // Global settings dialog + ["Set_Title"] = "Глобальные настройки", + ["Set_StartMinimized"] = "Запускать свёрнутым", + ["Set_StartMenu"] = "Меню Пуск", + ["Set_StartMenuTip"] = "Добавить YMouseButtonControl в меню Пуск.\nОтключено для macOS.", + ["Set_Logging"] = "Ведение журнала", + ["Set_LoggingTip"] = + "Вести или нет журнал в файл YMouseButtonControl.log. Требует перезапуска.", + ["Set_Theme"] = "Тема", + ["Set_ThemeTip"] = + "Тема приложения. Требует перезапуска.\nПо умолчанию: следовать теме ОС. Не работает в Linux.\nСветлая: светлая тема.\nТёмная: тёмная тема.", + ["Set_Language"] = "Язык", + ["Set_LanguageTip"] = "Язык приложения. Требует перезапуска.", + ["Set_LanguageSystem"] = "Системный язык по умолчанию", + }; + + public static readonly IReadOnlyDictionary De = new Dictionary + { + // Shared / buttons + ["Btn_Apply"] = "Übernehmen", + ["Btn_Cancel"] = "Abbrechen", + ["Btn_Ok"] = "OK", + ["Btn_Close"] = "Schließen", + ["Btn_Copy"] = "Kopieren", + ["Btn_Add"] = "Hinzufügen", + ["Btn_Edit"] = "Bearbeiten", + ["Btn_Remove"] = "Entfernen", + ["Btn_Import"] = "Importieren", + ["Btn_Export"] = "Exportieren", + ["Btn_Up"] = "Nach oben", + ["Btn_Down"] = "Nach unten", + ["Btn_Refresh"] = "Aktualisieren", + + // Main window + ["Main_AppWindowProfiles"] = "Anwendungs-/Fensterprofile", + ["Main_ProfileInformation"] = "Profilinformationen", + ["Main_Settings"] = "Einstellungen", + ["Main_SaveProfile"] = "Profil speichern", + ["Main_LoadProfile"] = "Profil laden", + + // Profile information + ["Info_Description"] = "Beschreibung", + ["Info_WindowCaption"] = "Fenstertitel", + ["Info_Process"] = "Prozess", + ["Info_WindowClass"] = "Fensterklasse", + ["Info_ParentClass"] = "Übergeordnete Klasse", + ["Info_MatchType"] = "Übereinstimmungstyp", + + // Layer view + ["Layer_Tab1"] = "Ebene 1", + ["Layer_Tab2"] = "Ebene 2", + ["Layer_TabScrolling"] = "Scrollen", + ["Layer_TabOptions"] = "Optionen", + ["Layer_Name"] = "Ebenenname", + ["Layer_1Default"] = "Ebene 1 (Standard)", + ["Layer_Swap"] = "Tauschen", + ["Layer_Reset"] = "Zurücksetzen", + + // Mouse button labels + ["Mb_Left"] = "Linke Taste", + ["Mb_Right"] = "Rechte Taste", + ["Mb_Middle"] = "Mittlere Taste", + ["Mb_Button4"] = "Mouse Button 4", + ["Mb_Button5"] = "Mouse Button 5", + ["Mb_WheelUp"] = "Rad nach oben", + ["Mb_WheelDown"] = "Rad nach unten", + ["Mb_WheelLeft"] = "Rad nach links", + ["Mb_WheelRight"] = "Rad nach rechts", + + // Button-mapping dropdown entries + ["Map_Disabled"] = "Deaktiviert", + ["Map_NoChange"] = "** Keine Änderung (nicht abfangen) **", + ["Map_SimulatedUndefined"] = "Simulierte Tasten (undefiniert)", + ["Map_RightClick"] = "Rechtsklick", + ["Map_SimulatedKeysFmt"] = "Simulierte Tasten: ({0})", + + // Simulated keystroke action types (long descriptions) + ["Skt_AsPressedReleased"] = "Beim Drücken und Loslassen der Maustaste", + ["Skt_During"] = "Während (drücken beim Runter, loslassen beim Hoch)", + ["Skt_ThreadPressed"] = "In einem anderen Thread beim Drücken der Maustaste", + ["Skt_ThreadReleased"] = "In einem anderen Thread beim Loslassen der Maustaste", + ["Skt_AsPressed"] = "Beim Drücken der Maustaste", + ["Skt_AsReleased"] = "Beim Loslassen der Maustaste", + ["Skt_Repeat"] = "Wiederholen, solange die Taste gedrückt ist", + ["Skt_StickyHold"] = "Einrasten (gehalten bis erneutes Drücken der Taste)", + ["Skt_StickyRepeat"] = "Einrasten (wiederholen bis erneutes Drücken der Taste)", + + // Simulated keystroke action types (short descriptions) + ["SktShort_AsPressedReleased"] = "gedrückt & losgelassen", + ["SktShort_During"] = "während", + ["SktShort_ThreadPressed"] = "Thread-Runter", + ["SktShort_ThreadReleased"] = "Thread-Hoch", + ["SktShort_AsPressed"] = "gedrückt", + ["SktShort_AsReleased"] = "losgelassen", + ["SktShort_Repeat"] = "wiederholen", + ["SktShort_StickyHold"] = "eingerastet halten", + ["SktShort_StickyRepeat"] = "eingerastet wiederholen", + + // Process selector dialog + ["Proc_Title"] = "Anwendung auswählen", + ["Proc_SelectRunning"] = "Aus der Liste der laufenden Anwendungen auswählen:", + ["Proc_FilterWatermark"] = "Prozessfilter", + ["Proc_ColProcess"] = "Prozess", + ["Proc_ColProcessName"] = "Prozessname", + ["Proc_ColWindowTitle"] = "Fenstertitel", + ["Proc_ColFileName"] = "Dateiname", + ["Proc_OrBrowse"] = "Oder geben Sie den Pfad zur ausführbaren Datei (.EXE) ein", + ["Proc_Application"] = "Anwendung", + ["Proc_SpecificWindow"] = "Bestimmtes Fenster", + + // Simulated keystrokes dialog + ["Sk_TitleFmt"] = "Simulierte Tastatureingaben – {0}", + ["Sk_EnterCustomKeys"] = "Benutzerdefinierte Taste(n) eingeben", + ["Sk_MenuModifier"] = "Modifikatortasten", + ["Sk_MenuStandard"] = "Standardtasten", + ["Sk_MenuDirection"] = "Richtungstasten", + ["Sk_MenuFunction"] = "Funktionstasten", + ["Sk_MenuNumeric"] = "Nummerntastatur", + ["Sk_MenuMedia"] = "Medientasten", + ["Sk_MenuBrowser"] = "Browsertasten", + ["Sk_MenuMouse"] = "Maustasten", + ["Sk_HowToSend"] = "Art der simulierten Tastatureingaben:", + ["Sk_Mode6Tip"] = "Modus 6 (Wiederholen bei gedrückter Maustaste) funktioniert nicht unter Linux.", + ["Sk_BlockInput"] = "Originale Mauseingabe blockieren", + ["Sk_BlockInputTip"] = "Den originalen Mausklick unterdrücken oder nicht.\nNur Windows und macOS.", + ["Sk_AutoRepeatDelay"] = "Automatische Wiederholungsverzögerung", + ["Sk_AutoRepeatDelayTip"] = + "Die Verzögerung in Millisekunden vor dem Wiederholen. Standard ist 33.\nJe kleiner die Zahl, desto schneller die Wiederholung. Je größer die Zahl, desto langsamer.", + ["Sk_RandomizeDelay"] = "Automatische Wiederholungsverzögerung zufällig variieren 0%-10%", + ["Sk_DescriptionDropdown"] = "Beschreibung (in der Schaltflächen-Dropdown anzeigen)", + ["Sk_CursorPosition"] = "Cursorposition: X,Y", + + // Global settings dialog + ["Set_Title"] = "Globale Einstellungen", + ["Set_StartMinimized"] = "Minimiert starten", + ["Set_StartMenu"] = "Startmenü", + ["Set_StartMenuTip"] = "YMouseButtonControl zum Startmenü hinzufügen.\nFür macOS deaktiviert.", + ["Set_Logging"] = "Protokollierung", + ["Set_LoggingTip"] = + "Ob die Protokollierung in die Datei YMouseButtonControl.log erfolgt. Erfordert einen Neustart.", + ["Set_Theme"] = "Design", + ["Set_ThemeTip"] = + "Das Anwendungsdesign. Erfordert einen Neustart.\nStandard: Betriebssystem-Design übernehmen. Funktioniert nicht unter Linux.\nHell: helles Design.\nDunkel: dunkles Design.", + ["Set_Language"] = "Sprache", + ["Set_LanguageTip"] = "Die Anwendungssprache. Erfordert einen Neustart.", + ["Set_LanguageSystem"] = "Systemstandard", + }; + + public static readonly IReadOnlyDictionary Es = new Dictionary + { + // Shared / buttons + ["Btn_Apply"] = "Aplicar", + ["Btn_Cancel"] = "Cancelar", + ["Btn_Ok"] = "OK", + ["Btn_Close"] = "Cerrar", + ["Btn_Copy"] = "Copiar", + ["Btn_Add"] = "Agregar", + ["Btn_Edit"] = "Editar", + ["Btn_Remove"] = "Eliminar", + ["Btn_Import"] = "Importar", + ["Btn_Export"] = "Exportar", + ["Btn_Up"] = "Arriba", + ["Btn_Down"] = "Abajo", + ["Btn_Refresh"] = "Actualizar", + + // Main window + ["Main_AppWindowProfiles"] = "Perfiles de aplicación / ventana", + ["Main_ProfileInformation"] = "Información del perfil", + ["Main_Settings"] = "Configuración", + ["Main_SaveProfile"] = "Guardar perfil", + ["Main_LoadProfile"] = "Cargar perfil", + + // Profile information + ["Info_Description"] = "Descripción", + ["Info_WindowCaption"] = "Título de ventana", + ["Info_Process"] = "Proceso", + ["Info_WindowClass"] = "Clase de ventana", + ["Info_ParentClass"] = "Clase principal", + ["Info_MatchType"] = "Tipo de coincidencia", + + // Layer view + ["Layer_Tab1"] = "Capa 1", + ["Layer_Tab2"] = "Capa 2", + ["Layer_TabScrolling"] = "Desplazamiento", + ["Layer_TabOptions"] = "Opciones", + ["Layer_Name"] = "Nombre de capa", + ["Layer_1Default"] = "Capa 1 (Predeterminado)", + ["Layer_Swap"] = "Intercambiar", + ["Layer_Reset"] = "Restablecer", + + // Mouse button labels + ["Mb_Left"] = "Botón izquierdo", + ["Mb_Right"] = "Botón derecho", + ["Mb_Middle"] = "Botón central", + ["Mb_Button4"] = "Mouse Button 4", + ["Mb_Button5"] = "Mouse Button 5", + ["Mb_WheelUp"] = "Rueda arriba", + ["Mb_WheelDown"] = "Rueda abajo", + ["Mb_WheelLeft"] = "Rueda izquierda", + ["Mb_WheelRight"] = "Rueda derecha", + + // Button-mapping dropdown entries + ["Map_Disabled"] = "Desactivado", + ["Map_NoChange"] = "** Sin cambio (no interceptar) **", + ["Map_SimulatedUndefined"] = "Teclas simuladas (sin definir)", + ["Map_RightClick"] = "Clic derecho", + ["Map_SimulatedKeysFmt"] = "Teclas simuladas: ({0})", + + // Simulated keystroke action types (long descriptions) + ["Skt_AsPressedReleased"] = "Al presionar y soltar el botón del ratón", + ["Skt_During"] = "Durante (presionar al bajar, soltar al subir)", + ["Skt_ThreadPressed"] = "En otro hilo al presionar el botón del ratón", + ["Skt_ThreadReleased"] = "En otro hilo al soltar el botón del ratón", + ["Skt_AsPressed"] = "Al presionar el botón del ratón", + ["Skt_AsReleased"] = "Al soltar el botón del ratón", + ["Skt_Repeat"] = "Repetidamente mientras se mantiene el botón", + ["Skt_StickyHold"] = "Fijo (mantenido hasta presionar el botón de nuevo)", + ["Skt_StickyRepeat"] = "Fijo (repetir hasta presionar el botón de nuevo)", + + // Simulated keystroke action types (short descriptions) + ["SktShort_AsPressedReleased"] = "presionado y soltado", + ["SktShort_During"] = "durante", + ["SktShort_ThreadPressed"] = "hilo-abajo", + ["SktShort_ThreadReleased"] = "hilo-arriba", + ["SktShort_AsPressed"] = "presionado", + ["SktShort_AsReleased"] = "soltado", + ["SktShort_Repeat"] = "repetir", + ["SktShort_StickyHold"] = "fijo mantenido", + ["SktShort_StickyRepeat"] = "fijo repetir", + + // Process selector dialog + ["Proc_Title"] = "Elegir aplicación", + ["Proc_SelectRunning"] = "Seleccione de la lista de aplicaciones en ejecución:", + ["Proc_FilterWatermark"] = "Filtro de procesos", + ["Proc_ColProcess"] = "Proceso", + ["Proc_ColProcessName"] = "Nombre de proceso", + ["Proc_ColWindowTitle"] = "Título de ventana", + ["Proc_ColFileName"] = "Nombre de archivo", + ["Proc_OrBrowse"] = "O escriba / busque el archivo ejecutable de la aplicación (.EXE)", + ["Proc_Application"] = "Aplicación", + ["Proc_SpecificWindow"] = "Ventana específica", + + // Simulated keystrokes dialog + ["Sk_TitleFmt"] = "Teclas simuladas - {0}", + ["Sk_EnterCustomKeys"] = "Introduzca la(s) tecla(s) personalizada(s)", + ["Sk_MenuModifier"] = "Teclas modificadoras", + ["Sk_MenuStandard"] = "Teclas estándar", + ["Sk_MenuDirection"] = "Teclas de dirección", + ["Sk_MenuFunction"] = "Teclas de función", + ["Sk_MenuNumeric"] = "Teclado numérico", + ["Sk_MenuMedia"] = "Teclas multimedia", + ["Sk_MenuBrowser"] = "Teclas del navegador", + ["Sk_MenuMouse"] = "Botones del ratón", + ["Sk_HowToSend"] = "Cómo enviar las pulsaciones de teclas simuladas:", + ["Sk_Mode6Tip"] = "El modo 6 (repetir mientras se mantiene el ratón) no funciona en Linux.", + ["Sk_BlockInput"] = "Bloquear entrada original del ratón", + ["Sk_BlockInputTip"] = "Suprimir o no el clic original del ratón.\nSolo Windows y macOS.", + ["Sk_AutoRepeatDelay"] = "Retardo de repetición automática", + ["Sk_AutoRepeatDelayTip"] = + "El retardo en milisegundos antes de repetir. 33 es el valor predeterminado.\nCuanto menor sea el número, más rápida la repetición. Cuanto mayor sea el número, más lenta la repetición.", + ["Sk_RandomizeDelay"] = "Aleatorizar retardo de repetición automática 0%-10%", + ["Sk_DescriptionDropdown"] = "Descripción (para mostrar en el menú desplegable del botón)", + ["Sk_CursorPosition"] = "Posición del cursor: X,Y", + + // Global settings dialog + ["Set_Title"] = "Configuración global", + ["Set_StartMinimized"] = "Iniciar minimizado", + ["Set_StartMenu"] = "Menú de inicio", + ["Set_StartMenuTip"] = "Agregar YMouseButtonControl al menú de inicio.\nDesactivado para macOS.", + ["Set_Logging"] = "Registro", + ["Set_LoggingTip"] = + "Si se realiza o no el registro en el archivo YMouseButtonControl.log. Requiere reinicio.", + ["Set_Theme"] = "Tema", + ["Set_ThemeTip"] = + "El tema de la aplicación. Requiere reinicio.\nPredeterminado: seguir el tema del SO. No funciona en Linux.\nClaro: tema claro.\nOscuro: tema oscuro.", + ["Set_Language"] = "Idioma", + ["Set_LanguageTip"] = "El idioma de la aplicación. Requiere reinicio.", + ["Set_LanguageSystem"] = "Predeterminado del sistema", + }; + + public static readonly IReadOnlyDictionary Fr = new Dictionary + { + // Shared / buttons + ["Btn_Apply"] = "Appliquer", + ["Btn_Cancel"] = "Annuler", + ["Btn_Ok"] = "OK", + ["Btn_Close"] = "Fermer", + ["Btn_Copy"] = "Copier", + ["Btn_Add"] = "Ajouter", + ["Btn_Edit"] = "Modifier", + ["Btn_Remove"] = "Supprimer", + ["Btn_Import"] = "Importer", + ["Btn_Export"] = "Exporter", + ["Btn_Up"] = "Haut", + ["Btn_Down"] = "Bas", + ["Btn_Refresh"] = "Actualiser", + + // Main window + ["Main_AppWindowProfiles"] = "Profils d'application / fenêtre", + ["Main_ProfileInformation"] = "Informations sur le profil", + ["Main_Settings"] = "Paramètres", + ["Main_SaveProfile"] = "Enregistrer le profil", + ["Main_LoadProfile"] = "Charger le profil", + + // Profile information + ["Info_Description"] = "Description", + ["Info_WindowCaption"] = "Titre de la fenêtre", + ["Info_Process"] = "Processus", + ["Info_WindowClass"] = "Classe de fenêtre", + ["Info_ParentClass"] = "Classe parente", + ["Info_MatchType"] = "Type de correspondance", + + // Layer view + ["Layer_Tab1"] = "Couche 1", + ["Layer_Tab2"] = "Couche 2", + ["Layer_TabScrolling"] = "Défilement", + ["Layer_TabOptions"] = "Options", + ["Layer_Name"] = "Nom de la couche", + ["Layer_1Default"] = "Couche 1 (Par défaut)", + ["Layer_Swap"] = "Permuter", + ["Layer_Reset"] = "Réinitialiser", + + // Mouse button labels + ["Mb_Left"] = "Bouton gauche", + ["Mb_Right"] = "Bouton droit", + ["Mb_Middle"] = "Bouton central", + ["Mb_Button4"] = "Mouse Button 4", + ["Mb_Button5"] = "Mouse Button 5", + ["Mb_WheelUp"] = "Molette haut", + ["Mb_WheelDown"] = "Molette bas", + ["Mb_WheelLeft"] = "Molette gauche", + ["Mb_WheelRight"] = "Molette droite", + + // Button-mapping dropdown entries + ["Map_Disabled"] = "Désactivé", + ["Map_NoChange"] = "** Aucun changement (ne pas intercepter) **", + ["Map_SimulatedUndefined"] = "Touches simulées (non définies)", + ["Map_RightClick"] = "Clic droit", + ["Map_SimulatedKeysFmt"] = "Touches simulées : ({0})", + + // Simulated keystroke action types (long descriptions) + ["Skt_AsPressedReleased"] = "Lors de l'appui et du relâchement du bouton de la souris", + ["Skt_During"] = "Pendant (appuyer à la descente, relâcher à la montée)", + ["Skt_ThreadPressed"] = "Dans un autre fil lors de l'appui sur le bouton de la souris", + ["Skt_ThreadReleased"] = "Dans un autre fil lors du relâchement du bouton de la souris", + ["Skt_AsPressed"] = "Lors de l'appui sur le bouton de la souris", + ["Skt_AsReleased"] = "Lors du relâchement du bouton de la souris", + ["Skt_Repeat"] = "En répétition tant que le bouton est maintenu", + ["Skt_StickyHold"] = "Verrouillé (maintenu jusqu'à nouvel appui sur le bouton)", + ["Skt_StickyRepeat"] = "Verrouillé (en répétition jusqu'à nouvel appui sur le bouton)", + + // Simulated keystroke action types (short descriptions) + ["SktShort_AsPressedReleased"] = "appuyé & relâché", + ["SktShort_During"] = "pendant", + ["SktShort_ThreadPressed"] = "fil-descente", + ["SktShort_ThreadReleased"] = "fil-montée", + ["SktShort_AsPressed"] = "appuyé", + ["SktShort_AsReleased"] = "relâché", + ["SktShort_Repeat"] = "répéter", + ["SktShort_StickyHold"] = "verrouillé maintenu", + ["SktShort_StickyRepeat"] = "verrouillé répété", + + // Process selector dialog + ["Proc_Title"] = "Choisir une application", + ["Proc_SelectRunning"] = "Sélectionner dans la liste des applications en cours :", + ["Proc_FilterWatermark"] = "Filtre de processus", + ["Proc_ColProcess"] = "Processus", + ["Proc_ColProcessName"] = "Nom du processus", + ["Proc_ColWindowTitle"] = "Titre de la fenêtre", + ["Proc_ColFileName"] = "Nom du fichier", + ["Proc_OrBrowse"] = "Ou saisissez / naviguez vers le fichier exécutable de l'application (.EXE)", + ["Proc_Application"] = "Application", + ["Proc_SpecificWindow"] = "Fenêtre spécifique", + + // Simulated keystrokes dialog + ["Sk_TitleFmt"] = "Touches simulées - {0}", + ["Sk_EnterCustomKeys"] = "Saisir la ou les touche(s) personnalisée(s)", + ["Sk_MenuModifier"] = "Touches de modification", + ["Sk_MenuStandard"] = "Touches standard", + ["Sk_MenuDirection"] = "Touches directionnelles", + ["Sk_MenuFunction"] = "Touches de fonction", + ["Sk_MenuNumeric"] = "Pavé numérique", + ["Sk_MenuMedia"] = "Touches multimédia", + ["Sk_MenuBrowser"] = "Touches du navigateur", + ["Sk_MenuMouse"] = "Boutons de la souris", + ["Sk_HowToSend"] = "Comment envoyer les frappes simulées :", + ["Sk_Mode6Tip"] = "Le mode 6 (répéter pendant que la souris est maintenue) ne fonctionne pas sous Linux.", + ["Sk_BlockInput"] = "Bloquer l'entrée originale de la souris", + ["Sk_BlockInputTip"] = "Supprimer ou non le clic d'origine de la souris.\nWindows et macOS uniquement.", + ["Sk_AutoRepeatDelay"] = "Délai de répétition automatique", + ["Sk_AutoRepeatDelayTip"] = + "Le délai en millisecondes avant la répétition. 33 est la valeur par défaut.\nPlus le nombre est petit, plus la répétition est rapide. Plus le nombre est grand, plus elle est lente.", + ["Sk_RandomizeDelay"] = "Randomiser le délai de répétition automatique 0%-10%", + ["Sk_DescriptionDropdown"] = "Description (à afficher dans la liste déroulante du bouton)", + ["Sk_CursorPosition"] = "Position du curseur : X,Y", + + // Global settings dialog + ["Set_Title"] = "Paramètres globaux", + ["Set_StartMinimized"] = "Démarrer réduit", + ["Set_StartMenu"] = "Menu Démarrer", + ["Set_StartMenuTip"] = "Ajouter YMouseButtonControl au menu Démarrer.\nDésactivé pour macOS.", + ["Set_Logging"] = "Journalisation", + ["Set_LoggingTip"] = + "Si la journalisation dans le fichier YMouseButtonControl.log est effectuée ou non. Nécessite un redémarrage.", + ["Set_Theme"] = "Thème", + ["Set_ThemeTip"] = + "Le thème de l'application. Nécessite un redémarrage.\nPar défaut : suivre le thème du système d'exploitation. Ne fonctionne pas sous Linux.\nClair : thème clair.\nSombre : thème sombre.", + ["Set_Language"] = "Langue", + ["Set_LanguageTip"] = "La langue de l'application. Nécessite un redémarrage.", + ["Set_LanguageSystem"] = "Langue système par défaut", + }; +} diff --git a/YMouseButtonControl.Core/Services/KeyboardAndMouse/Implementations/Queries/CurrentWindow/GetCurrentWindowLinuxX11.cs b/YMouseButtonControl.Core/Services/KeyboardAndMouse/Implementations/Queries/CurrentWindow/GetCurrentWindowLinuxX11.cs index 24bf8fe..1af3b6a 100644 --- a/YMouseButtonControl.Core/Services/KeyboardAndMouse/Implementations/Queries/CurrentWindow/GetCurrentWindowLinuxX11.cs +++ b/YMouseButtonControl.Core/Services/KeyboardAndMouse/Implementations/Queries/CurrentWindow/GetCurrentWindowLinuxX11.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Runtime.InteropServices; +using System.Text; using YMouseButtonControl.Core.Services.KeyboardAndMouse.Implementations.Queries.CurrentWindow; namespace YMouseButtonControl.Core.Services.KeyboardAndMouse.Implementations.MouseListener.Queries.CurrentWindow; @@ -11,32 +12,60 @@ public class GetCurrentWindowLinuxX11 : IGetCurrentWindow private static string GetForegroundWindow() { - var display = X11.XOpenDisplay(nint.Zero); - if (display == nint.Zero) - { - throw new Exception("Error opening display"); - } - try { - var pid = GetForegroundWindowPid(display); - if (pid is null) + var display = X11.XOpenDisplay(nint.Zero); + if (display == nint.Zero) { - return ""; + // No X11 display reachable (e.g. a native Wayland window is focused). Fall back to + // matching every profile instead of throwing on the mouse-hook thread. + return "*"; } - return GetPathFromPid(pid) ?? ""; + try + { + var pid = GetForegroundWindowPid(display); + if (pid is null) + { + return ""; + } + + return GetIdentityFromPid(pid.Value); + } + finally + { + X11.XCloseDisplay(display); + } } - finally + catch { - X11.XCloseDisplay(display); + return "*"; } } - private static string? GetPathFromPid(int? pid) + /// + /// Builds the string a profile's process is matched against. It combines the executable path + /// (/proc/<pid>/exe) with the process' command line (/proc/<pid>/cmdline). + /// The command line is what lets WINE/Proton games match: their /exe link points at the + /// wine loader, but the actual game.exe path appears as a command-line argument (#29). + /// + private static string GetIdentityFromPid(int pid) { - var fi = new FileInfo($"/proc/{pid}/exe"); - return fi.LinkTarget; + var exe = new FileInfo($"/proc/{pid}/exe").LinkTarget ?? ""; + + var cmdline = ""; + try + { + // cmdline arguments are NUL-separated; flatten to spaces so a simple Contains works. + var raw = File.ReadAllBytes($"/proc/{pid}/cmdline"); + cmdline = Encoding.UTF8.GetString(raw).Replace('\0', ' ').Trim(); + } + catch + { + // /proc entry may be unreadable (permissions/race); use the exe path alone. + } + + return string.IsNullOrEmpty(cmdline) ? exe : $"{exe} {cmdline}"; } private static unsafe int? GetForegroundWindowPid(nint display) diff --git a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogHandlerRegistrations.cs b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogHandlerRegistrations.cs index 52a0104..2b8e358 100644 --- a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogHandlerRegistrations.cs +++ b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogHandlerRegistrations.cs @@ -21,6 +21,7 @@ public static void RegisterCommon(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped.Handler>() .AddScoped.Handler>() diff --git a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogViewModel.cs b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogViewModel.cs index 5b83bc2..cc9360b 100644 --- a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogViewModel.cs +++ b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/GlobalSettingsDialogViewModel.cs @@ -26,6 +26,8 @@ public class GlobalSettingsDialogViewModel : DialogBase, IGlobalSettingsDialogVi private GetIntSetting.IntSettingVm _themeSetting; private ObservableCollection _themeCollection; private ListThemes.ThemeVm _selectedTheme; + private ObservableCollection _languageCollection; + private LanguageOption _selectedLanguage; private readonly ObservableAsPropertyHelper? _applyIsExec; private readonly ThemeVariant _themeVariant; @@ -38,8 +40,10 @@ public GlobalSettingsDialogViewModel( DisableLogging.Handler disableLoggingHandler, GetLoggingState.Handler loggingStateHandler, GetIntSetting.Handler getIntSettingHandler, + GetStringSetting.Handler getStringSettingHandler, UpdateSetting.Handler updateSettingIntHandler, UpdateSetting.Handler updateSettingBoolHandler, + UpdateSetting.Handler updateSettingStringHandler, GetThemeVariant.Handler getThemeVariantHandler, ListThemes.Handler listThemesHandler ) @@ -54,6 +58,13 @@ ListThemes.Handler listThemesHandler _themeSetting = getIntSettingHandler.Execute(new Queries.Settings.Models.Query("Theme")); _themeCollection = [.. listThemesHandler.Execute()]; _selectedTheme = _themeCollection.First(x => x.Id == _themeSetting.Value); + _languageCollection = [.. LanguageOption.All]; + var currentLanguage = getStringSettingHandler.Execute( + new Queries.Settings.Models.Query("Language") + ); + _selectedLanguage = + _languageCollection.FirstOrDefault(x => x.Code == currentLanguage) + ?? _languageCollection.First(); // Update the theme setting selected theme value this.WhenAnyValue(x => x.SelectedTheme).Subscribe(x => ThemeSetting.Value = x.Id); @@ -79,12 +90,19 @@ ListThemes.Handler listThemesHandler getIntSettingHandler.Execute(new Queries.Settings.Models.Query("Theme")).Value != val ); + var languageChanged = this.WhenAnyValue( + x => x.SelectedLanguage, + selector: val => + val.Code + != getStringSettingHandler.Execute(new Queries.Settings.Models.Query("Language")) + ); var applyIsExecObs = this.WhenAnyValue(x => x.AppIsExec); var canSave = startMinimizedChanged .Merge(loggingChanged) .Merge(startMenuChanged) .Merge(applyIsExecObs) - .Merge(themeChanged); + .Merge(themeChanged) + .Merge(languageChanged); ApplyCommand = ReactiveCommand.CreateFromTask( async () => { @@ -121,6 +139,9 @@ await updateSettingBoolHandler.ExecuteAsync( await updateSettingIntHandler.ExecuteAsync( new UpdateSetting.Command("Theme", ThemeSetting.Value) ); + await updateSettingStringHandler.ExecuteAsync( + new UpdateSetting.Command("Language", SelectedLanguage.Code) + ); }, canSave ); @@ -167,6 +188,18 @@ public ObservableCollection ThemeCollection set => this.RaiseAndSetIfChanged(ref _themeCollection, value); } + public ObservableCollection LanguageCollection + { + get => _languageCollection; + set => this.RaiseAndSetIfChanged(ref _languageCollection, value); + } + + public LanguageOption SelectedLanguage + { + get => _selectedLanguage; + set => this.RaiseAndSetIfChanged(ref _selectedLanguage, value); + } + public ReactiveCommand ApplyCommand { get; init; } public ThemeVariant ThemeVariant => _themeVariant; diff --git a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/LanguageOption.cs b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/LanguageOption.cs new file mode 100644 index 0000000..c1e92e7 --- /dev/null +++ b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/LanguageOption.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using YMouseButtonControl.Core.Localization; + +namespace YMouseButtonControl.Core.ViewModels.Dialogs.GlobalSettingsDialog; + +/// An entry in the language drop-down: a stored code plus its display name. +public sealed record LanguageOption(string Code, string Display) +{ + /// + /// The selectable languages: "system" (follow the OS, label localized) followed by each + /// supported language shown in its own name. + /// + public static IReadOnlyList All => + [ + new("system", Localizer.Instance["Set_LanguageSystem"]), + new("en", "English"), + new("ru", "Русский"), + new("de", "Deutsch"), + new("es", "Español"), + new("fr", "Français"), + ]; +} diff --git a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetBoolSetting.cs b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetBoolSetting.cs index 7b7dd84..a54ff71 100644 --- a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetBoolSetting.cs +++ b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetBoolSetting.cs @@ -10,8 +10,18 @@ public static class GetBoolSetting { public sealed class BoolSettingVm(SettingBool setting) : ReactiveObject { + private bool _value = setting.BoolValue; public string Name { get; } = setting.Name; - public bool Value { get; } = setting.BoolValue; + + // Must be a settable reactive property: the "Start Minimized" checkbox binds two-way to + // this value. As a get-only auto-property the checkbox could never write the new value + // back, so toggling it neither enabled the Apply button (WhenAnyValue never fired) nor + // persisted the change. Mirrors IntSettingVm. + public bool Value + { + get => _value; + set => this.RaiseAndSetIfChanged(ref _value, value); + } } public sealed class Handler(YMouseButtonControlDbContext db) diff --git a/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetStringSetting.cs b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetStringSetting.cs new file mode 100644 index 0000000..34984a9 --- /dev/null +++ b/YMouseButtonControl.Core/ViewModels/Dialogs/GlobalSettingsDialog/Queries/Settings/GetStringSetting.cs @@ -0,0 +1,14 @@ +using System.Linq; +using YMouseButtonControl.Core.ViewModels.Dialogs.GlobalSettingsDialog.Queries.Settings.Models; +using YMouseButtonControl.Infrastructure.Context; + +namespace YMouseButtonControl.Core.ViewModels.Dialogs.GlobalSettingsDialog.Queries.Settings; + +public static class GetStringSetting +{ + public sealed class Handler(YMouseButtonControlDbContext db) + { + public string? Execute(Query q) => + db.SettingStrings.FirstOrDefault(x => x.Name == q.Name)?.StringValue; + } +} diff --git a/YMouseButtonControl.Core/ViewModels/Dialogs/SimulatedKeystrokesDialog/SimulatedKeystrokesDialogViewModel.cs b/YMouseButtonControl.Core/ViewModels/Dialogs/SimulatedKeystrokesDialog/SimulatedKeystrokesDialogViewModel.cs index dd17302..e2f86a1 100644 --- a/YMouseButtonControl.Core/ViewModels/Dialogs/SimulatedKeystrokesDialog/SimulatedKeystrokesDialogViewModel.cs +++ b/YMouseButtonControl.Core/ViewModels/Dialogs/SimulatedKeystrokesDialog/SimulatedKeystrokesDialogViewModel.cs @@ -4,6 +4,7 @@ using System.Reactive; using Avalonia.Styling; using ReactiveUI; +using YMouseButtonControl.Core.Localization; using YMouseButtonControl.Core.Services.KeyboardAndMouse.EventArgs; using YMouseButtonControl.Core.Services.KeyboardAndMouse.Implementations; using YMouseButtonControl.Core.ViewModels.Dialogs.SimulatedKeystrokesDialog.Queries.Theme; @@ -50,7 +51,7 @@ public SimulatedKeystrokesDialogViewModel( ) { ThemeVariant = getThemeVariantHandler.Execute(); - _title = $"SimulatedKeystrokes - {buttonName}"; + _title = Localizer.Instance.Format("Sk_TitleFmt", buttonName); _mouseListener = mouseListener; currentMapping ??= new SimulatedKeystrokeVm(); var newMapping = new SimulatedKeystrokeVm(); diff --git a/YMouseButtonControl.Core/ViewModels/Layer/LayerViewModel.cs b/YMouseButtonControl.Core/ViewModels/Layer/LayerViewModel.cs index aa155a7..a44368a 100644 --- a/YMouseButtonControl.Core/ViewModels/Layer/LayerViewModel.cs +++ b/YMouseButtonControl.Core/ViewModels/Layer/LayerViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Reactive.Linq; using ReactiveUI; +using YMouseButtonControl.Core.Localization; using YMouseButtonControl.Core.Services.Profiles; using YMouseButtonControl.Core.ViewModels.Dialogs.SimulatedKeystrokesDialog; using YMouseButtonControl.Core.ViewModels.Models; @@ -39,63 +40,63 @@ IProfilesCache profilesService Mb1ComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mb1, - "Left Button", + Localizer.Instance["Mb_Left"], profileVm.Mb1Mappings, ShowSimulatedKeystrokesPickerInteraction ); Mb2ComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mb2, - "Right Button", + Localizer.Instance["Mb_Right"], profileVm.Mb2Mappings, ShowSimulatedKeystrokesPickerInteraction ); Mb3ComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mb3, - "Middle Button", + Localizer.Instance["Mb_Middle"], profileVm.Mb3Mappings, ShowSimulatedKeystrokesPickerInteraction ); Mb4ComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mb4, - "Mouse Button 4", + Localizer.Instance["Mb_Button4"], profileVm.Mb4Mappings, ShowSimulatedKeystrokesPickerInteraction ); Mb5ComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mb5, - "Mouse Button 5", + Localizer.Instance["Mb_Button5"], profileVm.Mb5Mappings, ShowSimulatedKeystrokesPickerInteraction ); MwuComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mwu, - "Wheel Up", + Localizer.Instance["Mb_WheelUp"], profileVm.MwuMappings, ShowSimulatedKeystrokesPickerInteraction ); MwdComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mwd, - "Wheel Down", + Localizer.Instance["Mb_WheelDown"], profileVm.MwdMappings, ShowSimulatedKeystrokesPickerInteraction ); MwlComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mwl, - "Wheel Left", + Localizer.Instance["Mb_WheelLeft"], profileVm.MwlMappings, ShowSimulatedKeystrokesPickerInteraction ); MwrComboVm = mbComboViewModelFactory.CreateWithMouseButton( profileVm.BtnSc, MouseButton.Mwr, - "Wheel Right", + Localizer.Instance["Mb_WheelRight"], profileVm.MwrMappings, ShowSimulatedKeystrokesPickerInteraction ); diff --git a/YMouseButtonControl.Core/ViewModels/MainWindow/Commands/Profiles/ApplyProfiles.cs b/YMouseButtonControl.Core/ViewModels/MainWindow/Commands/Profiles/ApplyProfiles.cs index 1f62e14..3096b3b 100644 --- a/YMouseButtonControl.Core/ViewModels/MainWindow/Commands/Profiles/ApplyProfiles.cs +++ b/YMouseButtonControl.Core/ViewModels/MainWindow/Commands/Profiles/ApplyProfiles.cs @@ -19,94 +19,103 @@ public async Task ExecuteAsync() var dbProfiles = await db.Profiles.AsNoTracking().ToListAsync(); // delete profiles that exist in the db but not in the profiles service. User had to remove a profile for this to occur - dbProfiles - .Where(x => profilesCache.Profiles.All(y => y.Id != x.Id)) - .ToList() - .ForEach(async x => + // NOTE: these loops use awaited foreach rather than List.ForEach(async ...). + // List.ForEach(async ...) compiles to fire-and-forget async void: the work is not + // awaited before SaveChangesAsync below and can run concurrently on this + // non-thread-safe DbContext. With the synchronous SQLite provider the continuations + // usually complete inline so writes are not lost in practice, but the pattern is a + // latent correctness bug (lost writes / "a second operation was started on this + // context" exceptions) and is avoided here. + foreach ( + var dbProfile in dbProfiles.Where(x => + profilesCache.Profiles.All(y => y.Id != x.Id) + ) + ) + { + var ent = await db.Profiles.FindAsync(dbProfile.Id); + if (ent is not null) { - var ent = await db.Profiles.FindAsync(x.Id); - if (ent is not null) - { - db.Profiles.Remove(ent); - } - }); + db.Profiles.Remove(ent); + } + } // update profiles that exist in both profiles service and db - profilesCache - .Profiles.Where(x => dbProfiles.Any(y => y.Id == x.Id)) - .ToList() - .ForEach(async profilesServicePvm => + foreach ( + var profilesServicePvm in profilesCache.Profiles.Where(x => + dbProfiles.Any(y => y.Id == x.Id) + ) + ) + { + var ent = + await db.Profiles.FindAsync(profilesServicePvm.Id) + ?? throw new Exception("Profile not found"); + ent.Checked = profilesServicePvm.Checked; + ent.Description = profilesServicePvm.Description; + ent.DisplayPriority = profilesServicePvm.DisplayPriority; + ent.IsDefault = profilesServicePvm.IsDefault; + ent.MatchType = profilesServicePvm.MatchType; + ent.Name = profilesServicePvm.Name; + ent.ParentClass = profilesServicePvm.ParentClass; + ent.Process = profilesServicePvm.Process; + ent.WindowCaption = profilesServicePvm.WindowCaption; + ent.WindowClass = profilesServicePvm.WindowClass; + + foreach (var profilesServicePvmBtnMapVm in profilesServicePvm.ButtonMappings) { - var ent = - await db.Profiles.FindAsync(profilesServicePvm.Id) - ?? throw new Exception("Profile not found"); - ent.Checked = profilesServicePvm.Checked; - ent.Description = profilesServicePvm.Description; - ent.DisplayPriority = profilesServicePvm.DisplayPriority; - ent.IsDefault = profilesServicePvm.IsDefault; - ent.MatchType = profilesServicePvm.MatchType; - ent.Name = profilesServicePvm.Name; - ent.ParentClass = profilesServicePvm.ParentClass; - ent.Process = profilesServicePvm.Process; - ent.WindowCaption = profilesServicePvm.WindowCaption; - ent.WindowClass = profilesServicePvm.WindowClass; + var dbBm = await db.ButtonMappings.FindAsync(profilesServicePvmBtnMapVm.Id); - foreach (var profilesServicePvmBtnMapVm in profilesServicePvm.ButtonMappings) + // if button mapping doesn't exist in the db, add it + // else if button mapping exists in the db and does not equal the button mapping in the profiles service profile, update button mapping + if (dbBm is null) { - var dbBm = db.ButtonMappings.Find(profilesServicePvmBtnMapVm.Id); - - // if button mapping doesn't exist in the db, add it - // else if button mapping exists in the db and does not equal the button mapping in the profiles service profile, update button mapping - if (dbBm is null) + var newBm = ButtonMappingMapper.MapToEntity(profilesServicePvmBtnMapVm); + db.ButtonMappings.Add(newBm); + } + else + { + var dbBmMapped = ButtonMappingMapper.MapToViewModel(dbBm); + if (profilesServicePvmBtnMapVm.Equals(dbBmMapped)) { - var newBm = ButtonMappingMapper.MapToEntity(profilesServicePvmBtnMapVm); - db.ButtonMappings.Add(newBm); + continue; } - else - { - var dbBmMapped = ButtonMappingMapper.MapToViewModel(dbBm); - if (profilesServicePvmBtnMapVm.Equals(dbBmMapped)) - { - continue; - } - dbBm.AutoRepeatDelay = profilesServicePvmBtnMapVm.AutoRepeatDelay; - dbBm.AutoRepeatRandomizeDelayEnabled = - profilesServicePvmBtnMapVm.AutoRepeatRandomizeDelayEnabled; - dbBm.BlockOriginalMouseInput = - profilesServicePvmBtnMapVm.BlockOriginalMouseInput; - dbBm.Keys = profilesServicePvmBtnMapVm.Keys; - dbBm.MouseButton = profilesServicePvmBtnMapVm.MouseButton; - dbBm.Selected = profilesServicePvmBtnMapVm.Selected; - if (profilesServicePvmBtnMapVm.SimulatedKeystrokeType is not null) - { - dbBm.SimulatedKeystrokeType = - profilesServicePvmBtnMapVm.SimulatedKeystrokeType switch - { - AsMousePressedAndReleasedActionTypeVm => - SimulatedKeystrokeType.AsMousePressedAndReleasedActionType, - DuringMouseActionTypeVm => - SimulatedKeystrokeType.DuringMouseActionType, - InAnotherThreadPressedActionTypeVm => - SimulatedKeystrokeType.InAnotherThreadPressedActionType, - InAnotherThreadReleasedActionTypeVm => - SimulatedKeystrokeType.InAnotherThreadReleasedActionType, - MouseButtonPressedActionTypeVm => - SimulatedKeystrokeType.MouseButtonPressedActionType, - MouseButtonReleasedActionTypeVm => - SimulatedKeystrokeType.MouseButtonReleasedActionType, - RepeatedlyWhileButtonDownActionTypeVm => - SimulatedKeystrokeType.RepeatedlyWhileButtonDownActionType, - StickyHoldActionTypeVm => - SimulatedKeystrokeType.StickyHoldActionType, - StickyRepeatActionTypeVm => - SimulatedKeystrokeType.StickyRepeatActionType, - _ => throw new NotImplementedException(), - }; - } + dbBm.AutoRepeatDelay = profilesServicePvmBtnMapVm.AutoRepeatDelay; + dbBm.AutoRepeatRandomizeDelayEnabled = + profilesServicePvmBtnMapVm.AutoRepeatRandomizeDelayEnabled; + dbBm.BlockOriginalMouseInput = + profilesServicePvmBtnMapVm.BlockOriginalMouseInput; + dbBm.Keys = profilesServicePvmBtnMapVm.Keys; + dbBm.MouseButton = profilesServicePvmBtnMapVm.MouseButton; + dbBm.Selected = profilesServicePvmBtnMapVm.Selected; + if (profilesServicePvmBtnMapVm.SimulatedKeystrokeType is not null) + { + dbBm.SimulatedKeystrokeType = + profilesServicePvmBtnMapVm.SimulatedKeystrokeType switch + { + AsMousePressedAndReleasedActionTypeVm => + SimulatedKeystrokeType.AsMousePressedAndReleasedActionType, + DuringMouseActionTypeVm => + SimulatedKeystrokeType.DuringMouseActionType, + InAnotherThreadPressedActionTypeVm => + SimulatedKeystrokeType.InAnotherThreadPressedActionType, + InAnotherThreadReleasedActionTypeVm => + SimulatedKeystrokeType.InAnotherThreadReleasedActionType, + MouseButtonPressedActionTypeVm => + SimulatedKeystrokeType.MouseButtonPressedActionType, + MouseButtonReleasedActionTypeVm => + SimulatedKeystrokeType.MouseButtonReleasedActionType, + RepeatedlyWhileButtonDownActionTypeVm => + SimulatedKeystrokeType.RepeatedlyWhileButtonDownActionType, + StickyHoldActionTypeVm => + SimulatedKeystrokeType.StickyHoldActionType, + StickyRepeatActionTypeVm => + SimulatedKeystrokeType.StickyRepeatActionType, + _ => throw new NotImplementedException(), + }; } } - }); + } + } // add profiles that exist in the profile service but not the db. this occurs when a user adds a profile await db.Profiles.AddRangeAsync( diff --git a/YMouseButtonControl.Core/ViewModels/MainWindow/Queries/Profiles/IsCacheDirty.cs b/YMouseButtonControl.Core/ViewModels/MainWindow/Queries/Profiles/IsCacheDirty.cs index 9202692..994ad23 100644 --- a/YMouseButtonControl.Core/ViewModels/MainWindow/Queries/Profiles/IsCacheDirty.cs +++ b/YMouseButtonControl.Core/ViewModels/MainWindow/Queries/Profiles/IsCacheDirty.cs @@ -1,5 +1,5 @@ -using System; -using System.Linq; +using System.Linq; +using Microsoft.EntityFrameworkCore; using YMouseButtonControl.Core.Mappings; using YMouseButtonControl.Core.Services.Profiles; using YMouseButtonControl.Infrastructure.Context; @@ -10,7 +10,20 @@ public static class IsCacheDirty { public sealed class Handler(IProfilesCache profilesCache, YMouseButtonControlDbContext db) { - public bool Execute() => - !profilesCache.Profiles.SequenceEqual(db.Profiles.Select(ProfileMapper.MapToViewModel)); + public bool Execute() + { + // Load the persisted profiles the same way the cache itself is loaded: include the + // button mappings (ProfileMapper reads them) and read without tracking. Without the + // Include the database side has empty button mappings, so the comparison below would + // never match a cache profile and the app would always look "dirty". + var dbProfiles = db + .Profiles.AsNoTracking() + .Include(x => x.ButtonMappings) + .ToList() + .Select(ProfileMapper.MapToViewModel) + .OrderBy(x => x.Id); + + return !profilesCache.Profiles.OrderBy(x => x.Id).SequenceEqual(dbProfiles); + } } } diff --git a/YMouseButtonControl.Core/ViewModels/Models/BaseButtonMappingVm.cs b/YMouseButtonControl.Core/ViewModels/Models/BaseButtonMappingVm.cs index 49c0019..7543cae 100644 --- a/YMouseButtonControl.Core/ViewModels/Models/BaseButtonMappingVm.cs +++ b/YMouseButtonControl.Core/ViewModels/Models/BaseButtonMappingVm.cs @@ -1,6 +1,7 @@ using System; using Newtonsoft.Json; using ReactiveUI; +using YMouseButtonControl.Core.Localization; using YMouseButtonControl.Domain.Models; namespace YMouseButtonControl.Core.ViewModels.Models; @@ -186,7 +187,7 @@ public class DisabledMappingVm : BaseButtonMappingVm public DisabledMappingVm() { Index = 1; - Description = "Disabled"; + Description = Localizer.Instance["Map_Disabled"]; } public override BaseButtonMappingVm Clone() => CreateClone(new DisabledMappingVm()); @@ -197,7 +198,7 @@ public class NothingMappingVm : BaseButtonMappingVm public NothingMappingVm() { Index = 0; - Description = "** No Change (Don't Intercept) **"; + Description = Localizer.Instance["Map_NoChange"]; } public override BaseButtonMappingVm Clone() => CreateClone(new NothingMappingVm()); @@ -208,7 +209,7 @@ public class SimulatedKeystrokeVm : BaseButtonMappingVm public SimulatedKeystrokeVm() { Index = 2; - Description = "Simulated Keys (undefined)"; + Description = Localizer.Instance["Map_SimulatedUndefined"]; CanRaiseDialog = true; BlockOriginalMouseInput = true; } @@ -218,7 +219,10 @@ public SimulatedKeystrokeVm() public override string? ToString() { var myStr = SimulatedKeystrokeType is not null - ? $"Simulated Keys: ({SimulatedKeystrokeType.ShortDescription})" + ? Localizer.Instance.Format( + "Map_SimulatedKeysFmt", + SimulatedKeystrokeType.ShortDescription + ) : Description; if (!string.IsNullOrWhiteSpace(PriorityDescription)) @@ -240,7 +244,7 @@ public class RightClickVm : BaseButtonMappingVm public RightClickVm() { Index = 3; - Description = "Right Click"; + Description = Localizer.Instance["Map_RightClick"]; } public override BaseButtonMappingVm Clone() => CreateClone(new RightClickVm()); diff --git a/YMouseButtonControl.Core/ViewModels/Models/BaseSimulatedKeystrokeTypeVm.cs b/YMouseButtonControl.Core/ViewModels/Models/BaseSimulatedKeystrokeTypeVm.cs index 35ac957..34902d3 100644 --- a/YMouseButtonControl.Core/ViewModels/Models/BaseSimulatedKeystrokeTypeVm.cs +++ b/YMouseButtonControl.Core/ViewModels/Models/BaseSimulatedKeystrokeTypeVm.cs @@ -1,6 +1,7 @@ using System; using Newtonsoft.Json; using ReactiveUI; +using YMouseButtonControl.Core.Localization; namespace YMouseButtonControl.Core.ViewModels.Models; @@ -81,8 +82,8 @@ public class AsMousePressedAndReleasedActionTypeVm : BaseSimulatedKeystrokeTypeV public AsMousePressedAndReleasedActionTypeVm() { Index = 8; - Description = "As mouse button is pressed & when released"; - ShortDescription = "pressed & released"; + Description = Localizer.Instance["Skt_AsPressedReleased"]; + ShortDescription = Localizer.Instance["SktShort_AsPressedReleased"]; Enabled = true; } @@ -95,8 +96,8 @@ public class DuringMouseActionTypeVm : BaseSimulatedKeystrokeTypeVm public DuringMouseActionTypeVm() { Index = 2; - Description = "During (press on down, release on up)"; - ShortDescription = "during"; + Description = Localizer.Instance["Skt_During"]; + ShortDescription = Localizer.Instance["SktShort_During"]; Enabled = true; } @@ -109,8 +110,8 @@ public class InAnotherThreadPressedActionTypeVm : BaseSimulatedKeystrokeTypeVm public InAnotherThreadPressedActionTypeVm() { Index = 3; - Description = "In another thread as mouse button is pressed"; - ShortDescription = "thread-down"; + Description = Localizer.Instance["Skt_ThreadPressed"]; + ShortDescription = Localizer.Instance["SktShort_ThreadPressed"]; Enabled = false; } @@ -123,8 +124,8 @@ public class InAnotherThreadReleasedActionTypeVm : BaseSimulatedKeystrokeTypeVm public InAnotherThreadReleasedActionTypeVm() { Index = 4; - Description = "In another thread as mouse button is released"; - ShortDescription = "thread-up"; + Description = Localizer.Instance["Skt_ThreadReleased"]; + ShortDescription = Localizer.Instance["SktShort_ThreadReleased"]; Enabled = false; } @@ -137,8 +138,8 @@ public class MouseButtonPressedActionTypeVm : BaseSimulatedKeystrokeTypeVm public MouseButtonPressedActionTypeVm() { Index = 0; - Description = "As mouse button is pressed"; - ShortDescription = "pressed"; + Description = Localizer.Instance["Skt_AsPressed"]; + ShortDescription = Localizer.Instance["SktShort_AsPressed"]; Enabled = true; } @@ -151,8 +152,8 @@ public class MouseButtonReleasedActionTypeVm : BaseSimulatedKeystrokeTypeVm public MouseButtonReleasedActionTypeVm() { Index = 1; - Description = "As mouse button is released"; - ShortDescription = "released"; + Description = Localizer.Instance["Skt_AsReleased"]; + ShortDescription = Localizer.Instance["SktShort_AsReleased"]; Enabled = true; } @@ -165,8 +166,8 @@ public class RepeatedlyWhileButtonDownActionTypeVm : BaseSimulatedKeystrokeTypeV public RepeatedlyWhileButtonDownActionTypeVm() { Index = 5; - Description = "Repeatedly while the button is down"; - ShortDescription = "repeat"; + Description = Localizer.Instance["Skt_Repeat"]; + ShortDescription = Localizer.Instance["SktShort_Repeat"]; Enabled = true; } @@ -179,8 +180,8 @@ public class StickyHoldActionTypeVm : BaseSimulatedKeystrokeTypeVm public StickyHoldActionTypeVm() { Index = 7; - Description = "Sticky (held down until button is pressed again)"; - ShortDescription = "sticky hold"; + Description = Localizer.Instance["Skt_StickyHold"]; + ShortDescription = Localizer.Instance["SktShort_StickyHold"]; Enabled = true; } @@ -193,8 +194,8 @@ public class StickyRepeatActionTypeVm : BaseSimulatedKeystrokeTypeVm public StickyRepeatActionTypeVm() { Index = 6; - Description = "Sticky (repeatedly until button is pressed again)"; - ShortDescription = "sticky repeat"; + Description = Localizer.Instance["Skt_StickyRepeat"]; + ShortDescription = Localizer.Instance["SktShort_StickyRepeat"]; Enabled = true; } diff --git a/YMouseButtonControl.DataAccess/Context/SqliteConnectionStringHelper.cs b/YMouseButtonControl.DataAccess/Context/SqliteConnectionStringHelper.cs new file mode 100644 index 0000000..e774e27 --- /dev/null +++ b/YMouseButtonControl.DataAccess/Context/SqliteConnectionStringHelper.cs @@ -0,0 +1,37 @@ +using Microsoft.Data.Sqlite; + +namespace YMouseButtonControl.Infrastructure.Context; + +public static class SqliteConnectionStringHelper +{ + /// + /// Rebases a relative SQLite Data Source onto an absolute directory so the database + /// location does not depend on the process' current working directory. + /// + /// + /// A relative Data Source is otherwise resolved against + /// , which differs between a normal launch + /// and an autostart launch. That made profile changes appear not to persist + /// (issues #41, #35, #32). In-memory sources are left untouched. + /// + public static string ToAbsolute(string? connectionString, string baseDirectory) + { + var builder = new SqliteConnectionStringBuilder( + string.IsNullOrWhiteSpace(connectionString) + ? "Data Source=YMouseButtonControl.db" + : connectionString + ); + + var dataSource = builder.DataSource; + if ( + !string.IsNullOrEmpty(dataSource) + && !dataSource.Equals(":memory:", System.StringComparison.Ordinal) + && !Path.IsPathRooted(dataSource) + ) + { + builder.DataSource = Path.Combine(baseDirectory, dataSource); + } + + return builder.ConnectionString; + } +} diff --git a/YMouseButtonControl.DataAccess/Context/YMouseButtonControlDbContext.cs b/YMouseButtonControl.DataAccess/Context/YMouseButtonControlDbContext.cs index 6cb0438..736134b 100644 --- a/YMouseButtonControl.DataAccess/Context/YMouseButtonControlDbContext.cs +++ b/YMouseButtonControl.DataAccess/Context/YMouseButtonControlDbContext.cs @@ -366,6 +366,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) IntValue = 3, } ); + // Settings use table-per-hierarchy, so this Id must be unique across all Setting rows. + // "system" means follow the OS UI language; see Localizer. Existing databases (created + // before this seed) are upgraded at startup, since EnsureCreated only seeds on creation. + modelBuilder + .Entity() + .HasData( + new SettingString + { + Id = 3, + Name = "Language", + StringValue = "system", + } + ); } } diff --git a/YMouseButtonControl.Tests/ApplyProfilesTests.cs b/YMouseButtonControl.Tests/ApplyProfilesTests.cs new file mode 100644 index 0000000..7a857b7 --- /dev/null +++ b/YMouseButtonControl.Tests/ApplyProfilesTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; +using YMouseButtonControl.Core.Services.Profiles; +using YMouseButtonControl.Core.Services.Profiles.Queries.Profiles; +using YMouseButtonControl.Core.ViewModels.MainWindow.Commands.Profiles; +using YMouseButtonControl.Core.ViewModels.Models; +using YMouseButtonControl.Domain.Models; +using YMouseButtonControl.Infrastructure.Context; + +namespace YMouseButtonControl.Tests; + +/// +/// Characterization tests for — they pin down that the Apply/save +/// path actually persists edits to the built-in "Default" profile (button-mapping selection and +/// the Checked flag). +/// +/// Note: these pass on both the old List.ForEach(async ...) implementation and the awaited +/// foreach version, because the SQLite provider runs EF Core "async" calls synchronously, +/// so the fire-and-forget continuations complete inline. They therefore do NOT reproduce the +/// reported persistence bug (#41/#35); the more likely root cause of that is the database file +/// resolving to a different path depending on the working directory — see +/// . +/// +/// Each test uses a real SQLite database (in-memory, shared connection) so the seeded "Default" +/// profile and its button mappings behave exactly like production. +/// +public class ApplyProfilesTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public ApplyProfilesTests() + { + // A shared, open in-memory connection keeps the schema + seed data alive for the + // lifetime of the test while still letting us open several independent DbContexts. + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Constructor calls Database.EnsureCreated(), which creates the schema and seed data. + using var _ = NewContext(); + } + + private YMouseButtonControlDbContext NewContext() => new(_options); + + [Fact] + public async Task ExecuteAsync_PersistsButtonMappingSelectionChange_OnExistingProfile() + { + // Arrange: load the seeded Default profile into the cache, exactly like the app does. + await using var db = NewContext(); + var cache = new ProfilesCache(new ListDbProfiles.Handler(db)); + var profile = cache.Profiles.Single(); + + // Simulate the user switching Mouse Button 4 from "No Change" to "Simulated Keystroke". + var nothingMb4 = profile.ButtonMappings.Single(b => + b is NothingMappingVm && b.MouseButton == MouseButton.Mb4 + ); + var simMb4 = profile.ButtonMappings.Single(b => + b is SimulatedKeystrokeVm && b.MouseButton == MouseButton.Mb4 + ); + nothingMb4.Selected = false; + simMb4.Selected = true; + + // Act + await new ApplyProfiles.Handler(db, cache).ExecuteAsync(); + + // Assert: read the database back through a fresh context. + await using var verifyDb = NewContext(); + var mb4 = verifyDb + .ButtonMappings.AsNoTracking() + .Where(b => b.ProfileId == profile.Id && b.MouseButton == MouseButton.Mb4) + .ToList(); + + Assert.True(mb4.Single(b => b is SimulatedKeystroke).Selected); + Assert.False(mb4.Single(b => b is NothingMapping).Selected); + } + + [Fact] + public async Task ExecuteAsync_PersistsCheckedToggle_OnExistingProfile() + { + // Arrange + await using var db = NewContext(); + var cache = new ProfilesCache(new ListDbProfiles.Handler(db)); + var profile = cache.Profiles.Single(); + Assert.True(profile.Checked); // seeded as checked + + // Act: user unchecks the profile. + profile.Checked = false; + await new ApplyProfiles.Handler(db, cache).ExecuteAsync(); + + // Assert + await using var verifyDb = NewContext(); + var saved = verifyDb.Profiles.AsNoTracking().Single(p => p.Id == profile.Id); + Assert.False(saved.Checked); + } + + public void Dispose() => _connection.Dispose(); +} diff --git a/YMouseButtonControl.Tests/DatabasePathTests.cs b/YMouseButtonControl.Tests/DatabasePathTests.cs new file mode 100644 index 0000000..a1f3851 --- /dev/null +++ b/YMouseButtonControl.Tests/DatabasePathTests.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; +using YMouseButtonControl.Infrastructure.Context; + +namespace YMouseButtonControl.Tests; + +/// +/// Tests for , which guards against the persistence +/// problem behind issues #41 ("saving profiles doesn't work"), #35 ("profile active when +/// unchecked") and #32 ("working directory incorrect at startup"). +/// +/// Root cause: the connection string in appsettings.json is Data Source=YMouseButtonControl.db +/// — a relative path. A relative SQLite data source is resolved against the process' current +/// working directory, which differs between a normal launch and an autostart launch, so the app +/// would read/write a different database file each time and changes appeared not to persist. +/// +public class DatabasePathTests +{ + private const string RelativeConnectionString = "Data Source=YMouseButtonControl.db"; + + [Fact] + public void RelativeDataSource_ResolvesToDifferentFiles_PerBaseDirectory() + { + // This is the bug: the same relative connection string points at a different physical + // file depending on the base directory (the working directory at launch time). + var fromDirA = new SqliteConnectionStringBuilder( + SqliteConnectionStringHelper.ToAbsolute( + RelativeConnectionString, + Path.Combine(Path.GetTempPath(), "ymbc-dir-a") + ) + ).DataSource; + + var fromDirB = new SqliteConnectionStringBuilder( + SqliteConnectionStringHelper.ToAbsolute( + RelativeConnectionString, + Path.Combine(Path.GetTempPath(), "ymbc-dir-b") + ) + ).DataSource; + + Assert.NotEqual(fromDirA, fromDirB); + } + + [Fact] + public void ToAbsolute_RebasesRelativeSource_OntoGivenDirectory() + { + var dataDirectory = Path.Combine(Path.GetTempPath(), "ymbc-data"); + + var result = new SqliteConnectionStringBuilder( + SqliteConnectionStringHelper.ToAbsolute(RelativeConnectionString, dataDirectory) + ).DataSource; + + Assert.True(Path.IsPathRooted(result)); + Assert.Equal(Path.Combine(dataDirectory, "YMouseButtonControl.db"), result); + } + + [Fact] + public void ToAbsolute_LeavesAbsoluteSource_Unchanged() + { + var absolute = Path.Combine(Path.GetTempPath(), "already-absolute.db"); + + var result = new SqliteConnectionStringBuilder( + SqliteConnectionStringHelper.ToAbsolute($"Data Source={absolute}", "/some/other/dir") + ).DataSource; + + Assert.Equal(absolute, result); + } + + [Fact] + public void ToAbsolute_LeavesInMemorySource_Unchanged() + { + var result = new SqliteConnectionStringBuilder( + SqliteConnectionStringHelper.ToAbsolute("Data Source=:memory:", "/some/dir") + ).DataSource; + + Assert.Equal(":memory:", result); + } + + [Fact] + public void ToAbsolute_NullOrEmpty_FallsBackToDefaultFileName() + { + var dataDirectory = Path.Combine(Path.GetTempPath(), "ymbc-default"); + + var result = new SqliteConnectionStringBuilder( + SqliteConnectionStringHelper.ToAbsolute(null, dataDirectory) + ).DataSource; + + Assert.Equal(Path.Combine(dataDirectory, "YMouseButtonControl.db"), result); + } + + [Fact] + public void DbContext_WithRebasedConnectionString_CreatesFileAndPersistsAcrossLaunches() + { + var dataDirectory = Path.Combine( + Path.GetTempPath(), + "ymbc-e2e-" + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(dataDirectory); + try + { + var connectionString = SqliteConnectionStringHelper.ToAbsolute( + RelativeConnectionString, + dataDirectory + ); + var expectedFile = Path.Combine(dataDirectory, "YMouseButtonControl.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + + // First "launch": the database is created and the user unchecks the Default profile. + using (var db = new YMouseButtonControlDbContext(options)) + { + var profile = db.Profiles.Single(); + profile.Checked = false; + db.SaveChanges(); + } + + Assert.True(File.Exists(expectedFile)); + + // Second "launch" from the same absolute path sees the persisted change. + using (var db = new YMouseButtonControlDbContext(options)) + { + Assert.False(db.Profiles.Single().Checked); + } + } + finally + { + SqliteConnection.ClearAllPools(); + Directory.Delete(dataDirectory, recursive: true); + } + } +} diff --git a/YMouseButtonControl.Tests/IsCacheDirtyTests.cs b/YMouseButtonControl.Tests/IsCacheDirtyTests.cs new file mode 100644 index 0000000..9dc532a --- /dev/null +++ b/YMouseButtonControl.Tests/IsCacheDirtyTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; +using YMouseButtonControl.Core.Services.Profiles; +using YMouseButtonControl.Core.Services.Profiles.Queries.Profiles; +using YMouseButtonControl.Core.ViewModels.MainWindow.Queries.Profiles; +using YMouseButtonControl.Infrastructure.Context; + +namespace YMouseButtonControl.Tests; + +/// +/// Tests for . The handler decides whether the in-memory profile cache +/// differs from what is persisted (which gates the "Apply" button). Previously it compared the +/// cache against a database projection that omitted the button mappings, so it reported "dirty" +/// even when nothing had changed. +/// +public class IsCacheDirtyTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public IsCacheDirtyTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + using var _ = NewContext(); + } + + private YMouseButtonControlDbContext NewContext() => new(_options); + + [Fact] + public void Execute_ReturnsFalse_WhenCacheMatchesDatabase() + { + using var db = NewContext(); + var cache = new ProfilesCache(new ListDbProfiles.Handler(db)); + + // Freshly loaded cache equals what is in the database -> not dirty. + Assert.False(new IsCacheDirty.Handler(cache, db).Execute()); + } + + [Fact] + public void Execute_ReturnsTrue_WhenACacheProfileWasEdited() + { + using var db = NewContext(); + var cache = new ProfilesCache(new ListDbProfiles.Handler(db)); + + // Edit a profile in the cache only (not yet saved) -> dirty. + cache.Profiles.Single().Checked = false; + + Assert.True(new IsCacheDirty.Handler(cache, db).Execute()); + } + + public void Dispose() => _connection.Dispose(); +} diff --git a/YMouseButtonControl.Tests/LocalizationTests.cs b/YMouseButtonControl.Tests/LocalizationTests.cs new file mode 100644 index 0000000..b574b70 --- /dev/null +++ b/YMouseButtonControl.Tests/LocalizationTests.cs @@ -0,0 +1,86 @@ +using System.Linq; +using Xunit; +using YMouseButtonControl.Core.Localization; + +namespace YMouseButtonControl.Tests; + +/// +/// Guards the translation tables: English is the source of truth and every other language must +/// define exactly the same set of keys, so nothing silently falls back to English at runtime. +/// +public class LocalizationTests +{ + public static TheoryData NonEnglishLanguages => + new() { "ru", "de", "es", "fr" }; + + [Theory] + [MemberData(nameof(NonEnglishLanguages))] + public void EveryLanguage_HasExactlySameKeysAsEnglish(string code) + { + var dict = code switch + { + "ru" => Translations.Ru, + "de" => Translations.De, + "es" => Translations.Es, + "fr" => Translations.Fr, + _ => Translations.En, + }; + + var missing = Translations.En.Keys.Except(dict.Keys).ToList(); + var extra = dict.Keys.Except(Translations.En.Keys).ToList(); + + Assert.True(missing.Count == 0, $"{code} is missing keys: {string.Join(", ", missing)}"); + Assert.True(extra.Count == 0, $"{code} has unknown keys: {string.Join(", ", extra)}"); + } + + [Theory] + [MemberData(nameof(NonEnglishLanguages))] + public void EveryLanguage_HasNoBlankTranslations(string code) + { + var dict = code switch + { + "ru" => Translations.Ru, + "de" => Translations.De, + "es" => Translations.Es, + "fr" => Translations.Fr, + _ => Translations.En, + }; + + Assert.DoesNotContain(dict, kvp => string.IsNullOrWhiteSpace(kvp.Value)); + } + + [Theory] + [InlineData("en", "en")] + [InlineData("ru", "ru")] + [InlineData("fr", "fr")] + [InlineData(null, null)] // system -> resolves to a supported code or english + [InlineData("system", null)] + [InlineData("zz", "en")] // unsupported -> english + public void Resolve_MapsToSupportedLanguageOrEnglish(string? input, string? expectedExact) + { + var resolved = Localizer.Resolve(input); + + Assert.Contains(resolved, Localizer.SupportedLanguages); + if (expectedExact is not null) + { + Assert.Equal(expectedExact, resolved); + } + } + + [Fact] + public void Get_UnknownKey_ReturnsKeyItself() + { + Localizer.Instance.SetLanguage("en"); + Assert.Equal("this.key.does.not.exist", Localizer.Instance["this.key.does.not.exist"]); + } + + [Fact] + public void SetLanguage_SwitchesTranslations() + { + Localizer.Instance.SetLanguage("ru"); + Assert.Equal(Translations.Ru["Btn_Apply"], Localizer.Instance["Btn_Apply"]); + + Localizer.Instance.SetLanguage("en"); + Assert.Equal("Apply", Localizer.Instance["Btn_Apply"]); + } +} diff --git a/YMouseButtonControl.Tests/SettingsPersistenceTests.cs b/YMouseButtonControl.Tests/SettingsPersistenceTests.cs new file mode 100644 index 0000000..8fd6ff7 --- /dev/null +++ b/YMouseButtonControl.Tests/SettingsPersistenceTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; +using YMouseButtonControl.Core.ViewModels.Dialogs.GlobalSettingsDialog.Commands.Settings; +using YMouseButtonControl.Core.ViewModels.Dialogs.GlobalSettingsDialog.Queries.Settings; +using YMouseButtonControl.Core.ViewModels.Dialogs.GlobalSettingsDialog.Queries.Settings.Models; +using YMouseButtonControl.Infrastructure.Context; + +namespace YMouseButtonControl.Tests; + +/// +/// Covers the settings round-trip behind the Global Settings dialog. In particular the bool case +/// pins down the fix where BoolSettingVm.Value was a get-only property: a toggled +/// "Start Minimized" value must actually reach the database and read back. +/// +public class SettingsPersistenceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public SettingsPersistenceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + using var _ = NewContext(); // EnsureCreated seeds the settings rows. + } + + private YMouseButtonControlDbContext NewContext() => new(_options); + + [Fact] + public async Task BoolSetting_ToggledValuePersistsAndReadsBack() + { + await using var db = NewContext(); + var get = new GetBoolSetting.Handler(db); + var update = new UpdateSetting.Handler(db); + + var before = get.Execute(new Query("StartMinimized")); + Assert.False(before.Value); // seeded default + + // The VM's settable Value is what the checkbox binds to; flip it and save. + before.Value = true; + await update.ExecuteAsync(new UpdateSetting.Command("StartMinimized", before.Value)); + + await using var verifyDb = NewContext(); + var after = new GetBoolSetting.Handler(verifyDb).Execute(new Query("StartMinimized")); + Assert.True(after.Value); + } + + [Fact] + public async Task LanguageSetting_DefaultsToSystemAndPersists() + { + await using var db = NewContext(); + var get = new GetStringSetting.Handler(db); + var update = new UpdateSetting.Handler(db); + + Assert.Equal("system", get.Execute(new Query("Language"))); + + await update.ExecuteAsync(new UpdateSetting.Command("Language", "ru")); + + await using var verifyDb = NewContext(); + Assert.Equal("ru", new GetStringSetting.Handler(verifyDb).Execute(new Query("Language"))); + } + + public void Dispose() => _connection.Dispose(); +} diff --git a/YMouseButtonControl.Tests/WineProcessMatchingTests.cs b/YMouseButtonControl.Tests/WineProcessMatchingTests.cs new file mode 100644 index 0000000..9bf8af0 --- /dev/null +++ b/YMouseButtonControl.Tests/WineProcessMatchingTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using YMouseButtonControl.Core.Services.KeyboardAndMouse.Enums; +using YMouseButtonControl.Core.Services.KeyboardAndMouse.EventArgs; +using YMouseButtonControl.Core.Services.KeyboardAndMouse.Implementations.Queries.CurrentWindow; +using YMouseButtonControl.Core.Services.KeyboardAndMouse.Implementations.Queries.SkipProfile; +using YMouseButtonControl.Core.ViewModels.Models; + +namespace YMouseButtonControl.Tests; + +/// +/// Covers the Linux profile-matching contract, in particular WINE/Proton (issue #29): the X11 +/// foreground identity includes the process command line, so a profile whose process is the +/// Windows game.exe matches even though the executable link points at the wine loader. +/// +public class WineProcessMatchingTests +{ + private sealed class FakeCurrentWindow(string foreground) : IGetCurrentWindow + { + public string ForegroundWindow { get; } = foreground; + } + + private static ProfileVm Profile(string process, bool @checked = true) => + new(new List()) + { + Description = process, + Name = process, + Process = process, + WindowCaption = "N/A", + WindowClass = "N/A", + ParentClass = "N/A", + MatchType = "N/A", + Checked = @checked, + }; + + private static NewMouseHookEventArgs Event() => + new(YMouseButton.MouseButton4, 0, 0, null); + + private static bool ShouldSkip(string foreground, ProfileVm profile) + { + var sut = new SkipProfileLinux( + NullLogger.Instance, + new FakeCurrentWindow(foreground) + ); + return sut.ShouldSkipProfile(profile, Event()); + } + + [Fact] + public void WineGame_MatchedViaCommandLine_NotSkipped() + { + // /exe points at the wine loader; the real exe is only in the command line. + const string foreground = + "/opt/wine/bin/wine64-preloader Z:\\games\\MyGame\\game.exe -windowed"; + + Assert.False(ShouldSkip(foreground, Profile("game.exe"))); + } + + [Fact] + public void NativeApp_MatchedViaExePath_NotSkipped() + { + const string foreground = "/usr/lib/firefox/firefox /usr/lib/firefox/firefox"; + Assert.False(ShouldSkip(foreground, Profile("firefox"))); + } + + [Fact] + public void DifferentProcess_IsSkipped() + { + const string foreground = "/usr/lib/firefox/firefox"; + Assert.True(ShouldSkip(foreground, Profile("game.exe"))); + } + + [Fact] + public void WildcardProfile_AlwaysMatches() + { + Assert.False(ShouldSkip("/usr/lib/firefox/firefox", Profile("*"))); + } + + [Fact] + public void UncheckedProfile_IsSkipped() + { + const string foreground = "/opt/wine/bin/wine64-preloader Z:\\games\\game.exe"; + Assert.True(ShouldSkip(foreground, Profile("game.exe", @checked: false))); + } +} diff --git a/YMouseButtonControl.Tests/YMouseButtonControl.Tests.csproj b/YMouseButtonControl.Tests/YMouseButtonControl.Tests.csproj new file mode 100644 index 0000000..19e95d8 --- /dev/null +++ b/YMouseButtonControl.Tests/YMouseButtonControl.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/YMouseButtonControl.sln b/YMouseButtonControl.sln index ff98def..97ab22b 100644 --- a/YMouseButtonControl.sln +++ b/YMouseButtonControl.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YMouseButtonControl", "YMou EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YMouseButtonControl.Domain", "YMouseButtonControl.Domain\YMouseButtonControl.Domain.csproj", "{4DE55456-2875-4979-BB42-8D57B0FDE353}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YMouseButtonControl.Tests", "YMouseButtonControl.Tests\YMouseButtonControl.Tests.csproj", "{1FBEF429-C938-44D3-BEE8-51656C435562}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {4DE55456-2875-4979-BB42-8D57B0FDE353}.Debug|Any CPU.Build.0 = Debug|Any CPU {4DE55456-2875-4979-BB42-8D57B0FDE353}.Release|Any CPU.ActiveCfg = Release|Any CPU {4DE55456-2875-4979-BB42-8D57B0FDE353}.Release|Any CPU.Build.0 = Release|Any CPU + {1FBEF429-C938-44D3-BEE8-51656C435562}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FBEF429-C938-44D3-BEE8-51656C435562}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FBEF429-C938-44D3-BEE8-51656C435562}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FBEF429-C938-44D3-BEE8-51656C435562}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/YMouseButtonControl/App.axaml.cs b/YMouseButtonControl/App.axaml.cs index c4337c8..51233c3 100644 --- a/YMouseButtonControl/App.axaml.cs +++ b/YMouseButtonControl/App.axaml.cs @@ -1,9 +1,12 @@ using System; +using System.IO; +using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -14,11 +17,13 @@ using Splat; using Splat.Microsoft.Extensions.DependencyInjection; using YMouseButtonControl.BackgroundTaskRunner; +using YMouseButtonControl.Core.Localization; using YMouseButtonControl.Core.ViewModels.App; using YMouseButtonControl.Core.ViewModels.MainWindow; using YMouseButtonControl.Core.ViewModels.Models; using YMouseButtonControl.Core.Views; using YMouseButtonControl.DependencyInjection; +using YMouseButtonControl.Domain.Models; using YMouseButtonControl.Infrastructure.Context; using YMouseButtonControl.Queries.Settings; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -42,12 +47,29 @@ private void Init() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); + + // Resolve the database and log file to an absolute, per-user, writable location. + // The connection string / log path in appsettings.json are relative, and a relative + // SQLite "Data Source" (and the relative log path) are resolved against the process' + // current working directory. That directory differs between a normal launch and an + // autostart launch, so the app would otherwise read/write a different database file + // each time, making profile changes appear not to persist (issues #41, #35, #32). + var dataDirectory = GetUserDataDirectory(); + configuration["Logging:File:Path"] = Path.Combine( + dataDirectory, + configuration["Logging:File:Path"] ?? "YMouseButtonControl.log" + ); + var connectionString = SqliteConnectionStringHelper.ToAbsolute( + configuration.GetConnectionString("YMouseButtonControlContext"), + dataDirectory + ); + var host = Host.CreateDefaultBuilder() .ConfigureServices(services => { services.UseMicrosoftDependencyResolver(); services.AddDbContext(opts => - opts.UseSqlite(configuration.GetConnectionString("YMouseButtonControlContext")) + opts.UseSqlite(connectionString) ); services.AddScoped(_ => configuration); //services.AddScoped(); @@ -81,9 +103,43 @@ private void Init() //Container.GetRequiredService().Init(); + // Apply the saved UI language before any window (and its localized strings) is created. + ApplyLanguageSetting(); + RxApp.MainThreadScheduler = AvaloniaScheduler.Instance; } + /// + /// Reads the persisted "Language" setting (creating it as "system" if a pre-existing database + /// doesn't have it yet) and applies it to the . Must run before any + /// localized view is constructed because translations are resolved at XAML load time. + /// + private void ApplyLanguageSetting() + { + try + { + var db = Container?.GetRequiredService(); + if (db is null) + { + return; + } + + var setting = db.SettingStrings.FirstOrDefault(x => x.Name == "Language"); + if (setting is null) + { + setting = new SettingString { Name = "Language", StringValue = "system" }; + db.SettingStrings.Add(setting); + db.SaveChanges(); + } + + Localizer.Instance.SetLanguage(setting.StringValue); + } + catch + { + // Localization must never block startup; fall back to the default (English/system). + } + } + public override void OnFrameworkInitializationCompleted() { Init(); @@ -135,4 +191,22 @@ private static partial void LogError( string? innerException, string? stackTrace ); + + /// + /// Returns a per-user, writable directory for the database and log file, creating it if + /// necessary. On Windows this is %APPDATA%, on Linux ~/.config (or $XDG_CONFIG_HOME), and + /// on macOS the user's Application Support / config directory. + /// + private static string GetUserDataDirectory() + { + var directory = Path.Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.ApplicationData, + Environment.SpecialFolderOption.Create + ), + "YMouseButtonControl" + ); + Directory.CreateDirectory(directory); + return directory; + } } diff --git a/YMouseButtonControl/Views/Dialogs/GlobalSettingsDialog.axaml b/YMouseButtonControl/Views/Dialogs/GlobalSettingsDialog.axaml index 77f22c4..da67952 100644 --- a/YMouseButtonControl/Views/Dialogs/GlobalSettingsDialog.axaml +++ b/YMouseButtonControl/Views/Dialogs/GlobalSettingsDialog.axaml @@ -1,8 +1,9 @@ - - + - - - Add YMouseButtonControl to the start menu. - - Disabled for macOS. - - - + IsChecked="{Binding StartMenuChecked}" + ToolTip.Tip="{i18n:Tr Set_StartMenuTip}" /> - - - Whether or not logging to file YMouseButtonControl.log is performed. Requires a restart. - - - + Content="{i18n:Tr Set_Logging}" + IsChecked="{Binding LoggingEnabled}" + ToolTip.Tip="{i18n:Tr Set_LoggingTip}" /> - - - + + + + - - - + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..00464ef --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,137 @@ +# Architecture + +YMouseButtonControl is a cross-platform clone of X-Mouse-Button-Control built with +[Avalonia](https://avaloniaui.net/) + [ReactiveUI](https://www.reactiveui.net/) (MVVM) on +.NET 8, with persistence through Entity Framework Core + SQLite. + +## High-level flow + +``` + global mouse/keyboard hook (SharpHook / libuiohook) + │ + ▼ + MouseListenerService ──► SkipProfile (per-OS) ──► KeyboardSimulatorWorker + │ │ + │ ▼ + ProfilesCache simulated keys / mouse + ▲ (EventSimulatorService) + │ + UI (Avalonia views) ◄─┴─► ViewModels ◄──► command/query handlers ◄──► DbContext +``` + +* **Input capture** – `MouseListenerService` + (`Core/Services/KeyboardAndMouse/Implementations`) subscribes to a SharpHook + `IReactiveGlobalHook`. For every mouse event it walks the active profiles and asks + `ISkipProfile` whether each profile applies to the foreground window. +* **Profile matching** – `ISkipProfile` has a per-OS implementation + (`SkipProfileWindows`, `SkipProfileLinux`, `SkipProfileOsx`). It returns `true` (skip) + when the profile is unchecked, or when the foreground window doesn't match the profile's + `Process`. `*` matches any window. +* **Action execution** – `KeyboardSimulatorWorker` plus the `SimulatedKeystrokesTypes` + and `SimulatedMousePressTypes` services translate a button mapping into simulated input + via `EventSimulatorService`. + +## Projects and dependencies + +``` +Domain ◄── DataAccess (Infrastructure) ◄── Core ◄── App (YMouseButtonControl) + ▲ + └── Tests +``` + +* **Domain** – POCO entities (`Profile`, `ButtonMapping` and its subclasses + `NothingMapping` / `DisabledMapping` / `SimulatedKeystroke` / `RightClick`, `Setting`, + `Theme`). No external dependencies. +* **DataAccess / Infrastructure** – `YMouseButtonControlDbContext` (SQLite). Schema and the + built-in **Default** profile are defined in `OnModelCreating` via `HasData`, and migrations + live under `Migrations/`. Button-mapping inheritance uses EF Core table-per-hierarchy. +* **Core** – the application logic: + * **ViewModels** (`ViewModels/…`) – ReactiveUI view models, one per screen/dialog. + * **Models / VMs** (`ViewModels/Models`) – `ProfileVm`, `BaseButtonMappingVm` and friends. + These are the in-memory, observable representations the UI binds to. + * **Mappers** (`Mappings/`) – `ProfileMapper` / `ButtonMappingMapper` convert between + Domain entities and the `*Vm` view models. + * **Command/Query handlers** – the codebase follows a lightweight CQRS style: each + operation is a small static class containing a `Handler` (e.g. + `ApplyProfiles.Handler`, `ListDbProfiles.Handler`, `GetCurrentWindow*`). Handlers are + registered in DI and injected into view models. + * **Services** – cross-cutting services (input, profiles cache, simulated input). +* **App (`YMouseButtonControl`)** – Avalonia views (`Views/*.axaml`), the program entry + point, and the DI bootstrappers under `DependencyInjection/`. + +## Profiles: in-memory cache vs. database + +The UI never edits the database directly. Instead: + +1. On startup `ProfilesCache` (`Core/Services/Profiles/ProfilesCache.cs`) loads all profiles + from the DB via `ListDbProfiles.Handler` into a DynamicData `SourceCache`. +2. The UI binds to and mutates those `ProfileVm` objects. This makes the cache "dirty". +3. Pressing **Apply** runs `ApplyProfiles.Handler.ExecuteAsync` + (`Core/ViewModels/MainWindow/Commands/Profiles/ApplyProfiles.cs`), which reconciles the + cache against the database: it **adds** new profiles, **updates** existing ones (including + each button mapping and the `Checked` flag), and **deletes** profiles removed from the cache, + then calls `SaveChangesAsync`. + +> The reconciliation loops are awaited (`foreach` + `await`). An earlier version used +> `List.ForEach(async …)`, producing fire-and-forget `async void` work over a non-thread-safe +> `DbContext`. With the synchronous SQLite provider those continuations usually complete inline +> (so this was not, by itself, the cause of the "doesn't save" reports), but the pattern is a +> latent correctness bug and has been removed. `ApplyProfilesTests` characterizes the save path. + +### Where the database lives + +The connection string in `appsettings.json` is intentionally relative +(`Data Source=YMouseButtonControl.db`). At startup `App` rebases it (and the log path) to an +absolute, per-user, writable directory via +`SqliteConnectionStringHelper.ToAbsolute(...)` + `App.GetUserDataDirectory()` +(`%APPDATA%` / `~/.config` / macOS config dir). + +> **This was the real root cause of issues #41 / #35 / #32.** A relative SQLite data source is +> resolved against the process' *current working directory*; that directory differs between a +> normal launch and an autostart launch, so the app used to read/write different database files +> and edits appeared not to persist. `DatabasePathTests` covers the rebasing and an end-to-end +> persist-across-launches scenario. + +The context is registered with `AddDbContext` (Scoped) but resolved from the root provider, so +in practice a single long-lived `DbContext` is shared across the app. + +## Dependency injection + +`DependencyInjection/Bootstrapper.Register` wires everything up from a single place, +delegating to focused bootstrappers (`Services`, `Factories`, `KeyboardAndMouse`, +`ViewModels`, `Views`). OS-specific services are chosen at runtime in +`KeyboardAndMouseBootstrapper` (`ISkipProfile`, current-window queries, etc.). + +> **Debug vs. Release input hook:** in `DEBUG` builds the global hook is a SharpHook +> `TestProvider` (no real input is captured); `RELEASE` builds use the real +> `SimpleReactiveGlobalHook`. Keep this in mind when "nothing happens" while debugging. + +## Localization + +UI strings are localized through a small in-process table rather than .NET satellite assemblies: + +* `Core/Localization/Translations.cs` — one dictionary per language (`En`, `Ru`, `De`, `Es`, + `Fr`). **English is the source of truth and defines every key**; other languages may omit keys + and fall back to English. `LocalizationTests` enforces that the non-English tables have exactly + the same keys and no blank values. +* `Core/Localization/Localizer.cs` — singleton that holds the active table and resolves keys + (`Localizer.Instance["Key"]`, or `.Format("Key", arg)` for composite strings). +* `Core/Localization/TrExtension.cs` — the `{i18n:Tr Key}` XAML markup extension. It resolves the + string **at load time**, which is why a language change requires a restart. + +The language is stored as a `SettingString` named `Language` (`"system"` follows the OS UI +culture) and applied in `App` **before any window is created**. To **add a language**: add its +code to `Localizer.SupportedLanguages`, add a dictionary to `Translations`, wire it into +`Localizer.SetLanguage` and `LanguageOption.All`. To **add a string**: add the key to `En` (and +ideally the other tables) and reference it via `{i18n:Tr}` or `Localizer.Instance`. + +## Platform notes + +* **Linux/Wayland** cannot tell an application which window currently has keyboard focus, and + cannot suppress the original mouse button. This limits profile matching and "block original + input"/mode-6 behaviour on Wayland. X11 is recommended. See `docs/TROUBLESHOOTING.md`. +* **WINE/Proton matching (X11):** foreground detection reads `/proc//cmdline` in addition to + the executable link, so a profile can match the Windows `game.exe` even though the process' + executable is the wine loader (`GetCurrentWindowLinuxX11`, issue #29). +* **More than 5 mouse buttons** is not supported: SharpHook/`libuiohook` only delivers + `Button1`–`Button5`, so buttons 6+ never reach the app (issue #44). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..24fc3d6 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,116 @@ +# Troubleshooting & Known Issues + +This page collects the most common problems reported on the issue tracker, with the cause and +the current status of each. + +## Profiles / button mappings don't save (issues #41, #35, #32) + +**Symptom:** You set a button (e.g. disabling MOUSE4/MOUSE5 or assigning Simulated Keys), +press **Apply** / **Save Profile**, but after restarting the app everything is back to +*"Do not intercept"*; or an **unchecked** profile is still active after a restart. + +**Cause:** The database connection string was a **relative** path +(`Data Source=YMouseButtonControl.db`). A relative SQLite data source is resolved against the +process' **current working directory**, which differs between a normal launch and an +autostart launch (issue #32). The app therefore read and wrote *different database files* +depending on how it was started, so changes appeared not to persist and the seeded defaults +kept coming back. + +**Status:** **Fixed.** The database (and log file) now resolve to an absolute, per-user +location: + +* Windows: `%APPDATA%\YMouseButtonControl\YMouseButtonControl.db` +* Linux: `~/.config/YMouseButtonControl/YMouseButtonControl.db` +* macOS: under the user's config / Application Support directory + +The path logic is covered by `YMouseButtonControl.Tests/DatabasePathTests.cs`. If you are on an +older build, update to a build that includes this fix. (Your previous settings lived in a +working-directory-dependent `YMouseButtonControl.db`; you may need to reconfigure once.) + +> A related hardening change also fixed a latent `async void` pattern in the save path +> (`ApplyProfiles`); see `docs/ARCHITECTURE.md`. + +## Simulated keys / mode 6 don't work on Wayland (issues #42, #36) + +**Symptom:** Simulated keystrokes pop up an error, or a mapping only works while the +YMouseButtonControl window itself is focused; "block original mouse input" / mode 6 has no +effect. + +**Cause:** Wayland intentionally does **not** let an application (a) query which window +currently has keyboard focus or (b) suppress the original mouse button. These capabilities +must be provided by the desktop environment, and GNOME/KDE do not expose them today. This is +a platform limitation, not a permissions issue — running with `sudo` does not help. + +**Workaround:** Use an **X11** session. On X11, foreground-window detection works and original +input can be suppressed, so profile matching and mode 6 behave correctly. +See also the project wiki: *Linux X11 vs. Wayland Considerations*. + +## A per-application profile never triggers on Linux (issue #46) + +**Symptom:** You add a profile for, say, `firefox`, but it never activates; the log shows +`Couldn't find foreground window firefox` while another app (e.g. `konsole`) is focused. + +**Things to check:** + +1. **Are you on Wayland?** If so, see the section above — the foreground window cannot be + detected and matching will not work. Switch to X11. +2. **Is the target app actually focused** when you click? The profile only applies while its + window is in the foreground. The log line `Foreground window: /usr/bin/konsole` tells you + what had focus at that moment. +3. **Process matching is a substring match** against the executable path. On X11 a profile + whose `Process` is `firefox` matches a foreground path like `/usr/lib64/firefox/firefox`. + If matching still fails, set the profile's process to the value shown after + `Foreground window:` in the logs. + +## Mouse buttons beyond 5 are not detected (issue #44) + +**Symptom:** Buttons 6+ on a gaming mouse can't be mapped. + +**Cause:** This is a **hard limitation of the input backend**, not just a missing feature. +YMouseButtonControl receives mouse events through **SharpHook** (`libuiohook`), whose +`MouseButton` enum only defines `Button1`–`Button5` (plus `NoButton`). Physical buttons 6+ +are never delivered to the application, so there is nothing to map. The entire data model, +database seed, and UI are likewise built around the fixed set of 9 inputs (MB1–MB5 + four +wheel directions). + +**Status:** **Not supportable on the current stack.** Adding it would require replacing the +input backend with a lower-level, per-platform one (Windows Raw Input, Linux `evdev`, macOS +IOKit/CGEvent) **and** reworking the model/seed/UI to be dynamic. That is a large, platform- +specific effort tracked as a future direction rather than a quick fix. On macOS, MB4/MB5 may +not be reported at all. + +## A WINE / Proton game profile never triggers on Linux (issue #29) + +**Symptom:** You create a profile for a Windows game running under WINE/Proton (e.g. +`game.exe`), but it never activates even on an X11 session. + +**Cause:** A WINE process' `/proc//exe` symlink points at the **wine loader** +(e.g. `wine64-preloader`), not at the Windows executable. Matching only the executable path +therefore never sees `game.exe`. + +**Status:** **Fixed (X11).** The foreground-window detection now also reads +`/proc//cmdline`, where the real `game.exe` path appears as an argument, and matches the +profile's `Process` against the combination of the executable path **and** the command line. + +**How to use it:** Set the profile's `Process` to the Windows executable name (e.g. `game.exe`, +or a distinctive part of its path). Substring matching is used, so a fragment is enough. On +**Wayland** this still cannot work — the foreground window can't be queried (see the Wayland +section above); use an X11 session. Covered by `YMouseButtonControl.Tests/WineProcessMatchingTests.cs`. + +## "Installation not working" with pip / Python errors (issue #45) + +**Symptom:** Following some instructions leads to `error: externally managed environment` or +`Could not find a version that satisfies the requirement requirements-txt` from `pip`/`pipx`. + +**Cause:** YMouseButtonControl is a **.NET 8** application — it has **nothing to do with +Python or pip**. Those errors come from following unrelated/incorrect instructions. + +**Fix:** Install per the [README](../README.md): download the release archive for your +platform (or build with the .NET SDK) and run the `YMouseButtonControl` executable. The only +runtime requirement is the **.NET 8 Runtime**. + +## "Nothing happens" while running a Debug build (for contributors) + +In **Debug** builds the global input hook is replaced by a SharpHook `TestProvider`, so real +mouse buttons are not captured. Build/run in **Release** to test real input handling. See +[`CONTRIBUTING.md`](../CONTRIBUTING.md). diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..91ff341 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Builds self-contained, single-file releases of YMouseButtonControl and then +# removes build artifacts that aren't needed to run it (debug symbols, LICENSE). +# +# Usage: +# scripts/build-release.sh # builds win-x64, linux-x64, osx-x64 +# scripts/build-release.sh linux-x64 # builds only the given RID(s) +# scripts/build-release.sh win-x64 osx-arm64 # any RIDs you like +# +# Output goes to bin/publish-/ at the repo root. + +set -euo pipefail + +# Resolve repo root from this script's location, so it works from any CWD. +# Use `pwd -P` (physical path) to canonicalize symlinks: if the repo is reachable via +# more than one alias (a symlinked parent directory), building under a mix of both +# prefixes corrupts MSBuild's incremental cache (CS0006 / missing references), so we +# always pin to the single physical path regardless of how the script was invoked. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/.." && pwd -P)" +project="$repo_root/YMouseButtonControl/YMouseButtonControl.csproj" + +# Default target runtimes if none are passed on the command line. +rids=("$@") +if [ ${#rids[@]} -eq 0 ]; then + rids=(win-x64 linux-x64 osx-x64) +fi + +for rid in "${rids[@]}"; do + out="$repo_root/bin/publish-$rid" + echo ">> Building $rid -> $out" + rm -rf "$out" + + dotnet publish "$project" \ + -c Release \ + -r "$rid" \ + -o "$out" \ + --self-contained true \ + /p:PublishSingleFile=true \ + /p:IncludeNativeLibrariesForSelfExtract=true \ + /p:EnableCompressionInSingleFile=true + + # Clean up files that are produced but not required to run the app. + # *.pdb -> debug symbols + # LICENSE -> legal text, not loaded at runtime + # Kept on purpose: the executable, appsettings.json (read at startup, required), + # and any native *.dylib/*.so/*.dll the runtime needs beside the binary (e.g. macOS). + find "$out" -maxdepth 1 -type f -name '*.pdb' -delete + rm -f "$out/LICENSE" + + echo " done. Remaining files:" + ( cd "$out" && ls -1 ) + echo +done + +echo "All builds complete."