Compare commits

...
Author SHA1 Message Date
agrynco 8a65e7f7fb Release KSwitcher v0.6.65 2026-11-12 01:18:21 +02:00
21 changed files with 570 additions and 111 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
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)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
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
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
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
@@ -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.
Deleted server versions are not silently recreated.
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
history, screenshots and dictionary files are never uploaded. Full synchronization identifies
The server profile includes all settings from KSwitcher Settings, including mouse events and
bindings, application language profiles, startup, keep-awake, logging, history limits and OCR.
Update the clients that use the same server profile before syncing the expanded format.
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.
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
@@ -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.
Languages remain installed and visible in Windows' language picker. Unavailable
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
settings export/import.
used. Application language profiles are included in server synchronization and
manual settings export/import.
**Українською.** Відкрий **Settings → Languages → Languages for applications…**.
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
@@ -1012,5 +1020,5 @@ settings export/import.
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації
загальний цикл. Профілі входять до серверної синхронізації
та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+24
View File
@@ -1,5 +1,29 @@
# 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
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": [
{
"path": "KSwitcher.exe",
"size": 2185728,
"sha256": "81ccb50fd3ac8a4768f0be9a4b70ef21c2a1f11e73b57871cb5123f29bfbd503"
"size": 2190336,
"sha256": "9d268cbb00fb9db3e52bc68994cefb50cee707f9dc1b940d876314ba1b6a06b3",
"path": "KSwitcher.exe"
},
{
"path": "KSwitcher.Config.exe",
"size": 2120192,
"sha256": "83d76af6ff44f56532d2537e57dfb7e3859d72f34180d3c7c02497e71763378e"
"size": 2129408,
"sha256": "d83fad30539e7b974bb8012859f3fee8143c5e4cc25684643b16e2fc34b97a5e",
"path": "KSwitcher.Config.exe"
},
{
"path": "KSwitcher.Screenshot.exe",
"size": 3207168,
"sha256": "6e93963d4252a87bd6ea9d53ccd2e602c9c2709ca3fe835dc90a633b77b00193"
"size": 3210240,
"sha256": "29f7846ee4d3c469779b66acf99c90a4173e1e1577020398d6e1dc4eae492e06",
"path": "KSwitcher.Screenshot.exe"
},
{
"path": "LICENSE",
"size": 4401,
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"path": "LICENSE"
},
{
"path": "json-LICENSE.txt",
"size": 1075,
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
"path": "json-LICENSE.txt"
},
{
"path": "README.md",
"size": 89826,
"sha256": "9148049aa80595e0de30ecc9af1478572f85a8076da981ea80dafd5bb9c4815b"
"size": 90358,
"sha256": "435daae289ca64dd9130ed772125de3f3303f1dbb80d1cbf7a9913058ded47ec",
"path": "README.md"
},
{
"path": "LAN-PREVIEW.md",
"size": 9900,
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e"
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e",
"path": "LAN-PREVIEW.md"
},
{
"path": "dictionaries/en.txt",
"size": 682867,
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
"path": "dictionaries/en.txt"
},
{
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20138,
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
},
{
"path": "dictionaries/README.md",
"size": 2230,
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec",
"path": "dictionaries/README.md"
},
{
"path": "dictionaries/ru.txt",
"size": 1060364,
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
"path": "dictionaries/ru.txt"
},
{
"path": "dictionaries/sources.json",
"size": 1279,
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86",
"path": "dictionaries/sources.json"
},
{
"path": "dictionaries/uk.txt",
"size": 898248,
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
"path": "dictionaries/uk.txt"
},
{
"path": "tools/README.txt",
"size": 993,
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"path": "tools/README.txt"
},
{
"path": "tools/StayHere.exe",
"size": 134656,
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
"path": "tools/StayHere.exe"
},
{
"path": "tools/StayHere-README.md",
"size": 2796,
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md"
}
],
"version": "0.6.65",
"schema": 1
}
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,64,0
PRODUCTVERSION 0,6,64,0
FILEVERSION 0,6,65,0
PRODUCTVERSION 0,6,65,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.64"
VALUE "FileVersion", "0.6.65"
END
END
BLOCK "VarFileInfo"
+56 -23
View File
@@ -733,6 +733,10 @@ Bytes App::lanCommand(const Bytes &command)
return reply;
}
void App::applySettings(const Settings &requested)
{
applySettings(requested, true);
}
void App::applySettings(const Settings &requested, bool validateOcrInstallation)
{
Settings s = requested;
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;
AutoCorrector predictor;
validateSettings(s);
validateTesseract(s);
if (validateOcrInstallation && ocrProfile(s) != ocrProfile(settings)) validateTesseract(s);
if (s.autoCorrect)
{
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
@@ -849,6 +853,8 @@ void App::resetLanInput()
chatSwitchHook = {};
pendingMouseClicks = {};
mouseActions.clear();
mouseWheelTarget = nullptr;
mouseWheelRemainder = 0;
resetInput();
}
void App::resetInput()
@@ -1399,6 +1405,8 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
auto &app = *current;
const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp);
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));
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 (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())
{
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 ||
wcscmp(windowClass, L"WorkerW") == 0 || wcscmp(windowClass, L"Shell_TrayWnd") == 0 ||
wcscmp(windowClass, L"Shell_SecondaryTrayWnd") == 0;
const auto *global = mouseBinding(app.settings, GlobalMouseProfile, button);
const bool globalLaunch = button >= 2 && global && global->type == MouseActionType::Launch &&
const int event = wheel >= 0 ? wheel : button;
const auto *global = mouseBinding(app.settings, GlobalMouseProfile, event);
const bool globalLaunch = event >= 2 && global && global->type == MouseActionType::Launch &&
mouseActionApplies(*global, name);
const bool restricted = ownWindow || shellSurface || name.empty();
const bool testButton = name == L"kswitcher.config" && hit == GetDlgItem(target, 737);
const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", button, false) : nullptr;
wchar_t targetClass[64]{};
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 bool eligible = target && !testButton && (!restricted || (button >= 2 && restrictedAction)) &&
(button >= 2 || target == GetForegroundWindow());
const bool eligible = target && !testButton && (!restricted || (event >= 2 && restrictedAction)) &&
(event >= 2 || target == GetForegroundWindow());
if (eligible)
{
const bool foreground = target == GetForegroundWindow();
bool singleScoped{}, doubleScoped{};
auto *single = restricted ? restrictedAction :
effectiveMouseAction(app.settings, name, button, foreground, &singleScoped);
auto *doubleClick = !restricted && button < 3 ?
effectiveMouseAction(app.settings, name, event, foreground, &singleScoped);
auto *doubleClick = wheel < 0 && !restricted && button < 3 ?
effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr;
if (!foreground)
{
@@ -1465,25 +1478,45 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr;
}
const auto now = GetTickCount64();
auto &pending = app.pendingMouseClicks[button];
const bool second = doubleClick && pending.target == target &&
doubleMouseClick(pending.point, mouse.pt, now - pending.started);
if (second) pending = {};
else if (pending.target) app.queueMouseAction(std::exchange(pending, {}));
const auto *action = second ? doubleClick : single;
if (action || doubleClick)
app.mousePresses[button] = {target, mouse.pt, now, action ? *action : MouseAction{},
true, doubleClick && !second};
if (action)
if (wheel >= 0 && single)
{
if (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name);
if (isMouseVolumeStep(action->type))
app.beginMouseVolume(button);
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];
const bool second = doubleClick && pending.target == target &&
doubleMouseClick(pending.point, mouse.pt, now - pending.started);
if (second) pending = {};
else if (pending.target) app.queueMouseAction(std::exchange(pending, {}));
const auto *action = second ? doubleClick : single;
if (action || doubleClick)
app.mousePresses[button] = {target, mouse.pt, now, action ? *action : MouseAction{},
true, doubleClick && !second};
if (action)
{
if (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name);
if (isMouseVolumeStep(action->type))
app.beginMouseVolume(button);
app.resetInput();
return 1;
}
}
}
}
if (wheel >= 0)
{
app.mouseWheelTarget = nullptr;
app.mouseWheelRemainder = 0;
}
// Remote clicks invalidate a typing range just like local clicks.
if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL)
{
+3
View File
@@ -106,6 +106,7 @@ class App
private:
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
void applySettings(const Settings &, bool validateOcrInstallation);
HHOOK keyboardHook{}, mouseHook{};
struct MousePress
{
@@ -118,6 +119,8 @@ class App
std::array<MousePress, std::size(MouseButtonNames)> mousePresses{};
std::array<MousePress, std::size(MouseButtonNames)> pendingMouseClicks{};
std::deque<MousePress> mouseActions;
HWND mouseWheelTarget{};
int mouseWheelSignal{-1}, mouseWheelRemainder{};
void queueMouseAction(MousePress);
void flushMouseClicks();
void beginMouseVolume(int button);
+2 -1
View File
@@ -79,7 +79,8 @@ void App::pollSync()
if (encodeSettings(next) != 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.";
}
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, AppLanguageSet> appLanguageSets;
std::map<std::wstring, std::map<std::wstring, MouseAction>> mouseBindings;
std::map<std::wstring, std::wstring> customMouseEvents;
std::set<std::wstring> disabledMouseProfiles;
std::map<std::wstring, std::set<std::wstring>> mouseProfileApplications;
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 *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",
L"Volume up", L"Volume down", L"Mute", L"Launch application"};
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)
{
if (profileScoped) *profileScoped = false;
if (!mouseEventEnabled(settings, event)) return nullptr;
auto *action = mouseBinding(settings, app, event);
if (!action)
{
+20 -1
View File
@@ -108,6 +108,15 @@ static void validateMouseProfileConflicts(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.");
for (const auto &[app, bindings] : s.mouseBindings)
{
@@ -124,7 +133,8 @@ void validateSettings(const Settings &s)
if (action.applications.size() > 1000 ||
!std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication))
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))
throw std::runtime_error("Invalid mouse event or action.");
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);
}
}
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"))
{
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 &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();
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));
}
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)
{
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) {
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));
}
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
{
auto j = parse(shared), original = parse(encodeSettings(local));
preserveMissingSettings(j, original);
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = 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)
throw std::runtime_error("Unsupported synchronized settings format.");
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.");
std::set<std::string> installed;
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
{
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));
merged = {selected, selected, selected, {}};
}
+12
View File
@@ -72,6 +72,14 @@ Json flatProfile(Json j)
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();
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.
if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object());
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);
// With no common history, union independent entries; differing scalar values
// 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)
{
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));
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)
{
const auto &click = *reinterpret_cast<const NMITEMACTIVATE *>(lp);
showMouseActionEditor(w, *v, click.iItem);
showMouseActionEditor(w, *v, mouseEventAtRow(click.hdr.hwndFrom, click.iItem));
return 0;
}
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 AddMouseEvent = 762, MouseEventCapture = 763, MouseEventNameField = 764, MouseEventStatus = 765;
std::wstring mouseActionLabel(const MouseAction &action)
{
@@ -14,11 +15,23 @@ std::wstring mouseActionLabel(const MouseAction &action)
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)
{
HWND list = GetDlgItem(w, 733);
ListView_SetItemState(list, event, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
ListView_EnsureVisible(list, event, FALSE);
LVFINDINFOW search{LVFI_PARAM};
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)
@@ -27,8 +40,17 @@ void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
EnableWindow(GetDlgItem(w, RenameMouseProfile), isNamedMouseProfile(profile));
EnableWindow(GetDlgItem(w, DeleteMouseProfile), isNamedMouseProfile(profile));
HWND list = GetDlgItem(w, 733);
ListView_DeleteAllItems(list);
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);
std::wstring text;
if (profile != GlobalMouseProfile && !action)
@@ -37,7 +59,7 @@ void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
action = mouseBinding(view.draft, GlobalMouseProfile, event);
}
text += action ? mouseActionLabel(*action) : MouseActions[0];
ListView_SetItemText(list, event, 1, text.data());
ListView_SetItemText(list, row, 1, text.data());
}
selectMouseEvent(w, selection);
}
@@ -97,6 +119,7 @@ bool blockMouseProfileStateChange(HWND w, SettingsView &view, const NMLISTVIEW &
{
Settings candidate;
candidate.mouseBindings = view.draft.mouseBindings;
candidate.customMouseEvents = view.draft.customMouseEvents;
candidate.disabledMouseProfiles = view.draft.disabledMouseProfiles;
candidate.mouseProfileApplications = view.draft.mouseProfileApplications;
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};
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};
FillRect(item.hDC, &vertical, ink);
@@ -391,6 +414,7 @@ void saveMouseAction(HWND w, MouseActionEditor &editor)
const auto *event = MouseEvents[editor.event];
Settings check;
check.mouseBindings = editor.view->draft.mouseBindings;
check.customMouseEvents = editor.view->draft.customMouseEvents;
check.disabledMouseProfiles = editor.view->draft.disabledMouseProfiles;
check.mouseProfileApplications = editor.view->draft.mouseProfileApplications;
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)
{
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 bool application = profile != GlobalMouseProfile;
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);
auto *editor = new MouseActionEditor{parent, &view, profile, event};
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);
label(w, L"&Action", 18, 43, 330);
windowLayoutState(w)->fixedClient = true;
@@ -578,7 +602,7 @@ void showMouseListMenu(HWND list, POINT point, bool profiles)
hit.pt = point;
ScreenToClient(list, &hit.pt);
event = ListView_SubItemHitTest(list, &hit);
if (event < 0) return;
if (event < 0 && profiles) return;
}
auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
HMENU menu = CreatePopupMenu();
@@ -592,9 +616,11 @@ void showMouseListMenu(HWND list, POINT point, bool profiles)
}
else
{
selectMouseEvent(w, event);
const bool canReset = mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr;
AppendMenuW(menu, MF_STRING, EditMouseEvent, L"&Edit…");
event = mouseEventAtRow(list, event);
if (event >= 0) selectMouseEvent(w, event);
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");
}
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;
Settings check;
check.mouseBindings = view.draft.mouseBindings;
check.customMouseEvents = view.draft.customMouseEvents;
check.disabledMouseProfiles = view.draft.disabledMouseProfiles;
check.mouseProfileApplications = view.draft.mouseProfileApplications;
if (!previous.empty() && key != previous)
@@ -768,9 +795,204 @@ void showMouseProfileName(HWND owner, SettingsView &view, bool renaming)
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)
{
const int id = LOWORD(wp);
if (id == AddMouseEvent) { showAddMouseEvent(w, view); return true; }
if (id == AddMouseProfile) { showMouseProfileName(w, view, false); return true; }
if (id == RenameMouseProfile || id == DeleteMouseProfile)
{
@@ -788,7 +1010,8 @@ bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp)
return true;
}
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 (id == EditMouseEvent) showMouseActionEditor(w, view, event);
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 profilesRight = profilesLeft + tableWidth;
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"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);
@@ -827,6 +1052,7 @@ void createMousePage(HWND w, SettingsView &view)
addActionApplicationTooltip(tips, w, AddMouseProfile, L"Add profile");
addActionApplicationTooltip(tips, w, RenameMouseProfile, L"Edit profile");
addActionApplicationTooltip(tips, w, DeleteMouseProfile, L"Delete profile");
addActionApplicationTooltip(tips, w, AddMouseEvent, L"Add mouse event");
constexpr int listTop = 90, listHeight = 224;
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
@@ -859,13 +1085,6 @@ void createMousePage(HWND w, SettingsView &view)
column.pszText = actionTitle;
column.cx = scale(w, tableWidth - 120);
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"BUTTON", L"Test mouse buttons here", WS_TABSTOP | BS_PUSHBUTTON,
737, profilesLeft, listTop + listHeight + 10, tableWidth, 56);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
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 UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+31
View File
@@ -125,6 +125,27 @@ struct InputRegressionAccess {
return a.notifiedUpdate;
}
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)
{
constexpr int button = 3;
@@ -203,6 +224,8 @@ int main(int argc, char **argv) {
require(app.window!=nullptr,"message-only test window");
require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app),
"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) &&
ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) &&
!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(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2,
"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{};
click.hdr = {events, 733, NM_DBLCLK};
click.iItem = 7;
+36 -3
View File
@@ -27,9 +27,9 @@ int main(){try{
if (merged.mouseBindings.at(L"chrome").size() != 2)
throw std::runtime_error("independent mouse bindings lost");
auto shared = sharedSyncSettings(merged);
if (shared != sharedSyncSettings(base) ||
restoreSyncSettings(shared, merged, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must stay local during sync");
if (shared == sharedSyncSettings(base) ||
restoreSyncSettings(shared, Settings{}, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must restore from a synchronized backup");
auto withButtons = merged;
draft = current = withButtons;
draft.mouseBindings.at(L"chrome").erase(L"XButton1");
@@ -100,5 +100,38 @@ int main(){try{
try { validateSettings(profiles); }
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");
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";
}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"});
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");
auto restored = restoreSyncSettings(base, local, {});
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
auto hostile = j; hostile["startWithSystem"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
require(j.contains("startWithSystem") && j.contains("tesseractPath") && j.contains("historyEnabled"),
"server backup includes startup, OCR and history settings");
auto restored = restoreSyncSettings(base, Settings{}, {});
require(sharedSyncSettings(restored) == base, "restore all settings onto a fresh installation");
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;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
hostile = j; hostile["layouts"] = Json::array({"00000422"});