Release KSwitcher v0.6.65

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

No files matched your search

+31
View File
@@ -125,6 +125,27 @@ struct InputRegressionAccess {
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
static bool wheelQueuesAction(App &app)
{
MSLLHOOKSTRUCT mouse{};
if (!GetCursorPos(&mouse.pt) || !WindowFromPoint(mouse.pt)) return false;
auto previous = app.settings;
app.settings.customMouseEvents[L"Wheel down"] = L"Wheel test";
app.settings.mouseBindings[GlobalMouseProfile][L"Wheel down"] = {MouseActionType::Mute};
mouse.mouseData = MAKEWPARAM(0, static_cast<WORD>(-30));
const auto wheel = [&] { return App::mouseProc(HC_ACTION, WM_MOUSEWHEEL, reinterpret_cast<LPARAM>(&mouse)); };
mouse.flags = LLMHF_INJECTED;
wheel();
const bool ignoredInjection = app.mouseActions.empty();
mouse.flags = 0;
const bool partial = wheel() == 1 && app.mouseActions.empty();
mouse.mouseData = MAKEWPARAM(0, static_cast<WORD>(-90));
const bool queued = wheel() == 1 && app.mouseActions.size() == 1 &&
app.mouseActions.front().action.type == MouseActionType::Mute;
app.mouseActions.clear();
app.settings = std::move(previous);
return ignoredInjection && partial && queued;
}
static bool volumeHoldStopsOnRelease(App &app)
{
constexpr int button = 3;
@@ -203,6 +224,8 @@ int main(int argc, char **argv) {
require(app.window!=nullptr,"message-only test window");
require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app),
"volume hold repeats after its delay and stops on release without an extra step");
require(ks::InputRegressionAccess::wheelQueuesAction(app),
"a registered physical wheel signal queues one action per detent");
require(ks::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) &&
ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) &&
!ks::isMouseVolumeStep(ks::MouseActionType::Mute),
@@ -828,6 +851,14 @@ int main(int argc, char **argv) {
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)), "button detection is not a settings edit");
require(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2,
"mouse events have eight rows and two columns");
SendMessageW(settings, WM_COMMAND, 762, 0);
const auto addEvent = FindWindowW(L"KSwitcher.AddMouseEvent", nullptr);
require(addEvent && GetDlgItem(addEvent, 764) && GetDlgItem(addEvent, 763) &&
!IsWindowEnabled(GetDlgItem(addEvent, IDOK)), "Add mouse event waits for a physical signal");
SendMessageW(addEvent, WM_COMMAND, IDOK, 0);
require(IsWindow(addEvent) && ListView_GetItemCount(events) == 8,
"the event editor cannot save an uncaptured signal");
SendMessageW(addEvent, WM_COMMAND, IDCANCEL, 0);
NMITEMACTIVATE click{};
click.hdr = {events, 733, NM_DBLCLK};
click.iItem = 7;
+36 -3
View File
@@ -27,9 +27,9 @@ int main(){try{
if (merged.mouseBindings.at(L"chrome").size() != 2)
throw std::runtime_error("independent mouse bindings lost");
auto shared = sharedSyncSettings(merged);
if (shared != sharedSyncSettings(base) ||
restoreSyncSettings(shared, merged, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must stay local during sync");
if (shared == sharedSyncSettings(base) ||
restoreSyncSettings(shared, Settings{}, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must restore from a synchronized backup");
auto withButtons = merged;
draft = current = withButtons;
draft.mouseBindings.at(L"chrome").erase(L"XButton1");
@@ -100,5 +100,38 @@ int main(){try{
try { validateSettings(profiles); }
catch (const std::runtime_error &e) { rejected = std::string(e.what()).starts_with("Mouse action conflict:"); }
if (!rejected) throw std::runtime_error("overlapping active profiles must be rejected");
Settings wheel;
const int down = mouseWheelEvent(WM_MOUSEWHEEL, -WHEEL_DELTA);
RAWMOUSE raw{};
raw.usButtonFlags = RI_MOUSE_WHEEL;
raw.usButtonData = static_cast<USHORT>(-WHEEL_DELTA);
if (rawMouseSignal(raw) != down || mouseEventEnabled(wheel, down))
throw std::runtime_error("wheel capture must match the runtime signal and be opt-in");
raw.usButtonFlags = RI_MOUSE_HWHEEL;
if (rawMouseSignal(raw) != mouseWheelEvent(WM_MOUSEHWHEEL, -WHEEL_DELTA))
throw std::runtime_error("horizontal wheel capture must match the runtime signal");
int remainder{};
if (mouseWheelSteps(remainder, -30) != 0 || mouseWheelSteps(remainder, -90) != 1 ||
mouseWheelSteps(remainder, 240) != 2 || remainder != 0)
throw std::runtime_error("high-resolution wheel deltas must accumulate by detent");
wheel.customMouseEvents.emplace(MouseEvents[down], L"Lower volume");
wheel.mouseBindings[GlobalMouseProfile][MouseEvents[down]] = {MouseActionType::VolumeDown};
wheel.mouseBindings[L"name:Browser"][L"XButton1"] = {MouseActionType::Back};
wheel.mouseProfileApplications[L"name:Browser"] = {L"chrome"};
wheel.disabledMouseProfiles.insert(L"name:Browser");
wheel.appLanguageSets[L"Browser"] = {{L"win:00000409"}, {L"chrome"}};
const auto restoredWheel = decodeSettings(encodeSettings(wheel), {});
const auto syncedWheel = restoreSyncSettings(sharedSyncSettings(wheel), Settings{}, {});
if (mouseEventName(restoredWheel, down) != L"Lower volume" ||
!effectiveMouseAction(restoredWheel, L"explorer", down) ||
sharedSyncSettings(syncedWheel) != sharedSyncSettings(wheel))
throw std::runtime_error("custom wheel event and action must restore from a synchronized backup");
if (!restoreSyncSettings(sharedSyncSettings(Settings{}), wheel, {}).customMouseEvents.empty())
throw std::runtime_error("an explicit empty backup must clear custom mouse events");
wheel.customMouseEvents.clear();
rejected = false;
try { validateSettings(wheel); }
catch (const std::runtime_error &) { rejected = true; }
if (!rejected) throw std::runtime_error("unregistered mouse signals cannot have bindings");
std::cout<<"Settings-save merge checks passed\n";
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
+19 -5
View File
@@ -155,11 +155,25 @@ int main()
rightScope["snippets"].back()["apps"]=Json::array({"NOTEPAD.exe"});
rejects([&]{mergeSyncSettings(base,bytes(leftScope),bytes(rightScope));}, "sync detects overlapping application scopes");
}
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
auto restored = restoreSyncSettings(base, local, {});
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
auto hostile = j; hostile["startWithSystem"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
require(j.contains("startWithSystem") && j.contains("tesseractPath") && j.contains("historyEnabled"),
"server backup includes startup, OCR and history settings");
auto restored = restoreSyncSettings(base, Settings{}, {});
require(sharedSyncSettings(restored) == base, "restore all settings onto a fresh installation");
auto legacySettings = j;
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents"})
legacySettings.erase(key);
FakeProvider legacyProvider;
legacyProvider.remote = {3, bytes(legacySettings)};
auto recovered = synchronize(legacyProvider, {}, base, SyncAction::Download);
require(sharedSyncSettings(restoreSyncSettings(recovered.settings, local, {})) == base &&
legacyProvider.writes == 0, "older server backup preserves fields it never stored");
auto upgraded = synchronize(legacyProvider, bytes(legacySettings), base, SyncAction::Automatic);
require(legacyProvider.writes == 1 && parse(upgraded.settings) == j,
"automatic sync upgrades an older server backup");
auto hostile = j; hostile["unknownSetting"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject unsupported settings");
hostile = j; hostile["schemaVersion"] = 2;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
hostile = j; hostile["layouts"] = Json::array({"00000422"});