Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a65e7f7fb | ||
|
|
14f33012a3 | ||
|
|
5a2106899d |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.62 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.65 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
|
||||
@@ -76,6 +76,26 @@ the preview does not change firewall rules. Broadcast-blocked networks, separate
|
||||
VLANs, public IPv4 and IPv6-only networks are not supported by discovery yet.
|
||||
A second LAN-enabled KSwitcher cannot use the same ports on one computer.
|
||||
|
||||
Input first tries TCP in the usual direction, then reverse TCP after 1.5 seconds.
|
||||
If TCP has not authenticated after 5 seconds, the lower-ID peer negotiates an
|
||||
encrypted UDP input session on the existing discovery socket (UDP 45883).
|
||||
UDP uses fresh session challenges, authenticated encryption, ordered event
|
||||
numbers, acknowledgements and bounded retries; duplicated packets do not repeat
|
||||
key presses. Two seconds without authenticated traffic or acknowledgement
|
||||
progress closes the session and returns control locally. A working UDP session
|
||||
stays on UDP until disconnected; it does not oscillate between transports.
|
||||
Both PCs must run an updated build to use all fallback methods. Older peers can
|
||||
still use the original TCP protocol. No extra ports or external relay are used.
|
||||
|
||||
After 15 seconds without a connection, the UI points to **Connection help**.
|
||||
The help window can copy PowerShell commands for the actual resident EXE path,
|
||||
including a path containing apostrophes. Run each PC's own command in an
|
||||
administrator PowerShell on that PC. These commands permit inbound TCP 45884
|
||||
and UDP 45883 only from the local subnet; the program does not execute them.
|
||||
Moving the EXE may require a new application firewall rule. A timeout alone
|
||||
does not establish that the firewall is the cause: check that input sharing is
|
||||
enabled on both PCs and that both are running the intended copy.
|
||||
|
||||
The instance ID belongs to a data directory, not a hostname/IP address. Discovered
|
||||
names and addresses are untrusted. Pairing uses a generated 128-bit code and
|
||||
mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never
|
||||
|
||||
@@ -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.
|
||||
@@ -47,6 +105,12 @@ Closing Settings with unsaved changes (including accepted auto-correction or OCR
|
||||
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
|
||||
If saving fails, the window stays open with your edits intact.
|
||||
Settings displays **No unsaved changes** with **Save** disabled until an edit is made.
|
||||
The Computers window applies and saves network changes immediately, separately from Settings.
|
||||
Pairing shows the target computer and remaining wait time; enter the same code and click **Pair** on both PCs.
|
||||
The code stays visible for copying or retry until the pair is saved. A saved pair still requires input sharing to be enabled on both PCs.
|
||||
Input automatically tries direct TCP, reverse TCP (after 1.5 seconds), and encrypted UDP over discovery (after 5 seconds).
|
||||
Both PCs need an updated build for all fallback methods. The connection status identifies the selected transport.
|
||||
If attempts remain unsuccessful for 15 seconds, **Connection help** explains checks and provides copyable administrator PowerShell commands for the running executable on that PC. Run each PC's own command locally; rules allow only the local subnet. Commands are never executed automatically.
|
||||
**Unsaved changes — click Save to apply** appears for pending edits, including accepted
|
||||
chat-language, auto-correction and OCR changes. Cancelling a child dialog or restoring
|
||||
the original values clears its pending changes. Snippets saved in their own window
|
||||
@@ -621,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
|
||||
@@ -929,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…**.
|
||||
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
|
||||
@@ -953,5 +1020,5 @@ settings export/import.
|
||||
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
|
||||
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
|
||||
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
|
||||
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації
|
||||
загальний цикл. Профілі входять до серверної синхронізації
|
||||
та входять до ручного експорту/імпорту налаштувань.
|
||||
Binary file not shown.
+28
-3
@@ -1,11 +1,36 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.62 — 2026-11-10
|
||||
## v0.6.65 — 2026-11-12
|
||||
|
||||
In **Settings → Languages → Languages for applications...**, drag **⊕ Drag to window** onto an open window. A frame follows the window under the crosshair. Release to select the program in the list, or press **Esc** to cancel. If the program is missing from the list, KSwitcher adds it for selection. Pending edits for other applications are preserved.
|
||||
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.
|
||||
|
||||
Choose the program's allowed layouts, then **Use languages** and **Save**. Application profiles apply to the executable, including all browser tabs in that executable. Contact profiles still take priority.
|
||||
OpenBao now backs up all KSwitcher Settings, including mouse events, bindings,
|
||||
application profiles, startup, clipboard history limits, and OCR preferences.
|
||||
Update all clients that share a server profile before syncing. Downloading an
|
||||
older backup preserves local settings for fields it never included. After
|
||||
reinstalling Windows, reconnect to OpenBao and choose **Download remote**.
|
||||
Executable paths and optional OCR software must still be available on the
|
||||
restored PC. Clipboard contents and Windows credentials stay on the device.
|
||||
|
||||
**Українською.** У **Settings → Mouse** натисни **+** біля **Mouse events**,
|
||||
щоб додати напрямок прокручування. Відомий сигнал виділить наявний рядок.
|
||||
OpenBao тепер зберігає всі налаштування KSwitcher, зокрема профілі миші,
|
||||
OCR й автозапуск. Перед синхронізацією онови всі клієнти спільного профілю.
|
||||
Після перевстановлення Windows підключись до OpenBao й натисни
|
||||
**Download remote**.
|
||||
|
||||
## v0.6.64 — 2026-11-12
|
||||
|
||||
The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut.
|
||||
|
||||
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 @@
|
||||
bd1fc311f17fcd26c438449c1ddbb82a30261824396ba18e7f0831f02d719f78 KSwitcher-win-x64.zip
|
||||
e60ca342737155dce14a2bb1dd43ec1cef890cf7d9e5efb2e15a5b9629eaf1af KSwitcher-win-x64.zip
|
||||
+46
-46
@@ -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
@@ -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,62,0
|
||||
PRODUCTVERSION 0,6,62,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.62"
|
||||
VALUE "FileVersion", "0.6.65"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+359
-10
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+27
-4
@@ -72,6 +72,7 @@ struct LanService::Impl
|
||||
std::map<std::string,uint64_t> inputReady;
|
||||
std::unique_ptr<lan::InputMesh> router;
|
||||
std::string inputStatus="Input sharing disabled.";
|
||||
bool inputNeedsHelp{};
|
||||
HWND inputWindow{};std::atomic<bool> inputNotification{false};
|
||||
explicit Impl(const std::filesystem::path &root,HWND window) : path(root/L"lan-pairing.bin"),inputWindow(window)
|
||||
{
|
||||
@@ -210,7 +211,7 @@ struct LanService::Impl
|
||||
auto previous=trusted;auto previousPositions=positions;
|
||||
trusted[other]={{"name",peer.name},{"key",pending->derived}};
|
||||
try {lan::fillPositions(positions,trusted,inputPeer,inputEdge);save();} catch(...) {trusted=std::move(previous);positions=std::move(previousPositions);throw;}
|
||||
layoutChanged=true;peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
|
||||
layoutChanged=true;peer.verified=now; status="Pair saved automatically. Arrange the monitors, then enable input sharing on both PCs.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,6 +233,10 @@ struct LanService::Impl
|
||||
if(bind(socket,(sockaddr*)&bound,sizeof(bound))) throw std::runtime_error("UDP port 45883 is in use. Only one LAN-enabled KSwitcher may run per computer.");
|
||||
uint64_t nextHello=0,nextOffer=0;
|
||||
lan::InputHub hub(id);
|
||||
hub.datagrams([&](const auto &target,const auto &packet){
|
||||
std::lock_guard lock(mutex);
|
||||
if(shareInput&&trusted.contains(target)&&peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||
});
|
||||
while(!stopping)
|
||||
{
|
||||
auto now=GetTickCount64();bool clipboardActive=false;
|
||||
@@ -279,7 +284,7 @@ struct LanService::Impl
|
||||
}
|
||||
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
|
||||
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
|
||||
{std::lock_guard lock(mutex);inputStatus=hub.status();}
|
||||
{std::lock_guard lock(mutex);inputStatus=hub.status();inputNeedsHelp=hub.needsHelp();}
|
||||
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,!endpoints.empty()||clipboardActive?5000:100000};
|
||||
int ready=select(0,&read,nullptr,nullptr,&delay);
|
||||
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
|
||||
@@ -289,7 +294,14 @@ struct LanService::Impl
|
||||
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
|
||||
if(received<=0) break;
|
||||
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
|
||||
try { auto j=Json::parse(buffer,buffer+received); std::lock_guard lock(mutex); receive(socket,sender,j,GetTickCount64()); }
|
||||
try {
|
||||
auto j=Json::parse(buffer,buffer+received);
|
||||
if(j.value("type","")=="input-udp"){
|
||||
bool allowed=false;auto peer=j.value("id","");
|
||||
{std::lock_guard lock(mutex);allowed=shareInput&&trusted.contains(peer)&&peers.contains(peer)&&peers.at(peer).address.sin_addr.s_addr==sender.sin_addr.s_addr;}
|
||||
if(allowed)hub.receiveDatagram(j,GetTickCount64());
|
||||
}else {std::lock_guard lock(mutex);receive(socket,sender,j,GetTickCount64());}
|
||||
}
|
||||
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
|
||||
}
|
||||
}
|
||||
@@ -334,6 +346,14 @@ struct LanService::Impl
|
||||
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
|
||||
}
|
||||
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
|
||||
if(action=="connectionHelp"){
|
||||
auto exe=utf8(executable());std::string quoted;
|
||||
for(char c:exe){quoted+=c;if(c=='\'')quoted+='\'';}
|
||||
auto script="$ksPath = '"+quoted+"'\r\n"
|
||||
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884 -RemoteAddress LocalSubnet -Profile Any\r\n"
|
||||
"New-NetFirewallRule -DisplayName 'KSwitcher LAN UDP' -Direction Inbound -Action Allow -Program $ksPath -Protocol UDP -LocalPort 45883 -RemoteAddress LocalSubnet -Profile Any";
|
||||
return encoded({{"script",script}});
|
||||
}
|
||||
if(action=="cancel") { pending.reset(); status="Pairing cancelled."; }
|
||||
if(action=="pair")
|
||||
{
|
||||
@@ -354,9 +374,12 @@ struct LanService::Impl
|
||||
}
|
||||
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
|
||||
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
|
||||
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"controlMode",router?int(router->mode()):0}};
|
||||
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"inputNeedsHelp",enabled&&shareInput&&inputNeedsHelp},{"controlMode",router?int(router->mode()):0}};
|
||||
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
|
||||
auto now=GetTickCount64();
|
||||
result["pendingPeer"]=pending&&!pending->confirmed ? pending->peer : "";
|
||||
result["pairSecondsRemaining"]=pending&&!pending->confirmed&&!pending->expired(now)
|
||||
? (lan::PairLifetime-(now-pending->started)+999)/1000 : 0;
|
||||
for(const auto &[peer,p]:peers)
|
||||
{
|
||||
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
|
||||
|
||||
+60
-18
@@ -1,6 +1,7 @@
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include "lan_channel.h"
|
||||
#include "lan_datagram.h"
|
||||
#include <bcrypt.h>
|
||||
#include <limits>
|
||||
|
||||
@@ -78,17 +79,18 @@ std::string inputSessionKey(const std::string &secret,const std::string &from,co
|
||||
}
|
||||
struct InputChannel::Impl
|
||||
{
|
||||
std::string local,peer,secret,nonce,state="Waiting for the paired computer...";
|
||||
std::string local,peer,secret,nonce,state="Waiting for the paired computer...",lastError;
|
||||
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
|
||||
bool connecting{},authenticated{},helloRead{},managed{};
|
||||
uint64_t changed{},lastRead{},lastPing{},retry{};
|
||||
uint64_t changed{},lastRead{},lastPing{},retry{},waitingSince{};
|
||||
bool reverse{};
|
||||
Bytes incoming,outgoing;
|
||||
std::unique_ptr<InputCipher> tx,rx;
|
||||
Impl(std::string a,std::string b,std::string key,int side,uint16_t p,bool multiple):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p),managed(multiple){}
|
||||
~Impl(){closeSocket(socket);closeSocket(listener);}
|
||||
void reset(uint64_t now,const std::string &reason)
|
||||
{
|
||||
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;state=reason;
|
||||
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;waitingSince=0;state=reason;lastError=reason;
|
||||
}
|
||||
void begin(uint64_t now)
|
||||
{
|
||||
@@ -101,6 +103,7 @@ struct InputChannel::Impl
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!waitingSince)waitingSince=now;
|
||||
if(socket==INVALID_SOCKET&&now<retry)return;
|
||||
if(!managed && listener==INVALID_SOCKET && local>peer)
|
||||
{
|
||||
@@ -111,14 +114,15 @@ struct InputChannel::Impl
|
||||
}
|
||||
if(socket==INVALID_SOCKET && address && now>=retry)
|
||||
{
|
||||
if(local<peer)
|
||||
if(local<peer||(managed&&now-waitingSince>=1500))
|
||||
{
|
||||
reverse=local>peer;
|
||||
socket=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(socket==INVALID_SOCKET||!nonblocking(socket))throw std::runtime_error("Cannot open input connection.");
|
||||
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);a.sin_addr.s_addr=address;
|
||||
int result=connect(socket,(sockaddr*)&a,sizeof(a));
|
||||
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
|
||||
connecting=result!=0;begin(now);
|
||||
if(connecting)state="Connecting to paired PC on TCP 45884...";
|
||||
if(connecting)state=reverse?"Trying reverse TCP connection...":"Trying direct TCP connection...";
|
||||
}
|
||||
else if(!managed)
|
||||
{
|
||||
@@ -185,7 +189,7 @@ struct InputChannel::Impl
|
||||
while(helloRead && incoming.size()>=36)
|
||||
{
|
||||
auto f=rx->decrypt(Bytes(incoming.begin(),incoming.begin()+36));incoming.erase(incoming.begin(),incoming.begin()+36);lastRead=now;
|
||||
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;state="Input connected. Move through the configured screen edge.";}
|
||||
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;lastError.clear();state="Input connected. Move through the configured screen edge.";}
|
||||
else if(f.type==InputType::Ready)throw std::runtime_error("Repeated input handshake.");
|
||||
else if(f.type!=InputType::Ping)received.push_back(f);
|
||||
}
|
||||
@@ -198,30 +202,44 @@ InputChannel::~InputChannel()=default;
|
||||
void InputChannel::poll(uint32_t ip,const std::vector<InputFrame>&out,std::vector<InputFrame>&in,uint64_t now){impl->poll(ip,out,in,now);}
|
||||
bool InputChannel::adopt(uintptr_t accepted,uint64_t now)
|
||||
{
|
||||
if(!impl->managed||impl->socket!=INVALID_SOCKET||!nonblocking(SOCKET(accepted)))return false;
|
||||
if(!impl->managed||impl->authenticated||!nonblocking(SOCKET(accepted)))return false;
|
||||
// Reverse connection wins a simultaneous connect race. Never replace an
|
||||
// authenticated channel or accept two sockets for the same input stream.
|
||||
if(impl->socket!=INVALID_SOCKET){
|
||||
if(impl->local>impl->peer||!impl->connecting)return false;
|
||||
impl->reset(now,"Accepting reverse TCP connection.");
|
||||
}
|
||||
impl->reverse=impl->local<impl->peer;
|
||||
impl->connecting=false;impl->begin(now);impl->socket=SOCKET(accepted);return true;
|
||||
}
|
||||
bool InputChannel::ready()const{return impl->authenticated;}
|
||||
uint64_t InputChannel::generation()const{return impl->changed;}
|
||||
std::string InputChannel::status()const{return impl->state;}
|
||||
std::string InputChannel::status()const{
|
||||
return impl->lastError.empty() ? impl->state : impl->lastError+" Retrying automatically.";
|
||||
}
|
||||
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
|
||||
std::string InputChannel::transport()const{return impl->reverse?"reverse TCP":"direct TCP";}
|
||||
struct InputHub::Impl
|
||||
{
|
||||
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{};std::string lastState; };
|
||||
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;std::unique_ptr<InputDatagram> udp;uint64_t generation{},epoch{},since{},failedSince{};bool udpReady{};std::string lastState; };
|
||||
struct Pending {SOCKET socket;uint32_t address;uint64_t since;};
|
||||
std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET;
|
||||
std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{};
|
||||
std::string error;
|
||||
InputHub::SendDatagram sendDatagram;
|
||||
uint64_t currentTime{};
|
||||
Impl(std::string id,uint16_t p):local(std::move(id)),port(p){}
|
||||
~Impl(){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);}
|
||||
void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now)
|
||||
{
|
||||
currentTime=now;
|
||||
for(auto it=links.begin();it!=links.end();){
|
||||
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port;});
|
||||
if(match==endpoints.end())it=links.erase(it);else ++it;
|
||||
}
|
||||
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
|
||||
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);link.epoch=++epoch;links.emplace(e.id,std::move(link));
|
||||
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);
|
||||
link.udp=std::make_unique<InputDatagram>(local,e.id,e.key);link.since=link.failedSince=now;link.epoch=++epoch;links.emplace(e.id,std::move(link));
|
||||
}
|
||||
// Discovery is a hint, not the lifetime of authenticated input sessions.
|
||||
// Keep the last usable address across missed announcements. A new address
|
||||
@@ -231,7 +249,7 @@ struct InputHub::Impl
|
||||
link.channel->disconnect();link.endpoint.address=e.address;
|
||||
}
|
||||
}
|
||||
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
|
||||
bool listenNeeded=!links.empty();
|
||||
if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();}
|
||||
if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){
|
||||
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
|
||||
@@ -253,7 +271,7 @@ struct InputHub::Impl
|
||||
else if(count>=int(length+4)){
|
||||
done=true;
|
||||
try{auto hello=Json::parse(bytes+4,bytes+4+length);auto id=hello.value("id","");auto link=links.find(id);
|
||||
if(id<local&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
|
||||
if(hello.value("app","")=="KSwitcher-input"&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&!link->second.udp->active()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
|
||||
}catch(const std::exception &){}
|
||||
}
|
||||
}
|
||||
@@ -261,8 +279,16 @@ struct InputHub::Impl
|
||||
}
|
||||
for(auto &[id,link]:links){
|
||||
std::vector<InputFrame> frames,received;for(auto &f:out)if(f.peer==id&&(!f.epoch||f.epoch==link.epoch))enqueueInput(frames,f.frame);
|
||||
link.channel->poll(link.endpoint.address,frames,received,now);
|
||||
auto state=link.channel->status();if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
|
||||
if(sendDatagram&&!link.channel->ready()&&!link.udp->active()&&local<id&&now-link.since>=5000){link.udp->start(now);link.channel->disconnect();frames.clear();}
|
||||
if(link.udp->active()){
|
||||
link.udp->poll(frames,received,now,sendDatagram);
|
||||
if(!link.udp->active()){link.since=now;link.channel->disconnect();received.clear();}
|
||||
}else link.channel->poll(link.endpoint.address,frames,received,now);
|
||||
if(link.udpReady!=link.udp->ready()){link.udpReady=link.udp->ready();link.epoch=++epoch;}
|
||||
bool ready=link.channel->ready()||link.udp->ready();
|
||||
if(ready)link.failedSince=link.since=now;
|
||||
auto state=link.udp->ready()?"Input connected via encrypted UDP.":link.udp->active()?"Trying encrypted UDP over discovery...":link.channel->ready()?"Input connected via "+link.channel->transport()+".":link.channel->status();
|
||||
if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
|
||||
if(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;}
|
||||
for(auto &f:received)in.push_back({id,f,link.epoch});
|
||||
}
|
||||
@@ -271,12 +297,28 @@ struct InputHub::Impl
|
||||
InputHub::InputHub(std::string id,uint16_t port):impl(std::make_unique<Impl>(std::move(id),port)){}
|
||||
InputHub::~InputHub()=default;
|
||||
void InputHub::poll(const std::vector<InputEndpoint> &peers,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now){impl->poll(peers,out,in,now);}
|
||||
std::map<std::string,uint64_t> InputHub::ready()const{std::map<std::string,uint64_t> result;for(auto &[id,l]:impl->links)if(l.channel->ready())result[id]=l.epoch;return result;}
|
||||
std::map<std::string,uint64_t> InputHub::ready()const{std::map<std::string,uint64_t> result;for(auto &[id,l]:impl->links)if(l.channel->ready()||l.udp->ready())result[id]=l.epoch;return result;}
|
||||
std::string InputHub::status()const{
|
||||
auto count=ready().size();if(count)return "Input connected to "+std::to_string(count)+" computer(s). Move through a neighboring screen edge.";
|
||||
auto count=ready().size();if(count==impl->links.size()&&count){
|
||||
std::string routes;for(auto &[id,l]:impl->links){if(!routes.empty())routes+=", ";routes+=id.substr(0,8)+": "+(l.udp->ready()?"encrypted UDP":l.channel->transport());}
|
||||
return "Input connected ("+routes+"). Move through a neighboring screen edge.";
|
||||
}
|
||||
if(needsHelp())return "Automatic connection attempts have not succeeded for every PC. Enable input on both PCs. Open Connection help for firewall commands.";
|
||||
if(!impl->error.empty())return impl->error;
|
||||
for(auto &[id,l]:impl->links)if(!l.channel->status().empty())return l.channel->status();
|
||||
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready())return l.lastState;
|
||||
return "Waiting for paired computers...";
|
||||
}
|
||||
void InputHub::disconnect(){for(auto &[id,l]:impl->links)l.channel->disconnect();}
|
||||
void InputHub::disconnect(){for(auto &[id,l]:impl->links){l.channel->disconnect();l.udp->reset();l.since=impl->currentTime;}}
|
||||
void InputHub::datagrams(SendDatagram send){impl->sendDatagram=std::move(send);}
|
||||
void InputHub::receiveDatagram(const Json &j,uint64_t now){
|
||||
if(!impl->sendDatagram)return;
|
||||
auto it=impl->links.find(j.value("id",""));if(it==impl->links.end()||it->second.channel->ready())return;
|
||||
auto &link=it->second;bool active=link.udp->active();
|
||||
try{link.udp->receive(j,now,impl->sendDatagram);}catch(const std::exception &){return;}
|
||||
if(!active&&link.udp->active())link.channel->disconnect();
|
||||
}
|
||||
bool InputHub::needsHelp()const{
|
||||
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready()&&impl->currentTime-l.failedSince>=15000)return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "lan_pairing.h"
|
||||
#include <memory>
|
||||
#include <map>
|
||||
#include <functional>
|
||||
namespace ks::lan
|
||||
{
|
||||
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
|
||||
@@ -57,6 +58,7 @@ class InputChannel
|
||||
uint64_t generation() const;
|
||||
std::string status() const;
|
||||
void disconnect();
|
||||
std::string transport()const;
|
||||
};
|
||||
struct InputEndpoint {std::string id,key;uint32_t address{};uint16_t port=45884;};
|
||||
struct RoutedInput {std::string peer;InputFrame frame;uint64_t epoch{};};
|
||||
@@ -70,5 +72,9 @@ class InputHub
|
||||
std::map<std::string,uint64_t> ready()const;
|
||||
std::string status()const;
|
||||
void disconnect();
|
||||
using SendDatagram=std::function<void(const std::string &,const Json &)>;
|
||||
void datagrams(SendDatagram);
|
||||
void receiveDatagram(const Json &,uint64_t now);
|
||||
bool needsHelp()const;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
#include "lan_channel.h"
|
||||
#include <deque>
|
||||
|
||||
namespace ks::lan
|
||||
{
|
||||
// Ordered, bounded input on the discovery socket. Retransmission uses fresh
|
||||
// authenticated packets; event sequence numbers prevent duplicate injection.
|
||||
// Fresh challenge/response nonces bind every reconnect to a new session.
|
||||
class InputDatagram
|
||||
{
|
||||
std::string local,peer,secret,nonce,remote,txKey,rxKey;
|
||||
uint64_t started{},lastRead{},lastSend{},sentNumber{},seenNumber{},nextEvent{1},receivedEvent{},acked{},progress{};
|
||||
bool live{},ackDue{};
|
||||
std::deque<std::pair<uint64_t,InputFrame>> queue;
|
||||
std::vector<InputFrame> incoming;
|
||||
static std::string hex(const Bytes &b){std::string s;for(auto v:b){s+="0123456789abcdef"[v>>4];s+="0123456789abcdef"[v&15];}return s;}
|
||||
static Bytes unhex(const std::string &s){
|
||||
if(s.empty()||s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid UDP input packet.");
|
||||
Bytes b;for(size_t i=0;i<s.size();i+=2)b.push_back(static_cast<unsigned char>(std::stoul(s.substr(i,2),nullptr,16)));return b;
|
||||
}
|
||||
std::string proof(const std::string &from,const std::string &to,const std::string &a,const std::string &b)const{
|
||||
return mac(secret,"KSwitcher UDP input v1|"+from+"|"+to+"|"+a+"|"+b);
|
||||
}
|
||||
void keys(){
|
||||
// Handshake proofs are public. Session keys use a separate domain and
|
||||
// must never be the MAC transmitted in a welcome packet.
|
||||
txKey=mac(secret,"KSwitcher UDP input key v1|"+local+"|"+peer+"|"+nonce+"|"+remote);
|
||||
rxKey=mac(secret,"KSwitcher UDP input key v1|"+peer+"|"+local+"|"+remote+"|"+nonce);
|
||||
}
|
||||
public:
|
||||
using Send=InputHub::SendDatagram;
|
||||
explicit InputDatagram(std::string a,std::string b,std::string key):local(std::move(a)),peer(std::move(b)),secret(std::move(key)){}
|
||||
bool active()const{return !nonce.empty();}
|
||||
bool ready()const{return live;}
|
||||
void reset(){nonce.clear();remote.clear();txKey.clear();rxKey.clear();queue.clear();incoming.clear();live=ackDue=false;sentNumber=seenNumber=receivedEvent=acked=lastSend=0;nextEvent=1;}
|
||||
void start(uint64_t now){reset();nonce=randomHex(16);started=lastRead=progress=now;}
|
||||
void receive(const Json &j,uint64_t now,const Send &send)
|
||||
{
|
||||
if(j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||j.value("type","")!="input-udp"||j.value("id","")!=peer||j.value("to","")!=local)return;
|
||||
auto op=j.value("op","");
|
||||
if(op=="hello"){
|
||||
auto other=j.value("nonce","");if(local<peer||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,"")))return;
|
||||
if(active()&&remote!=other)return;
|
||||
if(!active()){start(now);remote=other;keys();}
|
||||
auto response=envelope("input-udp",local);response["to"]=peer;response["op"]="welcome";response["nonce"]=nonce;response["echo"]=remote;response["proof"]=proof(local,peer,nonce,remote);send(peer,response);return;
|
||||
}
|
||||
if(op=="welcome"){
|
||||
auto other=j.value("nonce","");
|
||||
if(local>peer||!active()||j.value("echo","")!=nonce||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,nonce)))return;
|
||||
if(!remote.empty()&&remote!=other)return;
|
||||
remote=other;keys();ackDue=true;return;
|
||||
}
|
||||
if(op!="data"||rxKey.empty()||j.value("session","")!=nonce||j.value("fromSession","")!=remote)return;
|
||||
auto number=j.value("number",uint64_t{});if(!number||number<=seenNumber)return;
|
||||
InputCipher cipher(mac(rxKey,"packet|"+std::to_string(number)));
|
||||
auto body=Json::from_cbor(cipher.decryptPayload(unhex(j.at("data").get<std::string>())));
|
||||
auto ack=body.at("ack").get<uint64_t>(),first=body.at("first").get<uint64_t>();auto frames=body.at("frames");
|
||||
if(ack>=nextEvent||!frames.is_array()||frames.size()>12||!first)return;
|
||||
std::vector<InputFrame> decoded;
|
||||
for(auto &f:frames){
|
||||
if(!f.is_array()||f.size()!=5)return;
|
||||
auto type=f[0].get<uint32_t>();if(type<uint32_t(InputType::Enter)||type>uint32_t(InputType::Cross))return;
|
||||
decoded.push_back({InputType(type),f[1].get<uint32_t>(),f[2].get<int32_t>(),f[3].get<int32_t>(),f[4].get<int32_t>()});
|
||||
}
|
||||
seenNumber=number;lastRead=now;live=true;
|
||||
if(ack>acked){acked=ack;progress=now;while(!queue.empty()&&queue.front().first<=ack)queue.pop_front();}
|
||||
if(!decoded.empty()){
|
||||
if(first==receivedEvent+1){
|
||||
if(incoming.size()+decoded.size()>512){reset();return;}
|
||||
incoming.insert(incoming.end(),decoded.begin(),decoded.end());receivedEvent+=decoded.size();
|
||||
}
|
||||
ackDue=true;
|
||||
}
|
||||
}
|
||||
void poll(const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now,const Send &send)
|
||||
{
|
||||
if(!active())return;
|
||||
if((live&&(now-lastRead>2000||(!queue.empty()&&now-progress>2000)))||(!live&&now-started>3000)){reset();return;}
|
||||
if(live){
|
||||
if(queue.empty()&&!out.empty())progress=now;
|
||||
if(queue.size()+out.size()>512){reset();return;}
|
||||
for(auto &f:out)queue.emplace_back(nextEvent++,f);
|
||||
in.insert(in.end(),incoming.begin(),incoming.end());incoming.clear();
|
||||
}
|
||||
if(remote.empty()){
|
||||
if(!lastSend||now-lastSend>=250){auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="hello";j["nonce"]=nonce;j["proof"]=proof(local,peer,nonce,"");send(peer,j);lastSend=now;}return;
|
||||
}
|
||||
if(!ackDue&&lastSend&&now-lastSend<(queue.empty()?300:50))return;
|
||||
Json frames=Json::array();for(auto &[sequence,f]:queue){frames.push_back(Json::array({uint32_t(f.type),f.token,f.a,f.b,f.c}));if(frames.size()==12)break;}
|
||||
Json body={{"ack",receivedEvent},{"first",queue.empty()?nextEvent:queue.front().first},{"frames",frames}};
|
||||
auto number=++sentNumber;InputCipher cipher(mac(txKey,"packet|"+std::to_string(number)));
|
||||
auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="data";j["number"]=number;j["session"]=remote;j["fromSession"]=nonce;j["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));
|
||||
send(peer,j);lastSend=now;ackDue=false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
+118
-26
@@ -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, ¤t);
|
||||
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)
|
||||
{
|
||||
@@ -1045,7 +1095,10 @@ struct SettingsView
|
||||
int selection = -1;
|
||||
bool filling{};
|
||||
std::vector<std::vector<HWND>> pages;
|
||||
bool ready{}, refreshing{};
|
||||
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);
|
||||
}
|
||||
@@ -1646,7 +1701,8 @@ void updateSettingsState(HWND w, SettingsView *v)
|
||||
v->refreshing = true;
|
||||
bool changed = settingsChanged(w, v);
|
||||
EnableWindow(GetDlgItem(w, IDOK), changed);
|
||||
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." : L"No unsaved changes.";
|
||||
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." :
|
||||
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved changes.";
|
||||
if (value(w, 261) != text) value(w, 261, text);
|
||||
v->refreshing = false;
|
||||
}
|
||||
@@ -1709,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);
|
||||
@@ -1720,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);
|
||||
@@ -1743,8 +1827,9 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||
if (id == 262) { showLan(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)
|
||||
{
|
||||
@@ -1829,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;
|
||||
@@ -2133,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);
|
||||
@@ -2215,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
@@ -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);
|
||||
|
||||
+44
-7
@@ -20,6 +20,26 @@ std::string selectedLan(HWND w,LanView &v)
|
||||
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
|
||||
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
|
||||
}
|
||||
LRESULT CALLBACK lanHelpProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(msg==WM_DPICHANGED){resizeForDpi(w,wp,lp);return 0;}
|
||||
if(msg==WM_COMMAND&&LOWORD(wp)==724){SendDlgItemMessageW(w,723,EM_SETSEL,0,-1);SendDlgItemMessageW(w,723,WM_COPY,0,0);value(w,724,L"Copied");return 0;}
|
||||
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)){DestroyWindow(w);return 0;}
|
||||
if(msg==WM_DESTROY){auto parent=GetWindow(w,GW_OWNER);EnableWindow(parent,TRUE);SetForegroundWindow(parent);return 0;}
|
||||
return DefWindowProcW(w,msg,wp,lp);
|
||||
}
|
||||
void showLanHelp(HWND parent,App &app)
|
||||
{
|
||||
auto script=wide(lanRequest(app,{{"action","connectionHelp"}}).at("script").get<std::string>());
|
||||
auto w=makeWindow(app,L"KSwitcher.LAN.Help",L"KSwitcher — connection help",lanHelpProc,740,430,0,false);
|
||||
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
|
||||
control(w,L"STATIC",L"First enable input sharing on both PCs. KSwitcher tries direct TCP, reverse TCP, then encrypted UDP automatically. Both PCs need an updated version for all three methods.",0,725,18,16,700,60);
|
||||
control(w,L"STATIC",L"Still disconnected? A firewall may be blocking the app. On EACH PC, open this help and run its own command in PowerShell as administrator. This allows only local-network connections to that PC's current KSwitcher executable. No restart or new pairing is needed.",0,726,18,88,700,80);
|
||||
auto field=edit(w,723,18,180,700,180,ES_MULTILINE|ES_READONLY|ES_AUTOVSCROLL|WS_VSCROLL);
|
||||
SetWindowTextW(field,script.c_str());
|
||||
button(w,L"&Copy command",724,18,380,180);button(w,L"&Close",IDCANCEL,618,380,100);
|
||||
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
|
||||
}
|
||||
void refreshLan(HWND w,LanView &v)
|
||||
{
|
||||
auto selected=selectedLan(w,v);
|
||||
@@ -60,12 +80,26 @@ void refreshLan(HWND w,LanView &v)
|
||||
for(size_t i=0;i<v.ids.size();++i)if(v.ids[i]==selected)
|
||||
SendDlgItemMessageW(w,702,LB_SETCURSEL,i,0);
|
||||
selected=selectedLan(w,v);
|
||||
// Keep the code available while the user enters it on the other PC, and
|
||||
// after cancellation/timeout for retry. Clear it only after trust is saved.
|
||||
if(v.snapshot.value("pending",false))
|
||||
for(const auto &peer:next["peers"])
|
||||
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{})&&peer.value("paired",false))value(w,703,L"");
|
||||
v.snapshot=std::move(next);
|
||||
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
|
||||
for(const auto &peer:v.snapshot["peers"]) if(peer.at("id")==selected) {online=peer.at("online");paired=peer.at("paired");}
|
||||
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
|
||||
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
|
||||
value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get<std::string>()) : v.error);
|
||||
auto pairingStatus=wide(v.snapshot.at("status").get<std::string>());
|
||||
if(pending)
|
||||
{
|
||||
std::wstring peerName;
|
||||
for(const auto &peer:v.snapshot["peers"])
|
||||
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{}))peerName=wide(peer.at("name").get<std::string>());
|
||||
pairingStatus=L"Waiting for "+peerName+L" ("+std::to_wstring(v.snapshot.value("pairSecondsRemaining",0))+L" s).\nEnter the same code and click Pair there too. Pair is not saved yet.";
|
||||
}
|
||||
value(w,708,v.error.empty() ? pairingStatus : v.error);
|
||||
value(w,705,pending ? L"Waiting..." : L"&Pair");
|
||||
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
|
||||
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
|
||||
EnableWindow(GetDlgItem(w,706),pending);
|
||||
@@ -89,6 +123,7 @@ void refreshLan(HWND w,LanView &v)
|
||||
if(mode==2)inputStatus=L"Mouse and keyboard control the OTHER PC. Ctrl+Alt+Esc returns here.";
|
||||
else if(mode==3)inputStatus=L"This PC is controlled remotely. Local physical input takes control back.";
|
||||
value(w,718,inputStatus);
|
||||
value(w,722,v.snapshot.value("inputNeedsHelp",false)?L"Connection failed — &help":L"Connection &help");
|
||||
}
|
||||
#include "ui_lan_layout.inl"
|
||||
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
@@ -108,6 +143,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
|
||||
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
|
||||
v->error.clear();
|
||||
if(id==722){showLanHelp(w,*v->app);return 0;}
|
||||
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
|
||||
if(id==704)
|
||||
{
|
||||
@@ -115,7 +151,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
std::string grouped; for(size_t i=0;i<code.size();++i) {if(i&&i%4==0) grouped+='-';grouped+=code[i];}
|
||||
value(w,703,wide(grouped)); SetFocus(GetDlgItem(w,703)); SendDlgItemMessageW(w,703,EM_SETSEL,0,-1);
|
||||
}
|
||||
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
|
||||
if(id==705) lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});
|
||||
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
|
||||
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
|
||||
if(id==715)
|
||||
@@ -131,10 +167,10 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
void showLan(HWND parent,App &app)
|
||||
{
|
||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,836,0,false);
|
||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,908,0,false);
|
||||
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
|
||||
auto v=new LanView{parent,&app};SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)v);
|
||||
label(w,L"Find running KSwitcher instances on your local network and pair them.",18,16,700);
|
||||
label(w,L"Changes here save automatically. No need to click Save in Settings.",18,16,700);
|
||||
control(w,L"STATIC",L"",0,709,18,45,700,24);
|
||||
button(w,L"&Enable discovery",701,18,78,210);
|
||||
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
|
||||
@@ -154,9 +190,10 @@ void showLan(HWND parent,App &app)
|
||||
control(w,L"STATIC",L"",0,719,18,674,700,24);
|
||||
button(w,L"Enable &input sharing",715,18,706,330);
|
||||
button(w,L"&Stop input sharing",716,370,706,240);
|
||||
control(w,L"STATIC",L"",0,718,18,744,700,28);
|
||||
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,776,580,40);
|
||||
button(w,L"&Close",IDCANCEL,618,792,100);
|
||||
control(w,L"STATIC",L"",0,718,18,744,700,64);
|
||||
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,816,580,40);
|
||||
button(w,L"Connection &help",722,18,864,300);
|
||||
button(w,L"&Close",IDCANCEL,618,864,100);
|
||||
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
|
||||
SetTimer(w,1,1000,nullptr);
|
||||
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.62";
|
||||
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;
|
||||
|
||||
+18
-2
@@ -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};
|
||||
@@ -402,12 +402,28 @@ int main()
|
||||
require(GetDlgItem(lan,711)==nullptr,"obsolete side radios are removed");
|
||||
require(SendDlgItemMessageW(lan,702,LB_GETCOUNT,0,0)==0,"disabled discovery lists no generic network devices");
|
||||
require(!std::filesystem::exists(root/L"lan-pairing.bin"),"opening LAN settings does not enable or save discovery");
|
||||
SendMessageW(lan,WM_COMMAND,722,0);
|
||||
auto help=findTestWindow(L"KSwitcher.LAN.Help",nullptr);
|
||||
require(help&&!IsWindowEnabled(lan),"connection help opens without changing network settings");
|
||||
wchar_t command[4096]{};GetDlgItemTextW(help,723,command,4096);
|
||||
require(std::wstring(command).find(ks::executable())!=std::wstring::npos&&
|
||||
std::wstring(command).find(L"-LocalPort 45884")!=std::wstring::npos&&
|
||||
std::wstring(command).find(L"-LocalPort 45883")!=std::wstring::npos&&
|
||||
std::wstring(command).find(L"-RemoteAddress LocalSubnet")!=std::wstring::npos,
|
||||
"help supplies local-subnet firewall commands for the actual resident executable and both transports");
|
||||
RedrawWindow(help,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||
RECT helpRect{};GetClientRect(help,&helpRect);HDC helpDc=GetDC(help);
|
||||
auto helpImage=ks::screenshotClip(helpDc,helpRect.right,helpRect.bottom,{});ReleaseDC(help,helpDc);
|
||||
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-connection-help.png",ks::encodePng(ks::decodeImage(helpImage)));
|
||||
SendMessageW(help,WM_COMMAND,IDCANCEL,0);
|
||||
require(IsWindowEnabled(lan),"closing connection help returns to LAN setup");
|
||||
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
|
||||
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
|
||||
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
|
||||
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
||||
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
|
||||
require(status().find(L"Network changes save automatically")!=std::wstring::npos,"Settings explains why network actions do not enable Save");
|
||||
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||
}
|
||||
{
|
||||
|
||||
+255
@@ -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;
|
||||
|
||||
+82
-1
@@ -1,5 +1,6 @@
|
||||
#include <winsock2.h>
|
||||
#include "lan_input.h"
|
||||
#include "lan_datagram.h"
|
||||
#include "lan_mesh.h"
|
||||
#include <iostream>
|
||||
#include <deque>
|
||||
@@ -344,6 +345,82 @@ void hubChecks()
|
||||
endpoints[0].erase(endpoints[0].begin());hubs[0]->poll(endpoints[0],{},incoming[0],GetTickCount64()+1800);
|
||||
check(!hubs[0]->ready().contains(ids[1])&&hubs[0]->ready().contains(ids[2]),"forgetting one pair revokes only its channel");
|
||||
}
|
||||
void fallbackChecks()
|
||||
{
|
||||
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
|
||||
struct Close{~Close(){WSACleanup();}} close;
|
||||
std::string a(32,'a'),b(32,'b'),key(64,'f');
|
||||
for(bool udp:{false,true}){
|
||||
auto pa=freePort(),pb=freePort(),blocked=freePort();InputHub left(a,pa),right(b,pb);
|
||||
std::vector<InputEndpoint> ea{{b,key,htonl(INADDR_LOOPBACK),blocked}},eb{{a,key,htonl(INADDR_LOOPBACK),udp?blocked:pa}};
|
||||
std::vector<RoutedInput> ia,ib;std::vector<Json> wire;
|
||||
if(udp){auto send=[&](const auto &,const auto &j){wire.push_back(j);};left.datagrams(send);right.datagrams(send);}
|
||||
auto now=GetTickCount64();
|
||||
auto pump=[&]{
|
||||
now+=10;left.poll(ea,{},ia,now);right.poll(eb,{},ib,now);
|
||||
auto packets=std::move(wire);wire.clear();for(auto &j:packets){if(j.at("to")==a)left.receiveDatagram(j,now);else right.receiveDatagram(j,now);}Sleep(1);
|
||||
};
|
||||
for(int n=0;n<1000&&(left.ready().empty()||right.ready().empty());++n)pump();
|
||||
check(left.ready().size()==1&&right.ready().size()==1,udp?"encrypted UDP connects when both TCP directions fail":"reverse TCP connects when direct TCP destination is unreachable");
|
||||
check(left.status().find(udp?"encrypted UDP":"reverse TCP")!=std::string::npos,"status identifies the selected transport");
|
||||
InputFrame frame{InputType::Key,11,'B',0,1};left.poll(ea,{{b,frame}},ia,now);
|
||||
for(int n=0;n<100&&ib.empty();++n)pump();
|
||||
check(ib.size()==1&&ib[0].frame==frame,"fallback carries input once to the destination");
|
||||
auto old=left.ready().at(b);left.disconnect();ia.clear();ib.clear();
|
||||
for(int n=0;n<1500&&(left.ready().empty()||right.ready().empty()||left.ready().at(b)==old);++n)pump();
|
||||
check(left.ready().size()==1&&right.ready().size()==1&&left.ready().at(b)!=old,"fallback reconnect changes input epoch");
|
||||
left.poll({}, {},ia,now);check(left.ready().empty(),"forget removes fallback channel");
|
||||
}
|
||||
{
|
||||
InputHub hub(a,freePort());hub.datagrams([](const auto &,const auto &){});
|
||||
std::vector<InputEndpoint> peers{{b,key,htonl(INADDR_LOOPBACK),freePort()}};std::vector<RoutedInput> in;
|
||||
auto start=GetTickCount64();for(int i=0;i<=160;i++)hub.poll(peers,{},in,start+i*100);
|
||||
check(hub.ready().empty()&&hub.needsHelp(),"all transports unavailable expose connection help after bounded attempts");
|
||||
}
|
||||
}
|
||||
void datagramChecks()
|
||||
{
|
||||
std::string a(32,'a'),b(32,'b'),key(64,'f');InputDatagram left(a,b,key),right(b,a,key);
|
||||
uint64_t now=1000;std::vector<Json> wire,history;std::vector<InputFrame> ia,ib;
|
||||
auto send=[&](const auto &,const auto &j){wire.push_back(j);history.push_back(j);};
|
||||
int packet=0;bool losses=false;
|
||||
auto pump=[&]{
|
||||
now+=10;left.poll({},ia,now,send);right.poll({},ib,now,send);
|
||||
auto packets=std::move(wire);wire.clear();std::reverse(packets.begin(),packets.end());
|
||||
for(auto &j:packets){if(losses&&++packet%4==0)continue;auto &target=j.at("to")==a?left:right;target.receive(j,now,send);target.receive(j,now,send);}
|
||||
};
|
||||
left.start(now);for(int n=0;n<100&&(!left.ready()||!right.ready());++n)pump();
|
||||
check(left.ready()&&right.ready(),"UDP mutually authenticates saved pair keys");
|
||||
wire.clear();InputFrame probe{InputType::Move,17,10,20,0};left.poll({probe},ia,now+60,send);now+=60;
|
||||
auto valid=wire.back(),damaged=valid;auto ciphertext=damaged.at("data").get<std::string>();ciphertext[0]=ciphertext[0]=='0'?'1':'0';damaged["data"]=ciphertext;
|
||||
rejects([&]{right.receive(damaged,now,send);},"modified UDP ciphertext is rejected");
|
||||
auto publicWelcome=std::find_if(history.begin(),history.end(),[](const auto &j){return j.value("op","")=="welcome";});
|
||||
auto publicProof=publicWelcome->at("proof").get<std::string>();
|
||||
auto serverPacket=std::find_if(history.begin(),history.end(),[&](const auto &j){return j.value("op","")=="data"&&j.at("id")==b;});
|
||||
auto encoded=serverPacket->at("data").get<std::string>();Bytes payload;
|
||||
for(size_t i=0;i<encoded.size();i+=2)payload.push_back(static_cast<unsigned char>(std::stoul(encoded.substr(i,2),nullptr,16)));
|
||||
InputCipher exposed(mac(publicProof,"packet|"+std::to_string(serverPacket->at("number").get<uint64_t>())));
|
||||
rejects([&]{exposed.decryptPayload(payload);},"public handshake proof cannot decrypt UDP session data");
|
||||
right.receive(valid,now,send);right.poll({},ib,now,send);
|
||||
check(ib==std::vector<InputFrame>{probe},"rejected tampering does not advance the UDP sequence");
|
||||
ib.clear();wire.clear();
|
||||
std::vector<InputFrame> expected;for(int n=0;n<40;n++)expected.push_back({InputType::Key,17,n,0,n%2});
|
||||
losses=true;left.poll(expected,ia,now,send);for(int n=0;n<180&&ib.size()<expected.size();++n)pump();
|
||||
check(ib==expected,"lost reordered and duplicate UDP packets preserve exact event order without double key injection");
|
||||
for(int n=0;n<50;n++)pump();
|
||||
check(ib==expected,"replayed acknowledgements do not repeat delivered keys");
|
||||
auto captured=history;left.reset();right.reset();ia.clear();ib.clear();
|
||||
for(auto &j:captured)if(j.at("to")==a)left.receive(j,now,send);else right.receive(j,now,send);
|
||||
check(!left.ready()&&!right.ready()&&ia.empty()&&ib.empty(),"old UDP session cannot authorize input after reset");
|
||||
left.reset();right.reset();wire.clear();losses=false;left.start(now);
|
||||
for(int n=0;n<100&&(!left.ready()||!right.ready());++n)pump();
|
||||
check(left.ready()&&right.ready(),"fresh UDP session reconnects after reset");
|
||||
left.poll({},ia,now+2100,send);right.poll({},ib,now+2100,send);
|
||||
check(!left.ready()&&!right.ready(),"UDP heartbeat loss revokes input within two seconds");
|
||||
InputDatagram wrong(b,a,std::string(64,'e'));left.reset();left.start(now);wire.clear();left.poll({},ia,now,send);
|
||||
for(auto &j:wire)wrong.receive(j,now,[](const auto &,const auto &){});
|
||||
check(!wrong.active()&&!wrong.ready(),"wrong pair key cannot start UDP handshake");
|
||||
}
|
||||
void channelChecks()
|
||||
{
|
||||
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
|
||||
@@ -383,6 +460,10 @@ void channelChecks()
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,start+11000);
|
||||
check(!client.ready()&&client.status().find("authentication timed out")!=std::string::npos,
|
||||
"initial authentication timeout distinguished from lost active connection");
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,start+13000);
|
||||
check(client.status().find("authentication timed out")!=std::string::npos&&
|
||||
client.status().find("Retrying automatically")!=std::string::npos,
|
||||
"retry keeps the last connection failure visible");
|
||||
}
|
||||
{
|
||||
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||
@@ -426,6 +507,6 @@ void channelChecks()
|
||||
}
|
||||
int main()
|
||||
{
|
||||
try{channelChecks();hubChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
|
||||
try{channelChecks();hubChecks();fallbackChecks();datagramChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
|
||||
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
||||
}
|
||||
+117
-1
@@ -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
@@ -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"});
|
||||
|
||||
Reference in new issue
Block a user