Release KSwitcher v0.6.65

This commit is contained in:
agrynco committed 2026-11-12 01:18:21 +02:00
1 parent 14f33012a3
commit 8a65e7f7fb
21 files changed
+557 -98

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.64 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.65 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+15 -7
View File
@@ -43,6 +43,11 @@ In **Settings → Mouse**, put the pointer over **Test mouse buttons here** and
press the buttons you want to test. The page shows the last detected button and press the buttons you want to test. The page shows the last detected button and
counts presses separately for ordinary window events and Windows Raw Input. counts presses separately for ordinary window events and Windows Raw Input.
Use **+** beside **Mouse events** to capture and name a wheel direction.
If Windows reports an existing button or an already added direction, the
dialog selects that event in the list. Extra device controls need to emit a
supported Windows mouse signal before KSwitcher can bind them.
Choose **Global** to set up fallback actions. Use **+** beside **Profiles** to create Choose **Global** to set up fallback actions. Use **+** beside **Profiles** to create
a named profile and set its applications. Double-click a profile or choose a named profile and set its applications. Double-click a profile or choose
**Edit…** in its context menu to change its name or applications. The **Edit…** in its context menu to change its name or applications. The
@@ -680,10 +685,13 @@ required for ordinary conflicts. Every server write uses the version just read a
condition, including explicit Upload. A racing write is rejected rather than silently overwritten. condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
Deleted server versions are not silently recreated. Deleted server versions are not silently recreated.
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection The server profile includes all settings from KSwitcher Settings, including mouse events and
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits, bindings, application language profiles, startup, keep-awake, logging, history limits and OCR.
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard Update the clients that use the same server profile before syncing the expanded format.
history, screenshots and dictionary files are never uploaded. Full synchronization identifies Older server backups preserve the local values for fields they never stored. Restored executable
paths refer to the same locations and may need updating on a fresh installation.
Connection details and credentials stay local so you can sign in again after reinstalling.
Clipboard history, screenshots and dictionary files are never uploaded. Full synchronization identifies
missing keyboard layouts by language (when Windows can identify it) and exact ID. missing keyboard layouts by language (when Windows can identify it) and exact ID.
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
@@ -988,8 +996,8 @@ subset is returned to the first available allowed language by a periodic check
with an excluded language active or selecting one via Windows or a direct shortcut. with an excluded language active or selecting one via Windows or a direct shortcut.
Languages remain installed and visible in Windows' language picker. Unavailable Languages remain installed and visible in Windows' language picker. Unavailable
layouts are skipped; if the entire selection is unavailable, the default cycle is layouts are skipped; if the entire selection is unavailable, the default cycle is
used. Profiles stay local to this PC, survive cloud sync and are included in manual used. Application language profiles are included in server synchronization and
settings export/import. manual settings export/import.
**Українською.** Відкрий **Settings → Languages → Languages for applications…**. **Українською.** Відкрий **Settings → Languages → Languages for applications…**.
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
@@ -1012,5 +1020,5 @@ settings export/import.
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows. комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації загальний цикл. Профілі входять до серверної синхронізації
та входять до ручного експорту/імпорту налаштувань. та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+24
View File
@@ -1,5 +1,29 @@
# Latest Windows release # Latest Windows release
## v0.6.65 — 2026-11-12
Use **Settings → Mouse → +** beside **Mouse events** to capture and name a
vertical or horizontal wheel direction. Capturing a known button or an
already registered direction selects its existing row. A new signal is
accepted only when Windows delivers it through both the test and runtime
input paths. A configured wheel action replaces scrolling for that direction
where the profile applies.
OpenBao now backs up all KSwitcher Settings, including mouse events, bindings,
application profiles, startup, clipboard history limits, and OCR preferences.
Update all clients that share a server profile before syncing. Downloading an
older backup preserves local settings for fields it never included. After
reinstalling Windows, reconnect to OpenBao and choose **Download remote**.
Executable paths and optional OCR software must still be available on the
restored PC. Clipboard contents and Windows credentials stay on the device.
**Українською.** У **Settings → Mouse** натисни **+** біля **Mouse events**,
щоб додати напрямок прокручування. Відомий сигнал виділить наявний рядок.
OpenBao тепер зберігає всі налаштування KSwitcher, зокрема профілі миші,
OCR й автозапуск. Перед синхронізацією онови всі клієнти спільного профілю.
Після перевстановлення Windows підключись до OpenBao й натисни
**Download remote**.
## v0.6.64 — 2026-11-12 ## v0.6.64 — 2026-11-12
The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut. The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut.
+1 -1
View File
@@ -1 +1 @@
8fdd701d2ed9223056f7e624e888ef459c919f605c624d5f35c3dbd3652a430b KSwitcher-win-x64.zip e60ca342737155dce14a2bb1dd43ec1cef890cf7d9e5efb2e15a5b9629eaf1af KSwitcher-win-x64.zip
+37 -37
View File
@@ -1,86 +1,86 @@
{ {
"version": "0.6.64",
"files": [ "files": [
{ {
"path": "KSwitcher.exe", "size": 2190336,
"size": 2185728, "sha256": "9d268cbb00fb9db3e52bc68994cefb50cee707f9dc1b940d876314ba1b6a06b3",
"sha256": "81ccb50fd3ac8a4768f0be9a4b70ef21c2a1f11e73b57871cb5123f29bfbd503" "path": "KSwitcher.exe"
}, },
{ {
"path": "KSwitcher.Config.exe", "size": 2129408,
"size": 2120192, "sha256": "d83fad30539e7b974bb8012859f3fee8143c5e4cc25684643b16e2fc34b97a5e",
"sha256": "83d76af6ff44f56532d2537e57dfb7e3859d72f34180d3c7c02497e71763378e" "path": "KSwitcher.Config.exe"
}, },
{ {
"path": "KSwitcher.Screenshot.exe", "size": 3210240,
"size": 3207168, "sha256": "29f7846ee4d3c469779b66acf99c90a4173e1e1577020398d6e1dc4eae492e06",
"sha256": "6e93963d4252a87bd6ea9d53ccd2e602c9c2709ca3fe835dc90a633b77b00193" "path": "KSwitcher.Screenshot.exe"
}, },
{ {
"path": "LICENSE",
"size": 4401, "size": 4401,
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c" "sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"path": "LICENSE"
}, },
{ {
"path": "json-LICENSE.txt",
"size": 1075, "size": 1075,
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86" "sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
"path": "json-LICENSE.txt"
}, },
{ {
"path": "README.md", "size": 90358,
"size": 89826, "sha256": "435daae289ca64dd9130ed772125de3f3303f1dbb80d1cbf7a9913058ded47ec",
"sha256": "9148049aa80595e0de30ecc9af1478572f85a8076da981ea80dafd5bb9c4815b" "path": "README.md"
}, },
{ {
"path": "LAN-PREVIEW.md",
"size": 9900, "size": 9900,
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e" "sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e",
"path": "LAN-PREVIEW.md"
}, },
{ {
"path": "dictionaries/en.txt",
"size": 682867, "size": 682867,
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21" "sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
"path": "dictionaries/en.txt"
}, },
{ {
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20138, "size": 20138,
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" "sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
}, },
{ {
"path": "dictionaries/README.md",
"size": 2230, "size": 2230,
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec" "sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec",
"path": "dictionaries/README.md"
}, },
{ {
"path": "dictionaries/ru.txt",
"size": 1060364, "size": 1060364,
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b" "sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
"path": "dictionaries/ru.txt"
}, },
{ {
"path": "dictionaries/sources.json",
"size": 1279, "size": 1279,
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86" "sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86",
"path": "dictionaries/sources.json"
}, },
{ {
"path": "dictionaries/uk.txt",
"size": 898248, "size": 898248,
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86" "sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
"path": "dictionaries/uk.txt"
}, },
{ {
"path": "tools/README.txt",
"size": 993, "size": 993,
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a" "sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"path": "tools/README.txt"
}, },
{ {
"path": "tools/StayHere.exe",
"size": 134656, "size": 134656,
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376" "sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
"path": "tools/StayHere.exe"
}, },
{ {
"path": "tools/StayHere-README.md",
"size": 2796, "size": 2796,
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413" "sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md"
} }
], ],
"version": "0.6.65",
"schema": 1 "schema": 1
} }
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,64,0 FILEVERSION 0,6,65,0
PRODUCTVERSION 0,6,64,0 PRODUCTVERSION 0,6,65,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.64" VALUE "FileVersion", "0.6.65"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+43 -10
View File
@@ -733,6 +733,10 @@ Bytes App::lanCommand(const Bytes &command)
return reply; return reply;
} }
void App::applySettings(const Settings &requested) void App::applySettings(const Settings &requested)
{
applySettings(requested, true);
}
void App::applySettings(const Settings &requested, bool validateOcrInstallation)
{ {
Settings s = requested; Settings s = requested;
std::set<std::wstring> present, removed(s.snippetTombstones.begin(), s.snippetTombstones.end()); std::set<std::wstring> present, removed(s.snippetTombstones.begin(), s.snippetTombstones.end());
@@ -745,7 +749,7 @@ void App::applySettings(const Settings &requested)
bool autostartChanged = false; bool autostartChanged = false;
AutoCorrector predictor; AutoCorrector predictor;
validateSettings(s); validateSettings(s);
validateTesseract(s); if (validateOcrInstallation && ocrProfile(s) != ocrProfile(settings)) validateTesseract(s);
if (s.autoCorrect) if (s.autoCorrect)
{ {
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages); predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
@@ -849,6 +853,8 @@ void App::resetLanInput()
chatSwitchHook = {}; chatSwitchHook = {};
pendingMouseClicks = {}; pendingMouseClicks = {};
mouseActions.clear(); mouseActions.clear();
mouseWheelTarget = nullptr;
mouseWheelRemainder = 0;
resetInput(); resetInput();
} }
void App::resetInput() void App::resetInput()
@@ -1399,6 +1405,8 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
auto &app = *current; auto &app = *current;
const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp); const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp);
const int button = mouse.flags & LLMHF_INJECTED ? -1 : mouseButton(UINT(wp), HIWORD(mouse.mouseData)); const int button = mouse.flags & LLMHF_INJECTED ? -1 : mouseButton(UINT(wp), HIWORD(mouse.mouseData));
const auto wheelDelta = static_cast<SHORT>(HIWORD(mouse.mouseData));
const int wheel = mouse.flags & LLMHF_INJECTED ? -1 : mouseWheelEvent(UINT(wp), wheelDelta);
const bool down = mouseButtonDown(UINT(wp)); const bool down = mouseButtonDown(UINT(wp));
if (button >= 0 && app.mousePresses[button].held) if (button >= 0 && app.mousePresses[button].held)
{ {
@@ -1430,7 +1438,8 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
if (app.lanService->takeInputChange()) app.resetLanInput(); if (app.lanService->takeInputChange()) app.resetLanInput();
if (consumed) return 1; if (consumed) return 1;
} }
if (button >= 0 && down && !app.settings.mouseBindings.empty() && !app.busy && !app.suspended && if (((button >= 0 && down) || mouseEventEnabled(app.settings, wheel)) &&
!app.settings.mouseBindings.empty() && !app.busy && !app.suspended &&
!app.installingUpdate && !app.hotkeyCapture && modifiersReleased()) !app.installingUpdate && !app.hotkeyCapture && modifiersReleased())
{ {
const auto hit = WindowFromPoint(mouse.pt); const auto hit = WindowFromPoint(mouse.pt);
@@ -1442,22 +1451,26 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
const bool shellSurface = target == GetShellWindow() || wcscmp(windowClass, L"Progman") == 0 || const bool shellSurface = target == GetShellWindow() || wcscmp(windowClass, L"Progman") == 0 ||
wcscmp(windowClass, L"WorkerW") == 0 || wcscmp(windowClass, L"Shell_TrayWnd") == 0 || wcscmp(windowClass, L"WorkerW") == 0 || wcscmp(windowClass, L"Shell_TrayWnd") == 0 ||
wcscmp(windowClass, L"Shell_SecondaryTrayWnd") == 0; wcscmp(windowClass, L"Shell_SecondaryTrayWnd") == 0;
const auto *global = mouseBinding(app.settings, GlobalMouseProfile, button); const int event = wheel >= 0 ? wheel : button;
const bool globalLaunch = button >= 2 && global && global->type == MouseActionType::Launch && const auto *global = mouseBinding(app.settings, GlobalMouseProfile, event);
const bool globalLaunch = event >= 2 && global && global->type == MouseActionType::Launch &&
mouseActionApplies(*global, name); mouseActionApplies(*global, name);
const bool restricted = ownWindow || shellSurface || name.empty(); const bool restricted = ownWindow || shellSurface || name.empty();
const bool testButton = name == L"kswitcher.config" && hit == GetDlgItem(target, 737); wchar_t targetClass[64]{};
const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", button, false) : nullptr; if (ownWindow) GetClassNameW(target, targetClass, std::size(targetClass));
const bool testButton = ownWindow && (hit == GetDlgItem(target, 737) ||
wcscmp(targetClass, L"KSwitcher.AddMouseEvent") == 0);
const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", event, false) : nullptr;
const auto *restrictedAction = isMouseVolumeAction(fallback) ? fallback : globalLaunch ? global : nullptr; const auto *restrictedAction = isMouseVolumeAction(fallback) ? fallback : globalLaunch ? global : nullptr;
const bool eligible = target && !testButton && (!restricted || (button >= 2 && restrictedAction)) && const bool eligible = target && !testButton && (!restricted || (event >= 2 && restrictedAction)) &&
(button >= 2 || target == GetForegroundWindow()); (event >= 2 || target == GetForegroundWindow());
if (eligible) if (eligible)
{ {
const bool foreground = target == GetForegroundWindow(); const bool foreground = target == GetForegroundWindow();
bool singleScoped{}, doubleScoped{}; bool singleScoped{}, doubleScoped{};
auto *single = restricted ? restrictedAction : auto *single = restricted ? restrictedAction :
effectiveMouseAction(app.settings, name, button, foreground, &singleScoped); effectiveMouseAction(app.settings, name, event, foreground, &singleScoped);
auto *doubleClick = !restricted && button < 3 ? auto *doubleClick = wheel < 0 && !restricted && button < 3 ?
effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr; effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr;
if (!foreground) if (!foreground)
{ {
@@ -1465,6 +1478,20 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr; if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr;
} }
const auto now = GetTickCount64(); const auto now = GetTickCount64();
if (wheel >= 0 && single)
{
if (app.mouseWheelTarget != target || app.mouseWheelSignal != wheel)
app.mouseWheelRemainder = 0;
app.mouseWheelTarget = target;
app.mouseWheelSignal = wheel;
const int steps = mouseWheelSteps(app.mouseWheelRemainder, wheelDelta);
for (int i = 0; i < steps; ++i)
app.queueMouseAction({target, mouse.pt, now, *single});
app.resetInput();
return 1;
}
if (wheel < 0)
{
auto &pending = app.pendingMouseClicks[button]; auto &pending = app.pendingMouseClicks[button];
const bool second = doubleClick && pending.target == target && const bool second = doubleClick && pending.target == target &&
doubleMouseClick(pending.point, mouse.pt, now - pending.started); doubleMouseClick(pending.point, mouse.pt, now - pending.started);
@@ -1484,6 +1511,12 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
} }
} }
} }
}
if (wheel >= 0)
{
app.mouseWheelTarget = nullptr;
app.mouseWheelRemainder = 0;
}
// Remote clicks invalidate a typing range just like local clicks. // Remote clicks invalidate a typing range just like local clicks.
if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL) if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL)
{ {
+3
View File
@@ -106,6 +106,7 @@ class App
private: private:
friend struct InputRegressionAccess; friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now); void releaseIdleMemory(ULONGLONG now);
void applySettings(const Settings &, bool validateOcrInstallation);
HHOOK keyboardHook{}, mouseHook{}; HHOOK keyboardHook{}, mouseHook{};
struct MousePress struct MousePress
{ {
@@ -118,6 +119,8 @@ class App
std::array<MousePress, std::size(MouseButtonNames)> mousePresses{}; std::array<MousePress, std::size(MouseButtonNames)> mousePresses{};
std::array<MousePress, std::size(MouseButtonNames)> pendingMouseClicks{}; std::array<MousePress, std::size(MouseButtonNames)> pendingMouseClicks{};
std::deque<MousePress> mouseActions; std::deque<MousePress> mouseActions;
HWND mouseWheelTarget{};
int mouseWheelSignal{-1}, mouseWheelRemainder{};
void queueMouseAction(MousePress); void queueMouseAction(MousePress);
void flushMouseClicks(); void flushMouseClicks();
void beginMouseVolume(int button); void beginMouseVolume(int button);
+2 -1
View File
@@ -79,7 +79,8 @@ void App::pollSync()
if (encodeSettings(next) != snapshot) if (encodeSettings(next) != snapshot)
{ {
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot)); atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
applySettings(next); // A restored OCR configuration can precede the optional local installation.
applySettings(next, false);
syncStatus += L" Changes applied on this PC; local backup saved."; syncStatus += L" Changes applied on this PC; local backup saved.";
} }
else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." : else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." :
+1
View File
@@ -101,6 +101,7 @@ struct Settings
std::map<std::wstring, std::vector<std::wstring>> chatCycles; std::map<std::wstring, std::vector<std::wstring>> chatCycles;
std::map<std::wstring, AppLanguageSet> appLanguageSets; std::map<std::wstring, AppLanguageSet> appLanguageSets;
std::map<std::wstring, std::map<std::wstring, MouseAction>> mouseBindings; std::map<std::wstring, std::map<std::wstring, MouseAction>> mouseBindings;
std::map<std::wstring, std::wstring> customMouseEvents;
std::set<std::wstring> disabledMouseProfiles; std::set<std::wstring> disabledMouseProfiles;
std::map<std::wstring, std::set<std::wstring>> mouseProfileApplications; std::map<std::wstring, std::set<std::wstring>> mouseProfileApplications;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
+49 -1
View File
@@ -6,7 +6,54 @@ namespace ks
{ {
inline constexpr const wchar_t *MouseButtonNames[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2"}; inline constexpr const wchar_t *MouseButtonNames[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2"};
inline constexpr const wchar_t *MouseEvents[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2", inline constexpr const wchar_t *MouseEvents[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2",
L"Double left", L"Double right", L"Double middle"}; L"Double left", L"Double right", L"Double middle",
L"Wheel up", L"Wheel down", L"Wheel left", L"Wheel right"};
inline constexpr int BuiltInMouseEventCount = 8;
inline int mouseWheelEvent(UINT message, SHORT delta)
{
if (!delta) return -1;
if (message == WM_MOUSEWHEEL) return BuiltInMouseEventCount + (delta < 0);
if (message == WM_MOUSEHWHEEL) return BuiltInMouseEventCount + 2 + (delta > 0);
return -1;
}
inline int rawMouseSignal(const RAWMOUSE &mouse)
{
constexpr USHORT downFlags[] = {RI_MOUSE_LEFT_BUTTON_DOWN, RI_MOUSE_RIGHT_BUTTON_DOWN,
RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN};
int signal = -1;
for (int button = 0; button < static_cast<int>(std::size(downFlags)); ++button)
{
if (!(mouse.usButtonFlags & downFlags[button])) continue;
if (signal >= 0) return -1;
signal = button;
}
const auto wheel = mouse.usButtonFlags & (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL);
if (!wheel) return signal;
if (signal >= 0 || wheel == (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL)) return -1;
return mouseWheelEvent(wheel == RI_MOUSE_WHEEL ? WM_MOUSEWHEEL : WM_MOUSEHWHEEL,
static_cast<SHORT>(mouse.usButtonData));
}
inline int mouseWheelSteps(int &remainder, SHORT delta)
{
const int total = remainder + std::abs(static_cast<int>(delta));
remainder = total % WHEEL_DELTA;
return total / WHEEL_DELTA;
}
inline bool mouseEventEnabled(const Settings &settings, int event)
{
return event >= 0 && event < static_cast<int>(std::size(MouseEvents)) &&
(event < BuiltInMouseEventCount || settings.customMouseEvents.contains(MouseEvents[event]));
}
inline std::wstring mouseEventName(const Settings &settings, int event)
{
auto name = settings.customMouseEvents.find(MouseEvents[event]);
return name == settings.customMouseEvents.end() ? MouseEvents[event] : name->second;
}
inline constexpr const wchar_t *MouseActions[] = {L"Standard behavior", L"Send key combination", L"Back", L"Forward", inline constexpr const wchar_t *MouseActions[] = {L"Standard behavior", L"Send key combination", L"Back", L"Forward",
L"Volume up", L"Volume down", L"Mute", L"Launch application"}; L"Volume up", L"Volume down", L"Mute", L"Launch application"};
inline constexpr wchar_t GlobalMouseProfile[] = L"*"; inline constexpr wchar_t GlobalMouseProfile[] = L"*";
@@ -67,6 +114,7 @@ inline const MouseAction *effectiveMouseAction(const Settings &settings, const s
bool foreground = true, bool *profileScoped = nullptr) bool foreground = true, bool *profileScoped = nullptr)
{ {
if (profileScoped) *profileScoped = false; if (profileScoped) *profileScoped = false;
if (!mouseEventEnabled(settings, event)) return nullptr;
auto *action = mouseBinding(settings, app, event); auto *action = mouseBinding(settings, app, event);
if (!action) if (!action)
{ {
+20 -1
View File
@@ -108,6 +108,15 @@ static void validateMouseProfileConflicts(const Settings &s)
void validateSettings(const Settings &s) void validateSettings(const Settings &s)
{ {
std::set<std::wstring> eventNames;
for (int event = 0; event < BuiltInMouseEventCount; ++event) eventNames.insert(lower(MouseEvents[event]));
for (const auto &[signal, name] : s.customMouseEvents)
{
if (std::find(std::begin(MouseEvents) + BuiltInMouseEventCount, std::end(MouseEvents), signal) == std::end(MouseEvents) ||
name.empty() || name.size() > 80 || name.find_first_of(L"\r\n\t") != name.npos ||
!eventNames.insert(lower(name)).second)
throw std::runtime_error("Enter a unique event name (up to 80 characters) for a supported signal.");
}
if (s.mouseBindings.size() > 1000) throw std::runtime_error("Too many mouse profiles."); if (s.mouseBindings.size() > 1000) throw std::runtime_error("Too many mouse profiles.");
for (const auto &[app, bindings] : s.mouseBindings) for (const auto &[app, bindings] : s.mouseBindings)
{ {
@@ -124,7 +133,8 @@ void validateSettings(const Settings &s)
if (action.applications.size() > 1000 || if (action.applications.size() > 1000 ||
!std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication)) !std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication))
throw std::runtime_error("Enter valid target application executable names."); throw std::runtime_error("Enter valid target application executable names.");
if (std::find(std::begin(MouseEvents), std::end(MouseEvents), event) == std::end(MouseEvents) || const auto signal = std::find(std::begin(MouseEvents), std::end(MouseEvents), event);
if (!mouseEventEnabled(s, static_cast<int>(signal - std::begin(MouseEvents))) ||
static_cast<size_t>(action.type) >= std::size(MouseActions)) static_cast<size_t>(action.type) >= std::size(MouseActions))
throw std::runtime_error("Invalid mouse event or action."); throw std::runtime_error("Invalid mouse event or action.");
if (isMouseVolumeStep(action.type) && (action.volumeStep < 1 || action.volumeStep > 100)) if (isMouseVolumeStep(action.type) && (action.volumeStep < 1 || action.volumeStep > 100))
@@ -382,6 +392,13 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.appLanguageSets[wide(name)] = std::move(set); s.appLanguageSets[wide(name)] = std::move(set);
} }
} }
if (j.contains("customMouseEvents"))
{
const auto &events = j.at("customMouseEvents");
if (!events.is_object()) throw std::runtime_error("Invalid custom mouse events.");
for (const auto &[signal, name] : events.items())
s.customMouseEvents.emplace(wide(signal), wide(name.get<std::string>()));
}
if (j.contains("mouseBindings")) if (j.contains("mouseBindings"))
{ {
const auto &profiles = j.at("mouseBindings"); const auto &profiles = j.at("mouseBindings");
@@ -520,6 +537,8 @@ Bytes encodeSettings(const Settings &s)
for (const auto &id : set.languages) item["languages"].push_back(utf8(id)); for (const auto &id : set.languages) item["languages"].push_back(utf8(id));
for (const auto &app : set.applications) item["applications"].push_back(utf8(app)); for (const auto &app : set.applications) item["applications"].push_back(utf8(app));
} }
j["customMouseEvents"] = Json::object();
for (const auto &[signal, name] : s.customMouseEvents) j["customMouseEvents"][utf8(signal)] = utf8(name);
j["mouseBindings"] = Json::object(); j["mouseBindings"] = Json::object();
for (const auto &[app, bindings] : s.mouseBindings) for (const auto &[app, bindings] : s.mouseBindings)
{ {
+19 -6
View File
@@ -216,7 +216,20 @@ std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTranspor
{ {
return std::make_unique<OpenBao>(c, std::move(transport)); return std::make_unique<OpenBao>(c, std::move(transport));
} }
namespace { Json canonicalProfile(Json); } namespace {
Json canonicalProfile(Json);
constexpr const char *PreviouslyLocalSettings[] = {
"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents"
};
void preserveMissingSettings(Json &profile, const Json &fallback)
{
for (auto key : PreviouslyLocalSettings)
if (!profile.contains(key) && fallback.contains(key)) profile[key] = fallback.at(key);
}
}
Bytes sharedSyncSettings(const Settings &s) Bytes sharedSyncSettings(const Settings &s)
{ {
auto j = parse(encodeSettings(s)); auto j = parse(encodeSettings(s));
@@ -226,14 +239,12 @@ Bytes sharedSyncSettings(const Settings &s)
std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) { std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) {
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>(); return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
}); });
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications"}) j.erase(key);
return bytes(canonicalProfile(j)); return bytes(canonicalProfile(j));
} }
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts) Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
{ {
auto j = parse(shared), original = parse(encodeSettings(local)); auto j = parse(shared), original = parse(encodeSettings(local));
preserveMissingSettings(j, original);
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array(); if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false; if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false; if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
@@ -245,7 +256,7 @@ Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const s
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1) if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
throw std::runtime_error("Unsupported synchronized settings format."); throw std::runtime_error("Unsupported synchronized settings format.");
for (auto it = j.begin(); it != j.end(); ++it) for (auto it = j.begin(); it != j.end(); ++it)
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields."); if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported fields.");
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete."); if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
std::set<std::string> installed; std::set<std::string> installed;
for (const auto &layout : layouts) installed.insert(utf8(layout.id)); for (const auto &layout : layouts) installed.insert(utf8(layout.id));
@@ -307,7 +318,9 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
else else
{ {
auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal); auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal);
if (!profile.contains("chatCycles")) profile["chatCycles"] = parse(local).value("chatCycles", Json::object()); const auto current = parse(local);
if (!profile.contains("chatCycles")) profile["chatCycles"] = current.value("chatCycles", Json::object());
preserveMissingSettings(profile, current);
auto selected = bytes(normalizeProfile(profile)); auto selected = bytes(normalizeProfile(profile));
merged = {selected, selected, selected, {}}; merged = {selected, selected, selected, {}};
} }
+12
View File
@@ -72,6 +72,14 @@ Json flatProfile(Json j)
else ++profile; else ++profile;
} }
} }
if (j.contains("customMouseEvents"))
{
auto &events = j.at("customMouseEvents");
for (auto event = events.begin(); event != events.end();)
if (event.value().is_null()) event = events.erase(event);
else ++event;
if (events.is_null()) j.erase("customMouseEvents");
}
Json snippets = Json::array(), deleted = Json::array(); Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items()) for (const auto &[id, item] : j.at("snippets").items())
{ {
@@ -215,6 +223,10 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r
// client's chat profiles until both copies understand this field. // client's chat profiles until both copies understand this field.
if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object()); if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object());
if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles"); if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles");
// Older server backups omit the newly synchronized fields. An explicit
// empty value from a current client still means the user cleared it.
preserveMissingSettings(remoteProfile, localProfile);
preserveMissingSettings(localProfile, remoteProfile);
auto l = treeProfile(localProfile), r = treeProfile(remoteProfile); auto l = treeProfile(localProfile), r = treeProfile(remoteProfile);
// With no common history, union independent entries; differing scalar values // With no common history, union independent entries; differing scalar values
// remain on their respective devices until the user resolves the conflict. // remain on their respective devices until the user resolves the conflict.
+2 -2
View File
@@ -1766,7 +1766,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
switch (msg) switch (msg)
{ {
case WM_DRAWITEM: case WM_DRAWITEM:
if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile) if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile || wp == AddMouseEvent)
{ {
drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp)); drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp));
return TRUE; return TRUE;
@@ -1801,7 +1801,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (((NMHDR *)lp)->idFrom == 733 && ((NMHDR *)lp)->code == NM_DBLCLK) if (((NMHDR *)lp)->idFrom == 733 && ((NMHDR *)lp)->code == NM_DBLCLK)
{ {
const auto &click = *reinterpret_cast<const NMITEMACTIVATE *>(lp); const auto &click = *reinterpret_cast<const NMITEMACTIVATE *>(lp);
showMouseActionEditor(w, *v, click.iItem); showMouseActionEditor(w, *v, mouseEventAtRow(click.hdr.hwndFrom, click.iItem));
return 0; return 0;
} }
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE) if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
+238 -19
View File
@@ -5,6 +5,7 @@ std::wstring selectedMouseProfile(HWND w, const SettingsView &view)
} }
constexpr int RenameMouseProfile = 757, DeleteMouseProfile = 758; constexpr int RenameMouseProfile = 757, DeleteMouseProfile = 758;
constexpr int AddMouseEvent = 762, MouseEventCapture = 763, MouseEventNameField = 764, MouseEventStatus = 765;
std::wstring mouseActionLabel(const MouseAction &action) std::wstring mouseActionLabel(const MouseAction &action)
{ {
@@ -14,11 +15,23 @@ std::wstring mouseActionLabel(const MouseAction &action)
return MouseActions[static_cast<size_t>(action.type)]; return MouseActions[static_cast<size_t>(action.type)];
} }
int mouseEventAtRow(HWND list, int row)
{
if (row < 0) return -1;
LVITEMW item{LVIF_PARAM};
item.iItem = row;
return ListView_GetItem(list, &item) ? static_cast<int>(item.lParam) : -1;
}
void selectMouseEvent(HWND w, int event) void selectMouseEvent(HWND w, int event)
{ {
HWND list = GetDlgItem(w, 733); HWND list = GetDlgItem(w, 733);
ListView_SetItemState(list, event, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); LVFINDINFOW search{LVFI_PARAM};
ListView_EnsureVisible(list, event, FALSE); search.lParam = event;
const int row = ListView_FindItem(list, -1, &search);
if (row < 0) return;
ListView_SetItemState(list, row, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
ListView_EnsureVisible(list, row, FALSE);
} }
void fillMouseEvents(HWND w, SettingsView &view, int selection = 0) void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
@@ -27,8 +40,17 @@ void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
EnableWindow(GetDlgItem(w, RenameMouseProfile), isNamedMouseProfile(profile)); EnableWindow(GetDlgItem(w, RenameMouseProfile), isNamedMouseProfile(profile));
EnableWindow(GetDlgItem(w, DeleteMouseProfile), isNamedMouseProfile(profile)); EnableWindow(GetDlgItem(w, DeleteMouseProfile), isNamedMouseProfile(profile));
HWND list = GetDlgItem(w, 733); HWND list = GetDlgItem(w, 733);
ListView_DeleteAllItems(list);
for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event) for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event)
{ {
if (!mouseEventEnabled(view.draft, event)) continue;
auto name = mouseEventName(view.draft, event);
LVITEMW item{LVIF_TEXT | LVIF_PARAM};
item.iItem = ListView_GetItemCount(list);
item.lParam = event;
item.pszText = name.data();
const int row = ListView_InsertItem(list, &item);
if (row < 0) continue;
auto *action = mouseBinding(view.draft, profile, event); auto *action = mouseBinding(view.draft, profile, event);
std::wstring text; std::wstring text;
if (profile != GlobalMouseProfile && !action) if (profile != GlobalMouseProfile && !action)
@@ -37,7 +59,7 @@ void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
action = mouseBinding(view.draft, GlobalMouseProfile, event); action = mouseBinding(view.draft, GlobalMouseProfile, event);
} }
text += action ? mouseActionLabel(*action) : MouseActions[0]; text += action ? mouseActionLabel(*action) : MouseActions[0];
ListView_SetItemText(list, event, 1, text.data()); ListView_SetItemText(list, row, 1, text.data());
} }
selectMouseEvent(w, selection); selectMouseEvent(w, selection);
} }
@@ -97,6 +119,7 @@ bool blockMouseProfileStateChange(HWND w, SettingsView &view, const NMLISTVIEW &
{ {
Settings candidate; Settings candidate;
candidate.mouseBindings = view.draft.mouseBindings; candidate.mouseBindings = view.draft.mouseBindings;
candidate.customMouseEvents = view.draft.customMouseEvents;
candidate.disabledMouseProfiles = view.draft.disabledMouseProfiles; candidate.disabledMouseProfiles = view.draft.disabledMouseProfiles;
candidate.mouseProfileApplications = view.draft.mouseProfileApplications; candidate.mouseProfileApplications = view.draft.mouseProfileApplications;
candidate.disabledMouseProfiles.erase(profile); candidate.disabledMouseProfiles.erase(profile);
@@ -309,7 +332,7 @@ void drawActionApplicationIcon(HWND editor, const DRAWITEMSTRUCT &item)
RECT horizontal{x - half, y - weight / 2, x + half, y - weight / 2 + weight}; RECT horizontal{x - half, y - weight / 2, x + half, y - weight / 2 + weight};
FillRect(item.hDC, &horizontal, ink); FillRect(item.hDC, &horizontal, ink);
} }
if (item.CtlID == AddActionApp || item.CtlID == AddMouseProfile) if (item.CtlID == AddActionApp || item.CtlID == AddMouseProfile || item.CtlID == AddMouseEvent)
{ {
RECT vertical{x - weight / 2, y - half, x - weight / 2 + weight, y + half}; RECT vertical{x - weight / 2, y - half, x - weight / 2 + weight, y + half};
FillRect(item.hDC, &vertical, ink); FillRect(item.hDC, &vertical, ink);
@@ -391,6 +414,7 @@ void saveMouseAction(HWND w, MouseActionEditor &editor)
const auto *event = MouseEvents[editor.event]; const auto *event = MouseEvents[editor.event];
Settings check; Settings check;
check.mouseBindings = editor.view->draft.mouseBindings; check.mouseBindings = editor.view->draft.mouseBindings;
check.customMouseEvents = editor.view->draft.customMouseEvents;
check.disabledMouseProfiles = editor.view->draft.disabledMouseProfiles; check.disabledMouseProfiles = editor.view->draft.disabledMouseProfiles;
check.mouseProfileApplications = editor.view->draft.mouseProfileApplications; check.mouseProfileApplications = editor.view->draft.mouseProfileApplications;
if (type < 0 || (type == static_cast<int>(MouseActionType::Standard) && profile == GlobalMouseProfile)) if (type < 0 || (type == static_cast<int>(MouseActionType::Standard) && profile == GlobalMouseProfile))
@@ -487,7 +511,7 @@ LRESULT CALLBACK mouseActionEditorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
void showMouseActionEditor(HWND parent, SettingsView &view, int event) void showMouseActionEditor(HWND parent, SettingsView &view, int event)
{ {
if (event < 0 || event >= static_cast<int>(std::size(MouseEvents)) || !IsWindowEnabled(parent)) return; if (!mouseEventEnabled(view.draft, event) || !IsWindowEnabled(parent)) return;
const auto profile = selectedMouseProfile(parent, view); const auto profile = selectedMouseProfile(parent, view);
const bool application = profile != GlobalMouseProfile; const bool application = profile != GlobalMouseProfile;
const auto *action = mouseBinding(view.draft, profile, event); const auto *action = mouseBinding(view.draft, profile, event);
@@ -496,7 +520,7 @@ void showMouseActionEditor(HWND parent, SettingsView &view, int event)
480, MouseActionEditorHeight, 0, false, parent); 480, MouseActionEditorHeight, 0, false, parent);
auto *editor = new MouseActionEditor{parent, &view, profile, event}; auto *editor = new MouseActionEditor{parent, &view, profile, event};
SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(editor)); SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(editor));
label(w, std::wstring(MouseEvents[event]).append(L" · ").append(mouseProfileName(profile)).c_str(), label(w, mouseEventName(view.draft, event).append(L" · ").append(mouseProfileName(profile)).c_str(),
18, 14, 444); 18, 14, 444);
label(w, L"&Action", 18, 43, 330); label(w, L"&Action", 18, 43, 330);
windowLayoutState(w)->fixedClient = true; windowLayoutState(w)->fixedClient = true;
@@ -578,7 +602,7 @@ void showMouseListMenu(HWND list, POINT point, bool profiles)
hit.pt = point; hit.pt = point;
ScreenToClient(list, &hit.pt); ScreenToClient(list, &hit.pt);
event = ListView_SubItemHitTest(list, &hit); event = ListView_SubItemHitTest(list, &hit);
if (event < 0) return; if (event < 0 && profiles) return;
} }
auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA)); auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
HMENU menu = CreatePopupMenu(); HMENU menu = CreatePopupMenu();
@@ -592,9 +616,11 @@ void showMouseListMenu(HWND list, POINT point, bool profiles)
} }
else else
{ {
selectMouseEvent(w, event); event = mouseEventAtRow(list, event);
const bool canReset = mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr; if (event >= 0) selectMouseEvent(w, event);
AppendMenuW(menu, MF_STRING, EditMouseEvent, L"&Edit…"); const bool canReset = event >= 0 && mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr;
AppendMenuW(menu, MF_STRING, AddMouseEvent, L"+ &Add mouse event…");
AppendMenuW(menu, MF_STRING | (event >= 0 ? MF_ENABLED : MF_GRAYED), EditMouseEvent, L"✎ &Edit…");
AppendMenuW(menu, MF_STRING | (canReset ? MF_ENABLED : MF_GRAYED), ResetMouseEvent, L"&Reset to default"); AppendMenuW(menu, MF_STRING | (canReset ? MF_ENABLED : MF_GRAYED), ResetMouseEvent, L"&Reset to default");
} }
const auto command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON, const auto command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON,
@@ -687,6 +713,7 @@ LRESULT CALLBACK mouseProfileNameProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
const auto key = std::wstring(NamedMouseProfilePrefix) + name; const auto key = std::wstring(NamedMouseProfilePrefix) + name;
Settings check; Settings check;
check.mouseBindings = view.draft.mouseBindings; check.mouseBindings = view.draft.mouseBindings;
check.customMouseEvents = view.draft.customMouseEvents;
check.disabledMouseProfiles = view.draft.disabledMouseProfiles; check.disabledMouseProfiles = view.draft.disabledMouseProfiles;
check.mouseProfileApplications = view.draft.mouseProfileApplications; check.mouseProfileApplications = view.draft.mouseProfileApplications;
if (!previous.empty() && key != previous) if (!previous.empty() && key != previous)
@@ -768,9 +795,204 @@ void showMouseProfileName(HWND owner, SettingsView &view, bool renaming)
SetFocus(GetDlgItem(w, MouseProfileName)); SetFocus(GetDlgItem(w, MouseProfileName));
} }
struct AddMouseEventView
{
HWND parent{};
SettingsView *settings{};
int signal{-1}, lastButton{-1};
DWORD lastPress{};
POINT lastPoint{};
bool rawRegistered{};
HHOOK mouseHook{};
int rawWheel{-1}, hookWheel{-1};
ULONGLONG rawWheelAt{}, hookWheelAt{};
};
constexpr UINT CapturedMouseWheel = WM_APP + 86;
constexpr UINT_PTR MouseSignalTimeout = 1;
HWND mouseEventCaptureWindow{};
bool inMouseEventCapture(HWND w, POINT point)
{
RECT area{};
return GetWindowRect(GetDlgItem(w, MouseEventCapture), &area) && PtInRect(&area, point);
}
LRESULT CALLBACK captureMouseWheelProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && mouseEventCaptureWindow && GetForegroundWindow() == mouseEventCaptureWindow)
{
const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp);
const int signal = mouseWheelEvent(static_cast<UINT>(wp), static_cast<SHORT>(HIWORD(mouse.mouseData)));
if (signal >= 0 && !(mouse.flags & LLMHF_INJECTED) && inMouseEventCapture(mouseEventCaptureWindow, mouse.pt))
PostMessageW(mouseEventCaptureWindow, CapturedMouseWheel, signal, 0);
}
return CallNextHookEx(nullptr, code, wp, lp);
}
void showCapturedMouseSignal(HWND w, AddMouseEventView &view, int signal)
{
view.signal = signal;
const auto &draft = view.settings->draft;
const bool duplicate = mouseEventEnabled(draft, signal);
EnableWindow(GetDlgItem(w, IDOK), !duplicate);
if (duplicate)
{
selectMouseEvent(view.parent, signal);
value(w, MouseEventStatus, L"Already in the list: " + mouseEventName(draft, signal));
value(w, IDCANCEL, L"Close");
}
else
{
value(w, MouseEventStatus, std::wstring(L"Detected: ") + MouseEvents[signal]);
if (value(w, MouseEventNameField).empty()) value(w, MouseEventNameField, MouseEvents[signal]);
value(w, IDCANCEL, L"Cancel");
}
}
void matchCapturedMouseWheel(HWND w, AddMouseEventView &view, ULONGLONG now)
{
if (view.rawWheel >= 0 && view.rawWheel == view.hookWheel &&
now - view.rawWheelAt <= 500 && now - view.hookWheelAt <= 500)
{
showCapturedMouseSignal(w, view, view.rawWheel);
view.rawWheel = view.hookWheel = -1;
KillTimer(w, MouseSignalTimeout);
}
}
void readNewMouseSignal(HWND w, AddMouseEventView &view, HRAWINPUT handle)
{
POINT point{};
if (!GetCursorPos(&point) || !inMouseEventCapture(w, point)) return;
RAWINPUT input{};
UINT size = sizeof(input);
const auto bytes = GetRawInputData(handle, RID_INPUT, &input, &size, sizeof(RAWINPUTHEADER));
if (bytes == UINT(-1) || bytes < offsetof(RAWINPUT, data) + sizeof(RAWMOUSE) ||
input.header.dwType != RIM_TYPEMOUSE || !input.header.hDevice)
return;
int signal = rawMouseSignal(input.data.mouse);
if (signal < 0) return;
if (signal >= BuiltInMouseEventCount)
{
view.signal = -1;
EnableWindow(GetDlgItem(w, IDOK), FALSE);
value(w, MouseEventStatus, view.mouseHook ? L"Checking whether Windows delivers this wheel signal…" :
L"Windows mouse hook is unavailable.");
view.rawWheel = signal;
view.rawWheelAt = GetTickCount64();
if (view.mouseHook) SetTimer(w, MouseSignalTimeout, 550, nullptr);
matchCapturedMouseWheel(w, view, view.rawWheelAt);
return;
}
view.rawWheel = view.hookWheel = -1;
KillTimer(w, MouseSignalTimeout);
const DWORD now = static_cast<DWORD>(GetMessageTime());
const bool second = signal < 3 && view.lastButton == signal &&
doubleMouseClick(view.lastPoint, point, now - view.lastPress);
view.lastButton = second ? -1 : signal;
view.lastPress = now;
view.lastPoint = point;
if (second) signal += 5;
showCapturedMouseSignal(w, view, signal);
}
LRESULT CALLBACK addMouseEventProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *view = reinterpret_cast<AddMouseEventView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!view) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == WM_INPUT)
readNewMouseSignal(w, *view, reinterpret_cast<HRAWINPUT>(lp));
else if (msg == CapturedMouseWheel)
{
view->hookWheel = static_cast<int>(wp);
view->hookWheelAt = GetTickCount64();
matchCapturedMouseWheel(w, *view, view->hookWheelAt);
return 0;
}
else if (msg == WM_TIMER && wp == MouseSignalTimeout)
{
KillTimer(w, MouseSignalTimeout);
view->rawWheel = view->hookWheel = -1;
value(w, MouseEventStatus, L"Raw Input saw this signal, but the Windows mouse hook did not.");
return 0;
}
else if (msg == WM_COMMAND)
{
if (LOWORD(wp) == IDOK)
{
const int signal = view->signal;
auto &settings = *view->settings;
if (signal < BuiltInMouseEventCount || signal >= static_cast<int>(std::size(MouseEvents)) ||
mouseEventEnabled(settings.draft, signal)) return 0;
auto candidate = settings.draft;
candidate.customMouseEvents.emplace(MouseEvents[signal], trimApplicationText(value(w, MouseEventNameField)));
validateSettings(candidate);
settings.draft.customMouseEvents = std::move(candidate.customMouseEvents);
fillMouseEvents(view->parent, settings, signal);
DestroyWindow(w);
return 0;
}
if (LOWORD(wp) == IDCANCEL) { DestroyWindow(w); return 0; }
}
else if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
else if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
else if (msg == WM_NCDESTROY)
{
const HWND parent = view->parent;
mouseEventCaptureWindow = nullptr;
if (view->mouseHook) UnhookWindowsHookEx(view->mouseHook);
if (view->rawRegistered)
{
if (IsWindow(parent) && view->settings->rawMouseRegistered) registerMouseTestInput(parent);
else registerMouseTestInput(nullptr);
}
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
delete view;
disposeFont(w);
if (IsWindow(parent))
{
EnableWindow(parent, TRUE);
SetForegroundWindow(parent);
SetFocus(GetDlgItem(parent, 733));
}
}
}
catch (const std::exception &e) { error(w, e); }
return DefWindowProcW(w, msg, wp, lp);
}
void showAddMouseEvent(HWND parent, SettingsView &settings)
{
if (!IsWindowEnabled(parent)) return;
HWND w = makeWindow(*settings.app, L"KSwitcher.AddMouseEvent", L"Add mouse event", addMouseEventProc, 500, 275);
auto *view = new AddMouseEventView{parent, &settings};
SetWindowLongPtrW(w, GWLP_HWNDPARENT, reinterpret_cast<LONG_PTR>(parent));
SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(view));
label(w, L"&Name", 18, 16, 464);
edit(w, MouseEventNameField, 18, 39, 464);
control(w, L"STATIC", L"Press a mouse button or move the wheel here",
WS_BORDER | SS_CENTER | SS_CENTERIMAGE, MouseEventCapture, 18, 78, 464, 92);
view->rawRegistered = registerMouseTestInput(w);
mouseEventCaptureWindow = w;
view->mouseHook = SetWindowsHookExW(WH_MOUSE_LL, captureMouseWheelProc, settings.app->instance, 0);
control(w, L"STATIC", view->rawRegistered ? L"Waiting for a supported mouse signal…" : L"Windows Raw Input is unavailable.",
0, MouseEventStatus, 18, 181, 464, 22);
label(w, L"Buttons that send no Windows mouse event cannot be added.", 18, 205, 464);
control(w, L"BUTTON", L"&Add", WS_TABSTOP | BS_DEFPUSHBUTTON, IDOK, 270, 239, 100, 28);
button(w, L"Cancel", IDCANCEL, 382, 239, 100);
EnableWindow(GetDlgItem(w, IDOK), FALSE);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetFocus(GetDlgItem(w, MouseEventNameField));
}
bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp) bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp)
{ {
const int id = LOWORD(wp); const int id = LOWORD(wp);
if (id == AddMouseEvent) { showAddMouseEvent(w, view); return true; }
if (id == AddMouseProfile) { showMouseProfileName(w, view, false); return true; } if (id == AddMouseProfile) { showMouseProfileName(w, view, false); return true; }
if (id == RenameMouseProfile || id == DeleteMouseProfile) if (id == RenameMouseProfile || id == DeleteMouseProfile)
{ {
@@ -788,7 +1010,8 @@ bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp)
return true; return true;
} }
if (id != EditMouseEvent && id != ResetMouseEvent) return false; if (id != EditMouseEvent && id != ResetMouseEvent) return false;
const int event = ListView_GetNextItem(GetDlgItem(w, 733), -1, LVNI_SELECTED); const HWND list = GetDlgItem(w, 733);
const int event = mouseEventAtRow(list, ListView_GetNextItem(list, -1, LVNI_SELECTED));
if (event < 0) return true; if (event < 0) return true;
if (id == EditMouseEvent) showMouseActionEditor(w, view, event); if (id == EditMouseEvent) showMouseActionEditor(w, view, event);
else if (auto profile = view.draft.mouseBindings.find(selectedMouseProfile(w, view)); else if (auto profile = view.draft.mouseBindings.find(selectedMouseProfile(w, view));
@@ -817,7 +1040,9 @@ void createMousePage(HWND w, SettingsView &view)
constexpr int profilesLeft = 18, actionsLeft = 369, tableWidth = 333; constexpr int profilesLeft = 18, actionsLeft = 369, tableWidth = 333;
constexpr int profilesRight = profilesLeft + tableWidth; constexpr int profilesRight = profilesLeft + tableWidth;
label(w, L"Profiles", 18, 64, 120); label(w, L"Profiles", 18, 64, 120);
label(w, L"Mouse events", actionsLeft, 64, tableWidth); label(w, L"Mouse events", actionsLeft, 64, tableWidth - 30);
control(w, L"BUTTON", L"Add mouse event", WS_TABSTOP | BS_OWNERDRAW, AddMouseEvent,
actionsLeft + tableWidth - 24, 61, 24, 24);
control(w, L"BUTTON", L"Delete profile", WS_TABSTOP | BS_OWNERDRAW, DeleteMouseProfile, profilesRight - 80, 61, 24, 24); control(w, L"BUTTON", L"Delete profile", WS_TABSTOP | BS_OWNERDRAW, DeleteMouseProfile, profilesRight - 80, 61, 24, 24);
control(w, L"BUTTON", L"Edit profile", WS_TABSTOP | BS_OWNERDRAW, RenameMouseProfile, profilesRight - 52, 61, 24, 24); control(w, L"BUTTON", L"Edit profile", WS_TABSTOP | BS_OWNERDRAW, RenameMouseProfile, profilesRight - 52, 61, 24, 24);
control(w, L"BUTTON", L"Add profile", WS_TABSTOP | BS_OWNERDRAW, AddMouseProfile, profilesRight - 24, 61, 24, 24); control(w, L"BUTTON", L"Add profile", WS_TABSTOP | BS_OWNERDRAW, AddMouseProfile, profilesRight - 24, 61, 24, 24);
@@ -827,6 +1052,7 @@ void createMousePage(HWND w, SettingsView &view)
addActionApplicationTooltip(tips, w, AddMouseProfile, L"Add profile"); addActionApplicationTooltip(tips, w, AddMouseProfile, L"Add profile");
addActionApplicationTooltip(tips, w, RenameMouseProfile, L"Edit profile"); addActionApplicationTooltip(tips, w, RenameMouseProfile, L"Edit profile");
addActionApplicationTooltip(tips, w, DeleteMouseProfile, L"Delete profile"); addActionApplicationTooltip(tips, w, DeleteMouseProfile, L"Delete profile");
addActionApplicationTooltip(tips, w, AddMouseEvent, L"Add mouse event");
constexpr int listTop = 90, listHeight = 224; constexpr int listTop = 90, listHeight = 224;
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES}; INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls); InitCommonControlsEx(&controls);
@@ -859,13 +1085,6 @@ void createMousePage(HWND w, SettingsView &view)
column.pszText = actionTitle; column.pszText = actionTitle;
column.cx = scale(w, tableWidth - 120); column.cx = scale(w, tableWidth - 120);
ListView_InsertColumn(list, 1, &column); ListView_InsertColumn(list, 1, &column);
for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event)
{
LVITEMW item{LVIF_TEXT};
item.iItem = event;
item.pszText = const_cast<wchar_t *>(MouseEvents[event]);
ListView_InsertItem(list, &item);
}
control(w, L"STATIC", MouseEventHint, 0, 740, actionsLeft, 324, tableWidth, 22); control(w, L"STATIC", MouseEventHint, 0, 740, actionsLeft, 324, tableWidth, 22);
control(w, L"BUTTON", L"Test mouse buttons here", WS_TABSTOP | BS_PUSHBUTTON, control(w, L"BUTTON", L"Test mouse buttons here", WS_TABSTOP | BS_PUSHBUTTON,
737, profilesLeft, listTop + listHeight + 10, tableWidth, 56); 737, profilesLeft, listTop + listHeight + 10, tableWidth, 56);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.64"; inline constexpr wchar_t AppVersion[] = L"0.6.65";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+31
View File
@@ -125,6 +125,27 @@ struct InputRegressionAccess {
return a.notifiedUpdate; return a.notifiedUpdate;
} }
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); } static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
static bool wheelQueuesAction(App &app)
{
MSLLHOOKSTRUCT mouse{};
if (!GetCursorPos(&mouse.pt) || !WindowFromPoint(mouse.pt)) return false;
auto previous = app.settings;
app.settings.customMouseEvents[L"Wheel down"] = L"Wheel test";
app.settings.mouseBindings[GlobalMouseProfile][L"Wheel down"] = {MouseActionType::Mute};
mouse.mouseData = MAKEWPARAM(0, static_cast<WORD>(-30));
const auto wheel = [&] { return App::mouseProc(HC_ACTION, WM_MOUSEWHEEL, reinterpret_cast<LPARAM>(&mouse)); };
mouse.flags = LLMHF_INJECTED;
wheel();
const bool ignoredInjection = app.mouseActions.empty();
mouse.flags = 0;
const bool partial = wheel() == 1 && app.mouseActions.empty();
mouse.mouseData = MAKEWPARAM(0, static_cast<WORD>(-90));
const bool queued = wheel() == 1 && app.mouseActions.size() == 1 &&
app.mouseActions.front().action.type == MouseActionType::Mute;
app.mouseActions.clear();
app.settings = std::move(previous);
return ignoredInjection && partial && queued;
}
static bool volumeHoldStopsOnRelease(App &app) static bool volumeHoldStopsOnRelease(App &app)
{ {
constexpr int button = 3; constexpr int button = 3;
@@ -203,6 +224,8 @@ int main(int argc, char **argv) {
require(app.window!=nullptr,"message-only test window"); require(app.window!=nullptr,"message-only test window");
require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app), require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app),
"volume hold repeats after its delay and stops on release without an extra step"); "volume hold repeats after its delay and stops on release without an extra step");
require(ks::InputRegressionAccess::wheelQueuesAction(app),
"a registered physical wheel signal queues one action per detent");
require(ks::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) && require(ks::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) &&
ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) && ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) &&
!ks::isMouseVolumeStep(ks::MouseActionType::Mute), !ks::isMouseVolumeStep(ks::MouseActionType::Mute),
@@ -828,6 +851,14 @@ int main(int argc, char **argv) {
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)), "button detection is not a settings edit"); require(!IsWindowEnabled(GetDlgItem(settings, IDOK)), "button detection is not a settings edit");
require(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2, require(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2,
"mouse events have eight rows and two columns"); "mouse events have eight rows and two columns");
SendMessageW(settings, WM_COMMAND, 762, 0);
const auto addEvent = FindWindowW(L"KSwitcher.AddMouseEvent", nullptr);
require(addEvent && GetDlgItem(addEvent, 764) && GetDlgItem(addEvent, 763) &&
!IsWindowEnabled(GetDlgItem(addEvent, IDOK)), "Add mouse event waits for a physical signal");
SendMessageW(addEvent, WM_COMMAND, IDOK, 0);
require(IsWindow(addEvent) && ListView_GetItemCount(events) == 8,
"the event editor cannot save an uncaptured signal");
SendMessageW(addEvent, WM_COMMAND, IDCANCEL, 0);
NMITEMACTIVATE click{}; NMITEMACTIVATE click{};
click.hdr = {events, 733, NM_DBLCLK}; click.hdr = {events, 733, NM_DBLCLK};
click.iItem = 7; click.iItem = 7;
+36 -3
View File
@@ -27,9 +27,9 @@ int main(){try{
if (merged.mouseBindings.at(L"chrome").size() != 2) if (merged.mouseBindings.at(L"chrome").size() != 2)
throw std::runtime_error("independent mouse bindings lost"); throw std::runtime_error("independent mouse bindings lost");
auto shared = sharedSyncSettings(merged); auto shared = sharedSyncSettings(merged);
if (shared != sharedSyncSettings(base) || if (shared == sharedSyncSettings(base) ||
restoreSyncSettings(shared, merged, {}, false).mouseBindings != merged.mouseBindings) restoreSyncSettings(shared, Settings{}, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must stay local during sync"); throw std::runtime_error("mouse profiles must restore from a synchronized backup");
auto withButtons = merged; auto withButtons = merged;
draft = current = withButtons; draft = current = withButtons;
draft.mouseBindings.at(L"chrome").erase(L"XButton1"); draft.mouseBindings.at(L"chrome").erase(L"XButton1");
@@ -100,5 +100,38 @@ int main(){try{
try { validateSettings(profiles); } try { validateSettings(profiles); }
catch (const std::runtime_error &e) { rejected = std::string(e.what()).starts_with("Mouse action conflict:"); } catch (const std::runtime_error &e) { rejected = std::string(e.what()).starts_with("Mouse action conflict:"); }
if (!rejected) throw std::runtime_error("overlapping active profiles must be rejected"); if (!rejected) throw std::runtime_error("overlapping active profiles must be rejected");
Settings wheel;
const int down = mouseWheelEvent(WM_MOUSEWHEEL, -WHEEL_DELTA);
RAWMOUSE raw{};
raw.usButtonFlags = RI_MOUSE_WHEEL;
raw.usButtonData = static_cast<USHORT>(-WHEEL_DELTA);
if (rawMouseSignal(raw) != down || mouseEventEnabled(wheel, down))
throw std::runtime_error("wheel capture must match the runtime signal and be opt-in");
raw.usButtonFlags = RI_MOUSE_HWHEEL;
if (rawMouseSignal(raw) != mouseWheelEvent(WM_MOUSEHWHEEL, -WHEEL_DELTA))
throw std::runtime_error("horizontal wheel capture must match the runtime signal");
int remainder{};
if (mouseWheelSteps(remainder, -30) != 0 || mouseWheelSteps(remainder, -90) != 1 ||
mouseWheelSteps(remainder, 240) != 2 || remainder != 0)
throw std::runtime_error("high-resolution wheel deltas must accumulate by detent");
wheel.customMouseEvents.emplace(MouseEvents[down], L"Lower volume");
wheel.mouseBindings[GlobalMouseProfile][MouseEvents[down]] = {MouseActionType::VolumeDown};
wheel.mouseBindings[L"name:Browser"][L"XButton1"] = {MouseActionType::Back};
wheel.mouseProfileApplications[L"name:Browser"] = {L"chrome"};
wheel.disabledMouseProfiles.insert(L"name:Browser");
wheel.appLanguageSets[L"Browser"] = {{L"win:00000409"}, {L"chrome"}};
const auto restoredWheel = decodeSettings(encodeSettings(wheel), {});
const auto syncedWheel = restoreSyncSettings(sharedSyncSettings(wheel), Settings{}, {});
if (mouseEventName(restoredWheel, down) != L"Lower volume" ||
!effectiveMouseAction(restoredWheel, L"explorer", down) ||
sharedSyncSettings(syncedWheel) != sharedSyncSettings(wheel))
throw std::runtime_error("custom wheel event and action must restore from a synchronized backup");
if (!restoreSyncSettings(sharedSyncSettings(Settings{}), wheel, {}).customMouseEvents.empty())
throw std::runtime_error("an explicit empty backup must clear custom mouse events");
wheel.customMouseEvents.clear();
rejected = false;
try { validateSettings(wheel); }
catch (const std::runtime_error &) { rejected = true; }
if (!rejected) throw std::runtime_error("unregistered mouse signals cannot have bindings");
std::cout<<"Settings-save merge checks passed\n"; std::cout<<"Settings-save merge checks passed\n";
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}} }catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
+19 -5
View File
@@ -155,11 +155,25 @@ int main()
rightScope["snippets"].back()["apps"]=Json::array({"NOTEPAD.exe"}); rightScope["snippets"].back()["apps"]=Json::array({"NOTEPAD.exe"});
rejects([&]{mergeSyncSettings(base,bytes(leftScope),bytes(rightScope));}, "sync detects overlapping application scopes"); rejects([&]{mergeSyncSettings(base,bytes(leftScope),bytes(rightScope));}, "sync detects overlapping application scopes");
} }
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded"); require(j.contains("startWithSystem") && j.contains("tesseractPath") && j.contains("historyEnabled"),
auto restored = restoreSyncSettings(base, local, {}); "server backup includes startup, OCR and history settings");
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings"); auto restored = restoreSyncSettings(base, Settings{}, {});
auto hostile = j; hostile["startWithSystem"] = false; require(sharedSyncSettings(restored) == base, "restore all settings onto a fresh installation");
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting"); auto legacySettings = j;
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents"})
legacySettings.erase(key);
FakeProvider legacyProvider;
legacyProvider.remote = {3, bytes(legacySettings)};
auto recovered = synchronize(legacyProvider, {}, base, SyncAction::Download);
require(sharedSyncSettings(restoreSyncSettings(recovered.settings, local, {})) == base &&
legacyProvider.writes == 0, "older server backup preserves fields it never stored");
auto upgraded = synchronize(legacyProvider, bytes(legacySettings), base, SyncAction::Automatic);
require(legacyProvider.writes == 1 && parse(upgraded.settings) == j,
"automatic sync upgrades an older server backup");
auto hostile = j; hostile["unknownSetting"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject unsupported settings");
hostile = j; hostile["schemaVersion"] = 2; hostile = j; hostile["schemaVersion"] = 2;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema"); rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
hostile = j; hostile["layouts"] = Json::array({"00000422"}); hostile = j; hostile["layouts"] = Json::array({"00000422"});