Compare commits

..
Author SHA1 Message Date
agrynco 8a65e7f7fb Release KSwitcher v0.6.65 2026-11-12 01:18:21 +02:00
agrynco 14f33012a3 feat: release kswitcher 0.6.64
Add mouse button actions and application profiles, with volume controls and per-monitor settings layout fixes.
2026-11-12 00:39:12 +02:00
25 changed files with 2720 additions and 135 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.63 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.65 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+68 -7
View File
@@ -37,6 +37,64 @@ Saving Settings now merges independent resident/editor changes. If the same fiel
changed on both sides, choose whether to retain your draft without closing the
window. Declining preserves all draft edits. No export/import workaround is needed.
## Mouse buttons for applications
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
**Applications** column shows the scope of each profile.
Use the checkbox in each profile row to enable or disable its actions.
Double-click a mouse event, or right-click it and choose
**Edit…**, then pick its action and click **Apply** and **Save**. **Reset to default**
restores the global action for an application or standard behavior for Global.
Drag the crosshair in the profile editor onto an open window to add its
application. The folder icon accepts an executable or a Windows shortcut
to one. You can also type an executable name and click **+**.
An empty application list makes the profile's actions fallbacks. A scoped
profile's actions take precedence when one of its applications is active.
For example, set the side buttons to volume control in **Global** and to browser
navigation in a named profile with Chrome in its application list.
Volume actions show the current system level at the lower right of the
monitor under the pointer. The indicator stays visible during a hold and hides
one second after release.
For **Volume up** and **Volume down**, set **Step (%)** next to **Action**.
New values range from 1 to 50 percentage points.
The default is four percentage points. Hold an assigned button to repeat the action
at increasing speed. If the button also has a double-click action, a single
click waits for the double-click interval before adjusting the volume.
Enabled profiles cannot assign the same mouse event to the same application,
or both assign an unrestricted fallback to the same event.
An application can inherit a global action,
override it, or use standard mouse behavior. Available actions include
browser navigation, system volume, launching an executable, and sending a
key combination. For **Launch application**, enter a name Windows can find
(such as `calc.exe`) or select an executable with **Browse**.
The pointer selects the application profile and the target for browser
navigation. **Send key combination** works when the window under the pointer
is active. To record a combination, click the field and press the keys.
For example, press `Ctrl+W` to bind closing the active browser tab or
`Ctrl+V` to bind pasting in File Explorer. An inactive window receives
the standard mouse action.
Left and right
bindings apply only over the active window, so you can click another window
to switch applications. When both clicks have custom actions, a single click
waits for the Windows double-click interval. If only the double-click has an
action, the first click keeps its standard behavior.
Bindings apply to all mice on this PC. Windows exposes five
standard buttons. Hardware controls such as a wheel-mode or device switch
require a driver or device integration if they do not emit a standard click.
## Run
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
@@ -627,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
@@ -935,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…**.
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
@@ -959,5 +1020,5 @@ settings export/import.
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації
загальний цикл. Профілі входять до серверної синхронізації
та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+26 -6
View File
@@ -1,16 +1,36 @@
# Latest Windows release
## v0.6.63 — 2026-11-10
## v0.6.65 — 2026-11-12
LAN input now automatically tries direct TCP, reverse TCP after 1.5 seconds, and encrypted UDP over the discovery socket after 5 seconds without an authenticated TCP connection. A working UDP session remains on UDP until disconnected. Update KSwitcher on both PCs through the tray **Update** command to enable all fallback methods; keep each PC's own data directory.
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.
UDP input uses fresh authenticated sessions, ordered commands, acknowledgements and bounded retries. Lost, reordered or duplicated packets do not duplicate key presses. Loss of authenticated traffic or acknowledgement progress closes the session and restores local control.
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.
After 15 seconds without a connection, **Connection help** explains what to check and provides copyable administrator PowerShell commands for the actual running KSwitcher executable on that PC. Rules allow inbound LAN traffic only from the local subnet; commands are never executed automatically. A timeout alone is not presented as proof of a firewall block.
**Українською.** У **Settings → Mouse** натисни **+** біля **Mouse events**,
щоб додати напрямок прокручування. Відомий сигнал виділить наявний рядок.
OpenBao тепер зберігає всі налаштування KSwitcher, зокрема профілі миші,
OCR й автозапуск. Перед синхронізацією онови всі клієнти спільного профілю.
Після перевстановлення Windows підключись до OpenBao й натисни
**Download remote**.
Pairing now shows the target and remaining wait time, retains the code until pairing succeeds, and explicitly explains automatic saving independently of the main Settings **Save** button. Connection failures remain visible during retries.
## v0.6.64 — 2026-11-12
Validated with 227 LAN input checks, 43 discovery/pairing checks and 863 UI/settings checks. Fallback tests cover unavailable TCP directions, encrypted UDP, loss, reordering, duplicate/replay protection, tampering and reconnects. The new fallback implementation has not yet been tested between two physical PCs.
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.
Actions include keyboard combinations, browser navigation, launching applications, and system volume. Volume actions have a configurable step and an on-screen indicator. When Windows reports separate press and release events, holding the button repeats the volume change at increasing speed.
Use **Test mouse buttons here** to check which events Windows delivers. Mouse profiles are stored on the current PC. The configurator also keeps fixed-size settings dialogs fitted when moving them between displays with different scaling.
## Upgrade
+1 -1
View File
@@ -1 +1 @@
194b7c071a4a97d60e055342d49e046ad874c0636be5b1f620a0b77a34968100 KSwitcher-win-x64.zip
e60ca342737155dce14a2bb1dd43ec1cef890cf7d9e5efb2e15a5b9629eaf1af KSwitcher-win-x64.zip
+46 -46
View File
@@ -1,86 +1,86 @@
{
"files": [
{
"sha256": "a7be2f749082bb6417b4cf7e8c8ea221ef4f7ba63dabc3cbbe8694ab1a3d942c",
"path": "KSwitcher.exe",
"size": 2108416
"size": 2190336,
"sha256": "9d268cbb00fb9db3e52bc68994cefb50cee707f9dc1b940d876314ba1b6a06b3",
"path": "KSwitcher.exe"
},
{
"sha256": "c8a88364c9967ce5fd150519d632b14460e225a0cfae51a8de50f39782d8010c",
"path": "KSwitcher.Config.exe",
"size": 2005504
"size": 2129408,
"sha256": "d83fad30539e7b974bb8012859f3fee8143c5e4cc25684643b16e2fc34b97a5e",
"path": "KSwitcher.Config.exe"
},
{
"sha256": "68eed5abefed0208ef7bdd431094c7357c53bd3ca03dfd91aaeb7dbb4f291e06",
"path": "KSwitcher.Screenshot.exe",
"size": 3153408
"size": 3210240,
"sha256": "29f7846ee4d3c469779b66acf99c90a4173e1e1577020398d6e1dc4eae492e06",
"path": "KSwitcher.Screenshot.exe"
},
{
"size": 4401,
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"path": "LICENSE",
"size": 4401
"path": "LICENSE"
},
{
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
"path": "json-LICENSE.txt",
"size": 1096
"size": 1075,
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
"path": "json-LICENSE.txt"
},
{
"sha256": "87f396a6bf988725b14bf5b6eb7c2b60ce33ff51828290ce40e136c66942cc09",
"path": "README.md",
"size": 80168
"size": 90358,
"sha256": "435daae289ca64dd9130ed772125de3f3303f1dbb80d1cbf7a9913058ded47ec",
"path": "README.md"
},
{
"sha256": "72e131bdf46d32a8a17fb352292bc8983c0e5a7ed84812218b11cf6b69c9afc4",
"path": "LAN-PREVIEW.md",
"size": 8376
"size": 9900,
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e",
"path": "LAN-PREVIEW.md"
},
{
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
"path": "dictionaries/en.txt",
"size": 732485
"size": 682867,
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
"path": "dictionaries/en.txt"
},
{
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20566
"size": 20138,
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
},
{
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
"path": "dictionaries/README.md",
"size": 2265
"size": 2230,
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec",
"path": "dictionaries/README.md"
},
{
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
"path": "dictionaries/ru.txt",
"size": 1109134
"size": 1060364,
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
"path": "dictionaries/ru.txt"
},
{
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
"path": "dictionaries/sources.json",
"size": 1280
"size": 1279,
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86",
"path": "dictionaries/sources.json"
},
{
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
"path": "dictionaries/uk.txt",
"size": 940799
"size": 898248,
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
"path": "dictionaries/uk.txt"
},
{
"size": 993,
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"path": "tools/README.txt",
"size": 993
"path": "tools/README.txt"
},
{
"size": 134656,
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
"path": "tools/StayHere.exe",
"size": 134656
"path": "tools/StayHere.exe"
},
{
"size": 2796,
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md",
"size": 2796
"path": "tools/StayHere-README.md"
}
],
"schema": 1,
"version": "0.6.58"
"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,63,0
PRODUCTVERSION 0,6,63,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.63"
VALUE "FileVersion", "0.6.65"
END
END
BLOCK "VarFileInfo"
+359 -10
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);
@@ -847,6 +851,10 @@ void App::resetLanInput()
// shortcut/chat latches across an ownership handoff.
hotkeyHook = {};
chatSwitchHook = {};
pendingMouseClicks = {};
mouseActions.clear();
mouseWheelTarget = nullptr;
mouseWheelRemainder = 0;
resetInput();
}
void App::resetInput()
@@ -1169,23 +1177,351 @@ LRESULT App::keyboardAfterHooks(int code, WPARAM wp, LPARAM lp, LRESULT routed)
}
return routed;
}
constexpr UINT MouseActionMessage = WM_APP + 79;
constexpr UINT MouseVolumeReleasedMessage = WM_APP + 80;
constexpr UINT_PTR MouseClickTimer = 9;
constexpr UINT_PTR FirstMouseVolumeTimer = 10;
namespace
{
constexpr wchar_t VolumeIndicatorClass[] = L"KSwitcher.VolumeIndicator";
constexpr UINT_PTR VolumeIndicatorTimer = 1;
constexpr WORD VolumeIndicatorMuted = 1, VolumeIndicatorHeld = 2;
void paintVolumeIndicator(HWND w)
{
PAINTSTRUCT paint{};
const auto dc = BeginPaint(w, &paint);
RECT bounds{};
GetClientRect(w, &bounds);
FillRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOW));
FrameRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOWFRAME));
const int dpi = static_cast<int>(GetDpiForWindow(w));
const int padding = MulDiv(8, dpi, 96);
const int captionHeight = MulDiv(26, dpi, 96);
const auto state = GetWindowLongPtrW(w, GWLP_USERDATA);
const int percent = LOWORD(state);
const bool muted = (HIWORD(state) & VolumeIndicatorMuted) != 0;
RECT track{padding, padding, bounds.right - padding, bounds.bottom - captionHeight};
FillRect(dc, &track, GetSysColorBrush(COLOR_3DFACE));
RECT fill = track;
fill.top = fill.bottom - MulDiv(fill.bottom - fill.top, percent, 100);
FillRect(dc, &fill, GetSysColorBrush(muted ? COLOR_GRAYTEXT : COLOR_HIGHLIGHT));
RECT caption{0, track.bottom, bounds.right, bounds.bottom};
const auto text = muted ? std::wstring(L"Mute") : std::to_wstring(percent) + L"%";
const auto font = CreateFontW(-MulDiv(12, dpi, 96), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, 0, 0, 0, 0, L"Segoe UI");
const auto previous = font ? SelectObject(dc, font) : nullptr;
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, GetSysColor(COLOR_WINDOWTEXT));
DrawTextW(dc, text.c_str(), -1, &caption, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
if (previous) SelectObject(dc, previous);
if (font) DeleteObject(font);
EndPaint(w, &paint);
}
LRESULT CALLBACK volumeIndicatorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_PAINT: paintVolumeIndicator(w); return 0;
case WM_NCHITTEST: return HTTRANSPARENT;
case WM_MOUSEACTIVATE: return MA_NOACTIVATE;
case WM_TIMER:
if (wp == VolumeIndicatorTimer)
{
KillTimer(w, VolumeIndicatorTimer);
if (!(HIWORD(GetWindowLongPtrW(w, GWLP_USERDATA)) & VolumeIndicatorHeld))
ShowWindow(w, SW_HIDE);
return 0;
}
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
void setVolumeIndicatorHold(HWND indicator, bool held)
{
const auto state = GetWindowLongPtrW(indicator, GWLP_USERDATA);
const WORD flags = (HIWORD(state) & VolumeIndicatorMuted) | (held ? VolumeIndicatorHeld : 0);
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(LOWORD(state), flags));
if (held) KillTimer(indicator, VolumeIndicatorTimer);
else SetTimer(indicator, VolumeIndicatorTimer, 1000, nullptr);
}
void showVolumeIndicator(HWND owner, HINSTANCE instance, POINT point, SystemVolume volume, bool held)
{
MONITORINFO monitor{sizeof(monitor)};
const auto targetMonitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
if (!GetMonitorInfoW(targetMonitor, &monitor)) return;
const auto &area = monitor.rcWork;
auto indicator = reinterpret_cast<HWND>(GetPropW(owner, VolumeIndicatorClass));
if (!indicator)
{
WNDCLASSW type{};
type.lpfnWndProc = volumeIndicatorProc;
type.hInstance = instance;
type.lpszClassName = VolumeIndicatorClass;
RegisterClassW(&type);
indicator = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_TOPMOST |
WS_EX_LAYERED | WS_EX_TRANSPARENT, VolumeIndicatorClass, L"Volume",
WS_POPUP, area.left, area.top, 1, 1, owner, nullptr, instance, nullptr);
if (!indicator) return;
if (!SetPropW(owner, VolumeIndicatorClass, indicator) ||
!SetLayeredWindowAttributes(indicator, 0, 255, LWA_ALPHA))
{
RemovePropW(owner, VolumeIndicatorClass);
DestroyWindow(indicator);
return;
}
}
// Place the window on the destination monitor before querying its DPI.
if (MonitorFromWindow(indicator, MONITOR_DEFAULTTONEAREST) != targetMonitor)
SetWindowPos(indicator, nullptr, area.left, area.top, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW);
const int dpi = static_cast<int>(GetDpiForWindow(indicator));
const int width = MulDiv(48, dpi, 96);
const int height = MulDiv(132, dpi, 96);
const int margin = MulDiv(16, dpi, 96);
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(volume.percent, volume.muted ? VolumeIndicatorMuted : 0));
setVolumeIndicatorHold(indicator, held);
SetWindowPos(indicator, HWND_TOPMOST, area.right - width - margin, area.bottom - height - margin,
width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
InvalidateRect(indicator, nullptr, FALSE);
}
}
void App::queueMouseAction(MousePress press)
{
if (press.action.type == MouseActionType::Standard) return;
mouseActions.push_back(std::move(press));
PostMessageW(window, MouseActionMessage, 0, 0);
}
void App::flushMouseClicks()
{
ULONGLONG next = 0;
const auto now = GetTickCount64();
for (auto &press : pendingMouseClicks)
{
if (!press.target) continue;
const auto deadline = press.started + GetDoubleClickTime();
if (now >= deadline)
{
queueMouseAction(std::exchange(press, {}));
}
else if (!next || deadline < next) next = deadline;
}
if (next) SetTimer(window, MouseClickTimer, static_cast<UINT>(next - now), nullptr);
else KillTimer(window, MouseClickTimer);
}
void App::beginMouseVolume(int button)
{
const auto &press = mousePresses[button];
if (!press.waitForSecond) queueMouseAction(press);
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
SetTimer(window, FirstMouseVolumeTimer + button, delay, nullptr);
}
void App::repeatMouseVolume(int button)
{
const auto timer = FirstMouseVolumeTimer + button;
auto &press = mousePresses[button];
if (!press.held || !isMouseVolumeStep(press.action.type) ||
busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased() ||
!IsWindow(press.target) || (!press.action.applications.empty() && press.target != GetForegroundWindow()))
{
KillTimer(window, timer);
return;
}
const auto elapsed = GetTickCount64() - press.started;
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
if (elapsed < delay) return;
press.waitForSecond = false;
queueMouseAction(press);
SetTimer(window, timer, mouseVolumeRepeatInterval(elapsed), nullptr);
}
bool App::volumeButtonHeld() const
{
return std::any_of(mousePresses.begin(), mousePresses.end(), [](const MousePress &press)
{
return press.held && !press.waitForSecond && isMouseVolumeStep(press.action.type);
});
}
void App::dispatchMouseActions()
{
std::deque<MousePress> actions;
actions.swap(mouseActions);
if (busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased())
{
if (!actions.empty()) log(L"Mouse action skipped while input was unavailable.");
return;
}
for (const auto &press : actions)
{
if (!IsWindow(press.target)) continue;
const auto &action = press.action;
switch (action.type)
{
case MouseActionType::Standard: break;
case MouseActionType::Shortcut:
if (press.target != GetForegroundWindow())
log(L"Mouse shortcut skipped because the active window changed.");
else if (!chord(static_cast<WORD>(action.shortcut.key), action.shortcut.modifiers))
log(L"Windows did not accept all input for the mouse shortcut.");
else
log(L"Windows accepted input for the mouse shortcut.");
break;
case MouseActionType::Back:
case MouseActionType::Forward:
PostMessageW(press.target, WM_APPCOMMAND, reinterpret_cast<WPARAM>(press.target),
MAKELPARAM(0, FAPPCOMMAND_MOUSE | (action.type == MouseActionType::Back ?
APPCOMMAND_BROWSER_BACKWARD : APPCOMMAND_BROWSER_FORWARD)));
break;
case MouseActionType::VolumeUp:
case MouseActionType::VolumeDown:
case MouseActionType::Mute:
if (const auto volume = changeSystemVolume(action.type, action.volumeStep))
showVolumeIndicator(window, instance, press.point, *volume, volumeButtonHeld());
else log(L"Mouse action could not change or read system volume.");
break;
case MouseActionType::Launch:
if (!launchThroughExplorer(action.executable))
log(L"Mouse action could not launch " + action.executable);
else
log(L"Mouse action launched " + std::filesystem::path(action.executable).filename().wstring());
break;
}
}
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
if (current->lanService)
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)
{
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
!current->busy && !current->suspended && !current->hotkeyCapture);
if (current->lanService->takeInputChange()) current->resetLanInput();
auto &active = app.mousePresses[button];
const bool consumed = active.action.type != MouseActionType::Standard;
if (!down)
{
auto press = std::exchange(active, {});
if (isMouseVolumeStep(press.action.type))
{
KillTimer(app.window, FirstMouseVolumeTimer + button);
PostMessageW(app.window, MouseVolumeReleasedMessage, 0, 0);
}
if (press.waitForSecond)
{
app.pendingMouseClicks[button] = std::move(press);
app.flushMouseClicks();
}
else if (!isMouseVolumeStep(press.action.type)) app.queueMouseAction(std::move(press));
}
if (consumed) return 1;
}
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
if (app.lanService)
{
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
current->resetInput();
const bool mouseActionHeld = std::any_of(app.mousePresses.begin(), app.mousePresses.end(),
[](const MousePress &press) { return press.held; });
bool consumed = app.lanService->mouse(UINT(wp), mouse,
!app.busy && !app.suspended && !app.hotkeyCapture && !mouseActionHeld);
if (app.lanService->takeInputChange()) app.resetLanInput();
if (consumed) return 1;
}
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);
const auto target = GetAncestor(hit, GA_ROOT);
const auto name = target ? mouseApplicationName(target) : L"";
wchar_t windowClass[64]{};
if (target && name == L"explorer") GetClassNameW(target, windowClass, std::size(windowClass));
const bool ownWindow = name == L"kswitcher" || name == L"kswitcher.config";
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 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();
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 || (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, event, foreground, &singleScoped);
auto *doubleClick = wheel < 0 && !restricted && button < 3 ?
effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr;
if (!foreground)
{
if (single && single->type == MouseActionType::Shortcut) single = nullptr;
if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr;
}
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];
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)
{
if (app.busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
app.resetInput();
}
}
return CallNextHookEx(nullptr, code, wp, lp);
@@ -2205,6 +2541,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
case MouseActionMessage:
app->dispatchMouseActions();
return 0;
case MouseVolumeReleasedMessage:
if (const auto indicator = reinterpret_cast<HWND>(GetPropW(hwnd, VolumeIndicatorClass)))
setVolumeIndicatorHold(indicator, app->volumeButtonHeld());
return 0;
case WM_POWERBROADCAST:
if (wp == PBT_APMSUSPEND && app->lanService)
{
@@ -2294,6 +2637,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
return 0;
case WM_TIMER:
if (wp == MouseClickTimer) { app->flushMouseClicks(); return 0; }
if (wp >= FirstMouseVolumeTimer && wp < FirstMouseVolumeTimer + std::size(MouseButtonNames))
{
app->repeatMouseVolume(static_cast<int>(wp - FirstMouseVolumeTimer));
return 0;
}
if (wp == 8)
{
if (app->settings.appLanguageSets.empty() || app->busy || app->installingUpdate) return 0;
+22
View File
@@ -5,6 +5,8 @@
#include "sync.h"
#include "chat_context.h"
#include "lan.h"
#include "mouse_buttons.h"
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -104,7 +106,27 @@ class App
private:
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
void applySettings(const Settings &, bool validateOcrInstallation);
HHOOK keyboardHook{}, mouseHook{};
struct MousePress
{
HWND target{};
POINT point{};
ULONGLONG started{};
MouseAction action;
bool held{}, waitForSecond{};
};
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);
void repeatMouseVolume(int button);
bool volumeButtonHeld() const;
void dispatchMouseActions();
std::unique_ptr<LanService> lanService;
HANDLE historyProcess{};
std::mutex jobsMutex;
+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." :
+16
View File
@@ -3,6 +3,7 @@
#include <functional>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <windows.h>
@@ -58,6 +59,17 @@ struct AppLanguageSet
std::vector<std::wstring> languages, applications;
bool operator==(const AppLanguageSet &) const = default;
};
enum class MouseActionType { Standard, Shortcut, Back, Forward, VolumeUp, VolumeDown, Mute, Launch };
inline constexpr int DefaultMouseVolumeStep = 4;
struct MouseAction
{
MouseActionType type = MouseActionType::Standard;
Hotkey shortcut;
std::wstring executable;
std::set<std::wstring> applications{};
int volumeStep = DefaultMouseVolumeStep;
bool operator==(const MouseAction &) const = default;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
@@ -88,6 +100,10 @@ struct Settings
// Exact application + visible chat name; an absent entry uses the global cycle.
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"};
std::vector<Snippet> snippets;
std::vector<std::wstring> snippetTombstones;
+171
View File
@@ -0,0 +1,171 @@
#pragma once
#include "core.h"
#include <cstdlib>
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"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"*";
inline constexpr std::wstring_view NamedMouseProfilePrefix = L"name:";
bool validMouseApplication(const std::wstring &app);
inline bool isNamedMouseProfile(std::wstring_view profile)
{
return profile.starts_with(NamedMouseProfilePrefix);
}
inline std::wstring mouseProfileName(const std::wstring &profile)
{
if (profile == GlobalMouseProfile) return L"Global";
return isNamedMouseProfile(profile) ? profile.substr(NamedMouseProfilePrefix.size()) : profile;
}
inline bool mouseActionApplies(const MouseAction &action, const std::wstring &app)
{
return action.applications.empty() || action.applications.contains(app);
}
inline const std::set<std::wstring> &mouseActionScope(const Settings &settings, const std::wstring &profile,
const MouseAction &action)
{
const auto scope = settings.mouseProfileApplications.find(profile);
return scope == settings.mouseProfileApplications.end() ? action.applications : scope->second;
}
inline bool isMouseVolumeAction(const MouseAction *action)
{
return action && (action->type == MouseActionType::VolumeUp ||
action->type == MouseActionType::VolumeDown || action->type == MouseActionType::Mute);
}
inline bool isMouseVolumeStep(MouseActionType action)
{
return action == MouseActionType::VolumeUp || action == MouseActionType::VolumeDown;
}
inline constexpr UINT MouseVolumeHoldDelay = 350;
inline UINT mouseVolumeRepeatInterval(ULONGLONG heldFor)
{
if (heldFor < 1000) return 140;
if (heldFor < 2000) return 80;
return 40;
}
inline const MouseAction *mouseBinding(const Settings &settings, const std::wstring &app, int event)
{
auto profile = settings.mouseBindings.find(app);
if (profile == settings.mouseBindings.end()) return nullptr;
auto action = profile->second.find(MouseEvents[event]);
return action == profile->second.end() ? nullptr : &action->second;
}
inline const MouseAction *effectiveMouseAction(const Settings &settings, const std::wstring &app, int event,
bool foreground = true, bool *profileScoped = nullptr)
{
if (profileScoped) *profileScoped = false;
if (!mouseEventEnabled(settings, event)) return nullptr;
auto *action = mouseBinding(settings, app, event);
if (!action)
{
const MouseAction *unrestricted = nullptr;
for (auto profile = settings.mouseBindings.lower_bound(std::wstring(NamedMouseProfilePrefix));
profile != settings.mouseBindings.end() && isNamedMouseProfile(profile->first); ++profile)
{
if (settings.disabledMouseProfiles.contains(profile->first)) continue;
const auto binding = profile->second.find(MouseEvents[event]);
if (binding == profile->second.end()) continue;
const auto &candidate = binding->second;
const auto &scope = mouseActionScope(settings, profile->first, candidate);
if (foreground && scope.contains(app))
{
if (profileScoped) *profileScoped = true;
return candidate.type == MouseActionType::Standard ? nullptr : &candidate;
}
if (!unrestricted && scope.empty()) unrestricted = &candidate;
}
const auto *global = mouseBinding(settings, GlobalMouseProfile, event);
if (foreground && global && global->applications.contains(app)) action = global;
else if (unrestricted) action = unrestricted;
else if (global && global->applications.empty()) action = global;
}
return action && action->type != MouseActionType::Standard && mouseActionApplies(*action, app) ? action : nullptr;
}
inline bool doubleMouseClick(POINT first, POINT second, ULONGLONG elapsed)
{
return elapsed <= GetDoubleClickTime() &&
std::abs(static_cast<long long>(first.x) - second.x) * 2 <= GetSystemMetrics(SM_CXDOUBLECLK) &&
std::abs(static_cast<long long>(first.y) - second.y) * 2 <= GetSystemMetrics(SM_CYDOUBLECLK);
}
inline int mouseButton(UINT message, WORD xbutton)
{
switch (message)
{
case WM_LBUTTONDOWN: case WM_LBUTTONUP: return 0;
case WM_RBUTTONDOWN: case WM_RBUTTONUP: return 1;
case WM_MBUTTONDOWN: case WM_MBUTTONUP: return 2;
case WM_XBUTTONDOWN: case WM_XBUTTONUP:
if (xbutton == XBUTTON1) return 3;
if (xbutton == XBUTTON2) return 4;
}
return -1;
}
inline bool mouseButtonDown(UINT message)
{
return message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN ||
message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN;
}
}
+123 -1
View File
@@ -4,12 +4,19 @@
#include <fstream>
#include <iomanip>
#include <imm.h>
#include <endpointvolume.h>
#include <mmdeviceapi.h>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <shellapi.h>
#include <shlobj.h>
#include <exdisp.h>
#include <shldisp.h>
#include <tlhelp32.h>
#include <wincrypt.h>
#include <wrl/client.h>
namespace ks
{
@@ -265,6 +272,83 @@ std::wstring processName(HWND window)
CloseHandle(h);
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
}
std::wstring mouseApplicationName(HWND window)
{
auto name = lower(processName(window));
if (name != L"applicationframehost") return name;
std::wstring hosted;
EnumChildWindows(window, [](HWND child, LPARAM output) -> BOOL {
wchar_t kind[64]{};
if (!GetClassNameW(child, kind, std::size(kind)) || wcscmp(kind, L"Windows.UI.Core.CoreWindow") != 0)
return TRUE;
auto &app = *reinterpret_cast<std::wstring *>(output);
app = lower(processName(child));
return app.empty();
}, reinterpret_cast<LPARAM>(&hosted));
return hosted.empty() ? name : hosted;
}
bool launchThroughExplorer(const std::wstring &path)
{
HANDLE token{};
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false;
TOKEN_ELEVATION elevation{};
DWORD size{};
const auto queried = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size);
CloseHandle(token);
if (!queried) return false;
if (!elevation.TokenIsElevated)
{
SHELLEXECUTEINFOW launch{sizeof(launch)};
launch.fMask = SEE_MASK_FLAG_NO_UI;
launch.lpFile = path.c_str();
launch.nShow = SW_SHOWNORMAL;
return ShellExecuteExW(&launch) != FALSE;
}
// An elevated resident delegates execution to the interactive desktop shell.
auto release = [](IUnknown *object) { if (object) object->Release(); };
IShellWindows *windowsRaw{};
auto hr = CoCreateInstance(CLSID_ShellWindows, nullptr, CLSCTX_ALL, IID_IShellWindows,
reinterpret_cast<void **>(&windowsRaw));
std::unique_ptr<IShellWindows, decltype(release)> windows(windowsRaw, release);
if (FAILED(hr) || !windows) return false;
VARIANT empty{}, desktop{};
desktop.vt = VT_I4;
long windowHandle{};
IDispatch *desktopRaw{};
hr = windows->FindWindowSW(&desktop, &empty, SWC_DESKTOP, &windowHandle, SWFO_NEEDDISPATCH, &desktopRaw);
std::unique_ptr<IDispatch, decltype(release)> dispatch(desktopRaw, release);
if (FAILED(hr) || !dispatch) return false;
IServiceProvider *providerRaw{};
hr = dispatch->QueryInterface(IID_IServiceProvider, reinterpret_cast<void **>(&providerRaw));
std::unique_ptr<IServiceProvider, decltype(release)> provider(providerRaw, release);
if (FAILED(hr) || !provider) return false;
IShellBrowser *browserRaw{};
hr = provider->QueryService(SID_STopLevelBrowser, IID_IShellBrowser, reinterpret_cast<void **>(&browserRaw));
std::unique_ptr<IShellBrowser, decltype(release)> browser(browserRaw, release);
if (FAILED(hr) || !browser) return false;
IShellView *viewRaw{};
hr = browser->QueryActiveShellView(&viewRaw);
std::unique_ptr<IShellView, decltype(release)> view(viewRaw, release);
if (FAILED(hr) || !view) return false;
IShellFolderViewDual *folderRaw{};
hr = view->GetItemObject(SVGIO_BACKGROUND, IID_IShellFolderViewDual, reinterpret_cast<void **>(&folderRaw));
std::unique_ptr<IShellFolderViewDual, decltype(release)> folder(folderRaw, release);
if (FAILED(hr) || !folder) return false;
IDispatch *applicationRaw{};
hr = folder->get_Application(&applicationRaw);
std::unique_ptr<IDispatch, decltype(release)> application(applicationRaw, release);
if (FAILED(hr) || !application) return false;
IShellDispatch2 *shellRaw{};
hr = application->QueryInterface(IID_IShellDispatch2, reinterpret_cast<void **>(&shellRaw));
std::unique_ptr<IShellDispatch2, decltype(release)> shell(shellRaw, release);
if (FAILED(hr) || !shell) return false;
BSTR executable = SysAllocString(path.c_str());
if (!executable) return false;
hr = shell->ShellExecute(executable, empty, empty, empty, empty);
SysFreeString(executable);
return SUCCEEDED(hr);
}
Clip readClipboard(HWND window, size_t limit, ClipboardRead mode, std::optional<DWORD> expected)
{
ClipboardLock lock(window);
@@ -411,7 +495,9 @@ bool chord(WORD key, UINT mods)
auto add = [&](WORD k, bool up)
{
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
if ((k >= VK_PRIOR && k <= VK_DOWN) || k == VK_INSERT || k == VK_DELETE ||
k == VK_DIVIDE || k == VK_LWIN || k == VK_SNAPSHOT ||
(k >= VK_VOLUME_MUTE && k <= VK_VOLUME_UP))
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
};
@@ -431,6 +517,42 @@ bool chord(WORD key, UINT mods)
add(k, true);
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
}
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step)
{
using Microsoft::WRL::ComPtr;
ComPtr<IMMDeviceEnumerator> devices;
ComPtr<IMMDevice> output;
ComPtr<IAudioEndpointVolume> volume;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&devices))) ||
FAILED(devices->GetDefaultAudioEndpoint(eRender, eMultimedia, &output)) ||
FAILED(output->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_INPROC_SERVER, nullptr,
reinterpret_cast<void **>(volume.GetAddressOf()))))
return std::nullopt;
float level{};
BOOL muted{};
HRESULT changed;
switch (action)
{
case MouseActionType::VolumeUp:
case MouseActionType::VolumeDown:
{
if (FAILED(volume->GetMasterVolumeLevelScalar(&level))) return std::nullopt;
const float delta = (action == MouseActionType::VolumeUp ? step : -step) / 100.0f;
changed = volume->SetMasterVolumeLevelScalar(std::clamp(level + delta, 0.0f, 1.0f), nullptr);
break;
}
case MouseActionType::Mute:
{
if (FAILED(volume->GetMute(&muted))) return std::nullopt;
changed = volume->SetMute(!muted, nullptr);
break;
}
default: return std::nullopt;
}
if (FAILED(changed) || FAILED(volume->GetMasterVolumeLevelScalar(&level)) || FAILED(volume->GetMute(&muted)))
return std::nullopt;
return SystemVolume{std::clamp(static_cast<int>(level * 100 + 0.5f), 0, 100), muted != FALSE};
}
bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
+8
View File
@@ -43,6 +43,8 @@ bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
std::wstring processName(HWND);
std::wstring mouseApplicationName(HWND);
bool launchThroughExplorer(const std::wstring &);
enum class ClipboardRead { All, Selection, WithoutImages };
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024,
ClipboardRead = ClipboardRead::All, std::optional<DWORD> expected = {});
@@ -50,6 +52,12 @@ DWORD writeClipboard(HWND, const Clip &);
DWORD writeClipboardGuarded(HWND, const Clip &, std::optional<DWORD> expected);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
struct SystemVolume
{
int percent{};
bool muted{};
};
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step);
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
ULONG_PTR inputMarker();
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
+188
View File
@@ -1,4 +1,5 @@
#include "storage.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include "ocr.h"
#include <algorithm>
@@ -68,8 +69,97 @@ void validateLanguageCycle(const std::vector<std::wstring> &cycle)
throw std::runtime_error("Invalid or duplicate language layout.");
}
} // namespace
bool validMouseApplication(const std::wstring &app)
{
return !app.empty() && app.size() <= 260 && app == lower(app) &&
app.find_first_of(L"\\/:*?\"<>|\r\n") == app.npos &&
app != L"kswitcher" && app != L"kswitcher.config";
}
static void validateMouseProfileConflicts(const Settings &s)
{
std::map<std::pair<std::wstring, std::wstring>, std::wstring> claimed;
for (const auto &[profile, bindings] : s.mouseBindings)
{
if (s.disabledMouseProfiles.contains(profile)) continue;
for (const auto &[event, action] : bindings)
{
auto claim = [&](const std::wstring &app)
{
const auto [other, added] = claimed.try_emplace({event, app}, profile);
if (!added && (isNamedMouseProfile(profile) || isNamedMouseProfile(other->second)))
throw std::runtime_error(utf8(L"Mouse action conflict: " + event + L" for " +
(app.empty() ? L"all applications" : app + L".exe") + L" in " +
mouseProfileName(other->second) + L" and " + mouseProfileName(profile) + L"."));
};
if (profile != GlobalMouseProfile && !isNamedMouseProfile(profile))
{
if (mouseActionApplies(action, profile)) claim(profile);
}
else
{
const auto &scope = mouseActionScope(s, profile, action);
if (scope.empty()) claim(L"");
else for (const auto &app : scope) claim(app);
}
}
}
}
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)
{
if (isNamedMouseProfile(app))
{
const auto name = mouseProfileName(app);
if (name.empty() || name.size() > 80 || lower(name) == L"global" || name.find_first_of(L"\r\n\t") != name.npos)
throw std::runtime_error("Enter a profile name other than Global (up to 80 characters).");
}
else if (app != GlobalMouseProfile && !validMouseApplication(app))
throw std::runtime_error("Enter the target application's executable name.");
for (const auto &[event, action] : bindings)
{
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.");
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))
throw std::runtime_error("Enter a volume step from 1 to 100.");
if (action.type == MouseActionType::Shortcut &&
(!action.shortcut.key || Hotkey::parse(action.shortcut.format()) != action.shortcut))
throw std::runtime_error("Enter a shortcut such as Ctrl+Shift+Tab.");
if (action.type == MouseActionType::Launch)
{
const std::filesystem::path path(action.executable);
const bool executableName = !path.has_root_path() && !path.has_parent_path() &&
action.executable.find_first_of(L"\\/:*?\"<>|\r\n") == std::wstring::npos;
if ((!executableName && !path.is_absolute()) || lower(path.extension().wstring()) != L".exe")
throw std::runtime_error("Enter an executable name such as calc.exe, or choose a file with Browse.");
}
}
}
for (const auto &profile : s.disabledMouseProfiles)
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile))
throw std::runtime_error("Unknown disabled mouse profile.");
for (const auto &[profile, apps] : s.mouseProfileApplications)
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile) || apps.size() > 1000 ||
!std::all_of(apps.begin(), apps.end(), validMouseApplication))
throw std::runtime_error("Invalid applications for the mouse profile.");
validateMouseProfileConflicts(s);
if (s.appLanguageSets.size() > 1000) throw std::runtime_error("Too many application language sets");
std::set<std::wstring> assigned;
for (const auto &[name, set] : s.appLanguageSets)
@@ -302,6 +392,70 @@ 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");
if (!profiles.is_object()) throw std::runtime_error("Invalid mouse profiles.");
for (const auto &[app, bindings] : profiles.items())
{
if (!bindings.is_object()) throw std::runtime_error("Invalid mouse profile.");
if (isNamedMouseProfile(wide(app))) s.mouseBindings.try_emplace(wide(app));
for (const auto &[event, item] : bindings.items())
{
MouseAction action;
if (item.is_string())
{
auto shortcut = Hotkey::parse(wide(item.get<std::string>()));
if (!shortcut || !shortcut->key) throw std::runtime_error("Invalid mouse shortcut.");
action = {MouseActionType::Shortcut, *shortcut};
}
else
{
auto name = wide(item.at("action").get<std::string>());
if (name == L"Send shortcut") name = MouseActions[static_cast<size_t>(MouseActionType::Shortcut)];
const auto found = std::find(std::begin(MouseActions), std::end(MouseActions), name);
if (found == std::end(MouseActions)) throw std::runtime_error("Invalid mouse action.");
action.type = static_cast<MouseActionType>(found - std::begin(MouseActions));
if (action.type == MouseActionType::Shortcut)
action.shortcut = Hotkey::parse(wide(item.at("shortcut").get<std::string>())).value_or(Hotkey{});
if (action.type == MouseActionType::Launch)
action.executable = wide(item.at("executable").get<std::string>());
if (isMouseVolumeStep(action.type) && item.contains("volumeStep"))
{
const auto &step = item.at("volumeStep");
if (!step.is_number_integer() || step.get<double>() < 1 || step.get<double>() > 100)
throw std::runtime_error("Enter a volume step from 1 to 100.");
action.volumeStep = step.get<int>();
}
if (item.contains("applications"))
for (const auto &name : item.at("applications").get<std::vector<std::string>>())
action.applications.insert(wide(name));
}
s.mouseBindings[wide(app)][wide(event)] = std::move(action);
}
}
}
if (j.contains("mouseProfileApplications"))
{
const auto &scopes = j.at("mouseProfileApplications");
if (!scopes.is_object()) throw std::runtime_error("Invalid mouse profile applications.");
for (const auto &[profile, names] : scopes.items())
{
if (names.is_null()) continue;
auto &scope = s.mouseProfileApplications[wide(profile)];
for (const auto &name : names.get<std::vector<std::string>>()) scope.insert(wide(name));
}
}
if (j.contains("disabledMouseProfiles") && !j.at("disabledMouseProfiles").is_null())
for (const auto &profile : j.at("disabledMouseProfiles").get<std::vector<std::string>>())
s.disabledMouseProfiles.insert(wide(profile));
if (j.contains("chatCycles"))
{
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
@@ -383,6 +537,40 @@ 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)
{
if (isNamedMouseProfile(app)) j["mouseBindings"][utf8(app)] = Json::object();
for (const auto &[event, action] : bindings)
{
auto &item = j["mouseBindings"][utf8(app)][utf8(event)];
if (action.type == MouseActionType::Shortcut && action.applications.empty())
item = utf8(action.shortcut.format());
else
{
item = {{"action", utf8(MouseActions[static_cast<size_t>(action.type)])}};
if (action.type == MouseActionType::Shortcut)
{
item["action"] = "Send shortcut";
item["shortcut"] = utf8(action.shortcut.format());
}
if (action.type == MouseActionType::Launch) item["executable"] = utf8(action.executable);
if (isMouseVolumeStep(action.type) && action.volumeStep != DefaultMouseVolumeStep)
item["volumeStep"] = action.volumeStep;
for (const auto &app : action.applications) item["applications"].push_back(utf8(app));
}
}
}
j["mouseProfileApplications"] = Json::object();
for (const auto &[profile, apps] : s.mouseProfileApplications)
{
auto &scope = j["mouseProfileApplications"][utf8(profile)] = Json::array();
for (const auto &app : apps) scope.push_back(utf8(app));
}
j["disabledMouseProfiles"] = Json::array();
for (const auto &profile : s.disabledMouseProfiles) j["disabledMouseProfiles"].push_back(utf8(profile));
j["chatCycles"] = Json::object();
for (const auto &[key, cycle] : s.chatCycles)
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
+20 -5
View File
@@ -1,4 +1,5 @@
#include "sync.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include <winhttp.h>
#include <wincred.h>
@@ -215,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));
@@ -225,13 +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"}) 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;
@@ -243,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));
@@ -305,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, {}};
}
+32 -2
View File
@@ -58,6 +58,28 @@ Json flatProfile(Json j)
for (auto it = chats.begin(); it != chats.end();)
if (it.value().is_null()) it = chats.erase(it);
else ++it;
if (j.contains("mouseBindings"))
{
auto &profiles = j.at("mouseBindings");
for (auto profile = profiles.begin(); profile != profiles.end();)
{
auto &buttons = profile.value();
for (auto button = buttons.begin(); button != buttons.end();)
if (button.value().is_null()) button = buttons.erase(button);
else ++button;
if (buttons.is_null() || (buttons.empty() && !isNamedMouseProfile(wide(profile.key()))))
profile = profiles.erase(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();
for (const auto &[id, item] : j.at("snippets").items())
{
@@ -99,7 +121,7 @@ std::wstring fieldLabel(const std::string &key)
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
{"chatCycles", L"Chat languages"},
{"chatCycles", L"Chat languages"}, {"mouseBindings", L"Mouse buttons"},
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
@@ -133,7 +155,11 @@ MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::stri
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
if (!forcedChild && l == b) return {r, r, r};
if (!forcedChild && r == b) return {l, l, l};
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
// A mouse action and its application scope must come from the same edit.
constexpr std::string_view mouseProfiles = "/mouseBindings/";
const bool mouseAction = path.starts_with(mouseProfiles) && path.find('/', mouseProfiles.size()) != std::string::npos;
if (!mouseAction && l.is_object() && r.is_object() && (b.is_object() || b.is_null()) &&
!tombstone(l) && !tombstone(r) && !tombstone(b))
{
MergeNode out{Json::object(), Json::object(), Json::object()};
std::set<std::string> keys;
@@ -197,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.
+114 -23
View File
@@ -15,7 +15,9 @@
#include <richedit.h>
#include <set>
#include <shellapi.h>
#include <shlobj.h>
#include <sstream>
#include <wrl/client.h>
namespace ks
{
@@ -40,7 +42,12 @@ struct WindowLayout
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
bool autoOptions{};
bool fixedClient{}, mouseSettings{};
HWND rowCombo{};
};
#ifndef KS_RESIDENT_APP
void scaleMouseTableColumns(HWND w, int previousDpi, int nextDpi);
#endif
WindowLayout *windowLayoutState(HWND w)
{
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
@@ -57,24 +64,44 @@ void fitToMonitor(HWND w, RECT rect)
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
void fitFixedClient(HWND w, WindowLayout &layout, RECT frame)
{
RECT current{}, client{};
GetWindowRect(w, &current);
GetClientRect(w, &client);
const auto style = GetWindowLongPtrW(w, GWL_STYLE);
const UINT dpi = GetDpiForWindow(w);
const int vertical = (style & WS_VSCROLL) ? GetSystemMetricsForDpi(SM_CXVSCROLL, dpi) : 0;
const int horizontal = (style & WS_HSCROLL) ? GetSystemMetricsForDpi(SM_CYHSCROLL, dpi) : 0;
frame.right = frame.left + scale(w, layout.width) + current.right - current.left - client.right - vertical;
frame.bottom = frame.top + scale(w, layout.height) + current.bottom - current.top - client.bottom - horizontal;
fitToMonitor(w, frame);
}
void arrangeWindow(HWND w)
{
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling || layout->arranging) return;
layout->arranging = true;
// Showing either scrollbar can make the other necessary. Let the client size settle.
for (int pass = 0; pass < 3; ++pass)
{
RECT client{};
GetClientRect(w, &client);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
RECT client{}; GetClientRect(w, &client);
RECT client{};
GetClientRect(w, &client);
const auto style = GetWindowLongPtrW(w, GWL_STYLE);
const UINT dpi = GetDpiForWindow(w);
const int verticalBar = GetSystemMetricsForDpi(SM_CXVSCROLL, dpi);
const int horizontalBar = GetSystemMetricsForDpi(SM_CYHSCROLL, dpi);
// Start from the full client area so two obsolete bars cannot keep each other visible.
const int fullWidth = client.right + ((style & WS_VSCROLL) ? verticalBar : 0);
const int fullHeight = client.bottom + ((style & WS_HSCROLL) ? horizontalBar : 0);
const int width = scale(w, layout->width);
const int height = scale(w, layout->height);
const bool needsHorizontal = width > fullWidth || (height > fullHeight && width > fullWidth - verticalBar);
const bool needsVertical = height > fullHeight - (needsHorizontal ? horizontalBar : 0);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS, 0, width - 1,
(UINT)std::max(1, fullWidth - (needsVertical ? verticalBar : 0)), layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS, 0, height - 1,
(UINT)std::max(1, fullHeight - (needsHorizontal ? horizontalBar : 0)), layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
GetClientRect(w, &client);
bool expandable = layout->history || layout->stretch || layout->autoOptions;
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
@@ -143,6 +170,15 @@ void arrangeWindow(HWND w)
SWP_NOACTIVATE | SWP_NOZORDER);
}
}
if (layout->rowCombo)
{
RECT bounds{};
GetWindowRect(layout->rowCombo, &bounds);
const int difference = scale(w, 25) - (bounds.bottom - bounds.top);
const auto itemHeight = SendMessageW(layout->rowCombo, CB_GETITEMHEIGHT, static_cast<WPARAM>(-1), 0);
if (difference && itemHeight != CB_ERR && itemHeight + difference > 0)
SendMessageW(layout->rowCombo, CB_SETITEMHEIGHT, static_cast<WPARAM>(-1), itemHeight + difference);
}
layout->arranging = false;
}
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
@@ -191,7 +227,8 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
{
RECT rect{};
GetWindowRect(w, &rect);
fitToMonitor(w, rect);
if (layout->fixedClient) fitFixedClient(w, *layout, rect);
else fitToMonitor(w, rect);
arrangeWindow(w);
}
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
@@ -340,7 +377,7 @@ void error(HWND w, const std::exception &e)
MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING);
}
HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height,
DWORD extra = 0, bool resizable = true)
DWORD extra = 0, bool resizable = true, HWND owner = nullptr)
{
WNDCLASSW wc{};
wc.hInstance = app.instance;
@@ -354,22 +391,25 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
const DWORD exStyle = owner ? WS_EX_DLGMODALFRAME : WS_EX_APPWINDOW;
AdjustWindowRectExForDpi(&rect, style, FALSE, exStyle, dpi);
POINT cursor{};
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
HWND w =
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
CreateWindowExW(exStyle, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, owner, nullptr, app.instance, &app);
if (!w)
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
// The system DPI may still describe the old primary display. The HWND tells
// us the actual destination DPI, and must size the frame AND its controls.
dpi = GetDpiForWindow(w);
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
RECT frame{}, client{};
GetWindowRect(w, &frame);
GetClientRect(w, &client);
int outerWidth = scale(w, width) + (frame.right - frame.left) - client.right;
int outerHeight = scale(w, height) + (frame.bottom - frame.top) - client.bottom;
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
@@ -441,7 +481,17 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
if (layout && layout->history)
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
fitToMonitor(w, *(RECT *)lp);
if (layout && layout->fixedClient)
{
RECT frame{};
GetWindowRect(w, &frame);
fitFixedClient(w, *layout, frame);
}
arrangeWindow(w);
#ifndef KS_RESIDENT_APP
if (layout && layout->mouseSettings && previous != next)
scaleMouseTableColumns(w, previous, next);
#endif
}
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
{
@@ -1046,6 +1096,9 @@ struct SettingsView
bool filling{};
std::vector<std::vector<HWND>> pages;
bool ready{}, refreshing{}, visitedLan{};
std::vector<std::wstring> mouseProfiles;
unsigned mouseEvents{}, rawMouseEvents{};
bool rawMouseRegistered{};
};
void updateSettingsState(HWND w, SettingsView *v);
void selectSettingsPage(HWND w, SettingsView *v)
@@ -1190,6 +1243,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
SetFocus(GetDlgItem(w, 710));
}
#include "ui_app_languages.inl"
#include "ui_mouse_buttons.inl"
void collectLayout(HWND w, SettingsView *v)
{
if (v->selection < 0 || v->selection >= (int)v->order.size())
@@ -1268,6 +1322,7 @@ void fillSettings(HWND w, SettingsView *v)
v->order.push_back(i);
v->selection = 0;
fillLayouts(w, v);
fillMouseProfiles(w, *v);
v->filling = false;
updateSettingsState(w, v);
}
@@ -1710,6 +1765,16 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_DRAWITEM:
if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile || wp == AddMouseEvent)
{
drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp));
return TRUE;
}
break;
case WM_INPUT:
readMouseTestInput(w, *v, reinterpret_cast<HRAWINPUT>(lp));
return DefWindowProcW(w, msg, wp, lp);
case ConfigSettingsDownloaded:
v->draft = v->app->settings;
fillSettings(w, v);
@@ -1721,6 +1786,24 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (LOWORD(wp) != WA_INACTIVE) updateSettingsState(w, v);
break;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom == 731)
{
if (((NMHDR *)lp)->code == LVN_ITEMCHANGING)
return blockMouseProfileStateChange(w, *v, *reinterpret_cast<const NMLISTVIEW *>(lp));
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
{
const auto &change = *reinterpret_cast<const NMLISTVIEW *>(lp);
if (!(change.uOldState & LVIS_SELECTED) && (change.uNewState & LVIS_SELECTED))
fillMouseEvents(w, *v);
}
return 0;
}
if (((NMHDR *)lp)->idFrom == 733 && ((NMHDR *)lp)->code == NM_DBLCLK)
{
const auto &click = *reinterpret_cast<const NMITEMACTIVATE *>(lp);
showMouseActionEditor(w, *v, mouseEventAtRow(click.hdr.hwndFrom, click.iItem));
return 0;
}
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
{
selectSettingsPage(w, v);
@@ -1746,6 +1829,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
if (id == 262) { v->visitedLan=true; showLan(w, *v->app); return 0; }
if (id == 263) { collectLayout(w, v); showAppLanguages(w, v); return 0; }
if (handleMouseBindings(w, *v, wp)) return 0;
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
if (id == 230)
{
@@ -1830,6 +1914,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
closeSettings(w, v);
return 0;
case WM_NCDESTROY:
if (v->rawMouseRegistered) registerMouseTestInput(nullptr);
disposeFont(w);
v->app->settingsWindow = nullptr;
delete v;
@@ -2134,13 +2219,16 @@ void showSettings(App &app, bool visible)
return;
}
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 720, 510, 0, false);
auto &layout = *windowLayoutState(w);
layout.fixedClient = true;
layout.mouseSettings = true;
app.settingsWindow = w;
auto *v = (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
InitCommonControlsEx(&tabsInit);
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools"};
for (int i = 0; i < 5; ++i)
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools", L"Mouse"};
for (int i = 0; i < (int)std::size(tabNames); ++i)
{
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
TabCtrl_InsertItem(tabs, i, &item);
@@ -2216,6 +2304,9 @@ void showSettings(App &app, bool visible)
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
page();
createMousePage(w, *v);
page();
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
button(w, L"Impo&rt...", 250, 18, 466, 125);
button(w, L"&Export...", 251, 155, 466, 125);
+30 -20
View File
@@ -80,15 +80,15 @@ HWND appPickerTarget(HWND button, LPARAM position)
auto dialog = GetParent(button);
return target && target != dialog && target != GetWindow(dialog, GW_OWNER) ? target : nullptr;
}
void updatePickerOutline(HWND dialog, AppLanguagesView &v, HWND target)
void updatePickerOutline(HWND dialog, HWND &outline, HWND target)
{
RECT bounds{};
if (!target || !GetWindowRect(target, &bounds) || IsRectEmpty(&bounds))
{
if (v.pickerOutline) ShowWindow(v.pickerOutline, SW_HIDE);
if (outline) ShowWindow(outline, SW_HIDE);
return;
}
if (!v.pickerOutline)
if (!outline)
{
WNDCLASSW windowClass{};
windowClass.lpfnWndProc = DefWindowProcW;
@@ -96,18 +96,18 @@ void updatePickerOutline(HWND dialog, AppLanguagesView &v, HWND target)
windowClass.hbrBackground = GetSysColorBrush(COLOR_HIGHLIGHT);
windowClass.lpszClassName = L"KSwitcher.AppPickerOutline";
RegisterClassW(&windowClass);
v.pickerOutline = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
outline = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
windowClass.lpszClassName, nullptr, WS_POPUP, 0, 0, 0, 0, dialog, nullptr, windowClass.hInstance, nullptr);
if (!v.pickerOutline) return;
if (!SetLayeredWindowAttributes(v.pickerOutline, 0, 255, LWA_ALPHA))
if (!outline) return;
if (!SetLayeredWindowAttributes(outline, 0, 255, LWA_ALPHA))
{
DestroyWindow(v.pickerOutline);
v.pickerOutline = nullptr;
DestroyWindow(outline);
outline = nullptr;
return;
}
}
RECT previous{};
if (IsWindowVisible(v.pickerOutline) && GetWindowRect(v.pickerOutline, &previous) && EqualRect(&bounds, &previous)) return;
if (IsWindowVisible(outline) && GetWindowRect(outline, &previous) && EqualRect(&bounds, &previous)) return;
int width = bounds.right - bounds.left, height = bounds.bottom - bounds.top;
int stroke = scale(target, 4);
HRGN frame = CreateRectRgn(0, 0, width, height);
@@ -121,17 +121,17 @@ void updatePickerOutline(HWND dialog, AppLanguagesView &v, HWND target)
DeleteObject(opening);
}
}
if (!SetWindowRgn(v.pickerOutline, frame, TRUE))
if (!SetWindowRgn(outline, frame, TRUE))
{
DeleteObject(frame);
return;
}
SetWindowPos(v.pickerOutline, HWND_TOPMOST, bounds.left, bounds.top, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
SetWindowPos(outline, HWND_TOPMOST, bounds.left, bounds.top, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR)
LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR data)
{
HWND dialog = GetParent(button);
auto *view = (AppLanguagesView *)GetWindowLongPtrW(dialog, GWLP_USERDATA);
auto &outline = *reinterpret_cast<HWND *>(data);
if (msg == WM_LBUTTONDOWN)
{
SetFocus(button);
@@ -139,16 +139,16 @@ LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp
if (GetCapture() == button)
{
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
SetDlgItemTextW(dialog, 721, L"Drag to the application's window and release. Esc cancels.");
SendMessageW(dialog, AppWindowPicked, 0, -1);
}
return 0;
}
if (msg == WM_CAPTURECHANGED || msg == WM_NCDESTROY)
{
if (view && view->pickerOutline)
if (outline)
{
DestroyWindow(view->pickerOutline);
view->pickerOutline = nullptr;
DestroyWindow(outline);
outline = nullptr;
}
if (msg == WM_CAPTURECHANGED) SendMessageW(dialog, AppWindowPicked, 0, 0);
}
@@ -163,7 +163,7 @@ LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp
if (msg == WM_MOUSEMOVE || msg == WM_SETCURSOR)
{
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
if (msg == WM_MOUSEMOVE && view) updatePickerOutline(dialog, *view, appPickerTarget(button, lp));
if (msg == WM_MOUSEMOVE) updatePickerOutline(dialog, outline, appPickerTarget(button, lp));
return TRUE;
}
if (msg == WM_LBUTTONUP)
@@ -177,6 +177,12 @@ LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp
if (msg == WM_NCDESTROY) RemoveWindowSubclass(button, appWindowPickerProc, id);
return DefSubclassProc(button, msg, wp, lp);
}
void createAppWindowPicker(HWND w, int id, int x, int y, int width, HWND &outline)
{
control(w, L"BUTTON", L" ⊕ Drag to window", WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,
id, x, y, width, 28);
SetWindowSubclass(GetDlgItem(w, id), appWindowPickerProc, 1, reinterpret_cast<DWORD_PTR>(&outline));
}
LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *v = (AppLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
@@ -185,6 +191,11 @@ LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == AppWindowPicked)
{
if (lp == -1)
{
value(w, 721, L"Drag to the application's window and release. Esc cancels.");
return 0;
}
if (!lp) { showAppLanguageStatus(w, *v); return 0; }
HWND target = (HWND)wp;
auto app = target && target != w && target != v->parent ? applicationName(processName(target)) : L"";
@@ -278,8 +289,7 @@ void showAppLanguages(HWND parent, SettingsView *settings)
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Application", 18, 16, 108);
button(w, L"⊕ Drag to window", 728, 132, 10, 164);
SetWindowSubclass(GetDlgItem(w, 728), appWindowPickerProc, 1, 0);
createAppWindowPicker(w, 728, 132, 10, 164, v->pickerOutline);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
720, 18, 44, 278, 280);
label(w, L"Allowed languages (click to include)", 316, 16, 386);
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.63";
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;
+2 -2
View File
@@ -179,8 +179,8 @@ int main()
SendMessageW(automatic,WM_COMMAND,IDOK,0);
require(!saveEnabled(),"reverting auto-correction draft disables Save");
HWND tabs = GetDlgItem(parent, 260);
require(tabs && TabCtrl_GetItemCount(tabs) == 5, "five settings categories");
for (int page = 0; page < 5; ++page)
require(tabs && TabCtrl_GetItemCount(tabs) == 6, "six settings categories");
for (int page = 0; page < TabCtrl_GetItemCount(tabs); ++page)
{
TabCtrl_SetCurSel(tabs, page);
NMHDR notification{tabs, 260, TCN_SELCHANGE};
+255
View File
@@ -125,6 +125,47 @@ 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;
auto &press = app.mousePresses[button];
press = {app.window, {}, GetTickCount64(), {MouseActionType::VolumeUp}, true, false};
app.beginMouseVolume(button);
const bool onDown = app.mouseActions.size() == 1 && app.volumeButtonHeld();
app.repeatMouseVolume(button);
const bool initialDelay = app.mouseActions.size() == 1;
press.started -= 2100;
app.repeatMouseVolume(button);
const bool repeated = app.mouseActions.size() == 2;
MSLLHOOKSTRUCT mouse{};
mouse.mouseData = MAKEWPARAM(0, XBUTTON1);
const bool consumed = App::mouseProc(HC_ACTION, WM_XBUTTONUP, reinterpret_cast<LPARAM>(&mouse)) == 1;
app.repeatMouseVolume(button);
const bool stopped = !app.volumeButtonHeld() && app.mouseActions.size() == 2;
app.mouseActions.clear();
return onDown && initialDelay && repeated && consumed && stopped;
}
};
}
int main(int argc, char **argv) {
@@ -181,6 +222,14 @@ int main(int argc, char **argv) {
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
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),
"volume repeat accelerates and excludes mute");
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
@@ -752,6 +801,212 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
{
auto settings = app.settingsWindow;
auto tabs = GetDlgItem(settings, 260);
auto capture = GetDlgItem(settings, 737);
auto events = GetDlgItem(settings, 733);
require(!(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE),
"mouse detection is hidden on General");
TabCtrl_SetCurSel(tabs, 5);
NMHDR changed{tabs, 260, TCN_SELCHANGE};
SendMessageW(settings, WM_NOTIFY, 260, reinterpret_cast<LPARAM>(&changed));
require(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE,
"mouse detection is visible on Mouse");
RECT suggestedSettings{};
GetWindowRect(settings, &suggestedSettings);
suggestedSettings.right -= 12;
suggestedSettings.bottom -= 12;
const UINT settingsDpi = GetDpiForWindow(settings);
SendMessageW(settings, WM_DPICHANGED, MAKEWPARAM(settingsDpi, settingsDpi),
reinterpret_cast<LPARAM>(&suggestedSettings));
require(!(GetWindowLongPtrW(settings, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
!(GetWindowLongPtrW(events, GWL_STYLE) & WS_HSCROLL),
"a DPI resize keeps both settings and the action table free of unnecessary scrollbars");
RECT profilesBounds{}, eventsBounds{};
require(GetWindowRect(GetDlgItem(settings, 731), &profilesBounds) &&
GetWindowRect(events, &eventsBounds) &&
profilesBounds.top == eventsBounds.top && profilesBounds.bottom == eventsBounds.bottom &&
profilesBounds.right - profilesBounds.left == eventsBounds.right - eventsBounds.left,
"mouse profile and action tables have equal dimensions and aligned edges");
SendMessageW(events, WM_KEYDOWN, VK_RETURN, 0);
auto editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
"Global actions do not include inheritance");
SendMessageW(editor, WM_COMMAND, IDCANCEL, 0);
require(!GetDlgItem(settings, 741) && !GetDlgItem(settings, 732) && !GetDlgItem(settings, 745),
"applications are chosen in the action editor only");
for (auto message : {WM_XBUTTONDOWN, WM_XBUTTONUP, WM_XBUTTONDBLCLK, WM_XBUTTONUP})
SendMessageW(capture, message, MAKEWPARAM(0, XBUTTON2), 0);
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 4,
"mouse button capture selects the second side button");
wchar_t detected[100]{};
GetDlgItemTextW(settings, 738, detected, 100);
require(std::wstring(detected) == L"Window event: XButton2 | Presses: 2",
"detection shows the last button and counts repeated presses");
SendMessageW(capture, WM_MBUTTONDBLCLK, 0, 0);
SendMessageW(capture, WM_MBUTTONUP, 0, 0);
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 7,
"a double middle click selects its own event");
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;
SendMessageW(settings, WM_NOTIFY, 733, reinterpret_cast<LPARAM>(&click));
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && !IsWindowEnabled(settings) && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
"double-click opens the Global action editor");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 1, 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
SendDlgItemMessageW(editor, 747, ks::CaptureHotkeyMessage, VK_TAB, MOD_CONTROL);
require(!GetDlgItem(editor, 748), "the action editor has no application list");
SendMessageW(editor, WM_COMMAND, IDOK, 0);
const auto profiles = GetDlgItem(settings, 731);
require(ListView_GetItemCount(profiles) == 1 &&
IsWindowEnabled(GetDlgItem(settings, IDOK)), "adding a mouse binding enables Save");
wchar_t action[80]{};
ListView_GetItemText(events, 7, 1, action, 80);
require(std::wstring(action) == L"Ctrl+Tab", "the Action column reflects the selected event's binding");
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor != nullptr, "F2 opens the selected mouse action");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)),
"removing the draft mouse binding clears pending changes");
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor != nullptr, "opens the action editor to configure volume");
RECT fullCombo{}, compactHeading{};
GetWindowRect(GetDlgItem(editor, 734), &fullCombo);
GetWindowRect(GetDlgItem(editor, IDOK), &compactHeading);
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
require(IsWindowVisible(GetDlgItem(editor, 759)) &&
GetDlgItemInt(editor, 759, nullptr, FALSE) == ks::DefaultMouseVolumeStep,
"volume actions show the default step");
RECT volumeCombo{}, volumeField{}, volumeSpin{}, volumeHeading{};
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
GetWindowRect(GetDlgItem(editor, IDOK), &volumeHeading);
int minimum{}, maximum{};
SendDlgItemMessageW(editor, 760, UDM_GETRANGE32,
reinterpret_cast<WPARAM>(&minimum), reinterpret_cast<LPARAM>(&maximum));
require(IsWindowVisible(GetDlgItem(editor, 760)) && minimum == 1 && maximum == 50 &&
volumeCombo.top == volumeField.top && volumeCombo.right < volumeField.left &&
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom &&
std::abs(volumeCombo.bottom - volumeField.bottom) <= 1 &&
volumeCombo.right < fullCombo.right && volumeHeading.top == compactHeading.top,
"volume step spinner is beside the shortened action dropdown");
RECT suggested{};
GetWindowRect(editor, &suggested);
suggested.right -= 12;
suggested.bottom -= 12;
const UINT editorDpi = GetDpiForWindow(editor);
SendMessageW(editor, WM_DPICHANGED, MAKEWPARAM(editorDpi, editorDpi),
reinterpret_cast<LPARAM>(&suggested));
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
require(!(GetWindowLongPtrW(editor, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
volumeCombo.top == volumeField.top && volumeCombo.bottom == volumeField.bottom &&
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom,
"a DPI resize keeps the action row aligned and restores the full client area");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::Back), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
RECT backCombo{}, backHeading{};
GetWindowRect(GetDlgItem(editor, 734), &backCombo);
GetWindowRect(GetDlgItem(editor, IDOK), &backHeading);
require(EqualRect(&fullCombo, &backCombo) && EqualRect(&compactHeading, &backHeading) &&
!IsWindowVisible(GetDlgItem(editor, 760)), "back restores full width without an unused row");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
SetDlgItemInt(editor, 759, 7, FALSE);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && GetDlgItemInt(editor, 759, nullptr, FALSE) == 7,
"reopening a volume action restores the configured step");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
SendMessageW(settings, WM_COMMAND, 754, 0);
auto profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog && GetDlgItem(profileDialog, 755), "add profile opens the Name dialog");
SetDlgItemTextW(profileDialog, 755, L"Browsing");
for (const auto *name : {L"chrome.exe", L"msedge.exe"})
{
SetDlgItemTextW(profileDialog, 749, name);
SendMessageW(profileDialog, WM_COMMAND, 750, 0);
}
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
wchar_t applications[80]{};
ListView_GetItemText(profiles, 1, 2, applications, 80);
require(std::wstring(applications) == L"chrome.exe, msedge.exe",
"applications are visible in the profile table");
require(ListView_GetNextItem(profiles, -1, LVNI_SELECTED) == 1 &&
IsWindowEnabled(GetDlgItem(settings, IDOK)), "new profile is selected and enables Save");
RECT profileRow{};
require(ListView_GetItemRect(profiles, 1, &profileRow, LVIR_BOUNDS),
"profile row bounds are available");
const int nameWidth = ListView_GetColumnWidth(profiles, 1);
const int rowCenter = (profileRow.top + profileRow.bottom) / 2;
int checkboxX = -1;
for (int x = nameWidth; x < profileRow.right; ++x)
{
LVHITTESTINFO hit{};
hit.pt = {x, rowCenter};
if (ListView_HitTest(profiles, &hit) == 1 && (hit.flags & LVHT_ONITEMSTATEICON))
{
checkboxX = x;
break;
}
}
require(checkboxX >= nameWidth && checkboxX < nameWidth + ListView_GetColumnWidth(profiles, 0) &&
ListView_GetCheckState(profiles, 1),
"the enabled checkbox is hit-testable in the second column");
SendMessageW(profiles, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(checkboxX, rowCenter));
SendMessageW(profiles, WM_LBUTTONUP, 0, MAKELPARAM(checkboxX, rowCenter));
require(!ListView_GetCheckState(profiles, 1), "clicking the row checkbox disables its profile");
ListView_SetItemState(profiles, 0, LVIS_SELECTED, LVIS_SELECTED);
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(nameWidth / 2, rowCenter));
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog != nullptr, "double-clicking a profile name opens the clicked row");
SendMessageW(profileDialog, WM_COMMAND, IDCANCEL, 0);
const int applicationsX = nameWidth + ListView_GetColumnWidth(profiles, 0) + 4;
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(applicationsX, rowCenter));
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog && SendDlgItemMessageW(profileDialog, 748, LB_GETCOUNT, 0, 0) == 2,
"double-click opens the profile editor with its applications");
SetDlgItemTextW(profileDialog, 755, L"Work browsing");
SendDlgItemMessageW(profileDialog, 748, LB_SETCURSEL, 0, 0);
SendMessageW(profileDialog, WM_COMMAND, 751, 0);
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
wchar_t renamed[80]{};
ListView_GetItemText(profiles, 1, 1, renamed, 80);
ListView_GetItemText(profiles, 1, 2, applications, 80);
require(std::wstring(renamed) == L"Work browsing" &&
std::wstring(applications) == L"msedge.exe" &&
!ListView_GetCheckState(profiles, 1),
"renaming keeps the profile selected and disabled");
const int appsWidth = ListView_GetColumnWidth(profiles, 2);
ListView_SetColumnWidth(profiles, 2, appsWidth + 100);
SendMessageW(settings, WM_SIZE, 0, 0);
require(ListView_GetColumnWidth(profiles, 2) == appsWidth + 100 &&
(GetWindowLongPtrW(profiles, GWL_STYLE) & WS_HSCROLL),
"the Applications column retains its wider size and can scroll");
SendMessageW(settings, ks::ConfigSettingsDownloaded, 0, 0);
}
{
auto window = app.settingsWindow;
auto previous = app.settings;
+117 -1
View File
@@ -1,4 +1,5 @@
#include "settings_merge.h"
#include "mouse_buttons.h"
#include <iostream>
using namespace ks;
int main(){try{
@@ -17,5 +18,120 @@ int main(){try{
draft=base;current=base;draft.chatCycles[L"telegram\nfirst"]={L"win:04090409"};current.chatCycles[L"telegram\nsecond"]={L"win:04220422"};
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
if(merged.chatCycles.size()!=2)throw std::runtime_error("independent nested changes lost");
std::cout<<"Passed 5 settings-save merge checks\n";
draft = base;
current = base;
draft.mouseBindings[L"chrome"][L"XButton1"] = {MouseActionType::Shortcut, {MOD_CONTROL, VK_TAB}};
current.mouseBindings[L"chrome"][L"XButton2"] = {MouseActionType::Shortcut, {MOD_CONTROL | MOD_SHIFT, VK_TAB}};
merged = decodeSettings(mergeConfigurationSettings(encodeSettings(base), encodeSettings(draft),
encodeSettings(current)), {});
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, 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");
current.mouseBindings.at(L"chrome")[L"XButton2"] = {MouseActionType::Shortcut, {MOD_CONTROL, VK_F4}};
merged = decodeSettings(mergeConfigurationSettings(encodeSettings(withButtons), encodeSettings(draft),
encodeSettings(current)), {});
const auto &buttons = merged.mouseBindings.at(L"chrome");
if (buttons.size() != 1 || buttons.at(L"XButton2") != MouseAction{MouseActionType::Shortcut, {MOD_CONTROL, VK_F4}})
throw std::runtime_error("mouse button removal lost during merge");
merged.mouseBindings[GlobalMouseProfile][L"Double middle"] = {MouseActionType::Mute};
merged.mouseBindings[L"chrome"][L"Double middle"] = {MouseActionType::Standard};
merged.mouseBindings[L"edge"][L"XButton1"] = {MouseActionType::Launch, {}, L"C:\\Tools\\app.exe"};
merged.mouseBindings[L"edge"][L"XButton2"] = {MouseActionType::Launch, {}, L"calc.exe"};
merged.mouseBindings[GlobalMouseProfile][L"Left"] = {MouseActionType::Shortcut, {MOD_CONTROL, 'W'}, {}, {L"chrome", L"msedge"}};
merged.mouseBindings[GlobalMouseProfile][L"Middle"] = {MouseActionType::Back, {}, {}, {L"chrome"}};
auto restored = decodeSettings(encodeSettings(merged), {});
if (restored.mouseBindings != merged.mouseBindings ||
effectiveMouseAction(restored, L"chrome", 7) ||
!effectiveMouseAction(restored, L"edge", 7) ||
effectiveMouseAction(restored, L"edge", 7)->type != MouseActionType::Mute)
throw std::runtime_error("mouse action override or inheritance failed");
if (!effectiveMouseAction(restored, L"chrome", 0) || !effectiveMouseAction(restored, L"msedge", 0) ||
effectiveMouseAction(restored, L"explorer", 0) || !effectiveMouseAction(restored, L"chrome", 2) ||
effectiveMouseAction(restored, L"msedge", 2) || !effectiveMouseAction(restored, L"explorer", 7))
throw std::runtime_error("application restrictions or unrestricted fallback failed");
draft = current = restored;
draft.mouseBindings[GlobalMouseProfile][L"Middle"].applications.clear();
current.mouseBindings[GlobalMouseProfile][L"Middle"].type = MouseActionType::Mute;
const auto competing = mergeSyncProfile(encodeSettings(restored), encodeSettings(draft), encodeSettings(current));
if (competing.conflicts.size() != 1)
throw std::runtime_error("competing edits to a mouse action and its scope must conflict");
Settings profiles;
profiles.mouseBindings[L"name:Browsing"][L"XButton1"] = {MouseActionType::Forward};
profiles.mouseBindings[L"name:Browsing"][L"XButton2"] = {MouseActionType::Back};
profiles.mouseProfileApplications[L"name:Browsing"] = {L"chrome", L"msedge"};
profiles.mouseBindings[L"name:Empty"];
profiles.mouseBindings[GlobalMouseProfile][L"XButton1"] = {MouseActionType::VolumeUp};
profiles.mouseBindings[GlobalMouseProfile][L"XButton2"] = {MouseActionType::VolumeDown};
profiles.mouseBindings[GlobalMouseProfile][L"XButton2"].volumeStep = 7;
auto resolve = [&](const wchar_t *app, int event, bool foreground = true)
{
const auto *action = effectiveMouseAction(profiles, app, event, foreground);
return action ? action->type : MouseActionType::Standard;
};
profiles = decodeSettings(mergeConfigurationSettings(encodeSettings(base), encodeSettings(profiles), encodeSettings(base)), {});
if (profiles.mouseBindings.at(GlobalMouseProfile).at(L"XButton1").volumeStep != DefaultMouseVolumeStep ||
profiles.mouseBindings.at(GlobalMouseProfile).at(L"XButton2").volumeStep != 7)
throw std::runtime_error("default and custom volume steps must round-trip independently");
auto invalidVolume = profiles;
invalidVolume.mouseBindings[GlobalMouseProfile][L"XButton1"].volumeStep = 0;
rejected = false;
try { validateSettings(invalidVolume); }
catch (const std::runtime_error &) { rejected = true; }
if (!rejected) throw std::runtime_error("invalid volume steps must be rejected");
if (!profiles.mouseBindings.contains(L"name:Empty") ||
resolve(L"chrome", 3) != MouseActionType::Forward || resolve(L"msedge", 4) != MouseActionType::Back ||
resolve(L"chrome", 3, false) != MouseActionType::VolumeUp ||
resolve(L"explorer", 4) != MouseActionType::VolumeDown)
throw std::runtime_error("named profiles must persist and override fallback only for the matching foreground app");
profiles.mouseBindings[L"name:Alternative"][L"XButton1"] = {MouseActionType::Back};
profiles.mouseProfileApplications[L"name:Alternative"] = {L"chrome"};
profiles.disabledMouseProfiles.insert(L"name:Browsing");
profiles = decodeSettings(encodeSettings(profiles), {});
if (resolve(L"chrome", 3) != MouseActionType::Back)
throw std::runtime_error("disabled profiles must not resolve actions");
profiles.disabledMouseProfiles.clear();
rejected = false;
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"});