Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0387904a0 | ||
|
|
5de010ee44 |
No files matched your search
+19
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.56 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.58 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -178,3 +178,21 @@ install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||
|
||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||
install(DIRECTORY dictionaries DESTINATION .)
|
||||
|
||||
add_executable(KSwitcherLanClipboardTests tests/lan_clipboard.cpp)
|
||||
target_link_libraries(KSwitcherLanClipboardTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherLanClipboardTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherLanClipboardTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_test(NAME lan-clipboard-regressions COMMAND KSwitcherLanClipboardTests)
|
||||
|
||||
add_executable(KSwitcherSettingsMergeTests tests/settings_merge.cpp)
|
||||
target_link_libraries(KSwitcherSettingsMergeTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherSettingsMergeTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherSettingsMergeTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_test(NAME settings-save-merge COMMAND KSwitcherSettingsMergeTests)
|
||||
+37
-21
@@ -1,41 +1,46 @@
|
||||
# LAN mouse and keyboard — v0.6.56 (experimental)
|
||||
# LAN mouse, keyboard and clipboard — v0.6.58 (experimental)
|
||||
|
||||
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
|
||||
code on both computers, and shares mouse and keyboard through a screen edge.
|
||||
The module runs inside the resident process. This experimental feature supports **one active
|
||||
pair of computers**, with the neighbour on the left, right, above or below.
|
||||
Several trusted pairs may be saved, but only the selected neighbour is active.
|
||||
Clipboard and file transfer are not implemented yet.
|
||||
The module runs inside the resident process. Multiple paired computers can be
|
||||
arranged on a monitor grid. Mouse and keyboard follow neighboring cells, including
|
||||
A → B → C, without relaying input through B. New text and images are also shared
|
||||
with all online paired computers: history and the active clipboard. File transfer
|
||||
is not implemented by clipboard synchronization.
|
||||
|
||||
## Set up two computers
|
||||
## Set up multiple computers
|
||||
|
||||
1. Update both PCs to v0.6.56 or newer using Update in the tray menu. Preserve local data; do not copy pairing identities between PCs.
|
||||
1. Install the v0.6.58 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
|
||||
2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs.
|
||||
Only running LAN-enabled KSwitcher instances appear, not generic network devices.
|
||||
3. Match the computer name and instance-ID prefix displayed on the other PC.
|
||||
Select each other, generate one code, enter that same code on both, then click
|
||||
Pair on both within two minutes. Spaces, hyphens and uppercase hex are accepted.
|
||||
4. Select the paired neighbour and its position relative to this computer.
|
||||
Example: B stands to the right of A, so choose **Right on A** and **Left on B**.
|
||||
For a vertical arrangement choose **Above** on the lower PC and **Below** on
|
||||
the upper PC. Click **Apply position / enable input** on both.
|
||||
4. Pair each physical input source with every destination it must control. For
|
||||
unrestricted control from any PC, pair every pair of PCs. Drag monitors into
|
||||
neighboring grid cells and arrange the same physical layout relative to each
|
||||
local PC. For A → B → C, A sees B at (1,0), C at (2,0); B sees A at (-1,0),
|
||||
C at (1,0). Enable input sharing on all PCs. The button controls all pairs,
|
||||
independently of the selected list item. Positions save immediately.
|
||||
5. Stop MWB or another mouse/keyboard router on both computers before trying
|
||||
KSwitcher input sharing, to avoid two tools forwarding the same events.
|
||||
6. Wait for the authenticated input connection. Move the cursor through the
|
||||
configured outer screen edge with no keys or buttons held. After the other
|
||||
computer acknowledges the handoff, both mouse and keyboard control that PC.
|
||||
Crossing its return edge returns control to the source computer.
|
||||
Crossing another shared edge switches directly to that neighbor. Crossing back
|
||||
into the source computer restores its cursor at the corresponding edge. Empty
|
||||
cells, diagonal-only neighbors and offline PCs do not transfer control.
|
||||
|
||||
The source cursor is parked and hidden beneath a tiny no-activation window. A
|
||||
small banner identifies remote control; no full-screen layered bitmap is retained.
|
||||
The destination uses its own keyboard layout and runs its own hotkeys/snippets.
|
||||
The source suppresses forwarded events before its local hotkeys, snippets and
|
||||
other low-level hook consumers. Received input is marked so it is not forwarded
|
||||
again. Source recent-input tracking resets when ownership changes.
|
||||
again. Source recent-input tracking and shortcut latches reset when ownership changes.
|
||||
|
||||
**Ctrl+Alt+Esc** is emergency return, not the normal switching mechanism. Local
|
||||
physical input on a remotely controlled PC takes control back as well. Stop input
|
||||
sharing, disabling discovery, forgetting the active pair, suspension, secure
|
||||
sharing, disabling discovery, forgetting the currently controlled pair, suspension, secure
|
||||
desktop changes and connection loss cancel remote ownership. TCP heartbeat loss
|
||||
is detected after two seconds; a handoff request expires after one second.
|
||||
Held remote keys/buttons are released. Releases blocked by a secure desktop are
|
||||
@@ -44,9 +49,12 @@ sent remotely are consumed after return, avoiding stray local modifier events.
|
||||
|
||||
## Layout and limitations
|
||||
|
||||
- Positions are relative to this PC and must be opposite on the two computers.
|
||||
Inconsistent positions are rejected before input is authorized. Changes apply
|
||||
immediately and reconnect the input channel.
|
||||
- Positions are relative to this PC. All peers have independent authenticated
|
||||
connections; a failed peer does not disconnect the others. The physical source
|
||||
uses its own grid to choose every destination. Moving a monitor cancels current
|
||||
control safely and applies the updated layout without dropping all connections.
|
||||
Layouts are not synchronized automatically. One source controls a destination
|
||||
at a time. Each grid tile represents an entire PC, not an individual monitor.
|
||||
- Geometry uses the virtual desktop's outer bounding rectangle, including negative
|
||||
monitor coordinates. Internal monitor boundaries do not transfer control.
|
||||
Different resolutions map the entry position proportionally. Irregular layouts
|
||||
@@ -75,7 +83,8 @@ transmitted or logged. The pair key is separate from the setup code.
|
||||
|
||||
The input channel authenticates both peers with the saved pair key and fresh
|
||||
session challenges. Directional AES-256-GCM session keys bind the identities,
|
||||
nonces and screen positions. Fixed-size authenticated frames use strict sequence
|
||||
nonces and the input protocol domain. Screen positions determine routing after
|
||||
authentication; they are no longer part of the pair channel key. Fixed-size authenticated frames use strict sequence
|
||||
numbers, so tampering, reflection and replay are rejected. Input is not dispatched
|
||||
until authentication succeeds. This PSK channel does not provide forward secrecy.
|
||||
Queues and frame sizes are bounded; overflow disconnects rather than dropping a
|
||||
@@ -98,9 +107,16 @@ Tests cover pairing, encrypted frames, replay/tamper rejection, actual loopback
|
||||
TCP authentication and bidirectional delivery, all four edges, ownership races,
|
||||
source suppression, scan codes, clicks/wheel, emergency return, held-key release,
|
||||
blocked injection, secure desktop recovery, exact one-pixel movement and timeout.
|
||||
UI tests check all four positions and preserve an edited choice during refresh.
|
||||
Tests additionally exercise three actual TCP hubs, independent reconnect,
|
||||
A → B → C and back, vertical turns, unavailable neighbors, stale ownership,
|
||||
modifier delivery to only the active destination, and local shortcut recovery.
|
||||
UI tests drag three monitors and check persistence.
|
||||
|
||||
Two physical computers still need an end-to-end trial for their adapters,
|
||||
Three physical computers still need an end-to-end trial for their adapters,
|
||||
firewalls, displays and application privilege levels. The automated TCP tests
|
||||
use local sockets, and input-state tests use simulated desktop environments;
|
||||
they do not claim a completed two-machine hardware test.
|
||||
they do not claim a completed three-machine hardware test.
|
||||
|
||||
## v0.6.58 connection fixes
|
||||
|
||||
Saved trust keeps TCP listeners available even when discovery announcements are missed. Working authenticated input sessions no longer depend on discovery presence. Pair saved is distinct from input connected; the list refreshes every second. A changed instance ID requires a new pairing. Keep each PC's own data folder during upgrades. Connection state changes and listener Windows errors are written to the diagnostic log.
|
||||
@@ -10,21 +10,32 @@ The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data r
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
|
||||
## KSwitcher computers — v0.6.56 (experimental)
|
||||
## KSwitcher computers — v0.6.58 (experimental)
|
||||
|
||||
KSwitcher includes opt-in LAN discovery, same-code pairing and
|
||||
mouse/keyboard sharing for one active pair. Place the neighbour left, right,
|
||||
above or below; moving through that outer screen edge transfers both mouse and
|
||||
keyboard. Ctrl+Alt+Esc returns control in an emergency. See
|
||||
[LAN-PREVIEW.md](LAN-PREVIEW.md) for setup, encryption and validation limits.
|
||||
Clipboard and file sharing are still pending.
|
||||
TCP input listeners and authenticated connections survive missed discovery announcements.
|
||||
The list distinguishes saved pairing from connected input and refreshes every second.
|
||||
Keep each PC’s own data folder during updates; a different instance ID requires new pairing.
|
||||
|
||||
**Українською.** У v0.6.56 є пошук KSwitcher у мережі, спарювання однаковим
|
||||
кодом і передача миші та клавіатури для однієї активної пари ПК. Укажіть, де
|
||||
стоїть сусідній ПК: ліворуч, праворуч, зверху або знизу. Курсор і клавіатура
|
||||
перемикаються разом через відповідний край екрана. Ctrl+Alt+Esc аварійно повертає
|
||||
керування. Передача буфера та файлів ще не реалізована; перевірка на двох
|
||||
фізичних ПК ще потрібна.
|
||||
Arrange multiple paired PCs on a monitor grid. Mouse and keyboard follow shared
|
||||
edges, including A → B → C and back. New text and images go to every online paired
|
||||
PC's history and active clipboard. The history Computers menu filters by multiple
|
||||
sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md).
|
||||
|
||||
**Update every participating PC to v0.6.58:** input protocol v2 is incompatible
|
||||
with older one-pair builds. Keep each PC's own data directory. Pair every input
|
||||
source with all destinations, arrange the same physical layout on each PC and
|
||||
enable input sharing. Empty cells and offline neighbors block passage.
|
||||
|
||||
**Українською.** Розставте кілька спарованих ПК по сітці: миша й клавіатура
|
||||
переходять через спільний край, зокрема A → B → C і назад. Нові текст та зображення
|
||||
передаються всім доступним спарованим ПК в історію й активний буфер. Фільтр
|
||||
Computers в історії має чекбокси. Оновіть усі ПК до v0.6.58, залишивши кожному
|
||||
власну папку data. Спаруйте джерело вводу з усіма потрібними ПК й задайте
|
||||
однакове фізичне розташування на кожному. Передача файлів не реалізована.
|
||||
|
||||
Saving Settings now merges independent resident/editor changes. If the same field
|
||||
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.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
Binary file not shown.
+7
-7
@@ -1,16 +1,16 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.56 — 2026-11-06
|
||||
## v0.6.58 — 2026-11-06
|
||||
|
||||
## Changes
|
||||
- Experimental, opt-in LAN mouse and keyboard sharing inside the existing resident process; no extra service.
|
||||
- Discover running KSwitcher instances and pair them using the same generated code on both PCs. Trust is DPAPI-protected locally, outside settings sync/export.
|
||||
- Arrange the active neighbour left, right, above or below. Select opposite sides on both PCs; mouse and keyboard transfer together through that screen edge.
|
||||
- Authenticated AES-256-GCM input channel; Ctrl+Alt+Esc emergency return, disconnect recovery and held-key release.
|
||||
- One active pair. Clipboard/files and remote secure desktops are not supported. Stop MWB before using input sharing. Disabled by default.
|
||||
- Keep trusted TCP input listeners and connections alive across missing discovery announcements. Retain the last known address for reconnection; authenticate incoming trusted peers even before discovery sees their address.
|
||||
- Show saved pairing separately from actual input connectivity: connected, disabled here, offline or not connected. Background identity verification does not flash the list.
|
||||
- Log TCP listener failures with the Windows error and peer connection state changes for troubleshooting.
|
||||
|
||||
## Validation
|
||||
124 input/channel, 41 pairing/configuration/network and 813 settings/chat-profile checks passed before release preparation. Full release regression passed: 124 LAN input, 34 LAN pairing (41 with network lifecycle), 813 chat/settings, 568 input, 332 native, 126 sync, dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Physical two-PC validation is pending: network tests use loopback and input-state tests simulate desktops. LAN sharing remains experimental; follow LAN-PREVIEW.md on both PCs.
|
||||
Passed: 205 LAN input checks (including missed discovery and authentication before discovery), 289 clipboard transfer, 43 pairing, 829 settings/chat UI, 570 input, 341 native, 126 sync and 5 settings-save merge checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. LAN remains experimental; three physical PCs have not been verified together.
|
||||
|
||||
Update both PCs to receive these fixes. Preserve each PC's own data directory and pairing identity. Input protocol v2 remains compatible with v0.6.57. A changed instance ID requires pairing again. The computer list refreshes automatically once per second while open.
|
||||
|
||||
## Upgrade and compatibility
|
||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
|
||||
@@ -1 +1 @@
|
||||
7ddf8545bcd1694e760503e712b6b34f396311446596bca348b3cc27ee838abe KSwitcher-win-x64.zip
|
||||
16d8138c63442931bf947707b28820d9b89943169ac9f25db3cb50d23c0ff319 KSwitcher-win-x64.zip
|
||||
+40
-40
@@ -1,86 +1,86 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"version": "0.6.56",
|
||||
"files": [
|
||||
{
|
||||
"sha256": "02f709062f32047f2befc4de67efe5e033c48a8155bb0230bf012496fe20db9b",
|
||||
"size": 1974784,
|
||||
"path": "KSwitcher.exe"
|
||||
"sha256": "a7be2f749082bb6417b4cf7e8c8ea221ef4f7ba63dabc3cbbe8694ab1a3d942c",
|
||||
"path": "KSwitcher.exe",
|
||||
"size": 2108416
|
||||
},
|
||||
{
|
||||
"sha256": "9b3ec79ae158ee51e61249f01e46a20917db9c25c6a8f58c52d927f8a6c3ee9c",
|
||||
"size": 1887232,
|
||||
"path": "KSwitcher.Config.exe"
|
||||
"sha256": "c8a88364c9967ce5fd150519d632b14460e225a0cfae51a8de50f39782d8010c",
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"size": 2005504
|
||||
},
|
||||
{
|
||||
"sha256": "21a5422be5e97c7f758d46481fe2c9481d2c09b231cc7fe90b6e7a0793f32b9d",
|
||||
"size": 3046912,
|
||||
"path": "KSwitcher.Screenshot.exe"
|
||||
"sha256": "68eed5abefed0208ef7bdd431094c7357c53bd3ca03dfd91aaeb7dbb4f291e06",
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"size": 3153408
|
||||
},
|
||||
{
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
||||
"size": 4401,
|
||||
"path": "LICENSE"
|
||||
"path": "LICENSE",
|
||||
"size": 4401
|
||||
},
|
||||
{
|
||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
|
||||
"size": 1096,
|
||||
"path": "json-LICENSE.txt"
|
||||
"path": "json-LICENSE.txt",
|
||||
"size": 1096
|
||||
},
|
||||
{
|
||||
"sha256": "6b7b8a912c10c04c8bdddce90572a6a8addcb6c76d478fe8455c2685fc3384d8",
|
||||
"size": 79329,
|
||||
"path": "README.md"
|
||||
"sha256": "87f396a6bf988725b14bf5b6eb7c2b60ce33ff51828290ce40e136c66942cc09",
|
||||
"path": "README.md",
|
||||
"size": 80168
|
||||
},
|
||||
{
|
||||
"sha256": "a8ec24f5a6cc9145324a52b73525418d1f8bd0eb3b4cf75fc93b57dcc7918603",
|
||||
"size": 6773,
|
||||
"path": "LAN-PREVIEW.md"
|
||||
"sha256": "72e131bdf46d32a8a17fb352292bc8983c0e5a7ed84812218b11cf6b69c9afc4",
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"size": 8376
|
||||
},
|
||||
{
|
||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
|
||||
"size": 732485,
|
||||
"path": "dictionaries/en.txt"
|
||||
"path": "dictionaries/en.txt",
|
||||
"size": 732485
|
||||
},
|
||||
{
|
||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
|
||||
"size": 20566,
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"size": 20566
|
||||
},
|
||||
{
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
||||
"size": 2265,
|
||||
"path": "dictionaries/README.md"
|
||||
"path": "dictionaries/README.md",
|
||||
"size": 2265
|
||||
},
|
||||
{
|
||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
|
||||
"size": 1109134,
|
||||
"path": "dictionaries/ru.txt"
|
||||
"path": "dictionaries/ru.txt",
|
||||
"size": 1109134
|
||||
},
|
||||
{
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
||||
"size": 1280,
|
||||
"path": "dictionaries/sources.json"
|
||||
"path": "dictionaries/sources.json",
|
||||
"size": 1280
|
||||
},
|
||||
{
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
||||
"size": 940799,
|
||||
"path": "dictionaries/uk.txt"
|
||||
"path": "dictionaries/uk.txt",
|
||||
"size": 940799
|
||||
},
|
||||
{
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
||||
"size": 993,
|
||||
"path": "tools/README.txt"
|
||||
"path": "tools/README.txt",
|
||||
"size": 993
|
||||
},
|
||||
{
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
|
||||
"size": 134656,
|
||||
"path": "tools/StayHere.exe"
|
||||
"path": "tools/StayHere.exe",
|
||||
"size": 134656
|
||||
},
|
||||
{
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
||||
"size": 2796,
|
||||
"path": "tools/StayHere-README.md"
|
||||
"path": "tools/StayHere-README.md",
|
||||
"size": 2796
|
||||
}
|
||||
]
|
||||
],
|
||||
"schema": 1,
|
||||
"version": "0.6.58"
|
||||
}
|
||||
@@ -47,6 +47,14 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
|
||||
& .\build\KSwitcherSettingsMergeTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Settings merge tests failed' }
|
||||
& $compiler @common -Werror tests/lan_clipboard.cpp build/lan_channel.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanClipboardTests.exe
|
||||
if ($LASTEXITCODE) { throw 'LAN clipboard test build failed' }
|
||||
& .\build\KSwitcherLanClipboardTests.exe
|
||||
if ($LASTEXITCODE) { throw 'LAN clipboard tests failed' }
|
||||
& $compiler @common -Werror tests/lan_input.cpp build/lan_channel.o build/lan_input.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'LAN input test build failed' }
|
||||
& .\build\KSwitcherLanInputTests.exe
|
||||
|
||||
+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,56,0
|
||||
PRODUCTVERSION 0,6,56,0
|
||||
FILEVERSION 0,6,58,0
|
||||
PRODUCTVERSION 0,6,58,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.56"
|
||||
VALUE "FileVersion", "0.6.58"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+84
-10
@@ -3,6 +3,7 @@
|
||||
#include "../vendor/json.hpp"
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "lan_clipboard_image.h"
|
||||
#include "configuration.h"
|
||||
#include "diagnostics.h"
|
||||
#include "paste_probe.h"
|
||||
@@ -724,11 +725,11 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
||||
|
||||
Bytes App::lanCommand(const Bytes &command)
|
||||
{
|
||||
if (!lanService) lanService = std::make_unique<LanService>(data);
|
||||
if (!lanService) lanService = std::make_unique<LanService>(data,window);
|
||||
auto reply = lanService->command(command);
|
||||
if (lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
||||
else KillTimer(window, 7);
|
||||
if (lanService->takeInputChange()) resetInput();
|
||||
if (lanService->takeInputChange()) resetLanInput();
|
||||
return reply;
|
||||
}
|
||||
void App::applySettings(const Settings &requested)
|
||||
@@ -840,6 +841,14 @@ bool App::isHotkey(DWORD key, UINT mods)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void App::resetLanInput()
|
||||
{
|
||||
// Releases may have been consumed on another desktop. Do not retain local
|
||||
// shortcut/chat latches across an ownership handoff.
|
||||
hotkeyHook = {};
|
||||
chatSwitchHook = {};
|
||||
resetInput();
|
||||
}
|
||||
void App::resetInput()
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
@@ -1132,7 +1141,7 @@ LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
|
||||
{
|
||||
bool consumed = current->lanService->keyboard(*(KBDLLHOOKSTRUCT *)lp, wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN);
|
||||
if (current->lanService->takeInputChange()) current->resetInput();
|
||||
if (current->lanService->takeInputChange()) current->resetLanInput();
|
||||
if (consumed) return 1; // Forwarded keys must not reach local hotkeys/snippets or other routers.
|
||||
}
|
||||
// MWB suppresses source-machine events that it forwards elsewhere. Let
|
||||
@@ -1168,7 +1177,7 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
|
||||
!current->busy && !current->suspended && !current->hotkeyCapture);
|
||||
if (current->lanService->takeInputChange()) current->resetInput();
|
||||
if (current->lanService->takeInputChange()) current->resetLanInput();
|
||||
if (consumed) return 1;
|
||||
}
|
||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||
@@ -1805,15 +1814,56 @@ void App::pasteEntry(std::wstring id, bool plain)
|
||||
}
|
||||
});
|
||||
}
|
||||
namespace {
|
||||
Clip sharedClipboard(Clip clip)
|
||||
{
|
||||
// File handles/paths and application-private formats are local to their PC.
|
||||
std::erase_if(clip.formats,[](const Format &f){return !(
|
||||
(f.name.empty()&&(f.id==CF_UNICODETEXT||f.id==CF_TEXT||f.id==CF_LOCALE||f.id==CF_DIB||f.id==CF_DIBV5))||
|
||||
f.name==L"PNG"||f.name==L"HTML Format"||f.name==L"Rich Text Format");});
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
void App::publishClipboard(Clip clip,const std::wstring &app)
|
||||
{
|
||||
clip=sharedClipboard(std::move(clip));if(clip.formats.empty())return;
|
||||
auto originalSize=clip.size();
|
||||
clip=lan::compactClipboardImage(std::move(clip));
|
||||
auto packet=new LanClipboard{{},{},utf8(app),encodeClip(clip)};
|
||||
log(L"LAN clipboard queued: original="+std::to_wstring(originalSize)+L" bytes, wire="+std::to_wstring(packet->data.size())+L" bytes");
|
||||
if(!PostMessageW(window,LanClipboardPublishMessage,0,reinterpret_cast<LPARAM>(packet)))delete packet;
|
||||
}
|
||||
void App::receiveClipboard()
|
||||
{
|
||||
if(!lanService)return;
|
||||
auto packets=lanService->takeClipboard();auto config=settings;
|
||||
for(auto &packet:packets)enqueue([this,packet=std::move(packet),config]{
|
||||
try {
|
||||
auto clip=sharedClipboard(decodeClip(packet.data));if(clip.formats.empty())return;
|
||||
auto sequence=GetClipboardSequenceNumber();
|
||||
if(!suspended&&(sequence==packet.sequence||sequence==lanSequence.load())){
|
||||
const std::wstring marker=L"KSwitcher.LAN.Clipboard";
|
||||
auto active=lan::clipboardImageForPaste(clip);
|
||||
active.formats.push_back({RegisterClipboardFormatW(marker.c_str()),marker,Bytes{1}});
|
||||
ownSequence=lanSequence=writeClipboardGuarded(window,active,sequence);
|
||||
log(L"LAN clipboard activated: "+std::to_wstring(packet.data.size())+L" bytes");
|
||||
}
|
||||
if(config.history&&history){history->add(clip,wide(packet.app),config,packet.peer,wide(packet.name));
|
||||
scheduleHistoryOcr(config);PostMessageW(window,HistoryMessage,0,0);}
|
||||
}catch(const std::exception &e){log(L"LAN clipboard receive: "+wide(e.what()));}
|
||||
});
|
||||
}
|
||||
void App::captureHistory()
|
||||
{
|
||||
DWORD sequence = GetClipboardSequenceNumber();
|
||||
if (suspended || sequence == ownSequence || !settings.history || !history ||
|
||||
if (suspended || sequence == ownSequence || !history ||
|
||||
GetTickCount64() < pauseUntil)
|
||||
return;
|
||||
const bool share=lanService&&lanService->clipboardEnabled();
|
||||
if(!settings.history&&!share)return;
|
||||
Settings config = settings;
|
||||
enqueue(
|
||||
[this, sequence, config]
|
||||
[this, sequence, config, share]
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1821,19 +1871,28 @@ void App::captureHistory()
|
||||
return;
|
||||
UINT exclude = RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"),
|
||||
ignore = RegisterClipboardFormatW(L"Clipboard Viewer Ignore");
|
||||
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore))
|
||||
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore) ||
|
||||
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"KSwitcher.LAN.Clipboard")))
|
||||
return;
|
||||
auto owner = GetClipboardOwner();
|
||||
auto app = processName(owner ? owner : GetForegroundWindow());
|
||||
for (auto &name : config.excluded)
|
||||
if (lower(name) == lower(app))
|
||||
return;
|
||||
if(!config.history){
|
||||
auto clip=readClipboard(window,(size_t)config.maxImageMiB*1024*1024,ClipboardRead::All,sequence);
|
||||
for(const auto &f:clip.formats)if(f.name==L"CanIncludeInClipboardHistory"&&f.data.size()>=4&&!f.data[0]&&!f.data[1]&&!f.data[2]&&!f.data[3])return;
|
||||
if(sequence==GetClipboardSequenceNumber())publishClipboard(std::move(clip),app);
|
||||
return;
|
||||
}
|
||||
if (IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
|
||||
IsClipboardFormatAvailable(CF_BITMAP) ||
|
||||
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG")))
|
||||
{
|
||||
if (history->captureImage(sequence, app, config))
|
||||
{
|
||||
auto entries=history->entries();
|
||||
if(share&&!entries.empty())publishClipboard(history->load(entries.front().id),app);
|
||||
scheduleHistoryOcr(config);
|
||||
PostMessageW(window, HistoryMessage, 0, 0);
|
||||
}
|
||||
@@ -1848,6 +1907,7 @@ void App::captureHistory()
|
||||
!f.data[1] && !f.data[2] && !f.data[3])
|
||||
return;
|
||||
history->add(clip, app, config);
|
||||
if(share)publishClipboard(clip,app);
|
||||
scheduleHistoryOcr(config);
|
||||
PostMessageW(window, HistoryMessage, 0, 0);
|
||||
}
|
||||
@@ -2222,13 +2282,26 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
case LanClipboardPublishMessage:
|
||||
{
|
||||
std::unique_ptr<LanClipboard> packet(reinterpret_cast<LanClipboard*>(lp));
|
||||
try{if(app->lanService)app->lanService->publishClipboard(std::move(packet->data),packet->app);}
|
||||
catch(const std::exception &e){log(L"LAN clipboard send: "+wide(e.what()));}
|
||||
return 0;
|
||||
}
|
||||
case LanClipboardReadyMessage: app->receiveClipboard();return 0;
|
||||
case LanInputReadyMessage:
|
||||
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
|
||||
return 0;
|
||||
case WM_TIMER:
|
||||
if (wp == 7)
|
||||
{
|
||||
if (app->lanService)
|
||||
{
|
||||
if (app->suspended) app->lanService->cancelInput();
|
||||
if (app->lanService->tickInput(!app->busy && !app->hotkeyCapture)) app->resetInput();
|
||||
// suspended also guards clipboard/text transactions (correction,
|
||||
// conversion, snippets). Those must not disconnect remote input.
|
||||
// Actual system suspension is handled by WM_POWERBROADCAST.
|
||||
if (app->lanService->tickInput(!app->busy && !app->suspended && !app->hotkeyCapture)) app->resetLanInput();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -2346,6 +2419,7 @@ int App::execute()
|
||||
historyTop = wide(state.value("top", std::string{}));
|
||||
historyQuery = wide(state.value("query", std::string{}));
|
||||
historyPinnedOnly = state.value("pinnedOnly", false);
|
||||
historyComputers = state.value("computers",std::vector<std::string>{});
|
||||
} catch (...) {}
|
||||
showHistory(*this);
|
||||
remoteHistory("ready", std::to_wstring(reinterpret_cast<uintptr_t>(historyWindow)));
|
||||
@@ -2373,7 +2447,7 @@ int App::execute()
|
||||
addTray();
|
||||
if (!smoke) startBrowserChatMonitor();
|
||||
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
|
||||
try { lanService = std::make_unique<LanService>(data); }
|
||||
try { lanService = std::make_unique<LanService>(data,window); }
|
||||
catch (const std::exception &e) { report(L"LAN module: " + wide(e.what())); }
|
||||
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
||||
SetTimer(window, 1, 60000, nullptr);
|
||||
|
||||
@@ -40,6 +40,7 @@ class App
|
||||
unsigned historyEntryWindows{};
|
||||
std::wstring historySelection, historyTop, historyQuery;
|
||||
bool historyPinnedOnly{};
|
||||
std::vector<std::string> historyComputers;
|
||||
HINSTANCE instance{};
|
||||
std::filesystem::path data;
|
||||
std::vector<Layout> layouts;
|
||||
@@ -48,7 +49,7 @@ class App
|
||||
AutoCorrector autoCorrector;
|
||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<DWORD> ownSequence{0}, lanSequence{0};
|
||||
std::atomic<uint64_t> inputVersion{0};
|
||||
std::atomic<ULONGLONG> pauseUntil{0};
|
||||
HWND pasteTarget{};
|
||||
@@ -70,6 +71,8 @@ class App
|
||||
void pasteEntry(std::wstring, bool plain);
|
||||
void recognizeEntry(std::wstring);
|
||||
void captureHistory();
|
||||
void publishClipboard(Clip, const std::wstring &);
|
||||
void receiveClipboard();
|
||||
void enqueue(std::function<void()>);
|
||||
void enqueueHistorySearch(std::function<void()>);
|
||||
void report(const std::wstring &);
|
||||
@@ -90,6 +93,7 @@ class App
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||
void resetInput();
|
||||
void resetLanInput();
|
||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
||||
Bytes syncConflictData();
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ void App::saveHistoryView(const std::wstring &selection, const std::wstring &top
|
||||
{
|
||||
if (!historyService) return;
|
||||
try {
|
||||
auto text = HistoryJson{{"selection", utf8(selection)}, {"top", utf8(top)}, {"query", utf8(query)}, {"pinnedOnly", pinnedOnly}}.dump();
|
||||
auto text = HistoryJson{{"selection", utf8(selection)}, {"top", utf8(top)}, {"query", utf8(query)}, {"pinnedOnly", pinnedOnly}, {"computers", historyComputers}}.dump();
|
||||
atomicWrite(data / L"history-view.bin", protect(Bytes(text.begin(), text.end())));
|
||||
} catch (...) {} // Closing the UI must still release its process if state cannot be saved.
|
||||
}
|
||||
+10
-1
@@ -102,7 +102,16 @@ void App::applySettings(const Settings &next)
|
||||
memcpy(payload.data(), &length, sizeof(length));
|
||||
payload.insert(payload.end(), before.begin(), before.end());
|
||||
payload.insert(payload.end(), after.begin(), after.end());
|
||||
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
|
||||
Bytes saved;
|
||||
try {saved=request(*this,ConfigSave,payload);}
|
||||
catch(const std::exception &error){
|
||||
if(!std::string(error.what()).starts_with("Settings conflict: "))throw;
|
||||
auto text=wide(error.what())+L"\n\nThese settings changed both here and in KSwitcher.\nKeep your edits for these settings? Other changes will be preserved.\n\nNo leaves this window open with all your edits.";
|
||||
if(MessageBoxW(settingsWindow?settingsWindow:snippetsWindow,text.c_str(),L"Save conflicting settings",MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2)!=IDYES)
|
||||
throw std::runtime_error("Not saved. Your edits are still in this window.");
|
||||
saved=request(*this,ConfigSaveEdited,payload);
|
||||
}
|
||||
settings=decodeSettings(saved,layouts);
|
||||
}
|
||||
void App::reloadLayouts()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "configuration.h"
|
||||
#include "settings_merge.h"
|
||||
#include <cstring>
|
||||
namespace ks
|
||||
{
|
||||
@@ -67,6 +68,7 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
break;
|
||||
}
|
||||
case ConfigSave:
|
||||
case ConfigSaveEdited:
|
||||
{
|
||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
|
||||
@@ -74,11 +76,10 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
memcpy(&length, message.lpData, sizeof(length));
|
||||
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
|
||||
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
|
||||
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
|
||||
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
|
||||
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
|
||||
if (next.empty()) throw std::runtime_error("Empty settings request.");
|
||||
app.applySettings(decodeSettings(next, app.layouts));
|
||||
auto merged=mergeConfigurationSettings(Bytes(begin,begin+length),next,encodeSettings(app.settings),message.dwData==ConfigSaveEdited);
|
||||
app.applySettings(decodeSettings(merged, app.layouts));
|
||||
reply = encodeSettings(app.settings);
|
||||
break;
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigEnd = ConfigRead + 12;
|
||||
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = ConfigRead + 13;
|
||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||
bool configurationPeer(HWND, const wchar_t *filename);
|
||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||
|
||||
+134
-71
@@ -3,7 +3,9 @@
|
||||
#include <iphlpapi.h>
|
||||
#include "lan.h"
|
||||
#include "lan_pairing.h"
|
||||
#include "lan_input.h"
|
||||
#include "lan_mesh.h"
|
||||
#include "lan_clipboard.h"
|
||||
#include "lan_layout.h"
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
@@ -50,8 +52,9 @@ struct LanService::Impl
|
||||
std::filesystem::path path;
|
||||
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
|
||||
std::map<std::string,Peer> peers;
|
||||
Json trusted=Json::object();
|
||||
Json trusted=Json::object(),positions=Json::object();
|
||||
std::unique_ptr<lan::Pairing> pending;
|
||||
std::unique_ptr<lan::ClipboardExchange> clipboard;
|
||||
bool enabled{};
|
||||
std::atomic<bool> stopping{false};
|
||||
std::mutex mutex;
|
||||
@@ -62,20 +65,24 @@ struct LanService::Impl
|
||||
std::atomic<bool> inputConnected{false},abortInput{false};
|
||||
std::atomic<uint64_t> inputEpoch{0};
|
||||
std::atomic<uint64_t> inputConfiguration{0};
|
||||
uint64_t observedEpoch{},observedRevision{};
|
||||
std::atomic<bool> layoutChanged{false};
|
||||
uint64_t observedEpoch{},observedRevision{},observedConfiguration{};
|
||||
std::mutex inputMutex;
|
||||
std::vector<lan::InputFrame> inputOut,inputIn;
|
||||
std::unique_ptr<lan::InputRouter> router;
|
||||
std::vector<lan::RoutedInput> inputOut,inputIn;
|
||||
std::map<std::string,uint64_t> inputReady;
|
||||
std::unique_ptr<lan::InputMesh> router;
|
||||
std::string inputStatus="Input sharing disabled.";
|
||||
explicit Impl(const std::filesystem::path &root) : path(root/L"lan-pairing.bin")
|
||||
HWND inputWindow{};std::atomic<bool> inputNotification{false};
|
||||
explicit Impl(const std::filesystem::path &root,HWND window) : path(root/L"lan-pairing.bin"),inputWindow(window)
|
||||
{
|
||||
if(std::filesystem::exists(path))
|
||||
{
|
||||
auto j=Json::parse(unprotect(readFile(path,256*1024)));
|
||||
if(j.value("version",0)!=1 || !lan::identifier(j.value("id",""))) throw std::runtime_error("Invalid local LAN configuration.");
|
||||
id=j.at("id"); enabled=j.value("enabled",false); trusted=j.at("trusted");
|
||||
positions=j.value("positions",Json::object());
|
||||
shareInput=j.value("shareInput",false);inputPeer=j.value("inputPeer","");inputEdge=j.value("inputEdge",1);
|
||||
if(inputEdge<0||inputEdge>3||(shareInput&&!trusted.contains(inputPeer)))throw std::runtime_error("Invalid input sharing configuration.");
|
||||
if(inputEdge<0||inputEdge>3||(shareInput&&trusted.empty()))throw std::runtime_error("Invalid input sharing configuration.");
|
||||
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
|
||||
for(auto &[key,entry]:trusted.items())
|
||||
if(!lan::identifier(key)||key==id||entry.at("key").get<std::string>().size()!=64||
|
||||
@@ -83,41 +90,53 @@ struct LanService::Impl
|
||||
throw std::runtime_error("Invalid paired computer key.");
|
||||
}
|
||||
else id=lan::randomHex(16);
|
||||
lan::fillPositions(positions,trusted,inputPeer,inputEdge);
|
||||
clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||
configureRouter();
|
||||
if(enabled) start();
|
||||
}
|
||||
~Impl() { stop(); }
|
||||
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
|
||||
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge}}))); }
|
||||
bool queue(lan::InputFrame frame)
|
||||
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||
bool queue(const std::string &peer,lan::InputFrame frame)
|
||||
{
|
||||
if(!inputConnected)return false;
|
||||
std::lock_guard lock(inputMutex);
|
||||
if(!inputReady.contains(peer))return false;
|
||||
if(inputOut.size()>=512){abortInput=true;return false;}
|
||||
inputOut.push_back(frame);return true;
|
||||
if(!inputOut.empty()&&inputOut.back().peer==peer&&inputOut.back().epoch==inputReady.at(peer)){
|
||||
std::vector<lan::InputFrame> tail{inputOut.back().frame};lan::enqueueInput(tail,frame);
|
||||
if(tail.size()==1){inputOut.back().frame=tail.front();return true;}
|
||||
}
|
||||
inputOut.push_back({peer,frame,inputReady.at(peer)});return true;
|
||||
}
|
||||
void configureRouter()
|
||||
{
|
||||
router.reset();inputConnected=false;abortInput=true;++inputEpoch;++inputConfiguration;
|
||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
|
||||
if(enabled&&shareInput)router=std::make_unique<lan::InputRouter>(lan::windowsInputEnvironment(),[this](auto f){return queue(f);},inputEdge,id<inputPeer);
|
||||
inputStatus=enabled&&shareInput?"Connecting input channel...":"Input sharing disabled.";
|
||||
if(router)router->cancel(GetTickCount64());
|
||||
router.reset();++inputConfiguration;
|
||||
if(enabled&&shareInput){
|
||||
std::map<std::string,POINT> layout;for(auto &[peer,cell]:positions.items())if(trusted.contains(peer))layout[peer]={cell[0].get<LONG>(),cell[1].get<LONG>()};
|
||||
router=std::make_unique<lan::InputMesh>(id,lan::windowsInputEnvironment(),std::move(layout),[this](const auto &peer,auto f){return queue(peer,f);});
|
||||
}
|
||||
inputStatus=enabled&&shareInput?"Connecting input channels...":"Input sharing disabled.";
|
||||
observedRevision=0;
|
||||
}
|
||||
bool takeChange()
|
||||
{
|
||||
auto revision=router?router->revision():0;bool changed=revision!=observedRevision;observedRevision=revision;return changed;
|
||||
auto revision=router?router->revision():0;auto configuration=inputConfiguration.load();
|
||||
bool changed=revision!=observedRevision||configuration!=observedConfiguration;
|
||||
observedRevision=revision;observedConfiguration=configuration;return changed;
|
||||
}
|
||||
bool tickInput(bool allowEnter)
|
||||
{
|
||||
inputNotification=false;
|
||||
if(layoutChanged.exchange(false)){std::lock_guard lock(mutex);configureRouter();}
|
||||
if(!router)return false;
|
||||
auto epoch=inputEpoch.load();if(epoch!=observedEpoch){router->cancel(GetTickCount64());observedEpoch=epoch;}
|
||||
router->tick(inputConnected,GetTickCount64());
|
||||
std::vector<lan::InputFrame> incoming;
|
||||
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);}
|
||||
for(auto &frame:incoming)
|
||||
if(!allowEnter&&frame.type==lan::InputType::Enter)queue({lan::InputType::Return,frame.token,32768});
|
||||
else router->receive(frame,GetTickCount64());
|
||||
std::vector<lan::RoutedInput> incoming;std::map<std::string,uint64_t> ready;
|
||||
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);ready=inputReady;}
|
||||
router->tick(ready,GetTickCount64());
|
||||
for(auto &frame:incoming)if(ready.contains(frame.peer)&&ready.at(frame.peer)==frame.epoch)
|
||||
router->receive(frame.peer,frame.frame,GetTickCount64(),allowEnter);
|
||||
return takeChange();
|
||||
}
|
||||
void start()
|
||||
@@ -131,8 +150,8 @@ struct LanService::Impl
|
||||
stopping=true;
|
||||
if(worker.joinable()) worker.join();
|
||||
inputConnected=false;++inputEpoch;
|
||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
|
||||
std::lock_guard lock(mutex); pending.reset(); peers.clear();
|
||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();inputReady.clear();}
|
||||
std::lock_guard lock(mutex); pending.reset(); peers.clear();clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||
}
|
||||
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
|
||||
{
|
||||
@@ -154,7 +173,12 @@ struct LanService::Impl
|
||||
// Discovery is untrusted and never grants input access.
|
||||
if(!peers.contains(other) && peers.size()>=64) return;
|
||||
auto &peer=peers[other];
|
||||
if(peer.epoch!=session || peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
|
||||
// A fresh announcement rotates the challenge every three seconds; it
|
||||
// does not revoke a recent successful proof for the same address.
|
||||
// Only a valid presence response renews verified, so untrusted/replayed
|
||||
// announcements cannot extend its PeerLifetime expiry.
|
||||
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
|
||||
clipboard->peerSession(other,j.value("clipboardSession",""));
|
||||
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
|
||||
if(trusted.contains(other))
|
||||
{
|
||||
@@ -167,6 +191,14 @@ struct LanService::Impl
|
||||
if(!peers.contains(other)||j.value("to","")!=id) return;
|
||||
auto &peer=peers.at(other);
|
||||
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
|
||||
if(type=="clipboard" && trusted.contains(other))
|
||||
{
|
||||
try {clipboard->receive(other,trusted.at(other).at("key"),j,now,GetClipboardSequenceNumber(),
|
||||
[&](const auto &target,const auto &packet){send(socket,peers.at(target).address,packet);});}
|
||||
catch(const std::exception &) {return;}
|
||||
if(!clipboard->deliveries.empty()&&inputWindow)PostMessageW(inputWindow,LanClipboardReadyMessage,0,0);
|
||||
return;
|
||||
}
|
||||
if(type=="presence" && trusted.contains(other) && j.value("epoch","")==peer.epoch &&
|
||||
lan::equal(j.value("proof",""),lan::mac(trusted[other].at("key"),heartbeat(other,id,peer.epoch,epoch))))
|
||||
{ peer.verified=now; return; }
|
||||
@@ -175,10 +207,10 @@ struct LanService::Impl
|
||||
for(const auto &response:pending->receive(j,now)) send(socket,address,response);
|
||||
if(pending->confirmed && (!trusted.contains(other)||trusted[other].at("key")!=pending->derived))
|
||||
{
|
||||
auto previous=trusted;
|
||||
auto previous=trusted;auto previousPositions=positions;
|
||||
trusted[other]={{"name",peer.name},{"key",pending->derived}};
|
||||
try {save();} catch(...) {trusted=std::move(previous);throw;}
|
||||
peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,27 +225,29 @@ struct LanService::Impl
|
||||
if(socket==INVALID_SOCKET || setsockopt(socket,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes)) ||
|
||||
setsockopt(socket,SOL_SOCKET,SO_BROADCAST,(char*)&yes,sizeof(yes)) || ioctlsocket(socket,FIONBIO,&nonblock))
|
||||
throw std::runtime_error("Cannot open LAN discovery socket.");
|
||||
int clipboardBuffer=1024*1024;
|
||||
setsockopt(socket,SOL_SOCKET,SO_RCVBUF,reinterpret_cast<const char*>(&clipboardBuffer),sizeof(clipboardBuffer));
|
||||
setsockopt(socket,SOL_SOCKET,SO_SNDBUF,reinterpret_cast<const char*>(&clipboardBuffer),sizeof(clipboardBuffer));
|
||||
sockaddr_in bound{}; bound.sin_family=AF_INET; bound.sin_port=htons(lan::Port);
|
||||
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,lastChannelGeneration=0;
|
||||
std::unique_ptr<lan::InputChannel> channel;
|
||||
std::string channelPeer,channelSecret;int channelEdge=-1;
|
||||
uint64_t nextHello=0,nextOffer=0;
|
||||
lan::InputHub hub(id);
|
||||
while(!stopping)
|
||||
{
|
||||
auto now=GetTickCount64();
|
||||
std::string selectedPeer,selectedSecret;int selectedEdge=-1;uint32_t selectedAddress=0;uint64_t configuration=0;
|
||||
auto now=GetTickCount64();bool clipboardActive=false;
|
||||
std::vector<lan::InputEndpoint> endpoints;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
configuration=inputConfiguration.load();
|
||||
if(shareInput&&trusted.contains(inputPeer))
|
||||
{
|
||||
selectedPeer=inputPeer;selectedSecret=trusted.at(inputPeer).at("key");selectedEdge=inputEdge;
|
||||
if(peers.contains(inputPeer))selectedAddress=peers.at(inputPeer).address.sin_addr.s_addr;
|
||||
}
|
||||
clipboard->poll(trusted,now,[&](const auto &target,const auto &packet){
|
||||
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||
});
|
||||
clipboardActive=clipboard->active();
|
||||
if(shareInput)for(auto &[peer,entry]:trusted.items())
|
||||
endpoints.push_back({peer,entry.at("key"),peers.contains(peer)?peers.at(peer).address.sin_addr.s_addr:0});
|
||||
if(now>=nextHello)
|
||||
{
|
||||
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
|
||||
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;
|
||||
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
|
||||
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
|
||||
nextHello=now+3000;
|
||||
}
|
||||
@@ -228,30 +262,25 @@ struct LanService::Impl
|
||||
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
|
||||
}
|
||||
}
|
||||
if(channelPeer!=selectedPeer||channelEdge!=selectedEdge||channelSecret!=selectedSecret)
|
||||
{
|
||||
inputConnected=false;++inputEpoch;channel.reset();
|
||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
|
||||
channelPeer=selectedPeer;channelEdge=selectedEdge;channelSecret=selectedSecret;lastChannelGeneration=0;
|
||||
if(!selectedPeer.empty())channel=std::make_unique<lan::InputChannel>(id,selectedPeer,selectedSecret,selectedEdge);
|
||||
}
|
||||
if(channel)
|
||||
{
|
||||
std::vector<lan::InputFrame> out,in;
|
||||
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
|
||||
if(abortInput.exchange(false)){channel->disconnect();out.clear();}
|
||||
channel->poll(selectedAddress,out,in,now);
|
||||
if(configuration!=inputConfiguration.load()){in.clear();channel->disconnect();}
|
||||
if(lastChannelGeneration!=channel->generation())
|
||||
{
|
||||
lastChannelGeneration=channel->generation();inputConnected=false;++inputEpoch;
|
||||
std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();
|
||||
std::vector<lan::RoutedInput> out,in;
|
||||
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
|
||||
if(abortInput.exchange(false)){hub.disconnect();out.clear();}
|
||||
hub.poll(endpoints,out,in,now);
|
||||
auto readyPeers=hub.ready();inputConnected=!readyPeers.empty();
|
||||
{std::lock_guard lock(inputMutex);inputReady=std::move(readyPeers);
|
||||
for(auto &frame:in){
|
||||
if(!inputIn.empty()&&inputIn.back().peer==frame.peer&&inputIn.back().epoch==frame.epoch){
|
||||
std::vector<lan::InputFrame> tail{inputIn.back().frame};lan::enqueueInput(tail,frame.frame);
|
||||
if(tail.size()==1){inputIn.back().frame=tail.front();continue;}
|
||||
}
|
||||
inputIn.push_back(frame);
|
||||
}
|
||||
inputConnected=channel->ready()&&!abortInput.load()&&configuration==inputConfiguration.load();
|
||||
{std::lock_guard lock(inputMutex);if(inputIn.size()+in.size()>512)abortInput=true;else inputIn.insert(inputIn.end(),in.begin(),in.end());}
|
||||
{std::lock_guard lock(mutex);inputStatus=channel->status();}
|
||||
if(inputIn.size()>512){inputIn.clear();abortInput=true;}
|
||||
}
|
||||
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,channel?5000:100000};
|
||||
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
|
||||
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
|
||||
{std::lock_guard lock(mutex);inputStatus=hub.status();}
|
||||
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.");
|
||||
if(ready>0) for(int count=0;count<64;++count)
|
||||
@@ -283,13 +312,25 @@ struct LanService::Impl
|
||||
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
if(action=="position")
|
||||
{
|
||||
auto peer=j.at("peer").get<std::string>();int x=j.at("x"),y=j.at("y");
|
||||
if(!trusted.contains(peer))throw std::runtime_error("Pair this computer before arranging it.");
|
||||
lan::validateCell(positions,peer,x,y);
|
||||
auto before=positions;int beforeEdge=inputEdge;bool beforeActive=shareInput;
|
||||
positions[peer]=Json::array({x,y});
|
||||
if(peer==inputPeer){int edge=lan::cellEdge(positions[peer]);if(edge>=0)inputEdge=edge;}
|
||||
try{save();}catch(...){positions=std::move(before);inputEdge=beforeEdge;shareInput=beforeActive;throw;}
|
||||
configureRouter();
|
||||
}
|
||||
if(action=="input")
|
||||
{
|
||||
bool active=j.at("enabled");auto peer=j.value("peer",inputPeer);int edge=j.value("edge",inputEdge);
|
||||
if(edge<0||edge>3||(active&&(!enabled||!trusted.contains(peer))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
|
||||
auto beforePeer=inputPeer;int beforeEdge=inputEdge;bool beforeActive=shareInput;
|
||||
if(edge<0||edge>3||(active&&(!enabled||trusted.empty()||(j.contains("peer")&&!trusted.contains(peer)))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
|
||||
auto beforePeer=inputPeer;auto beforePositions=positions;int beforeEdge=inputEdge;bool beforeActive=shareInput;
|
||||
if(j.contains("edge")&&trusted.contains(peer)){auto cell=lan::edgeCell(edge);lan::validateCell(positions,peer,cell[0],cell[1]);positions[peer]=cell;}
|
||||
shareInput=active;inputPeer=peer;inputEdge=edge;
|
||||
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;throw;}
|
||||
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;positions=std::move(beforePositions);throw;}
|
||||
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
|
||||
}
|
||||
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
|
||||
@@ -304,16 +345,17 @@ struct LanService::Impl
|
||||
}
|
||||
if(action=="forget")
|
||||
{
|
||||
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;bool oldSharing=shareInput;
|
||||
trusted.erase(peer);if(peer==inputPeer)shareInput=false;
|
||||
try{save();}catch(...){trusted=std::move(oldTrusted);shareInput=oldSharing;throw;}
|
||||
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;auto oldPositions=positions;bool oldSharing=shareInput;
|
||||
trusted.erase(peer);positions.erase(peer);if(trusted.empty())shareInput=false;
|
||||
try{save();}catch(...){trusted=std::move(oldTrusted);positions=std::move(oldPositions);shareInput=oldSharing;throw;}
|
||||
if(pending&&pending->peer==peer)pending.reset();
|
||||
if(peer==inputPeer)configureRouter();
|
||||
configureRouter();
|
||||
status="Pair removed on this computer.";
|
||||
}
|
||||
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
|
||||
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
|
||||
{"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}};
|
||||
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
|
||||
auto now=GetTickCount64();
|
||||
for(const auto &[peer,p]:peers)
|
||||
{
|
||||
@@ -326,9 +368,30 @@ struct LanService::Impl
|
||||
return encoded(result);
|
||||
}
|
||||
};
|
||||
LanService::LanService(const std::filesystem::path &path) : impl(std::make_unique<Impl>(path)) {}
|
||||
LanService::LanService(const std::filesystem::path &path,HWND window) : impl(std::make_unique<Impl>(path,window)) {}
|
||||
LanService::~LanService()=default;
|
||||
Bytes LanService::command(const Bytes &command) { return impl->command(command); }
|
||||
bool LanService::clipboardEnabled()const
|
||||
{
|
||||
std::lock_guard lock(impl->mutex);
|
||||
if(!impl->enabled)return false;
|
||||
for(auto &[id,entry]:impl->trusted.items())if(impl->peers.contains(id))return true;
|
||||
return false;
|
||||
}
|
||||
void LanService::publishClipboard(Bytes bytes,const std::string &app)
|
||||
{
|
||||
std::lock_guard lock(impl->mutex);if(!impl->enabled)return;
|
||||
std::vector<std::string> targets;
|
||||
for(auto &[id,entry]:impl->trusted.items())if(impl->peers.contains(id))targets.push_back(id);
|
||||
if(!targets.empty())impl->clipboard->queue(targets,std::move(bytes),app,GetTickCount64());
|
||||
}
|
||||
std::vector<LanClipboard> LanService::takeClipboard()
|
||||
{
|
||||
std::lock_guard lock(impl->mutex);std::vector<LanClipboard> result;
|
||||
for(auto &d:impl->clipboard->deliveries)if(impl->trusted.contains(d.peer))
|
||||
result.push_back({d.peer,impl->trusted.at(d.peer).at("name"),d.app,std::move(d.bytes),d.sequence});
|
||||
impl->clipboard->deliveries.clear();return result;
|
||||
}
|
||||
bool LanService::inputEnabled()const{return impl->enabled&&impl->shareInput;}
|
||||
bool LanService::tickInput(bool allowEnter){return impl->tickInput(allowEnter);}
|
||||
bool LanService::keyboard(const KBDLLHOOKSTRUCT &k,bool down){return impl->router&&impl->router->keyboard(k,down,GetTickCount64());}
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
|
||||
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
|
||||
// Discovery, pairing and optional encrypted input within the resident process.
|
||||
class LanService
|
||||
{
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl;
|
||||
public:
|
||||
explicit LanService(const std::filesystem::path &);
|
||||
explicit LanService(const std::filesystem::path &, HWND inputWindow=nullptr);
|
||||
~LanService();
|
||||
Bytes command(const Bytes &);
|
||||
bool clipboardEnabled() const;
|
||||
void publishClipboard(Bytes, const std::string &);
|
||||
std::vector<LanClipboard> takeClipboard();
|
||||
bool inputEnabled() const;
|
||||
bool tickInput(bool allowEnter = true);
|
||||
bool keyboard(const KBDLLHOOKSTRUCT &, bool down);
|
||||
|
||||
+111
-14
@@ -49,6 +49,16 @@ struct InputCipher::Impl
|
||||
};
|
||||
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
|
||||
InputCipher::~InputCipher()=default;
|
||||
Bytes InputCipher::encryptPayload(const Bytes &plain)
|
||||
{
|
||||
if(plain.empty()||plain.size()>1024)throw std::runtime_error("Invalid file packet size.");
|
||||
Bytes tag(16);auto result=impl->crypt(plain,tag,true);result.insert(result.end(),tag.begin(),tag.end());return result;
|
||||
}
|
||||
Bytes InputCipher::decryptPayload(const Bytes &packet)
|
||||
{
|
||||
if(packet.size()<17||packet.size()>1040)throw std::runtime_error("Invalid file packet size.");
|
||||
Bytes tag(packet.end()-16,packet.end());return impl->crypt(Bytes(packet.begin(),packet.end()-16),tag,false);
|
||||
}
|
||||
Bytes InputCipher::encrypt(const InputFrame &f)
|
||||
{
|
||||
Bytes plain;put32(plain,uint32_t(f.type));put32(plain,f.token);put32(plain,uint32_t(f.a));put32(plain,uint32_t(f.b));put32(plain,uint32_t(f.c));
|
||||
@@ -58,7 +68,7 @@ InputFrame InputCipher::decrypt(const Bytes &packet)
|
||||
{
|
||||
if(packet.size()!=36)throw std::runtime_error("Invalid encrypted input frame.");
|
||||
Bytes tag(packet.begin()+20,packet.end());auto plain=impl->crypt(Bytes(packet.begin(),packet.begin()+20),tag,false);
|
||||
auto type=get32(plain.data());if(type>uint32_t(InputType::Wheel))throw std::runtime_error("Unsupported input frame.");
|
||||
auto type=get32(plain.data());if(type>uint32_t(InputType::Cross))throw std::runtime_error("Unsupported input frame.");
|
||||
return {InputType(type),get32(plain.data()+4),int32_t(get32(plain.data()+8)),int32_t(get32(plain.data()+12)),int32_t(get32(plain.data()+16))};
|
||||
}
|
||||
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,const std::string &a,const std::string &b,int edge)
|
||||
@@ -70,11 +80,11 @@ struct InputChannel::Impl
|
||||
{
|
||||
std::string local,peer,secret,nonce,state="Waiting for the paired computer...";
|
||||
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
|
||||
bool connecting{},authenticated{},helloRead{};
|
||||
bool connecting{},authenticated{},helloRead{},managed{};
|
||||
uint64_t changed{},lastRead{},lastPing{},retry{};
|
||||
Bytes incoming,outgoing;
|
||||
std::unique_ptr<InputCipher> tx,rx;
|
||||
Impl(std::string a,std::string b,std::string key,int side,uint16_t p):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p){}
|
||||
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)
|
||||
{
|
||||
@@ -83,7 +93,7 @@ struct InputChannel::Impl
|
||||
void begin(uint64_t now)
|
||||
{
|
||||
nonce=randomHex(16);lastRead=lastPing=now;
|
||||
auto hello=Json{{"app","KSwitcher-input"},{"version",1},{"id",local},{"to",peer},{"nonce",nonce},{"edge",edge}}.dump();
|
||||
auto hello=Json{{"app","KSwitcher-input"},{"version",managed?2:1},{"id",local},{"to",peer},{"nonce",nonce},{"edge",edge}}.dump();
|
||||
put32(outgoing,uint32_t(hello.size()));outgoing.insert(outgoing.end(),hello.begin(),hello.end());
|
||||
state="Authenticating paired KSwitcher...";
|
||||
}
|
||||
@@ -92,7 +102,7 @@ struct InputChannel::Impl
|
||||
try
|
||||
{
|
||||
if(socket==INVALID_SOCKET&&now<retry)return;
|
||||
if(listener==INVALID_SOCKET && local>peer)
|
||||
if(!managed && listener==INVALID_SOCKET && local>peer)
|
||||
{
|
||||
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;
|
||||
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)){closeSocket(listener);throw std::runtime_error("Cannot open input listener.");}
|
||||
@@ -108,8 +118,9 @@ struct InputChannel::Impl
|
||||
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...";
|
||||
}
|
||||
else
|
||||
else if(!managed)
|
||||
{
|
||||
sockaddr_in a{};int length=sizeof(a);socket=accept(listener,(sockaddr*)&a,&length);
|
||||
if(socket!=INVALID_SOCKET)
|
||||
@@ -121,13 +132,20 @@ struct InputChannel::Impl
|
||||
}
|
||||
}
|
||||
if(socket==INVALID_SOCKET)return;
|
||||
if(now-lastRead>2000)throw std::runtime_error("Input connection timed out; control returned locally.");
|
||||
// Give TCP establishment time for SYN retransmission / firewall approval.
|
||||
// Keep the short fail-safe only for an already authenticated input session.
|
||||
if(now-lastRead>(authenticated?2000:10000))
|
||||
throw std::runtime_error(authenticated ? "Input connection lost (no heartbeat for 2 seconds); control returned locally." :
|
||||
connecting ? "Cannot reach TCP 45884. Enable input on both PCs and check the receiving PC firewall." :
|
||||
"TCP connected, but input authentication timed out. Check pairing and input setup on both PCs.");
|
||||
if(connecting)
|
||||
{
|
||||
fd_set writes,errors;FD_ZERO(&writes);FD_ZERO(&errors);FD_SET(socket,&writes);FD_SET(socket,&errors);timeval wait{};
|
||||
if(select(0,nullptr,&writes,&errors,&wait)<=0)return;
|
||||
int error=0;int size=sizeof(error);if(getsockopt(socket,SOL_SOCKET,SO_ERROR,(char*)&error,&size)||error)throw std::runtime_error("Cannot connect to paired KSwitcher.");
|
||||
connecting=false;
|
||||
int error=0;int size=sizeof(error);
|
||||
if(getsockopt(socket,SOL_SOCKET,SO_ERROR,(char*)&error,&size))error=WSAGetLastError();
|
||||
if(error)throw std::runtime_error("Cannot connect to TCP 45884 (Windows error "+std::to_string(error)+"). Enable input on both PCs and check the receiving PC firewall.");
|
||||
connecting=false;state="TCP connected; authenticating paired KSwitcher...";
|
||||
}
|
||||
if(authenticated)
|
||||
{
|
||||
@@ -155,12 +173,12 @@ struct InputChannel::Impl
|
||||
size_t length=get32(incoming.data());if(length>512||length<32)throw std::runtime_error("Invalid input handshake.");
|
||||
if(incoming.size()<length+4)return;
|
||||
auto hello=Json::parse(incoming.begin()+4,incoming.begin()+4+length);
|
||||
if(hello.value("app","")!="KSwitcher-input"||hello.value("version",0)!=1||hello.value("id","")!=peer||hello.value("to","")!=local)
|
||||
if(hello.value("app","")!="KSwitcher-input"||hello.value("version",0)!=(managed?2:1)||hello.value("id","")!=peer||hello.value("to","")!=local)
|
||||
throw std::runtime_error("Unexpected input peer.");
|
||||
if(hello.value("edge",-1)!=(edge^1))throw std::runtime_error("Screen positions must be opposite on the two computers.");
|
||||
if(!managed&&hello.value("edge",-1)!=(edge^1))throw std::runtime_error("Screen positions must be opposite on the two computers.");
|
||||
auto remoteNonce=hello.value("nonce","");
|
||||
tx=std::make_unique<InputCipher>(inputSessionKey(secret,local,peer,nonce,remoteNonce,edge));
|
||||
rx=std::make_unique<InputCipher>(inputSessionKey(secret,peer,local,remoteNonce,nonce,edge^1));
|
||||
tx=std::make_unique<InputCipher>(inputSessionKey(secret,local,peer,nonce,remoteNonce,managed?-1:edge));
|
||||
rx=std::make_unique<InputCipher>(inputSessionKey(secret,peer,local,remoteNonce,nonce,managed?-1:(edge^1)));
|
||||
incoming.erase(incoming.begin(),incoming.begin()+4+length);helloRead=true;
|
||||
auto bytes=tx->encrypt({InputType::Ready});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());
|
||||
}
|
||||
@@ -175,11 +193,90 @@ struct InputChannel::Impl
|
||||
catch(const std::exception &e){received.clear();reset(now,e.what());}
|
||||
}
|
||||
};
|
||||
InputChannel::InputChannel(std::string a,std::string b,std::string key,int edge,uint16_t port):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port)){}
|
||||
InputChannel::InputChannel(std::string a,std::string b,std::string key,int edge,uint16_t port,bool managed):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port,managed)){}
|
||||
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;
|
||||
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;}
|
||||
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
|
||||
struct InputHub::Impl
|
||||
{
|
||||
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{};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;
|
||||
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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
// Discovery is a hint, not the lifetime of authenticated input sessions.
|
||||
// Keep the last usable address across missed announcements. A new address
|
||||
// is used only once a channel is down, never to tear down working input.
|
||||
for(auto &e:endpoints){if(e.id==local)continue;auto &link=links.at(e.id);
|
||||
if(e.address&&!link.channel->ready()&&e.address!=link.endpoint.address){
|
||||
link.channel->disconnect();link.endpoint.address=e.address;
|
||||
}
|
||||
}
|
||||
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
|
||||
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);
|
||||
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){
|
||||
auto detail="TCP input port "+std::to_string(port)+" is unavailable (Windows error "+std::to_string(WSAGetLastError())+").";
|
||||
if(error!=detail)log(L"LAN input: "+wide(detail));
|
||||
closeSocket(listener);retry=now+1500;error=std::move(detail);
|
||||
}else {error.clear();log(L"LAN input listener ready on TCP "+std::to_wstring(port));}
|
||||
}
|
||||
if(listener!=INVALID_SOCKET)for(int n=0;n<16&&pending.size()<64;++n){
|
||||
sockaddr_in a{};int size=sizeof(a);SOCKET sock=accept(listener,(sockaddr*)&a,&size);if(sock==INVALID_SOCKET)break;
|
||||
if(!nonblocking(sock)){closeSocket(sock);continue;}pending.push_back({sock,a.sin_addr.s_addr,now});
|
||||
}
|
||||
for(auto it=pending.begin();it!=pending.end();){
|
||||
bool done=now-it->since>2000;unsigned char bytes[516];int count=recv(it->socket,(char*)bytes,sizeof(bytes),MSG_PEEK);
|
||||
if(count==0||(count<0&&WSAGetLastError()!=WSAEWOULDBLOCK))done=true;
|
||||
if(!done&&count>=4){
|
||||
auto length=get32(bytes);if(length<32||length>512)done=true;
|
||||
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;
|
||||
}catch(const std::exception &){}
|
||||
}
|
||||
}
|
||||
if(done){closeSocket(it->socket);it=pending.erase(it);}else ++it;
|
||||
}
|
||||
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(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;}
|
||||
for(auto &f:received)in.push_back({id,f,link.epoch});
|
||||
}
|
||||
}
|
||||
};
|
||||
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::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.";
|
||||
if(!impl->error.empty())return impl->error;
|
||||
for(auto &[id,l]:impl->links)if(!l.channel->status().empty())return l.channel->status();
|
||||
return "Waiting for paired computers...";
|
||||
}
|
||||
void InputHub::disconnect(){for(auto &[id,l]:impl->links)l.channel->disconnect();}
|
||||
}
|
||||
+36
-2
@@ -1,9 +1,10 @@
|
||||
#pragma once
|
||||
#include "lan_pairing.h"
|
||||
#include <memory>
|
||||
#include <map>
|
||||
namespace ks::lan
|
||||
{
|
||||
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel };
|
||||
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
|
||||
struct InputFrame
|
||||
{
|
||||
InputType type{};
|
||||
@@ -11,6 +12,23 @@ struct InputFrame
|
||||
int32_t a{},b{},c{};
|
||||
bool operator==(const InputFrame &) const = default;
|
||||
};
|
||||
// Collapse only contiguous, not-yet-delivered wheel events. A reversal replaces
|
||||
// stale queued motion; key/button boundaries must never be crossed.
|
||||
inline void enqueueInput(std::vector<InputFrame> &queue,const InputFrame &frame)
|
||||
{
|
||||
if(frame.type==InputType::Wheel&&!queue.empty())
|
||||
{
|
||||
auto &last=queue.back();
|
||||
if(last.type==InputType::Wheel&&last.token==frame.token&&last.b==frame.b)
|
||||
{
|
||||
if((last.a<0&&frame.a>0)||(last.a>0&&frame.a<0)){last=frame;return;}
|
||||
auto total=int64_t(last.a)+frame.a;
|
||||
if(total>=-32768&&total<=32767){last.a=int32_t(total);return;}
|
||||
}
|
||||
}
|
||||
queue.push_back(frame);
|
||||
|
||||
}
|
||||
// One directional AES-256-GCM key per session. Strict sequence numbers prevent replay.
|
||||
class InputCipher
|
||||
{
|
||||
@@ -20,6 +38,8 @@ class InputCipher
|
||||
~InputCipher();
|
||||
Bytes encrypt(const InputFrame &);
|
||||
InputFrame decrypt(const Bytes &);
|
||||
Bytes encryptPayload(const Bytes &);
|
||||
Bytes decryptPayload(const Bytes &);
|
||||
};
|
||||
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,
|
||||
const std::string &fromNonce,const std::string &toNonce,int fromEdge=-1);
|
||||
@@ -29,12 +49,26 @@ class InputChannel
|
||||
struct Impl; std::unique_ptr<Impl> impl;
|
||||
public:
|
||||
InputChannel(std::string local,std::string peer,std::string secret,int edge,
|
||||
uint16_t port=45884);
|
||||
uint16_t port=45884,bool managed=false);
|
||||
~InputChannel();
|
||||
void poll(uint32_t peerAddress,const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now);
|
||||
bool adopt(uintptr_t socket,uint64_t now);
|
||||
bool ready() const;
|
||||
uint64_t generation() const;
|
||||
std::string status() const;
|
||||
void disconnect();
|
||||
};
|
||||
struct InputEndpoint {std::string id,key;uint32_t address{};uint16_t port=45884;};
|
||||
struct RoutedInput {std::string peer;InputFrame frame;uint64_t epoch{};};
|
||||
class InputHub
|
||||
{
|
||||
struct Impl;std::unique_ptr<Impl> impl;
|
||||
public:
|
||||
explicit InputHub(std::string local,uint16_t port=45884);
|
||||
~InputHub();
|
||||
void poll(const std::vector<InputEndpoint> &,const std::vector<RoutedInput> &,std::vector<RoutedInput> &,uint64_t now);
|
||||
std::map<std::string,uint64_t> ready()const;
|
||||
std::string status()const;
|
||||
void disconnect();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#pragma once
|
||||
#include "lan_channel.h"
|
||||
#include <map>
|
||||
#include <deque>
|
||||
#include <set>
|
||||
|
||||
namespace ks::lan
|
||||
{
|
||||
// Reliable bounded clipboard messages over the existing discovery socket.
|
||||
// Every datagram has a fresh directional AES-GCM key; no plaintext clipboard
|
||||
// metadata or contents appear outside the encrypted CBOR body.
|
||||
class ClipboardExchange
|
||||
{
|
||||
static constexpr size_t Limit=32*1024*1024, Chunk=480, Window=32;
|
||||
struct Item {std::string id,app;Bytes bytes;};
|
||||
struct Sending {std::deque<std::shared_ptr<Item>> queue;size_t ack{};bool begun{};uint64_t last{},progress{};};
|
||||
struct Receiving {std::string id,app;Bytes bytes;size_t size{};uint64_t last{};DWORD sequence{};};
|
||||
std::string local, boot=randomHex(16);
|
||||
std::map<std::string,std::string> sessions;
|
||||
std::map<std::string,Sending> sending;
|
||||
std::map<std::string,Receiving> receiving;
|
||||
std::map<std::string,std::deque<std::pair<std::string,size_t>>> completed;
|
||||
size_t incomingBytes{};uint64_t nextPacket{};
|
||||
std::map<std::string,std::map<std::string,uint64_t>> receivedPackets;
|
||||
static std::string hex(const Bytes &bytes){std::string s;for(auto b:bytes){s+="0123456789abcdef"[b>>4];s+="0123456789abcdef"[b&15];}return s;}
|
||||
static Bytes unhex(const std::string &s)
|
||||
{
|
||||
if(s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid clipboard 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;
|
||||
}
|
||||
Json seal(const std::string &peer,const std::string &key,const Json &body)
|
||||
{
|
||||
auto nonce=randomHex(16);auto number=++nextPacket;
|
||||
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
|
||||
auto packet=envelope("clipboard",local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
|
||||
packet["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));return packet;
|
||||
}
|
||||
public:
|
||||
struct Delivery {std::string peer,id,app;Bytes bytes;DWORD sequence{};};
|
||||
std::vector<Delivery> deliveries;
|
||||
std::string status="Clipboard sharing ready.";
|
||||
explicit ClipboardExchange(std::string id):local(std::move(id)){}
|
||||
const std::string &session()const{return boot;}
|
||||
bool active()const{return !sending.empty();}
|
||||
void peerSession(const std::string &peer,const std::string &value){
|
||||
if(!identifier(value))return;
|
||||
if(sessions.contains(peer)&&sessions.at(peer)!=value){
|
||||
auto found=sending.find(peer);if(found!=sending.end()){found->second.ack=0;found->second.begun=false;found->second.last=0;}
|
||||
}
|
||||
sessions[peer]=value;
|
||||
}
|
||||
void queue(const std::vector<std::string> &peers,Bytes bytes,std::string app,uint64_t now)
|
||||
{
|
||||
if(bytes.empty()||bytes.size()>Limit)throw std::runtime_error("LAN clipboard limit is 32 MiB.");
|
||||
size_t total=bytes.size();std::set<const Item*> counted;
|
||||
for(auto &[peer,s]:sending)for(auto &item:s.queue)if(counted.insert(item.get()).second)total+=item->bytes.size();
|
||||
if(total>2*Limit)throw std::runtime_error("LAN clipboard queue is full.");
|
||||
auto item=std::make_shared<Item>(Item{randomHex(16),app.size()>128?std::string("Application"):app,std::move(bytes)});
|
||||
for(auto &peer:peers){auto &s=sending[peer];if(s.queue.size()>=8)continue;if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
|
||||
if(!peers.empty())status="Sending clipboard to "+std::to_string(peers.size())+" paired computers...";
|
||||
}
|
||||
template<class Send>void poll(const Json &trusted,uint64_t now,Send send)
|
||||
{
|
||||
for(auto it=receiving.begin();it!=receiving.end();)
|
||||
if(!trusted.contains(it->first)||now-it->second.last>30000){incomingBytes-=it->second.size;it=receiving.erase(it);}else ++it;
|
||||
for(auto it=sending.begin();it!=sending.end();)
|
||||
{
|
||||
auto &peer=it->first;auto &s=it->second;
|
||||
if(!trusted.contains(peer)||s.queue.empty()){it=sending.erase(it);continue;}
|
||||
if(now-s.progress>30000){status="Clipboard transfer timed out.";it=sending.erase(it);continue;}
|
||||
if(sessions.contains(peer)&&(!s.last||now-s.last>=100))
|
||||
{
|
||||
auto &item=*s.queue.front();auto key=trusted.at(peer).at("key").template get<std::string>();
|
||||
if(!s.begun)send(peer,seal(peer,key,{{"op","begin"},{"item",item.id},{"size",item.bytes.size()},{"app",item.app}}));
|
||||
else for(size_t offset=s.ack,n=0;offset<item.bytes.size()&&n<Window;offset+=Chunk,++n)
|
||||
send(peer,seal(peer,key,{{"op","chunk"},{"item",item.id},{"offset",offset},
|
||||
{"bytes",Json::binary(Bytes(item.bytes.begin()+offset,item.bytes.begin()+std::min(offset+Chunk,item.bytes.size())))}}));
|
||||
s.last=now;
|
||||
}
|
||||
++it;
|
||||
}
|
||||
}
|
||||
template<class Send>void receive(const std::string &peer,const std::string &key,const Json &packet,uint64_t now,DWORD sequence,Send send)
|
||||
{
|
||||
if(packet.value("session","")!=boot||!sessions.contains(peer)||packet.value("to","")!=local||!identifier(packet.value("nonce","")))return;
|
||||
auto senderBoot=packet.value("fromSession","");auto number=packet.value("number",uint64_t{});
|
||||
if(!identifier(senderBoot)||!number)return;
|
||||
auto &seen=receivedPackets[peer];
|
||||
if((seen.contains(senderBoot)&&number<=seen.at(senderBoot))||(!seen.contains(senderBoot)&&seen.size()>=1024))return;
|
||||
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
|
||||
auto body=Json::from_cbor(cipher.decryptPayload(unhex(packet.at("data").get<std::string>())));
|
||||
seen[senderBoot]=number; // Retransmissions use new numbers; reordered old packets are retried.
|
||||
auto id=body.value("item","");if(!identifier(id))return;
|
||||
auto ack=[&](size_t offset){send(peer,seal(peer,key,{{"op","ack"},{"item",id},{"offset",offset}}));};
|
||||
auto op=body.value("op","");
|
||||
if(op=="ack")
|
||||
{
|
||||
auto found=sending.find(peer);if(found==sending.end()||found->second.queue.empty())return;
|
||||
auto &s=found->second;auto &item=*s.queue.front();auto offset=body.at("offset").get<size_t>();
|
||||
if(item.id!=id||offset>item.bytes.size()||offset<s.ack||offset>s.ack+Window*Chunk||(offset!=item.bytes.size()&&offset%Chunk))return;
|
||||
if(offset==s.ack&&s.begun)return;
|
||||
s.begun=true;s.ack=offset;s.progress=now;s.last=0;
|
||||
if(offset==item.bytes.size()){s.queue.pop_front();s.ack=0;s.begun=false;status="Clipboard delivered.";}
|
||||
return;
|
||||
}
|
||||
for(auto &done:completed[peer])if(done.first==id){ack(done.second);return;}
|
||||
if(op=="begin")
|
||||
{
|
||||
auto size=body.at("size").get<size_t>();auto app=body.value("app","");
|
||||
if(!size||size>Limit||app.size()>128)return;
|
||||
auto found=receiving.find(peer);
|
||||
if(found!=receiving.end()){if(found->second.id==id)ack(found->second.bytes.size());return;}
|
||||
size_t waiting=0;for(auto &d:deliveries)waiting+=d.bytes.size();
|
||||
if(incomingBytes+waiting+size>2*Limit||deliveries.size()>=8)return;
|
||||
receiving.emplace(peer,Receiving{id,app,{},size,now,sequence});incomingBytes+=size;ack(0);return;
|
||||
}
|
||||
if(op!="chunk")return;
|
||||
auto found=receiving.find(peer);if(found==receiving.end()||found->second.id!=id)return;
|
||||
auto &r=found->second;auto offset=body.at("offset").get<size_t>();
|
||||
if(!body.at("bytes").is_binary())return;
|
||||
const auto &bytes=body.at("bytes").get_binary();
|
||||
if(bytes.empty()||bytes.size()>Chunk||offset>r.size||bytes.size()>r.size-offset)return;
|
||||
if(offset==r.bytes.size()){r.bytes.insert(r.bytes.end(),bytes.begin(),bytes.end());r.last=now;}
|
||||
ack(r.bytes.size());
|
||||
if(r.bytes.size()==r.size)
|
||||
{
|
||||
deliveries.push_back({peer,r.id,r.app,std::move(r.bytes),r.sequence});
|
||||
auto &done=completed[peer];done.push_back({id,r.size});if(done.size()>64)done.pop_front();
|
||||
incomingBytes-=r.size;receiving.erase(found);status="Clipboard received.";
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include "ocr.h"
|
||||
#include <algorithm>
|
||||
namespace ks::lan
|
||||
{
|
||||
// Send one lossless image, not PNG plus multiple uncompressed DIB copies.
|
||||
inline Clip compactClipboardImage(Clip clip)
|
||||
{
|
||||
auto png=std::find_if(clip.formats.begin(),clip.formats.end(),[](const Format &f){return f.name==L"PNG";});
|
||||
bool image=png!=clip.formats.end()||std::any_of(clip.formats.begin(),clip.formats.end(),[](const Format &f){return f.id==CF_DIB||f.id==CF_DIBV5;});
|
||||
if(!image)return clip;
|
||||
Bytes bytes=png!=clip.formats.end()?png->data:encodePng(decodeImage(clip));
|
||||
std::erase_if(clip.formats,[](const Format &f){return f.id==CF_DIB||f.id==CF_DIBV5||f.name==L"PNG";});
|
||||
clip.formats.push_back({RegisterClipboardFormatW(L"PNG"),L"PNG",std::move(bytes)});
|
||||
return clip;
|
||||
}
|
||||
// Older applications paste DIB rather than PNG; expand only at the destination.
|
||||
inline Clip clipboardImageForPaste(Clip clip)
|
||||
{
|
||||
bool dib=false,png=false;
|
||||
for(const auto &f:clip.formats){dib|=f.id==CF_DIB||f.id==CF_DIBV5;png|=f.name==L"PNG";}
|
||||
if(png&&!dib)clip.formats.push_back(imageDib(decodeImage(clip)));
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
+154
-39
@@ -1,5 +1,7 @@
|
||||
#include "lan_input.h"
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <atomic>
|
||||
|
||||
namespace ks::lan
|
||||
{
|
||||
@@ -7,9 +9,30 @@ ULONG_PTR remoteInputMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e49);}
|
||||
ULONG_PTR cursorWarpMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e57);}
|
||||
namespace
|
||||
{
|
||||
uint32_t ownershipToken()
|
||||
{
|
||||
static std::atomic<uint32_t> next{1};
|
||||
auto token=next.fetch_add(1);return token?token:next.fetch_add(1);
|
||||
}
|
||||
HCURSOR parkedCursor()
|
||||
{
|
||||
static HCURSOR cursor=[] {unsigned char mask[128],pixels[128]{};
|
||||
std::fill(std::begin(mask),std::end(mask),0xff);
|
||||
return CreateCursor(GetModuleHandleW(nullptr),0,0,32,32,mask,pixels);}();
|
||||
return cursor;
|
||||
}
|
||||
int monitorDpi(POINT point)
|
||||
{
|
||||
static HMODULE module=LoadLibraryW(L"shcore.dll");
|
||||
using Query=HRESULT(WINAPI *)(HMONITOR,int,UINT*,UINT*);
|
||||
static Query query=module?std::bit_cast<Query>(GetProcAddress(module,"GetDpiForMonitor")):nullptr;
|
||||
UINT x=96,y=96;if(query)query(MonitorFromPoint(point,MONITOR_DEFAULTTONEAREST),0,&x,&y);
|
||||
return int(std::clamp<UINT>(x,48,768));
|
||||
}
|
||||
LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_SETCURSOR){SetCursor(nullptr);return TRUE;}
|
||||
if(message==WM_NCCREATE)SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)((CREATESTRUCTW*)lp)->lpCreateParams);
|
||||
if(message==WM_SETCURSOR){SetCursor(GetWindowLongPtrW(w,GWLP_USERDATA)?parkedCursor():LoadCursorW(nullptr,IDC_ARROW));return TRUE;}
|
||||
if(message==WM_MOUSEACTIVATE)return MA_NOACTIVATE;
|
||||
if(message==WM_PAINT)
|
||||
{
|
||||
@@ -23,23 +46,27 @@ LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
struct ControlOverlay
|
||||
{
|
||||
HWND cover{},notice{};
|
||||
HWND notice{},cover{};HCURSOR previous{};
|
||||
~ControlOverlay(){hide();}
|
||||
void hide(){if(notice)DestroyWindow(notice);if(cover)DestroyWindow(cover);notice=cover=nullptr;}
|
||||
void hide(){bool shown=notice||cover;if(cover)DestroyWindow(cover);if(notice)DestroyWindow(notice);notice=cover=nullptr;
|
||||
if(shown)SetCursor(previous?previous:LoadCursorW(nullptr,IDC_ARROW));
|
||||
previous=nullptr;}
|
||||
void show()
|
||||
{
|
||||
if(cover)return;
|
||||
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";
|
||||
if(notice)return;
|
||||
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);
|
||||
RegisterClassW(&cls);
|
||||
auto style=WS_EX_TOPMOST|WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE;
|
||||
POINT cursor{};GetCursorPos(&cursor);MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||
// Only cover the parked cursor, not a full-screen layered bitmap.
|
||||
int x=(monitor.rcMonitor.left+monitor.rcMonitor.right)/2-16,y=(monitor.rcMonitor.top+monitor.rcMonitor.bottom)/2-16;
|
||||
cover=CreateWindowExW(style|WS_EX_LAYERED,cls.lpszClassName,L"",WS_POPUP,x,y,32,32,nullptr,nullptr,cls.hInstance,nullptr);
|
||||
CURSORINFO ci{sizeof(ci)};if(GetCursorInfo(&ci))previous=ci.hCursor;
|
||||
int size=MulDiv(64,monitorDpi(cursor),96);
|
||||
cover=CreateWindowExW(style|WS_EX_LAYERED,cls.lpszClassName,L"",WS_POPUP,
|
||||
(monitor.rcMonitor.left+monitor.rcMonitor.right-size)/2,(monitor.rcMonitor.top+monitor.rcMonitor.bottom-size)/2,
|
||||
size,size,nullptr,nullptr,cls.hInstance,(void*)1);
|
||||
if(cover){SetLayeredWindowAttributes(cover,0,1,LWA_ALPHA);ShowWindow(cover,SW_SHOWNOACTIVATE);}
|
||||
notice=CreateWindowExW(style,cls.lpszClassName,L"KSwitcher remote control",WS_POPUP,monitor.rcMonitor.left+20,monitor.rcMonitor.top+20,400,48,nullptr,nullptr,cls.hInstance,nullptr);
|
||||
if(notice)ShowWindow(notice,SW_SHOWNOACTIVATE);
|
||||
SetCursor(nullptr);
|
||||
if(cover)SetCursor(parkedCursor());
|
||||
}
|
||||
};
|
||||
bool ordinaryDesktop()
|
||||
@@ -68,7 +95,8 @@ InputEnvironment windowsInputEnvironment()
|
||||
[]{POINT p{};GetCursorPos(&p);MONITORINFO info{sizeof(info)};
|
||||
if(GetMonitorInfoW(MonitorFromPoint(p,MONITOR_DEFAULTTONEAREST),&info))p={(info.rcMonitor.left+info.rcMonitor.right)/2,(info.rcMonitor.top+info.rcMonitor.bottom)/2};
|
||||
return p;},
|
||||
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();}
|
||||
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();},
|
||||
monitorDpi
|
||||
};
|
||||
}
|
||||
bool atEdge(POINT p,RECT r,int side)
|
||||
@@ -87,11 +115,11 @@ POINT edgePoint(RECT r,int side,int position,int inset)
|
||||
POINT p=side==0?POINT{r.left+inset,y}:side==1?POINT{r.right-1-inset,y}:side==2?POINT{x,r.top+inset}:POINT{x,r.bottom-1-inset};
|
||||
p.x=std::clamp(p.x,r.left,r.right-1);p.y=std::clamp(p.y,r.top,r.bottom-1);return p;
|
||||
}
|
||||
InputRouter::InputRouter(InputEnvironment e,std::function<bool(InputFrame)> s,int side,bool prefer)
|
||||
:env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),nextToken(prefer?1:2)
|
||||
InputRouter::InputRouter(InputEnvironment e,std::function<bool(InputFrame)> s,int side,bool prefer,bool multiple)
|
||||
:env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),multi(multiple)
|
||||
{if(side<0||side>3)throw std::runtime_error("Invalid screen position.");}
|
||||
InputRouter::~InputRouter(){cancel(GetTickCount64());release();}
|
||||
void InputRouter::mode(ControlMode value){if(mode_!=value){mode_=value;++revision_;if(env.modeChanged)env.modeChanged(value);}}
|
||||
void InputRouter::mode(ControlMode value){if(mode_!=value){mode_=value;++revision_;if(env.modeChanged&&!(value==ControlMode::Local&&returning)&&!(value==ControlMode::Requesting&&transit))env.modeChanged(value);}}
|
||||
bool InputRouter::emit(INPUT i)
|
||||
{
|
||||
if(i.type==INPUT_KEYBOARD)i.ki.dwExtraInfo=remoteInputMarker();
|
||||
@@ -105,7 +133,11 @@ bool InputRouter::move(POINT p,bool warp)
|
||||
// Target pixel centres, avoiding a one-pixel bias on every relative mouse event.
|
||||
input.mi.dx=LONG((2*int64_t(std::clamp(p.x,r.left,r.right-1)-r.left)+1)*32768/(r.right-r.left));
|
||||
input.mi.dy=LONG((2*int64_t(std::clamp(p.y,r.top,r.bottom-1)-r.top)+1)*32768/(r.bottom-r.top));
|
||||
input.mi.dwExtraInfo=warp?cursorWarpMarker():remoteInputMarker();return env.desktop()&&env.inject(input);
|
||||
input.mi.dwExtraInfo=warp?cursorWarpMarker():remoteInputMarker();
|
||||
if(warp&&mode_==ControlMode::Sending)awaitingWarp=true;
|
||||
bool ok=env.desktop()&&env.inject(input);
|
||||
if(!ok)awaitingWarp=false;
|
||||
return ok;
|
||||
}
|
||||
bool InputRouter::post(InputFrame f){return connected&&send(f);}
|
||||
bool InputRouter::held()const
|
||||
@@ -119,20 +151,59 @@ void InputRouter::release()
|
||||
}
|
||||
void InputRouter::local(uint64_t now,int position)
|
||||
{
|
||||
bool sending=mode_==ControlMode::Sending;
|
||||
bool sending=mode_==ControlMode::Sending||transit;
|
||||
if(position<0)position=edgePosition(returnPoint,env.bounds(),edge);
|
||||
release();mode(ControlMode::Local);cooldown=now+600;token=0;
|
||||
if(sending)move(edgePoint(env.bounds(),edge,position),true);
|
||||
if(sending){returning=true;returnTarget=edgePoint(env.bounds(),edge,position);returnStarted=now;}
|
||||
release();mode(ControlMode::Local);cooldown=now+600;token=0;transit=false;crossEdge=-1;remainderX=remainderY=0;awaitingWarp=awaitingEntry=false;
|
||||
if(sending&&!move(returnTarget,true)){returning=false;if(env.modeChanged)env.modeChanged(ControlMode::Local);}
|
||||
}
|
||||
void InputRouter::cancel(uint64_t now)
|
||||
bool InputRouter::handoff(int side,int position,uint64_t now)
|
||||
{
|
||||
if(!multi||mode_!=ControlMode::Sending||held()||side<0||side>3)return false;
|
||||
edge=side;transit=true;token=ownershipToken();
|
||||
requested=now;mode(ControlMode::Requesting);
|
||||
if(!post({InputType::Enter,token,edge,std::clamp(position,0,65535)})){cancel(now,"next computer unavailable");return false;}
|
||||
return true;
|
||||
}
|
||||
void InputRouter::returnAt(int side,int position,uint64_t now)
|
||||
{
|
||||
if(side>=0&&side<4)edge=side;
|
||||
local(now,std::clamp(position,0,65535));
|
||||
}
|
||||
void InputRouter::cancel(uint64_t now,const char *reason)
|
||||
{
|
||||
if(mode_!=ControlMode::Local)log(L"LAN input: "+wide(reason)+L"; mode="+std::to_wstring(int(mode_)));
|
||||
if(mode_!=ControlMode::Local)post({InputType::Return,token,edgePosition(env.cursor(),env.bounds(),edge)});
|
||||
local(now);
|
||||
}
|
||||
void InputRouter::tick(bool ready,uint64_t now)
|
||||
{
|
||||
if(returning)
|
||||
{
|
||||
auto p=env.cursor();
|
||||
if((p.x==returnTarget.x&&p.y==returnTarget.y)||now-returnStarted>250)
|
||||
{returning=false;if(env.modeChanged)env.modeChanged(ControlMode::Local);}
|
||||
}
|
||||
connected=ready;
|
||||
if(!ready||!env.desktop()||(mode_==ControlMode::Requesting&&now-requested>1000))cancel(now);
|
||||
if(!ready)cancel(now,"channel unavailable");
|
||||
else if(!env.desktop())cancel(now,"input desktop unavailable");
|
||||
else if(mode_==ControlMode::Requesting&&now-requested>1000)cancel(now,"handoff acknowledgement timed out");
|
||||
if(mode_==ControlMode::Sending&&awaitingWarp)
|
||||
{
|
||||
auto p=env.cursor();
|
||||
if(p.x==anchor.x&&p.y==anchor.y)awaitingWarp=false;
|
||||
else if(now-warpStarted>250)cancel(now,"source cursor parking did not complete");
|
||||
}
|
||||
if(mode_==ControlMode::Receiving&&awaitingEntry)
|
||||
{
|
||||
auto p=env.cursor();
|
||||
if(p.x==receivedPoint.x&&p.y==receivedPoint.y)
|
||||
{
|
||||
awaitingEntry=false;
|
||||
if(!post({InputType::Accept,token}))cancel(now,"cannot acknowledge cursor entry");
|
||||
}
|
||||
else if(now-entryStarted>250)cancel(now,"destination cursor entry did not complete");
|
||||
}
|
||||
if(mode_==ControlMode::Local)release(); // Retry releases blocked by a temporarily secure desktop.
|
||||
}
|
||||
void InputRouter::receive(const InputFrame &f,uint64_t now)
|
||||
@@ -140,40 +211,65 @@ void InputRouter::receive(const InputFrame &f,uint64_t now)
|
||||
if(!connected||!env.desktop()){cancel(now);return;}
|
||||
if(f.type==InputType::Enter)
|
||||
{
|
||||
if(!f.token||f.a!=(edge^1)||f.b<0||f.b>65535||held()||
|
||||
if(!f.token||f.a<0||f.a>3||(!multi&&f.a!=(edge^1))||f.b<0||f.b>65535||held()||returning||
|
||||
mode_==ControlMode::Sending||mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&preferLocal))
|
||||
{post({InputType::Return,f.token,32768});return;}
|
||||
if(mode_==ControlMode::Requesting)cancel(now);
|
||||
token=f.token;mode(ControlMode::Receiving);
|
||||
if(!move(edgePoint(env.bounds(),edge,f.b))||!post({InputType::Accept,token}))cancel(now);
|
||||
if(multi)edge=f.a^1;
|
||||
crossEdge=-1;token=f.token;remainderX=remainderY=0;mode(ControlMode::Receiving);
|
||||
receivedPoint=edgePoint(env.bounds(),edge,f.b);
|
||||
awaitingEntry=true;entryStarted=now;
|
||||
if(!move(receivedPoint))cancel(now,"destination cursor injection failed");
|
||||
return;
|
||||
}
|
||||
if(f.type==InputType::Accept)
|
||||
{
|
||||
if(mode_!=ControlMode::Requesting||f.token!=token||held()||!atEdge(env.cursor(),env.bounds(),edge)){post({InputType::Return,f.token,32768});if(f.token==token)cancel(now);return;}
|
||||
auto r=env.bounds();anchor=env.anchor?env.anchor():POINT{(r.left+r.right)/2,(r.top+r.bottom)/2};mode(ControlMode::Sending);
|
||||
if(!move(anchor,true))cancel(now);
|
||||
if(mode_!=ControlMode::Requesting||f.token!=token||held()||(!transit&&!atEdge(env.cursor(),env.bounds(),edge))){post({InputType::Return,f.token,32768});if(f.token==token)cancel(now);return;}
|
||||
transit=false;auto r=env.bounds();anchor=env.anchor?env.anchor():POINT{(r.left+r.right)/2,(r.top+r.bottom)/2};mode(ControlMode::Sending);
|
||||
warpStarted=now;
|
||||
if(!move(anchor,true))cancel(now,"source cursor parking failed");
|
||||
return;
|
||||
}
|
||||
if(f.token!=token||!token)return;
|
||||
if(f.type==InputType::Return){local(now,std::clamp(f.a,0,65535));return;}
|
||||
if(f.type==InputType::Return){log(L"LAN input: peer returned control");local(now,std::clamp(f.a,0,65535));return;}
|
||||
if(mode_!=ControlMode::Receiving)return;
|
||||
bool ok=true;
|
||||
if(f.type==InputType::Move)
|
||||
{
|
||||
if(std::abs(int64_t(f.a))>32768||std::abs(int64_t(f.b))>32768){cancel(now);return;}
|
||||
auto p=env.cursor();auto r=env.bounds();p.x=LONG(std::clamp<int64_t>(int64_t(p.x)+f.a,r.left,r.right-1));p.y=LONG(std::clamp<int64_t>(int64_t(p.y)+f.b,r.top,r.bottom-1));
|
||||
if(atEdge(p,r,edge)&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
|
||||
if(f.c!=0&&(f.c<48||f.c>768)){cancel(now);return;}
|
||||
// SendInput queues movement; GetCursorPos may still describe the old
|
||||
// centre while several network frames are drained in one timer tick.
|
||||
auto p=receivedPoint;auto r=env.bounds();
|
||||
double scale=f.c&&env.dpi?double(std::clamp(env.dpi(p),48,768))/f.c:1.0;
|
||||
double x=f.a*scale+remainderX,y=f.b*scale+remainderY;
|
||||
auto dx=int64_t(x),dy=int64_t(y);remainderX=x-dx;remainderY=y-dy;
|
||||
p.x=LONG(std::clamp<int64_t>(int64_t(p.x)+dx,r.left,r.right-1));p.y=LONG(std::clamp<int64_t>(int64_t(p.y)+dy,r.top,r.bottom-1));
|
||||
if(!multi&&atEdge(p,r,edge)&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
|
||||
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;}))
|
||||
{post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
|
||||
ok=move(p);
|
||||
{log(L"LAN input: cursor crossed return edge");post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
|
||||
ok=move(p);if(ok)receivedPoint=p;
|
||||
if(multi&&ok&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
|
||||
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;})){
|
||||
int horizontal=dx<0?0:1,vertical=dy<0?2:3,side=-1;
|
||||
if(std::abs(dy)>std::abs(dx)&&dy&&atEdge(p,r,vertical))side=vertical;
|
||||
else if(dx&&atEdge(p,r,horizontal))side=horizontal;
|
||||
else if(dy&&atEdge(p,r,vertical))side=vertical;
|
||||
if(side>=0&&(side!=crossEdge||now-crossAt>=150)){
|
||||
crossEdge=side;crossAt=now;ok=post({InputType::Cross,token,side,edgePosition(p,r,side)});
|
||||
}else if(side<0)crossEdge=-1;
|
||||
}
|
||||
}
|
||||
else if(f.type==InputType::Key)
|
||||
{
|
||||
if(f.a<1||f.a>255||f.b<0||f.b>255||f.c<0||f.c>3){cancel(now);return;}
|
||||
INPUT input{};input.type=INPUT_KEYBOARD;input.ki.wVk=WORD(f.a);input.ki.wScan=WORD(f.b);
|
||||
input.ki.dwFlags=(f.c&1?0:KEYEVENTF_KEYUP)|(f.c&2?KEYEVENTF_EXTENDEDKEY:0);
|
||||
if(f.b&&f.a!=VK_PAUSE)input.ki.dwFlags|=KEYEVENTF_SCANCODE;
|
||||
bool modifier=f.a==VK_SHIFT||f.a==VK_CONTROL||f.a==VK_MENU||
|
||||
(f.a>=VK_LSHIFT&&f.a<=VK_RMENU)||f.a==VK_LWIN||f.a==VK_RWIN;
|
||||
// Explicit modifier VKs preserve their Windows state independently of
|
||||
// the sending keyboard's scan-code mapping. Text keys remain physical.
|
||||
if(f.b&&f.a!=VK_PAUSE&&!modifier)input.ki.dwFlags|=KEYEVENTF_SCANCODE;
|
||||
ok=emit(input);if(ok){remoteKeys[size_t(f.a)]=(f.c&1)!=0;input.ki.dwFlags|=KEYEVENTF_KEYUP;releases[size_t(f.a)]=input;}
|
||||
}
|
||||
else if(f.type==InputType::Button)
|
||||
@@ -186,16 +282,22 @@ void InputRouter::receive(const InputFrame &f,uint64_t now)
|
||||
if(f.a<-32768||f.a>32767||f.b<0||f.b>1){cancel(now);return;}
|
||||
INPUT input{};input.type=INPUT_MOUSE;input.mi.dwFlags=f.b?MOUSEEVENTF_HWHEEL:MOUSEEVENTF_WHEEL;input.mi.mouseData=DWORD(f.a);ok=emit(input);
|
||||
}
|
||||
if(!ok)cancel(now);
|
||||
if(!ok)cancel(now,"Windows input injection failed");
|
||||
}
|
||||
bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
|
||||
{
|
||||
if(k.flags&LLKHF_INJECTED||k.vkCode>255||!k.vkCode)return false;
|
||||
// Conversion/layout changes can produce synthetic modifier releases.
|
||||
// Never reinterpret our own tagged input as a physical takeover, even if
|
||||
// another hook has changed the injection flag.
|
||||
if(k.flags&LLKHF_INJECTED||k.dwExtraInfo==inputMarker()||
|
||||
k.dwExtraInfo==remoteInputMarker()||k.vkCode>255||!k.vkCode)return false;
|
||||
auto key=size_t(k.vkCode);physical[key]=down;
|
||||
if(mode_!=ControlMode::Local&&down&&key==VK_ESCAPE&&(physical[VK_LCONTROL]||physical[VK_RCONTROL]||physical[VK_CONTROL])&&
|
||||
(physical[VK_LMENU]||physical[VK_RMENU]||physical[VK_MENU]))
|
||||
{outKeys[key]=true;cancel(now);return true;}
|
||||
if(mode_==ControlMode::Receiving||mode_==ControlMode::Requesting)cancel(now);
|
||||
// A real local press takes over. A key-up alone is not evidence of intent:
|
||||
// Windows may generate it while changing the target keyboard layout.
|
||||
if(down&&(mode_==ControlMode::Receiving||mode_==ControlMode::Requesting))cancel(now,"local physical keyboard takeover");
|
||||
if(mode_==ControlMode::Sending)
|
||||
{
|
||||
outKeys[key]=down;
|
||||
@@ -207,22 +309,35 @@ bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
|
||||
}
|
||||
bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool allowEnter)
|
||||
{
|
||||
if((m.flags&LLMHF_INJECTED)&&m.dwExtraInfo==cursorWarpMarker())
|
||||
{awaitingWarp=false;return false;}
|
||||
if(m.flags&LLMHF_INJECTED)return false;
|
||||
if(returning&&message==WM_MOUSEMOVE)return true;
|
||||
int index=-1;bool down=false;
|
||||
switch(message){case WM_LBUTTONDOWN:down=true;[[fallthrough]];case WM_LBUTTONUP:index=0;break;
|
||||
case WM_RBUTTONDOWN:down=true;[[fallthrough]];case WM_RBUTTONUP:index=1;break;
|
||||
case WM_MBUTTONDOWN:down=true;[[fallthrough]];case WM_MBUTTONUP:index=2;break;
|
||||
case WM_XBUTTONDOWN:down=true;[[fallthrough]];case WM_XBUTTONUP:index=HIWORD(m.mouseData)==XBUTTON1?3:4;break;}
|
||||
if(mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&message!=WM_MOUSEMOVE))cancel(now);
|
||||
if(mode_==ControlMode::Requesting&&message==WM_MOUSEMOVE&&!atEdge(m.pt,env.bounds(),edge))cancel(now);
|
||||
if(mode_==ControlMode::Receiving&&message==WM_MOUSEMOVE)
|
||||
{
|
||||
if(m.pt.x==receivedPoint.x&&m.pt.y==receivedPoint.y)return false;
|
||||
}
|
||||
if(mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&message!=WM_MOUSEMOVE))cancel(now,"local physical mouse takeover");
|
||||
if(mode_==ControlMode::Requesting&&!transit&&message==WM_MOUSEMOVE&&!atEdge(m.pt,env.bounds(),edge))cancel(now);
|
||||
if(mode_==ControlMode::Requesting&&transit&&message==WM_MOUSEMOVE)return true;
|
||||
if(mode_==ControlMode::Sending)
|
||||
{
|
||||
bool ok=true;
|
||||
if(message==WM_MOUSEMOVE)
|
||||
{
|
||||
// Do not interpret pre-warp edge coordinates as a huge delta from
|
||||
// the centre before Windows has actually parked the cursor.
|
||||
if(awaitingWarp)return true;
|
||||
auto dx=m.pt.x-anchor.x,dy=m.pt.y-anchor.y;
|
||||
if(dx||dy)ok=post({InputType::Move,token,dx,dy});
|
||||
ok=move(anchor,true)&&ok;
|
||||
if(dx||dy)ok=post({InputType::Move,token,dx,dy,env.dpi?std::clamp(env.dpi(anchor),48,768):0});
|
||||
// The hook consumes this physical motion, so Windows keeps the
|
||||
// cursor parked. Re-injecting an identical warp here can yield no
|
||||
// notification and must never become a gate for the next movement.
|
||||
}
|
||||
else if(index>=0){outButtons[size_t(index)]=down;ok=post({InputType::Button,token,index,down?1:0});}
|
||||
else if(message==WM_MOUSEWHEEL||message==WM_MOUSEHWHEEL)ok=post({InputType::Wheel,token,int16_t(HIWORD(m.mouseData)),message==WM_MOUSEHWHEEL?1:0});
|
||||
@@ -233,7 +348,7 @@ bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool a
|
||||
if(mode_==ControlMode::Local&&connected&&allowEnter&&message==WM_MOUSEMOVE&&now>=cooldown&&
|
||||
env.desktop()&&!held()&&atEdge(m.pt,env.bounds(),edge))
|
||||
{
|
||||
token=nextToken;nextToken+=2;if(!nextToken)nextToken=preferLocal?1:2;
|
||||
token=ownershipToken();
|
||||
returnPoint=m.pt;requested=now;mode(ControlMode::Requesting);
|
||||
if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now);
|
||||
}
|
||||
|
||||
+16
-5
@@ -13,6 +13,7 @@ struct InputEnvironment
|
||||
std::function<bool()> held;
|
||||
std::function<POINT()> anchor;
|
||||
std::function<void(ControlMode)> modeChanged;
|
||||
std::function<int(POINT)> dpi;
|
||||
};
|
||||
InputEnvironment windowsInputEnvironment();
|
||||
ULONG_PTR remoteInputMarker();
|
||||
@@ -25,10 +26,15 @@ class InputRouter
|
||||
InputEnvironment env;
|
||||
std::function<bool(InputFrame)> send;
|
||||
ControlMode mode_=ControlMode::Local;
|
||||
int edge{};bool preferLocal{},connected{};
|
||||
uint32_t nextToken{},token{};
|
||||
int edge{};bool preferLocal{},connected{},multi{},transit{};
|
||||
int crossEdge=-1;uint64_t crossAt{};
|
||||
uint32_t token{};
|
||||
uint64_t cooldown{},requested{},revision_{};
|
||||
POINT anchor{},returnPoint{};
|
||||
POINT anchor{},returnPoint{},receivedPoint{};
|
||||
bool awaitingWarp{},awaitingEntry{};
|
||||
uint64_t warpStarted{},entryStarted{};
|
||||
bool returning{};POINT returnTarget{};uint64_t returnStarted{};
|
||||
double remainderX{},remainderY{};
|
||||
std::array<bool,256> physical{},outKeys{},remoteKeys{};
|
||||
std::array<INPUT,256> releases{};
|
||||
std::array<bool,5> outButtons{},remoteButtons{};
|
||||
@@ -40,14 +46,19 @@ class InputRouter
|
||||
void release();
|
||||
void local(uint64_t now,int position=-1);
|
||||
public:
|
||||
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal);
|
||||
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal,bool multi=false);
|
||||
~InputRouter();
|
||||
void selectEdge(int side,bool prefer){edge=side;preferLocal=prefer;}
|
||||
uint32_t sessionToken()const{return token;}
|
||||
bool canHandoff()const{return mode_==ControlMode::Sending&&!held();}
|
||||
bool handoff(int side,int position,uint64_t now);
|
||||
void returnAt(int side,int position,uint64_t now);
|
||||
ControlMode mode()const{return mode_;}
|
||||
uint64_t revision()const{return revision_;}
|
||||
void tick(bool ready,uint64_t now);
|
||||
void receive(const InputFrame &,uint64_t now);
|
||||
bool keyboard(const KBDLLHOOKSTRUCT &,bool down,uint64_t now);
|
||||
bool mouse(UINT,const MSLLHOOKSTRUCT &,uint64_t now,bool allowEnter=true);
|
||||
void cancel(uint64_t now);
|
||||
void cancel(uint64_t now,const char *reason="control cancelled");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
#include "lan_pairing.h"
|
||||
#include <set>
|
||||
namespace ks::lan
|
||||
{
|
||||
inline Json edgeCell(int edge)
|
||||
{
|
||||
switch(edge){case 0:return Json::array({-1,0});case 1:return Json::array({1,0});case 2:return Json::array({0,-1});default:return Json::array({0,1});}
|
||||
}
|
||||
inline int cellEdge(const Json &cell)
|
||||
{
|
||||
for(int edge=0;edge<4;++edge)if(cell==edgeCell(edge))return edge;
|
||||
return -1;
|
||||
}
|
||||
inline void validateCell(const Json &positions,const std::string &peer,int x,int y)
|
||||
{
|
||||
if(x < -8||x > 8||y < -8||y > 8)throw std::runtime_error("Keep computers within the layout grid.");
|
||||
if(!x&&!y)throw std::runtime_error("This cell belongs to this computer.");
|
||||
for(auto &[id,cell]:positions.items())if(id!=peer&&cell==Json::array({x,y}))throw std::runtime_error("Another computer already occupies this cell.");
|
||||
}
|
||||
inline void fillPositions(Json &positions,const Json &trusted,const std::string &active,int edge)
|
||||
{
|
||||
if(!positions.is_object())throw std::runtime_error("Invalid computer layout.");
|
||||
for(auto it=positions.begin();it!=positions.end();)if(!trusted.contains(it.key()))it=positions.erase(it);else ++it;
|
||||
for(auto &[peer,cell]:positions.items()){
|
||||
if(!cell.is_array()||cell.size()!=2||!cell[0].is_number_integer()||!cell[1].is_number_integer())throw std::runtime_error("Invalid computer position.");
|
||||
validateCell(positions,peer,cell[0].get<int>(),cell[1].get<int>());
|
||||
}
|
||||
if(trusted.contains(active)&&!positions.contains(active)){
|
||||
auto cell=edgeCell(edge);bool free=true;for(auto &[peer,other]:positions.items())if(other==cell)free=false;
|
||||
if(free)positions[active]=cell;
|
||||
}
|
||||
for(auto &[peer,value]:trusted.items())if(!positions.contains(peer)){
|
||||
bool placed=false;
|
||||
for(int radius=1;radius<=8&&!placed;++radius)for(int y=-radius;y<=radius&&!placed;++y)for(int x=-radius;x<=radius&&!placed;++x){
|
||||
if(std::abs(x)+std::abs(y)!=radius)continue;
|
||||
bool free=true;for(auto &[id,cell]:positions.items())if(cell==Json::array({x,y}))free=false;
|
||||
if(free){positions[peer]=Json::array({x,y});placed=true;}
|
||||
}
|
||||
if(!placed)throw std::runtime_error("Computer layout is full.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
#include "lan_input.h"
|
||||
#include <map>
|
||||
namespace ks::lan
|
||||
{
|
||||
// The physical input source routes directly to the current destination. A
|
||||
// destination only reports edge crossings; it never relays injected key events.
|
||||
class InputMesh
|
||||
{
|
||||
std::string local,active;
|
||||
InputEnvironment env;
|
||||
std::map<std::string,POINT> positions;
|
||||
std::map<std::string,uint64_t> ready;
|
||||
std::function<bool(const std::string &,InputFrame)> send;
|
||||
uint64_t activeGeneration{},routeRevision{};
|
||||
InputRouter router;
|
||||
std::string neighbor(const std::string &from,int side)const
|
||||
{
|
||||
POINT p{};if(from!=local){auto found=positions.find(from);if(found==positions.end())return {};p=found->second;}
|
||||
if(side==0)--p.x;else if(side==1)++p.x;else if(side==2)--p.y;else if(side==3)++p.y;else return {};
|
||||
if(!p.x&&!p.y)return local;
|
||||
for(auto &[id,cell]:positions)if(cell.x==p.x&&cell.y==p.y)return id;
|
||||
return {};
|
||||
}
|
||||
void select(const std::string &peer,int side,uint64_t now)
|
||||
{
|
||||
if(active!=peer){active=peer;++routeRevision;}
|
||||
activeGeneration=ready.at(peer);router.selectEdge(side,local<peer);router.tick(true,now);
|
||||
}
|
||||
public:
|
||||
InputMesh(std::string id,InputEnvironment environment,std::map<std::string,POINT> layout,
|
||||
std::function<bool(const std::string &,InputFrame)> sender)
|
||||
:local(std::move(id)),env(std::move(environment)),positions(std::move(layout)),send(std::move(sender)),
|
||||
router(env,[this](InputFrame f){return !active.empty()&&ready.contains(active)&&send(active,f);},1,true,true){}
|
||||
ControlMode mode()const{return router.mode();}
|
||||
uint64_t revision()const{return router.revision()+routeRevision;}
|
||||
const std::string &peer()const{return active;}
|
||||
void cancel(uint64_t now){router.cancel(now);}
|
||||
void tick(std::map<std::string,uint64_t> connections,uint64_t now)
|
||||
{
|
||||
ready=std::move(connections);
|
||||
if(!active.empty()){
|
||||
bool connected=ready.contains(active);
|
||||
if(connected&&activeGeneration!=ready.at(active)){router.cancel(now,"peer reconnected");activeGeneration=ready.at(active);}
|
||||
router.tick(connected,now);
|
||||
}
|
||||
}
|
||||
void receive(const std::string &peer,const InputFrame &f,uint64_t now,bool allowEnter=true)
|
||||
{
|
||||
if(!ready.contains(peer)||!positions.contains(peer))return;
|
||||
if(f.type==InputType::Enter){
|
||||
if(!allowEnter||f.a<0||f.a>3||(router.mode()!=ControlMode::Local&&active!=peer)){
|
||||
send(peer,{InputType::Return,f.token,32768});return;
|
||||
}
|
||||
if(router.mode()==ControlMode::Local)select(peer,f.a^1,now);
|
||||
}
|
||||
if(active!=peer)return;
|
||||
if(f.type==InputType::Cross){
|
||||
if(f.token!=router.sessionToken()||!router.canHandoff()||f.a<0||f.a>3||f.b<0||f.b>65535)return;
|
||||
auto target=neighbor(peer,f.a);
|
||||
if(target.empty()||(target!=local&&!ready.contains(target)))return;
|
||||
if(!send(peer,{InputType::Return,f.token,f.b})){router.cancel(now,"cannot release previous computer");return;}
|
||||
if(target==local){router.returnAt(f.a^1,f.b,now);++routeRevision;return;}
|
||||
active=target;activeGeneration=ready.at(target);++routeRevision;
|
||||
router.handoff(f.a,f.b,now);return;
|
||||
}
|
||||
router.receive(f,now);
|
||||
}
|
||||
bool keyboard(const KBDLLHOOKSTRUCT &key,bool down,uint64_t now){return router.keyboard(key,down,now);}
|
||||
bool mouse(UINT message,const MSLLHOOKSTRUCT &mouse,uint64_t now,bool allowEnter=true)
|
||||
{
|
||||
bool enter=false;
|
||||
if(router.mode()==ControlMode::Local&&allowEnter&&message==WM_MOUSEMOVE&&!(mouse.flags&LLMHF_INJECTED)){
|
||||
for(int side=0;side<4;++side)if(atEdge(mouse.pt,env.bounds(),side)){
|
||||
auto target=neighbor(local,side);
|
||||
if(!target.empty()&&ready.contains(target)){select(target,side,now);enter=true;break;}
|
||||
}
|
||||
}
|
||||
return router.mouse(message,mouse,now,enter);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#include "sync.h"
|
||||
namespace ks
|
||||
{
|
||||
// The open editor is a draft based on a snapshot, not a replacement for all
|
||||
// settings that may have changed in the resident since the editor opened.
|
||||
inline Bytes mergeConfigurationSettings(const Bytes &base,const Bytes &draft,const Bytes ¤t,bool preferDraft=false)
|
||||
{
|
||||
auto merged=mergeSyncProfile(base,draft,current);
|
||||
if(!merged.conflicts.empty()){
|
||||
if(!preferDraft){
|
||||
std::string fields;
|
||||
for(auto &c:merged.conflicts){if(!fields.empty())fields+=", ";fields+=utf8(c.label);}
|
||||
throw std::runtime_error("Settings conflict: "+fields);
|
||||
}
|
||||
std::vector<SyncChoice> choices;
|
||||
for(auto &c:merged.conflicts)choices.push_back({c,true});
|
||||
merged=mergeSyncProfile(base,draft,current,choices);
|
||||
if(!merged.conflicts.empty())throw std::runtime_error("Resolve conflicting settings in this window before saving.");
|
||||
}
|
||||
return merged.local;
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -473,6 +473,7 @@ void History::loadIndex()
|
||||
e.value("image", false),
|
||||
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
|
||||
str(e, "ocrProfile", L"windows")};
|
||||
item.computer=e.value("computer",std::string{});item.computerName=str(e,"computerName");
|
||||
if (e.value("fingerprintVersion", 0) == 1) {
|
||||
item.contentHash = e.value("contentHash", std::string{});
|
||||
item.imageHash = e.value("imageHash", std::string{});
|
||||
@@ -644,7 +645,7 @@ void History::saveIndex()
|
||||
for (const auto &e : entries_)
|
||||
j.push_back({{"id", utf8(e.id)},
|
||||
{"preview", utf8(e.preview)},
|
||||
{"app", utf8(e.app)},
|
||||
{"app", utf8(e.app)}, {"computer", e.computer}, {"computerName", utf8(e.computerName)},
|
||||
{"bytes", e.bytes},
|
||||
{"at", e.at},
|
||||
{"pinned", e.pinned},
|
||||
@@ -753,7 +754,7 @@ bool sameClipboardImage(const Clip &a, const Clip &b)
|
||||
return false; // Undecodable data is not evidence of a duplicate.
|
||||
}
|
||||
}
|
||||
void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
||||
void History::add(const Clip &clip, const std::wstring &app, const Settings &s, const std::string &computer, const std::wstring &computerName)
|
||||
{
|
||||
if (clip.formats.empty() || !clip.size())
|
||||
return;
|
||||
@@ -769,15 +770,15 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
||||
std::lock_guard lock(mutex_);
|
||||
Entry fingerprints;
|
||||
fingerprints.contentHash = clipboardHash(data);
|
||||
if (!entries_.empty() && entries_.front().contentHash == fingerprints.contentHash)
|
||||
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().contentHash == fingerprints.contentHash)
|
||||
return;
|
||||
imageFingerprints(fingerprints, clip, false);
|
||||
if (!entries_.empty() && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
|
||||
if (!entries_.empty() && entries_.front().computer == computer && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
|
||||
return;
|
||||
imageFingerprints(fingerprints, clip);
|
||||
// Different payload kinds cannot be duplicates. Consult the index first so
|
||||
// copying text after a screenshot never loads/decrypts the previous image.
|
||||
if (!entries_.empty() && entries_.front().image == image && entries_.front().files == files)
|
||||
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().image == image && entries_.front().files == files)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -821,7 +822,7 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
||||
Entry{id, text, app, encrypted.size(),
|
||||
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
|
||||
image ? 1 : 0, ocrProfile(s), std::move(fingerprints.contentHash),
|
||||
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash)});
|
||||
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash), computer, computerName});
|
||||
prune(s);
|
||||
saveIndex();
|
||||
}
|
||||
|
||||
+3
-1
@@ -18,6 +18,8 @@ struct Entry
|
||||
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
|
||||
std::wstring ocrProfile = L"windows";
|
||||
std::string contentHash, imageHash, pixelHash;
|
||||
std::string computer;
|
||||
std::wstring computerName;
|
||||
};
|
||||
class History
|
||||
{
|
||||
@@ -33,7 +35,7 @@ class History
|
||||
void reload();
|
||||
std::vector<Entry> entries() const;
|
||||
Clip load(const std::wstring &id) const;
|
||||
void add(const Clip &, const std::wstring &app, const Settings &);
|
||||
void add(const Clip &, const std::wstring &app, const Settings &, const std::string &computer = {}, const std::wstring &computerName = {});
|
||||
bool captureImage(DWORD sequence, const std::wstring &app, const Settings &);
|
||||
std::wstring recognizeImage(const std::wstring &id, const Settings &,
|
||||
const std::function<bool()> &cancelled = {}) const;
|
||||
|
||||
+92
-11
@@ -1,3 +1,4 @@
|
||||
#include "lan_layout.h"
|
||||
#include "../resources/resource.h"
|
||||
#include "app.h"
|
||||
#include "history_preview.h"
|
||||
@@ -520,11 +521,13 @@ void search(HWND w, HistoryView *v)
|
||||
v->pending.clear();
|
||||
auto *app = v->app;
|
||||
app->enqueueHistorySearch(
|
||||
[w, app, q, generation, pinnedOnly]
|
||||
[w, app, q, generation, pinnedOnly, computers=app->historyComputers]
|
||||
{
|
||||
try
|
||||
{
|
||||
auto result = new SearchResult{generation, app->history->search(q, pinnedOnly)};
|
||||
if(!computers.empty())std::erase_if(result->items,[&](const Entry &e){return
|
||||
std::find(computers.begin(),computers.end(),e.computer)==computers.end();});
|
||||
if (!PostMessageW(w, ResultsMessage, 0, (LPARAM)result))
|
||||
delete result;
|
||||
}
|
||||
@@ -534,6 +537,79 @@ void search(HWND w, HistoryView *v)
|
||||
}
|
||||
});
|
||||
}
|
||||
struct ComputerFilter
|
||||
{
|
||||
HWND owner{},list{};HistoryView *view{};bool filling{};
|
||||
std::vector<std::pair<std::string,std::wstring>> computers;
|
||||
};
|
||||
LRESULT CALLBACK filterKeys(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(msg==WM_KEYDOWN&&wp==VK_ESCAPE){DestroyWindow(GetParent(w));return 0;}
|
||||
if(msg==WM_NCDESTROY)RemoveWindowSubclass(w,filterKeys,1);
|
||||
return DefSubclassProc(w,msg,wp,lp);
|
||||
}
|
||||
LRESULT CALLBACK computerFilterProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
auto *f=reinterpret_cast<ComputerFilter*>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
||||
if(msg==WM_NCCREATE){f=static_cast<ComputerFilter*>(reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);SetWindowLongPtrW(w,GWLP_USERDATA,reinterpret_cast<LONG_PTR>(f));}
|
||||
if(f&&msg==WM_NOTIFY&&!f->filling&&reinterpret_cast<NMHDR*>(lp)->code==LVN_ITEMCHANGED){
|
||||
auto *change=reinterpret_cast<NMLISTVIEW*>(lp);
|
||||
if(change->iItem>=0&&(change->uChanged&LVIF_STATE)&&((change->uOldState^change->uNewState)&LVIS_STATEIMAGEMASK)){
|
||||
f->filling=true;
|
||||
bool checked=ListView_GetCheckState(f->list,change->iItem)!=0;
|
||||
if(change->iItem==0)for(int i=1;i<=int(f->computers.size());++i)ListView_SetCheckState(f->list,i,checked);
|
||||
bool all=true;auto &chosen=f->view->app->historyComputers;chosen.clear();
|
||||
for(int i=0;i<int(f->computers.size());++i){if(ListView_GetCheckState(f->list,i+1))chosen.push_back(f->computers[i].first);else all=false;}
|
||||
ListView_SetCheckState(f->list,0,all);
|
||||
if(all)chosen.clear();else if(chosen.empty())chosen.push_back("__none");
|
||||
f->filling=false;
|
||||
SetDlgItemTextW(f->owner,118,all?L" Computers: All ▾":L" Computers: Selected ▾");
|
||||
search(f->owner,f->view);
|
||||
f->view->app->saveHistoryView(f->view->rememberedSelection,f->view->rememberedTop,value(f->owner,101),SendDlgItemMessageW(f->owner,117,BM_GETCHECK,0,0)==BST_CHECKED);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_ACTIVATE&&LOWORD(wp)==WA_INACTIVE){DestroyWindow(w);return 0;}
|
||||
if(msg==WM_CLOSE){DestroyWindow(w);return 0;}
|
||||
if(msg==WM_NCDESTROY){SetWindowLongPtrW(w,GWLP_USERDATA,0);delete f;}
|
||||
return DefWindowProcW(w,msg,wp,lp);
|
||||
}
|
||||
void showComputerFilter(HWND owner,HistoryView *v)
|
||||
{
|
||||
std::map<std::string,std::wstring> names{{"",L"This computer"}};
|
||||
for(auto &entry:v->app->history->entries())if(!entry.computer.empty())names[entry.computer]=entry.computerName;
|
||||
try{
|
||||
auto path=v->app->data/L"lan-pairing.bin";
|
||||
if(std::filesystem::exists(path)){
|
||||
auto config=nlohmann::json::parse(unprotect(readFile(path,256*1024)));
|
||||
for(auto &[id,peer]:config.at("trusted").items())names[id]=wide(peer.at("name").get<std::string>());
|
||||
}
|
||||
}catch(...){}
|
||||
auto *f=new ComputerFilter{owner,nullptr,v,true,{}};
|
||||
for(auto &[id,name]:names)f->computers.push_back({id,name});
|
||||
WNDCLASSW cls{};cls.lpfnWndProc=computerFilterProc;cls.hInstance=v->app->instance;cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);cls.hbrBackground=(HBRUSH)(COLOR_WINDOW+1);cls.lpszClassName=L"KSwitcher.ComputerFilter";RegisterClassW(&cls);
|
||||
RECT r{};GetWindowRect(GetDlgItem(owner,118),&r);
|
||||
int width=scale(owner,300),height=scale(owner,std::min(280,32+int(names.size()+1)*26));
|
||||
MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||
int x=std::clamp(int(r.left),int(monitor.rcWork.left),std::max(int(monitor.rcWork.left),int(monitor.rcWork.right)-width));
|
||||
int y=r.bottom+height>monitor.rcWork.bottom?std::max(int(monitor.rcWork.top),int(r.top)-height):int(r.bottom);
|
||||
auto popup=CreateWindowExW(WS_EX_TOOLWINDOW,cls.lpszClassName,L"Computers",WS_POPUP|WS_BORDER,x,y,width,height,owner,nullptr,v->app->instance,f);
|
||||
if(!popup){delete f;return;}
|
||||
RECT client{};GetClientRect(popup,&client);
|
||||
f->list=CreateWindowExW(0,WC_LISTVIEWW,L"Computers",WS_CHILD|WS_VISIBLE|WS_TABSTOP|LVS_REPORT|LVS_NOCOLUMNHEADER|LVS_SINGLESEL,0,0,client.right,client.bottom,popup,nullptr,v->app->instance,nullptr);
|
||||
SendMessageW(f->list,WM_SETFONT,SendDlgItemMessageW(owner,118,WM_GETFONT,0,0),TRUE);
|
||||
ListView_SetExtendedListViewStyle(f->list,LVS_EX_CHECKBOXES|LVS_EX_FULLROWSELECT|LVS_EX_DOUBLEBUFFER);
|
||||
LVCOLUMNW column{LVCF_WIDTH};column.cx=client.right-scale(owner,24);ListView_InsertColumn(f->list,0,&column);
|
||||
auto &chosen=v->app->historyComputers;
|
||||
for(int i=0;i<=int(f->computers.size());++i){
|
||||
std::wstring label=i?f->computers[i-1].second:L"All computers";
|
||||
LVITEMW item{LVIF_TEXT};item.iItem=i;item.pszText=label.data();ListView_InsertItem(f->list,&item);
|
||||
bool checked=chosen.empty()||(i&&std::find(chosen.begin(),chosen.end(),f->computers[i-1].first)!=chosen.end());
|
||||
ListView_SetCheckState(f->list,i,checked);
|
||||
}
|
||||
f->filling=false;SetWindowSubclass(f->list,filterKeys,1,0);
|
||||
ShowWindow(popup,SW_SHOW);SetFocus(f->list);
|
||||
}
|
||||
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
||||
{
|
||||
if(v->scrolling) return;
|
||||
@@ -659,7 +735,7 @@ void drawHistoryRow(HWND w, HistoryView *v, const DRAWITEMSTRUCT &draw)
|
||||
DrawTextW(dc, text.c_str(), -1, &title, DT_WORDBREAK | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||
RECT detail{title.left, box.bottom - scale(w, 26), title.right, box.bottom - scale(w, 8)};
|
||||
SetTextColor(dc, GetSysColor(selected ? COLOR_HIGHLIGHTTEXT : COLOR_GRAYTEXT));
|
||||
auto subtitle = (entry.pinned ? L"★ " : L"") + std::to_wstring(draw.itemID + 1) + L" · " + entry.app;
|
||||
auto subtitle = (entry.pinned ? L"★ " : L"") + std::to_wstring(draw.itemID + 1) + L" · " + entry.app + L" · " + (entry.computer.empty()?L"This computer":entry.computerName);
|
||||
DrawTextW(dc, subtitle.c_str(), -1, &detail, DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||
if (draw.itemState & ODS_FOCUS)
|
||||
{
|
||||
@@ -741,6 +817,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_COMMAND:
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if(id==118&&HIWORD(wp)==BN_CLICKED){showComputerFilter(w,v);return 0;}
|
||||
if (id == 117 && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
KillTimer(w, 1);
|
||||
@@ -1968,8 +2045,11 @@ void showHistory(App &app, bool visible)
|
||||
auto filter = control(w, L"BUTTON", L"★ Pinned only", WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||
117, 16, 38, 112, 25);
|
||||
SendMessageW(filter, BM_SETCHECK, app.historyPinnedOnly ? BST_CHECKED : BST_UNCHECKED, 0);
|
||||
label(w, L"&Search clipboard history", 136, 12, 500);
|
||||
edit(w, 101, 136, 38, 608);
|
||||
auto computers = control(w,L"BUTTON",app.historyComputers.empty()?L" Computers: All ▾":L" Computers: Selected ▾",
|
||||
WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,118,136,36,180,28);
|
||||
(void)computers;
|
||||
label(w, L"&Search clipboard history", 328, 12, 400);
|
||||
edit(w, 101, 328, 38, 416);
|
||||
auto list = control(w, L"LISTBOX", L"",
|
||||
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
||||
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
||||
@@ -2098,14 +2178,15 @@ void showSettings(App &app, bool visible)
|
||||
label(w, L"Additional tools and preferences", 24, 76, 660);
|
||||
button(w, L"S&nippets...", 252, 24, 116, 210);
|
||||
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
|
||||
button(w, L"OCR op&tions...", 257, 24, 180, 210);
|
||||
label(w, L"Configure text recognition from images.", 254, 184, 430);
|
||||
button(w, L"S&ync...", 258, 24, 244, 210);
|
||||
label(w, L"Synchronize preferences between computers.", 254, 248, 430);
|
||||
button(w, L"View l&og...", 256, 24, 308, 210);
|
||||
label(w, L"Inspect recent operations and diagnostics.", 254, 312, 430);
|
||||
button(w, L"OCR op&tions...", 257, 24, 170, 210);
|
||||
label(w, L"Configure text recognition from images.", 254, 174, 430);
|
||||
button(w, L"S&ync...", 258, 24, 224, 210);
|
||||
label(w, L"Synchronize preferences between computers.", 254, 228, 430);
|
||||
button(w, L"KSwitcher &computers...", 262, 24, 278, 210);
|
||||
label(w, L"Share mouse and keyboard with paired computers.", 254, 282, 430);
|
||||
button(w, L"View l&og...", 256, 24, 332, 210);
|
||||
label(w, L"Inspect recent operations and diagnostics.", 254, 336, 430);
|
||||
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
|
||||
button(w, L"KSwitcher &computers...", 262, 254, 374, 260);
|
||||
page();
|
||||
|
||||
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
|
||||
|
||||
+73
-43
@@ -3,10 +3,12 @@ struct LanView
|
||||
{
|
||||
HWND parent{};
|
||||
App *app{};
|
||||
nlohmann::json snapshot;
|
||||
nlohmann::json snapshot=nlohmann::json::object();
|
||||
std::vector<std::string> ids;
|
||||
std::wstring error;
|
||||
bool positionEdited{};
|
||||
std::string dragPeer;POINT dragStart{},dragPoint{};bool dragMoved{};
|
||||
std::string gridState;
|
||||
std::vector<std::wstring> rows;
|
||||
};
|
||||
nlohmann::json lanRequest(App &app,const nlohmann::json &request)
|
||||
{
|
||||
@@ -22,21 +24,42 @@ void refreshLan(HWND w,LanView &v)
|
||||
{
|
||||
auto selected=selectedLan(w,v);
|
||||
auto next=lanRequest(*v.app,{{"action","status"}});
|
||||
if(v.snapshot.is_null()||v.snapshot["peers"]!=next["peers"])
|
||||
if(selected.empty())
|
||||
{
|
||||
std::string onlyPaired;size_t pairedCount=0;
|
||||
for(const auto &peer:next["peers"])if(peer.value("paired",false))
|
||||
{onlyPaired=peer.at("id").get<std::string>();++pairedCount;}
|
||||
if(pairedCount==1)selected=onlyPaired;
|
||||
}
|
||||
std::vector<std::string> ids;
|
||||
std::vector<std::wstring> rows;
|
||||
for(const auto &peer:next["peers"])
|
||||
{
|
||||
auto id=peer.at("id").get<std::string>();ids.push_back(id);
|
||||
std::wstring state=L"Not paired";
|
||||
if(peer.at("paired").get<bool>()){
|
||||
bool connected=next.value("inputReady",nlohmann::json::object()).contains(id);
|
||||
state=connected?L"Pair saved — input connected":
|
||||
!next.value("shareInput",false)?L"Pair saved — input disabled here":
|
||||
!peer.value("online",false)?L"Pair saved — computer offline":L"Pair saved — input not connected";
|
||||
}
|
||||
rows.push_back(wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state);
|
||||
}
|
||||
// Presence/challenge refreshes must not repaint or reset the pairing list.
|
||||
if(v.ids!=ids||v.rows!=rows)
|
||||
{
|
||||
SendDlgItemMessageW(w,702,WM_SETREDRAW,FALSE,0);
|
||||
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids.clear();
|
||||
for(const auto &peer:next["peers"])
|
||||
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids=std::move(ids);v.rows=std::move(rows);
|
||||
for(size_t i=0;i<v.ids.size();++i)
|
||||
{
|
||||
auto id=peer.at("id").get<std::string>(); v.ids.push_back(id);
|
||||
std::wstring state=!peer.at("online").get<bool>() ? L"Offline" : peer.at("verified").get<bool>() ? L"Paired / verified" :
|
||||
peer.at("paired").get<bool>() ? L"Paired / checking identity" : L"Available / not paired";
|
||||
auto text=wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state;
|
||||
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)text.c_str());
|
||||
if(id==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
|
||||
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)v.rows[i].c_str());
|
||||
if(v.ids[i]==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
|
||||
}
|
||||
SendDlgItemMessageW(w,702,WM_SETREDRAW,TRUE,0); InvalidateRect(GetDlgItem(w,702),nullptr,TRUE);
|
||||
}
|
||||
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);
|
||||
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");}
|
||||
@@ -44,29 +67,36 @@ void refreshLan(HWND w,LanView &v)
|
||||
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);
|
||||
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);
|
||||
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
|
||||
EnableWindow(GetDlgItem(w,706),pending);
|
||||
EnableWindow(GetDlgItem(w,707),paired);
|
||||
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending);
|
||||
EnableWindow(GetDlgItem(w,715),enabled&&paired);
|
||||
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending&&!paired);
|
||||
auto positions=v.snapshot.value("positions",nlohmann::json::object());
|
||||
EnableWindow(GetDlgItem(w,715),enabled&&!positions.empty()&&!v.snapshot.value("shareInput",false));
|
||||
EnableWindow(GetDlgItem(w,716),v.snapshot.value("shareInput",false));
|
||||
for(int id:{711,712,713,714})EnableWindow(GetDlgItem(w,id),paired);
|
||||
if(!v.positionEdited)CheckRadioButton(w,711,714,711+v.snapshot.value("inputEdge",1));
|
||||
int position=1;for(int i=0;i<4;++i)if(checked(w,711+i))position=i;
|
||||
const wchar_t *diagrams[]={L"[ Other PC ] ← [ This PC ]",L"[ This PC ] → [ Other PC ]",L"Other PC is ABOVE this PC",L"Other PC is BELOW this PC"};
|
||||
value(w,719,diagrams[position]);
|
||||
auto gridState=positions.dump()+v.snapshot["peers"].dump()+selected+v.snapshot.value("controlPeer",std::string{})+v.snapshot.value("inputReady",nlohmann::json::object()).dump()+(v.snapshot.value("shareInput",false)?"1":"0");
|
||||
if(gridState!=v.gridState){v.gridState=std::move(gridState);InvalidateRect(GetDlgItem(w,721),nullptr,FALSE);}
|
||||
std::wstring detail=L"Drag paired monitors to empty cells. This PC stays fixed. Arrow keys also work.";
|
||||
detail=L"Drag monitors to neighboring cells. Mouse crosses shared edges; empty cells block passage.";
|
||||
value(w,719,v.error.empty()?detail:v.error);
|
||||
auto inputStatus=wide(v.snapshot.value("inputStatus",std::string("Input sharing disabled.")));
|
||||
if(!v.snapshot.value("shareInput",false))
|
||||
inputStatus=!enabled ? L"Enable discovery to set up mouse and keyboard sharing." :
|
||||
selected.empty() ? L"Select a computer in the list above to enable input." :
|
||||
!paired ? L"Pair the selected computer first." :
|
||||
L"Arrange the monitors, then enable input sharing on all PCs.";
|
||||
auto mode=v.snapshot.value("controlMode",0);
|
||||
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);
|
||||
}
|
||||
#include "ui_lan_layout.inl"
|
||||
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(msg==WM_DPICHANGED) {resizeForDpi(w,wp,lp);return 0;}
|
||||
auto v=(LanView*)GetWindowLongPtrW(w,GWLP_USERDATA);
|
||||
if(!v) return DefWindowProcW(w,msg,wp,lp);
|
||||
if(msg==WM_DESTROY) {KillTimer(w,1);EnableWindow(v->parent,TRUE);SetForegroundWindow(v->parent);delete v;SetWindowLongPtrW(w,GWLP_USERDATA,0);return 0;}
|
||||
if(msg==WM_DESTROY) {DestroyWindow(GetDlgItem(w,721));KillTimer(w,1);EnableWindow(v->parent,TRUE);SetForegroundWindow(v->parent);delete v;SetWindowLongPtrW(w,GWLP_USERDATA,0);return 0;}
|
||||
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)) {DestroyWindow(w);return 0;}
|
||||
try
|
||||
{
|
||||
@@ -74,8 +104,9 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
if(msg==WM_COMMAND)
|
||||
{
|
||||
int id=LOWORD(wp); auto peer=selectedLan(w,*v);
|
||||
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {v->positionEdited=false;refreshLan(w,*v);return 0;}
|
||||
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {refreshLan(w,*v);return 0;}
|
||||
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
|
||||
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
|
||||
v->error.clear();
|
||||
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
|
||||
if(id==704)
|
||||
@@ -87,12 +118,9 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
|
||||
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
|
||||
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
|
||||
if(id>=711&&id<=714) v->positionEdited=true;
|
||||
if(id==715)
|
||||
{
|
||||
int position=1;for(int i=0;i<4;++i)if(checked(w,711+i))position=i;
|
||||
lanRequest(*v->app,{{"action","input"},{"enabled",true},{"peer",peer},{"edge",position}});
|
||||
v->positionEdited=false;
|
||||
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
|
||||
}
|
||||
if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}});
|
||||
refreshLan(w,*v); return 0;
|
||||
@@ -103,30 +131,32 @@ 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,790);
|
||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,836,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);
|
||||
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);
|
||||
control(w,L"LISTBOX",L"",WS_TABSTOP|WS_VSCROLL|WS_BORDER|LBS_NOTIFY|LBS_NOINTEGRALHEIGHT,702,18,120,700,150);
|
||||
control(w,L"STATIC",L"",0,710,18,278,700,40);
|
||||
label(w,L"Same pairing c&ode on both computers",18,327,700);
|
||||
edit(w,703,18,355,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
|
||||
button(w,L"&Generate code",704,504,354,214);
|
||||
label(w,L"Generate once, enter that code on both PCs, select each other, then click Pair on both.",18,392,700);
|
||||
button(w,L"&Pair",705,18,424,140);button(w,L"Cancel &pairing",706,174,424,160);button(w,L"&Forget pair",707,350,424,160);
|
||||
control(w,L"STATIC",L"",0,708,18,466,700,40);
|
||||
label(w,L"Selected paired computer stands relative to this PC:",18,512,700);
|
||||
const wchar_t *positions[]={L"← &Left",L"&Right →",L"↑ &Above",L"↓ &Below"};
|
||||
for(int i=0;i<4;++i)control(w,L"BUTTON",positions[i],WS_TABSTOP|BS_AUTORADIOBUTTON|(i==0?WS_GROUP:0),711+i,18+i*175,540,166,28);
|
||||
control(w,L"STATIC",L"",SS_CENTER,719,18,578,700,28);
|
||||
button(w,L"Apply position / enable &input",715,18,620,330);
|
||||
button(w,L"&Stop input sharing",716,370,620,240);
|
||||
control(w,L"STATIC",L"",0,718,18,660,700,40);
|
||||
control(w,L"STATIC",L"Choose opposite sides on the two PCs (right ↔ left, above ↔ below). Changes apply immediately. Keyboard follows the mouse. Clipboard sharing is not included yet.",0,720,18,706,700,36);
|
||||
button(w,L"&Close",IDCANCEL,618,748,100);
|
||||
control(w,L"LISTBOX",L"",WS_TABSTOP|WS_VSCROLL|WS_BORDER|LBS_NOTIFY|LBS_NOINTEGRALHEIGHT,702,18,120,700,96);
|
||||
control(w,L"STATIC",L"",0,710,18,224,700,40);
|
||||
label(w,L"Same pairing c&ode on both computers",18,273,700);
|
||||
edit(w,703,18,301,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
|
||||
button(w,L"&Generate code",704,504,300,214);
|
||||
label(w,L"Generate once, enter that code on both PCs, select each other, then click Pair on both.",18,338,700);
|
||||
button(w,L"&Pair",705,18,370,140);button(w,L"Cancel &pairing",706,174,370,160);button(w,L"&Forget pair",707,350,370,160);
|
||||
control(w,L"STATIC",L"",0,708,18,412,700,40);
|
||||
label(w,L"Arrange your computers",18,458,700);
|
||||
WNDCLASSW gridClass{};gridClass.lpfnWndProc=lanGridProc;gridClass.hInstance=app.instance;gridClass.hCursor=LoadCursorW(nullptr,IDC_ARROW);gridClass.lpszClassName=L"KSwitcher.LAN.Layout";RegisterClassW(&gridClass);
|
||||
auto grid=CreateWindowExW(0,gridClass.lpszClassName,L"Computer layout. Select a computer above, then drag its monitor or use arrow keys.",WS_CHILD|WS_VISIBLE|WS_TABSTOP|WS_BORDER,
|
||||
scale(w,18),scale(w,486),scale(w,700),scale(w,180),w,reinterpret_cast<HMENU>(721),app.instance,v);
|
||||
windowLayoutState(w)->controls.push_back({grid,{18,486,718,666}});
|
||||
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);
|
||||
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);}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
struct LanGridMetrics
|
||||
{
|
||||
int left=-1,right=1,top=-1,bottom=1,width{},height{};
|
||||
RECT cell(int x,int y)const{return {(x-left)*width/(right-left+1),(y-top)*height/(bottom-top+1),(x-left+1)*width/(right-left+1),(y-top+1)*height/(bottom-top+1)};}
|
||||
POINT point(POINT p)const{return {std::clamp(left+int(p.x)*(right-left+1)/std::max(1,width),left,right),std::clamp(top+int(p.y)*(bottom-top+1)/std::max(1,height),top,bottom)};}
|
||||
};
|
||||
LanGridMetrics lanGridMetrics(HWND w,const LanView &v)
|
||||
{
|
||||
RECT r{};GetClientRect(w,&r);LanGridMetrics g;g.width=r.right;g.height=r.bottom;
|
||||
const auto positions=v.snapshot.value("positions",nlohmann::json::object());
|
||||
for(auto &[id,cell]:positions.items()){
|
||||
int x=cell[0],y=cell[1];g.left=std::min(g.left,x-1);g.right=std::max(g.right,x+1);g.top=std::min(g.top,y-1);g.bottom=std::max(g.bottom,y+1);
|
||||
}
|
||||
g.left=std::max(-8,g.left);g.right=std::min(8,g.right);g.top=std::max(-8,g.top);g.bottom=std::min(8,g.bottom);return g;
|
||||
}
|
||||
void moveLanMonitor(HWND parent,LanView &v,const std::string &peer,int x,int y)
|
||||
{
|
||||
try{lanRequest(*v.app,{{"action","position"},{"peer",peer},{"x",x},{"y",y}});v.error.clear();}
|
||||
catch(const std::exception &e){v.error=wide(e.what());}
|
||||
refreshLan(parent,v);
|
||||
}
|
||||
LRESULT CALLBACK lanGridProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
auto *v=reinterpret_cast<LanView*>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
||||
if(msg==WM_NCCREATE){v=static_cast<LanView*>(reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);SetWindowLongPtrW(w,GWLP_USERDATA,reinterpret_cast<LONG_PTR>(v));}
|
||||
if(!v)return DefWindowProcW(w,msg,wp,lp);
|
||||
auto parent=GetParent(w);
|
||||
if(msg==WM_ERASEBKGND)return 1;
|
||||
if(msg==WM_GETDLGCODE)return DLGC_WANTARROWS;
|
||||
if(msg==WM_SETFOCUS||msg==WM_KILLFOCUS){InvalidateRect(w,nullptr,FALSE);return 0;}
|
||||
if(msg==WM_LBUTTONDOWN){
|
||||
SetFocus(w);POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};auto g=lanGridMetrics(w,*v);
|
||||
const auto positions=v->snapshot.value("positions",nlohmann::json::object());
|
||||
for(auto &[id,cell]:positions.items()){
|
||||
auto r=g.cell(cell[0],cell[1]);if(!PtInRect(&r,p))continue;
|
||||
auto found=std::find(v->ids.begin(),v->ids.end(),id);if(found==v->ids.end())break;
|
||||
SendDlgItemMessageW(parent,702,LB_SETCURSEL,std::distance(v->ids.begin(),found),0);
|
||||
v->dragPeer=id;v->dragStart=p;v->dragPoint=p;v->dragMoved=false;SetCapture(w);refreshLan(parent,*v);break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if(msg==WM_MOUSEMOVE&&!v->dragPeer.empty()){
|
||||
v->dragPoint={GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
if(std::abs(v->dragPoint.x-v->dragStart.x)>scale(w,4)||std::abs(v->dragPoint.y-v->dragStart.y)>scale(w,4))v->dragMoved=true;
|
||||
InvalidateRect(w,nullptr,FALSE);return 0;
|
||||
}
|
||||
if(msg==WM_LBUTTONUP&&!v->dragPeer.empty()){
|
||||
auto peer=v->dragPeer;auto p=lanGridMetrics(w,*v).point({GET_X_LPARAM(lp),GET_Y_LPARAM(lp)});bool moved=v->dragMoved;
|
||||
RECT client{};GetClientRect(w,&client);POINT mouse{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||
v->dragPeer.clear();v->dragMoved=false;ReleaseCapture();
|
||||
if(moved&&PtInRect(&client,mouse))moveLanMonitor(parent,*v,peer,p.x,p.y);
|
||||
InvalidateRect(w,nullptr,FALSE);return 0;
|
||||
}
|
||||
if(msg==WM_CAPTURECHANGED){v->dragPeer.clear();v->dragMoved=false;InvalidateRect(w,nullptr,FALSE);return 0;}
|
||||
if(msg==WM_KEYDOWN){
|
||||
if(wp==VK_ESCAPE&&!v->dragPeer.empty()){v->dragPeer.clear();ReleaseCapture();InvalidateRect(w,nullptr,FALSE);return 0;}
|
||||
auto peer=selectedLan(parent,*v);auto positions=v->snapshot.value("positions",nlohmann::json::object());
|
||||
if(positions.contains(peer)&&(wp==VK_LEFT||wp==VK_RIGHT||wp==VK_UP||wp==VK_DOWN)){
|
||||
int x=positions[peer][0],y=positions[peer][1];x+=(wp==VK_RIGHT)-(wp==VK_LEFT);y+=(wp==VK_DOWN)-(wp==VK_UP);
|
||||
moveLanMonitor(parent,*v,peer,x,y);return 0;
|
||||
}
|
||||
}
|
||||
if(msg==WM_PAINT){
|
||||
PAINTSTRUCT ps{};auto target=BeginPaint(w,&ps);RECT bounds{};GetClientRect(w,&bounds);
|
||||
auto dc=CreateCompatibleDC(target);auto bitmap=CreateCompatibleBitmap(target,std::max(1L,bounds.right),std::max(1L,bounds.bottom));auto oldBitmap=SelectObject(dc,bitmap);
|
||||
auto fill=CreateSolidBrush(RGB(247,249,251));FillRect(dc,&bounds,fill);DeleteObject(fill);
|
||||
auto oldFont=SelectObject(dc,GetPropW(parent,L"Font"));SetBkMode(dc,TRANSPARENT);
|
||||
auto gridPen=CreatePen(PS_DOT,1,RGB(214,222,230));auto oldPen=SelectObject(dc,gridPen);auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
|
||||
auto g=lanGridMetrics(w,*v);
|
||||
for(int x=g.left;x<=g.right+1;++x){int at=(x-g.left)*g.width/(g.right-g.left+1);MoveToEx(dc,at,0,nullptr);LineTo(dc,at,g.height);}
|
||||
for(int y=g.top;y<=g.bottom+1;++y){int at=(y-g.top)*g.height/(g.bottom-g.top+1);MoveToEx(dc,0,at,nullptr);LineTo(dc,g.width,at);}
|
||||
auto selected=selectedLan(parent,*v);
|
||||
auto draw=[&](const std::string &id,const std::wstring &name,int x,int y,bool local){
|
||||
auto cell=g.cell(x,y);int margin=scale(w,8),cellWidth=cell.right-cell.left;
|
||||
TEXTMETRICW metrics{};GetTextMetricsW(dc,&metrics);
|
||||
RECT title{cell.left+scale(w,3),cell.bottom-scale(w,3)-metrics.tmHeight,cell.right-scale(w,3),cell.bottom-scale(w,3)};
|
||||
int top=cell.top+scale(w,4),height=std::min(scale(w,36),int(title.top)-top-scale(w,8));
|
||||
int width=std::min({scale(w,110),cellWidth-2*margin,height*2});
|
||||
if(width<8||height<8)return;
|
||||
int left=(cell.left+cell.right-width)/2;
|
||||
bool active=!local&&v->snapshot.value("shareInput",false)&&v->snapshot.value("controlMode",0)!=0&&v->snapshot.value("controlPeer",std::string{})==id;
|
||||
COLORREF color=id==selected?RGB(0,105,190):active?RGB(30,130,90):RGB(105,120,133);
|
||||
auto pen=CreatePen(PS_SOLID,scale(w,2),color);SelectObject(dc,pen);
|
||||
auto brush=CreateSolidBrush(local?RGB(220,237,252):RGB(255,255,255));SelectObject(dc,brush);
|
||||
RoundRect(dc,left,top,left+width,top+height,scale(w,4),scale(w,4));SelectObject(dc,GetStockObject(NULL_BRUSH));DeleteObject(brush);
|
||||
int center=left+width/2;MoveToEx(dc,center,top+height,nullptr);LineTo(dc,center,top+height+scale(w,5));
|
||||
MoveToEx(dc,center-scale(w,12),top+height+scale(w,5),nullptr);LineTo(dc,center+scale(w,12),top+height+scale(w,5));
|
||||
RECT screen{left+scale(w,3),top+scale(w,2),left+width-scale(w,3),top+height-scale(w,2)};SetTextColor(dc,color);
|
||||
const wchar_t *caption=local?L"Local":active?L"Active":L"Paired";
|
||||
if(height>=metrics.tmHeight+scale(w,4))DrawTextW(dc,caption,-1,&screen,DT_CENTER|DT_VCENTER|DT_SINGLELINE|DT_END_ELLIPSIS);
|
||||
SetTextColor(dc,RGB(30,40,50));
|
||||
DrawTextW(dc,name.c_str(),-1,&title,DT_CENTER|DT_SINGLELINE|DT_END_ELLIPSIS|DT_NOPREFIX);
|
||||
if(id==selected){RECT outline{cell.left+scale(w,2),cell.top+scale(w,2),cell.right-scale(w,2),cell.bottom-scale(w,2)};if(GetFocus()==w)DrawFocusRect(dc,&outline);}
|
||||
SelectObject(dc,gridPen);DeleteObject(pen);
|
||||
};
|
||||
draw("",wide(v->snapshot.value("name",std::string("This PC"))),0,0,true);
|
||||
for(const auto &peer:v->snapshot.value("peers",nlohmann::json::array())){
|
||||
auto id=peer.at("id").get<std::string>();auto positions=v->snapshot.value("positions",nlohmann::json::object());
|
||||
if(positions.contains(id))draw(id,wide(peer.at("name").get<std::string>()),positions[id][0],positions[id][1],false);
|
||||
}
|
||||
if(v->dragMoved&&!v->dragPeer.empty()){
|
||||
auto point=g.point(v->dragPoint);auto r=g.cell(point.x,point.y);bool valid=true;
|
||||
try{lan::validateCell(v->snapshot.at("positions"),v->dragPeer,point.x,point.y);}catch(...){valid=false;}
|
||||
auto pen=CreatePen(PS_SOLID,scale(w,2),valid?RGB(0,105,190):RGB(190,50,40));SelectObject(dc,pen);
|
||||
Rectangle(dc,r.left+2,r.top+2,r.right-2,r.bottom-2);SelectObject(dc,gridPen);DeleteObject(pen);
|
||||
}
|
||||
SelectObject(dc,oldPen);SelectObject(dc,oldBrush);SelectObject(dc,oldFont);DeleteObject(gridPen);
|
||||
BitBlt(target,0,0,bounds.right,bounds.bottom,dc,0,0,SRCCOPY);SelectObject(dc,oldBitmap);DeleteObject(bitmap);DeleteDC(dc);EndPaint(w,&ps);return 0;
|
||||
}
|
||||
return DefWindowProcW(w,msg,wp,lp);
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.56";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.58";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+53
-19
@@ -7,6 +7,15 @@
|
||||
|
||||
namespace
|
||||
{
|
||||
HWND findTestWindow(LPCWSTR className, LPCWSTR title)
|
||||
{
|
||||
HWND window=nullptr;
|
||||
while((window=FindWindowExW(nullptr,window,className,title))) {
|
||||
DWORD process{}; GetWindowThreadProcessId(window,&process);
|
||||
if(process==GetCurrentProcessId()) return window;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
int passed{};
|
||||
void require(bool ok, const char *message)
|
||||
{
|
||||
@@ -141,31 +150,31 @@ int main()
|
||||
SetDlgItemTextW(parent,240,originalLimit);
|
||||
require(!saveEnabled(),"restoring valid original text clears dirty state");
|
||||
SendMessageW(parent,WM_COMMAND,257,0);
|
||||
HWND ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||
HWND ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||
require(ocr!=nullptr,"OCR preferences open");
|
||||
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
|
||||
SetDlgItemTextW(ocr,503,L"eng");
|
||||
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
|
||||
require(!saveEnabled(),"cancelled child changes leave Settings clean");
|
||||
SendMessageW(parent,WM_COMMAND,257,0);
|
||||
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||
SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng");
|
||||
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
|
||||
SendMessageW(parent,WM_COMMAND,257,0);
|
||||
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||
SetDlgItemTextW(ocr,503,languages);
|
||||
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
|
||||
SendMessageW(parent,WM_COMMAND,255,0);
|
||||
HWND automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
HWND automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
|
||||
require(automatic!=nullptr,"automatic correction preferences open");
|
||||
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
|
||||
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
|
||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
|
||||
SendMessageW(parent,WM_COMMAND,255,0);
|
||||
automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
|
||||
CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED);
|
||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||
require(!saveEnabled(),"reverting auto-correction draft disables Save");
|
||||
@@ -201,7 +210,7 @@ int main()
|
||||
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
||||
require(!saveEnabled(),"changing tabs leaves Settings clean");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
HWND dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||
HWND dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
||||
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
require(index >= 0, "saved chat listed");
|
||||
@@ -220,7 +229,7 @@ int main()
|
||||
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
||||
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
@@ -236,7 +245,7 @@ int main()
|
||||
parent = app.settingsWindow;
|
||||
require(!saveEnabled(),"reopened saved Settings is clean");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
@@ -286,15 +295,16 @@ int main()
|
||||
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
||||
ks::showSettings(app);parent=app.settingsWindow;
|
||||
SendMessageW(parent,WM_COMMAND,262,0);
|
||||
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
||||
HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
|
||||
require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,707)),"cannot forget nonexistent pair");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input sharing requires a paired computer");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,716)),"stop input disabled when sharing is off");
|
||||
for(int id:{711,712,713,714}) require(!IsWindowEnabled(GetDlgItem(lan,id)),"screen positions unavailable until peer selected");
|
||||
require(IsDlgButtonChecked(lan,712)==BST_CHECKED,"default screen position is Right");
|
||||
require(GetDlgItem(lan,721)!=nullptr,"monitor arrangement grid is present");
|
||||
require(!(GetWindowLongPtrW(lan,GWL_STYLE)&(WS_THICKFRAME|WS_MAXIMIZEBOX)),"LAN window has a fixed size");
|
||||
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");
|
||||
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||
@@ -306,19 +316,25 @@ int main()
|
||||
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||
}
|
||||
{
|
||||
ks::lan::Json fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false},
|
||||
ks::lan::Json fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false},{"inputPeer",std::string(32,'b')},{"inputEdge",1},
|
||||
{"trusted",{{std::string(32,'b'),{{"name","Test paired KSwitcher"},{"key",std::string(64,'f')}}}}}};
|
||||
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
|
||||
ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp);
|
||||
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0);
|
||||
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
||||
SendDlgItemMessageW(lan,702,LB_SETCURSEL,0,0);
|
||||
SendMessageW(lan,WM_COMMAND,MAKEWPARAM(702,LBN_SELCHANGE),0);
|
||||
for(int id:{711,712,713,714})require(IsWindowEnabled(GetDlgItem(lan,id)),"paired computer exposes all four layout positions");
|
||||
SendDlgItemMessageW(lan,713,BM_CLICK,0,0);
|
||||
require(IsDlgButtonChecked(lan,713)==BST_CHECKED&&IsDlgButtonChecked(lan,712)==BST_UNCHECKED,"Above selection changes radio group");
|
||||
HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
|
||||
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
|
||||
wchar_t pairState[512]{};SendDlgItemMessageW(lan,702,LB_GETTEXT,0,(LPARAM)pairState);
|
||||
require(std::wstring(pairState).find(L"Pair saved — input disabled here")!=std::wstring::npos,"saved pairing does not imply an active input connection");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls");
|
||||
SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0);
|
||||
SendMessageW(lan,WM_TIMER,1,0);
|
||||
require(IsDlgButtonChecked(lan,713)==BST_CHECKED,"status refresh preserves unsaved position choice");
|
||||
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"refresh restores single paired selection without a peer list change");
|
||||
auto grid=GetDlgItem(lan,721);
|
||||
SendMessageW(grid,WM_KEYDOWN,VK_UP,0);SendMessageW(grid,WM_KEYDOWN,VK_LEFT,0);
|
||||
auto saved=ks::lan::Json::parse(ks::unprotect(ks::readFile(root/L"lan-pairing.bin",256*1024)));
|
||||
require(saved["positions"][std::string(32,'b')]==ks::lan::Json::array({0,-1}),"keyboard moves monitor above this PC and saves immediately");
|
||||
SendMessageW(lan,WM_TIMER,1,0);
|
||||
require(saved["inputEdge"]==2,"monitor arrangement updates the existing input direction");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input cannot start while discovery is disabled");
|
||||
// Let the native radio-button theme animation finish before visual capture.
|
||||
auto repaintUntil=GetTickCount64()+200;
|
||||
@@ -334,6 +350,24 @@ int main()
|
||||
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
||||
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,IDCANCEL,0);
|
||||
}
|
||||
{
|
||||
using J=ks::lan::Json;const std::string b(32,'b'),c(32,'c');
|
||||
J fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false},{"inputPeer",b},{"inputEdge",0},
|
||||
{"trusted",{{b,{{"name","Left desktop"},{"key",std::string(64,'f')}}},{c,{{"name","Right laptop"},{"key",std::string(64,'e')}}}}},
|
||||
{"positions",{{b,J::array({-1,0})},{c,J::array({1,0})}}}};
|
||||
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
|
||||
ks::App layoutApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(layoutApp);
|
||||
SendMessageW(layoutApp.settingsWindow,WM_COMMAND,262,0);auto lan=findTestWindow(L"KSwitcher.LAN",nullptr);auto grid=GetDlgItem(lan,721);
|
||||
RECT r{};GetClientRect(grid,&r);
|
||||
auto point=[&](int column,int row){return MAKELPARAM((2*column+1)*r.right/10,(2*row+1)*r.bottom/6);};
|
||||
SendMessageW(grid,WM_LBUTTONDOWN,MK_LBUTTON,point(1,1));SendMessageW(grid,WM_MOUSEMOVE,MK_LBUTTON,point(1,0));SendMessageW(grid,WM_LBUTTONUP,0,point(1,0));
|
||||
auto saved=J::parse(ks::unprotect(ks::readFile(root/L"lan-pairing.bin",256*1024)));
|
||||
require(saved["positions"][b]==J::array({-1,-1})&&saved["positions"][c]==J::array({1,0}),"drag moves one monitor without moving another paired PC");
|
||||
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||
GetClientRect(lan,&r);auto dc=GetDC(lan);auto screenshot=ks::screenshotClip(dc,r.right,r.bottom,{});ReleaseDC(lan,dc);
|
||||
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-three-computers.png",ks::encodePng(ks::decodeImage(screenshot)));
|
||||
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);SendMessageW(layoutApp.settingsWindow,WM_COMMAND,IDCANCEL,0);
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "Passed " << passed << " chat profile checks\n";
|
||||
CoUninitialize();
|
||||
|
||||
@@ -676,6 +676,19 @@ int main(int argc, char **argv) {
|
||||
};
|
||||
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
|
||||
auto f12=event(VK_F12);
|
||||
{
|
||||
auto localF12=f12;localF12.flags=0;localF12.dwExtraInfo=0;
|
||||
app.handleKeyboard(localF12,true,MOD_CONTROL);commands();
|
||||
app.resetLanInput();
|
||||
require(app.handleKeyboard(localF12,true,MOD_CONTROL)&&commands()==1,
|
||||
"local shortcut works after LAN handoff even when release happened on another PC");
|
||||
app.handleKeyboard(localF12,false,0);
|
||||
auto letter=event('A',30);letter.flags=0;letter.dwExtraInfo=0;
|
||||
app.resetLanInput();app.handleKeyboard(letter,true,0);
|
||||
require(!app.run.keys.empty(),"local typing tracking resumes after LAN ownership reset");
|
||||
app.run.reset();
|
||||
}
|
||||
|
||||
{
|
||||
const auto before = app.inputVersion.load();
|
||||
const auto recorded = app.run.keys;
|
||||
|
||||
@@ -114,6 +114,54 @@ int main(int argc,char **argv)
|
||||
check(request(restored,{{"action","status"}})["peers"].empty(),"forget removes offline pair");
|
||||
check(!request(restored,{{"action","status"}}).at("shareInput").get<bool>(),"forgetting the input peer revokes persisted input permission");
|
||||
}
|
||||
if(argc>1&&std::string(argv[1])=="--network")
|
||||
{
|
||||
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
|
||||
saved["trusted"][b]={{"name","Restart peer"},{"key",left.derived}};
|
||||
saved["enabled"]=true;saved["shareInput"]=true;saved["inputPeer"]=b;saved["inputEdge"]=3;
|
||||
auto text=saved.dump();atomicWrite(root/L"lan-pairing.bin",protect(Bytes(text.begin(),text.end())));
|
||||
for(int restart=0;restart<2;++restart)
|
||||
{
|
||||
LanService restored(root);Sleep(100);
|
||||
auto state=request(restored,{{"action","status"}});
|
||||
check(restored.inputEnabled()&&state.at("enabled")==true&&state.at("inputPeer")==b&&state.at("inputEdge")==3,
|
||||
"restart restores discovery, pairing, input permission and screen edge without opening settings");
|
||||
}
|
||||
}
|
||||
{
|
||||
auto folder=root/L"layout";std::filesystem::create_directories(folder);
|
||||
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
|
||||
saved["enabled"]=false;saved["shareInput"]=false;saved["inputPeer"]=b;saved["inputEdge"]=1;saved.erase("positions");
|
||||
saved["trusted"]={{b,{{"name","Right PC"},{"key",left.derived}}},{c,{{"name","Third PC"},{"key",left.derived}}}};
|
||||
auto text=saved.dump();atomicWrite(folder/L"lan-pairing.bin",protect(Bytes(text.begin(),text.end())));
|
||||
{
|
||||
LanService layout(folder);auto state=request(layout,{{"action","status"}});
|
||||
check(state["positions"][b]==Json::array({1,0}),"legacy side migrates to monitor grid");
|
||||
check(state["positions"].size()==2&&state["positions"][c]!=state["positions"][b],"three-PC layout has unique cells");
|
||||
request(layout,{{"action","position"},{"peer",c},{"x",-1},{"y",0}});
|
||||
request(layout,{{"action","position"},{"peer",b},{"x",0},{"y",-1}});
|
||||
rejects([&]{request(layout,{{"action","position"},{"peer",c},{"x",0},{"y",-1}});},"occupied cell rejected");
|
||||
rejects([&]{request(layout,{{"action","position"},{"peer",c},{"x",0},{"y",0}});},"local monitor cell protected");
|
||||
rejects([&]{request(layout,{{"action","position"},{"peer",c},{"x",9},{"y",0}});},"out-of-grid position rejected");
|
||||
check(request(layout,{{"action","status"}})["positions"][c]==Json::array({-1,0}),"failed drops preserve previous position");
|
||||
}
|
||||
{
|
||||
LanService layout(folder);auto state=request(layout,{{"action","status"}});
|
||||
check(state["positions"][b]==Json::array({0,-1})&&state["positions"][c]==Json::array({-1,0}),"all monitor positions survive restart");
|
||||
check(state["inputEdge"]==2,"active peer direction follows monitor position");
|
||||
if(argc>1&&std::string(argv[1])=="--network"){
|
||||
request(layout,{{"action","enable"},{"enabled",true}});
|
||||
request(layout,{{"action","input"},{"enabled",true}});
|
||||
request(layout,{{"action","position"},{"peer",c},{"x",0},{"y",-2}});
|
||||
auto multi=request(layout,{{"action","status"}});
|
||||
check(multi["shareInput"]==true&&multi["positions"][c]==Json::array({0,-2}),"multi-PC sharing remains enabled for a chained nonadjacent destination");
|
||||
request(layout,{{"action","forget"},{"peer",b}});
|
||||
check(request(layout,{{"action","status"}})["shareInput"]==true,"forgetting legacy selected pair keeps remaining pairs enabled");
|
||||
}
|
||||
request(layout,{{"action","forget"},{"peer",c}});
|
||||
check(!request(layout,{{"action","status"}})["positions"].contains(c),"forgetting pair removes its monitor");
|
||||
}
|
||||
}
|
||||
std::cout<<"Passed "<<passed<<" LAN discovery/pairing checks\n";
|
||||
}
|
||||
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#include <winsock2.h>
|
||||
#include "lan_clipboard.h"
|
||||
#include <iostream>
|
||||
using namespace ks;using namespace ks::lan;
|
||||
int checks{};
|
||||
void check(bool b,const char *s){if(!b)throw std::runtime_error(s);++checks;}
|
||||
int main(){try{
|
||||
const std::string aid(32,'a'),bid(32,'b'),cid(32,'c'),key(64,'d');
|
||||
ClipboardExchange a(aid),b(bid),c(cid);
|
||||
a.peerSession(bid,b.session());a.peerSession(cid,c.session());b.peerSession(aid,a.session());c.peerSession(aid,a.session());
|
||||
Json trusted{{bid,{{"key",key}}},{cid,{{"key",key}}}},back{{aid,{{"key",key}}}};
|
||||
Bytes payload(13000);for(size_t i=0;i<payload.size();++i)payload[i]=static_cast<unsigned char>(i);
|
||||
a.queue({bid,cid},payload,"editor.exe",1);
|
||||
std::deque<std::pair<std::string,Json>> packets,acks;Json firstPacket;int sent=0;
|
||||
auto output=[&](const std::string &to,const Json &p){check(p.dump().size()<=2048,"datagram fits discovery bound");if(firstPacket.is_null())firstPacket=p;packets.push_back({to,p});};
|
||||
auto ack=[&](const std::string &,const Json &p){acks.push_back({p.at("id"),p});};
|
||||
for(uint64_t now=10;now<10000;now+=20){
|
||||
a.poll(trusted,now,output);
|
||||
while(!packets.empty()){
|
||||
auto [to,p]=std::move(packets.front());packets.pop_front();if(++sent%7==0)continue;
|
||||
auto &receiver=to==bid?b:c;
|
||||
receiver.receive(aid,key,p,now,123,ack);
|
||||
receiver.receive(aid,key,p,now,123,ack); // duplicate datagrams do not duplicate history
|
||||
}
|
||||
while(!acks.empty()){
|
||||
auto [from,p]=std::move(acks.front());acks.pop_front();if(++sent%11==0)continue;
|
||||
a.receive(from,key,p,now,123,output);
|
||||
}
|
||||
}
|
||||
check(b.deliveries.size()==1&&c.deliveries.size()==1,"all paired instances receive exactly once despite loss/duplicates");
|
||||
check(b.deliveries[0].bytes==payload&&c.deliveries[0].bytes==payload,"binary clipboard survives retransmission");
|
||||
check(b.deliveries[0].sequence==123&&b.deliveries[0].app=="editor.exe","capture sequence and origin retained");
|
||||
b.receive(aid,key,firstPacket,10000,123,ack);
|
||||
check(acks.empty(),"authenticated old packet replay is ignored");
|
||||
ClipboardExchange restarted(bid);restarted.peerSession(aid,a.session());
|
||||
restarted.receive(aid,key,firstPacket,10001,123,ack);
|
||||
check(restarted.deliveries.empty()&&acks.empty(),"old datagrams rejected after recipient restart");
|
||||
auto damaged=firstPacket;damaged["number"]=1000000;auto data=damaged["data"].get<std::string>();data[0]=data[0]=='0'?'1':'0';damaged["data"]=data;
|
||||
bool rejected=false;try{b.receive(aid,key,damaged,10002,123,ack);}catch(...){rejected=true;}
|
||||
check(rejected,"modified ciphertext rejected");
|
||||
a.queue({bid},Bytes{1,2,3},"second",10003);
|
||||
a.peerSession(bid,restarted.session());
|
||||
for(uint64_t now=10004;now<11000;now+=20){
|
||||
a.poll(trusted,now,output);
|
||||
while(!packets.empty()){auto p=std::move(packets.front().second);packets.pop_front();restarted.receive(aid,key,p,now,456,ack);}
|
||||
while(!acks.empty()){auto p=std::move(acks.front().second);acks.pop_front();a.receive(bid,key,p,now,456,output);}
|
||||
}
|
||||
check(restarted.deliveries.size()==1&&restarted.deliveries[0].bytes==Bytes({1,2,3}),"clipboard works after peer restart");
|
||||
bool limited=false;try{a.queue({bid},Bytes(32*1024*1024+1),"large",12000);}catch(...){limited=true;}
|
||||
check(limited,"oversized clipboard bounded");
|
||||
{
|
||||
WSADATA startup{};check(WSAStartup(MAKEWORD(2,2),&startup)==0,"UDP benchmark starts");
|
||||
SOCKET sa=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP),sb=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
|
||||
struct Cleanup {SOCKET a,b;~Cleanup(){closesocket(a);closesocket(b);WSACleanup();}} cleanup{sa,sb};
|
||||
sockaddr_in aa{},ab{};aa.sin_family=ab.sin_family=AF_INET;aa.sin_addr.s_addr=ab.sin_addr.s_addr=htonl(INADDR_LOOPBACK);
|
||||
check(bind(sa,reinterpret_cast<sockaddr*>(&aa),sizeof(aa))==0&&bind(sb,reinterpret_cast<sockaddr*>(&ab),sizeof(ab))==0,"UDP benchmark binds");
|
||||
int length=sizeof(aa);getsockname(sa,reinterpret_cast<sockaddr*>(&aa),&length);length=sizeof(ab);getsockname(sb,reinterpret_cast<sockaddr*>(&ab),&length);
|
||||
for(auto socket:{sa,sb}){u_long nonblock=1;ioctlsocket(socket,FIONBIO,&nonblock);int buffer=1024*1024;setsockopt(socket,SOL_SOCKET,SO_RCVBUF,reinterpret_cast<char*>(&buffer),sizeof(buffer));}
|
||||
ClipboardExchange sender(aid),receiver(bid);sender.peerSession(bid,receiver.session());receiver.peerSession(aid,sender.session());
|
||||
Bytes large(2*1024*1024);uint32_t random=123;for(auto &v:large){random=random*1664525+1013904223;v=static_cast<unsigned char>(random>>24);}
|
||||
auto started=GetTickCount64();sender.queue({bid},large,"benchmark",started);
|
||||
auto send=[&](SOCKET socket,const sockaddr_in &address,const Json &p){auto data=p.dump();if(sendto(socket,data.data(),int(data.size()),0,reinterpret_cast<const sockaddr*>(&address),sizeof(address))!=int(data.size()))throw std::runtime_error("UDP benchmark send failed");};
|
||||
while(receiver.deliveries.empty()&&GetTickCount64()-started<15000){
|
||||
auto now=GetTickCount64();sender.poll(trusted,now,[&](const auto &,const Json &p){send(sa,ab,p);});
|
||||
bool progressed=false;
|
||||
for(int n=0;n<64;++n){char buffer[2048];auto size=recv(sb,buffer,sizeof(buffer),0);if(size<=0)break;progressed=true;
|
||||
receiver.receive(aid,key,Json::parse(buffer,buffer+size),now,123,[&](const auto &,const Json &p){send(sb,aa,p);});}
|
||||
for(int n=0;n<64;++n){char buffer[2048];auto size=recv(sa,buffer,sizeof(buffer),0);if(size<=0)break;progressed=true;
|
||||
sender.receive(bid,key,Json::parse(buffer,buffer+size),now,123,[&](const auto &,const Json &p){send(sa,ab,p);});}
|
||||
if(!progressed)Sleep(1);
|
||||
}
|
||||
check(receiver.deliveries.size()==1&&receiver.deliveries[0].bytes==large,"2 MiB crosses actual encrypted UDP sockets intact");
|
||||
std::cout<<"2 MiB encrypted UDP loopback: "<<(GetTickCount64()-started)<<" ms\n";
|
||||
}
|
||||
std::cout<<checks<<" LAN clipboard checks passed\n";return 0;
|
||||
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
|
||||
+233
-8
@@ -1,5 +1,6 @@
|
||||
#include <winsock2.h>
|
||||
#include "lan_input.h"
|
||||
#include "lan_mesh.h"
|
||||
#include <iostream>
|
||||
#include <deque>
|
||||
|
||||
@@ -12,23 +13,31 @@ template<class F>void rejects(F f,const char *text){bool failed=false;try{f();}c
|
||||
struct Desktop
|
||||
{
|
||||
RECT rect{-1920,-200,0,880};POINT point{-1000,300};bool available=true,physicalHeld=false,allow=true;
|
||||
std::vector<INPUT> events;
|
||||
std::vector<INPUT> events;int dpi=96;bool delayed=false;InputRouter *router{};
|
||||
InputEnvironment environment()
|
||||
{
|
||||
return {[this]{return rect;},[this]{return point;},[this](const INPUT &i){
|
||||
if(!allow)return false;
|
||||
events.push_back(i);
|
||||
if(i.type==INPUT_MOUSE&&(i.mi.dwFlags&MOUSEEVENTF_MOVE))
|
||||
if(i.type==INPUT_MOUSE&&(i.mi.dwFlags&MOUSEEVENTF_MOVE)&&!delayed)
|
||||
{
|
||||
point={rect.left+LONG(int64_t(i.mi.dx)*(rect.right-rect.left)/65536),rect.top+LONG(int64_t(i.mi.dy)*(rect.bottom-rect.top)/65536)};
|
||||
if(router){MSLLHOOKSTRUCT m{};m.pt=point;m.flags=LLMHF_INJECTED;m.dwExtraInfo=i.mi.dwExtraInfo;router->mouse(WM_MOUSEMOVE,m,1000);}
|
||||
}
|
||||
return true;
|
||||
},[this]{return available;},[this]{return physicalHeld;}};
|
||||
},[this]{return available;},[this]{return physicalHeld;},{},{},[this](POINT){return dpi;}};
|
||||
}
|
||||
unsigned keyEvents(bool down)const
|
||||
{return unsigned(std::count_if(events.begin(),events.end(),[down](auto &i){return i.type==INPUT_KEYBOARD&&bool(i.ki.dwFlags&KEYEVENTF_KEYUP)!=down;}));}
|
||||
};
|
||||
KBDLLHOOKSTRUCT key(unsigned vk,unsigned scan=0){KBDLLHOOKSTRUCT k{};k.vkCode=vk;k.scanCode=scan;return k;}
|
||||
bool motion(InputRouter &router,Desktop &desktop,POINT p,uint64_t now)
|
||||
{desktop.point=p;MSLLHOOKSTRUCT m{};m.pt=p;return router.mouse(WM_MOUSEMOVE,m,now);}
|
||||
{
|
||||
auto before=desktop.point;desktop.point=p;MSLLHOOKSTRUCT m{};m.pt=p;
|
||||
bool consumed=router.mouse(WM_MOUSEMOVE,m,now);
|
||||
if(consumed&&router.mode()==ControlMode::Sending)desktop.point=before;
|
||||
return consumed;
|
||||
}
|
||||
void exchange(InputRouter &source,InputRouter &target,std::deque<InputFrame> &out,uint64_t now)
|
||||
{(void)source;while(!out.empty()){auto f=out.front();out.pop_front();target.receive(f,now);}}
|
||||
struct Pair
|
||||
@@ -37,8 +46,9 @@ struct Pair
|
||||
InputRouter left,right;
|
||||
explicit Pair(int side):left(a.environment(),[this](auto f){ab.push_back(f);return true;},side,true),
|
||||
right(b.environment(),[this](auto f){ba.push_back(f);return true;},side^1,false)
|
||||
{b.rect={0,0,1600,900};b.point={600,400};left.tick(true,1000);right.tick(true,1000);}
|
||||
void deliver(uint64_t now=1100){exchange(left,right,ab,now);exchange(right,left,ba,now);}
|
||||
{a.router=&left;b.router=&right;b.rect={0,0,1600,900};b.point={600,400};left.tick(true,1000);right.tick(true,1000);}
|
||||
~Pair(){a.router=b.router=nullptr;}
|
||||
void deliver(uint64_t now=1100){exchange(left,right,ab,now);right.tick(true,now);exchange(right,left,ba,now);left.tick(true,now);}
|
||||
void enter(int edge)
|
||||
{
|
||||
motion(left,a,edgePoint(a.rect,edge,30000,0),1000);
|
||||
@@ -48,6 +58,93 @@ struct Pair
|
||||
};
|
||||
void routerChecks()
|
||||
{
|
||||
{
|
||||
std::vector<InputFrame> queue;
|
||||
for(int i=0;i<20;++i)enqueueInput(queue,{InputType::Wheel,1,120,0});
|
||||
check(queue.size()==1&&queue[0].a==2400,"pending wheel burst coalesces without losing same-direction distance");
|
||||
enqueueInput(queue,{InputType::Wheel,1,-120,0});
|
||||
check(queue.size()==1&&queue[0].a==-120,"wheel reversal replaces undelivered stale direction");
|
||||
enqueueInput(queue,{InputType::Key,1,VK_CONTROL,0,1});enqueueInput(queue,{InputType::Wheel,1,120,0});
|
||||
check(queue.size()==3&&queue[1].type==InputType::Key,"wheel coalescing preserves modifier barriers");
|
||||
enqueueInput(queue,{InputType::Wheel,1,120,1});enqueueInput(queue,{InputType::Wheel,2,120,1});
|
||||
check(queue.size()==5,"wheel coalescing preserves axis and ownership boundaries");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.enter(1);p.a.delayed=true;
|
||||
p.left.cancel(1200);
|
||||
auto before=p.a.point;
|
||||
check(motion(p.left,p.a,{before.x+10,before.y},1201),"physical movement is gated while return-to-edge is queued");
|
||||
p.a.point=edgePoint(p.a.rect,1,edgePosition(p.a.point,p.a.rect,1));p.left.tick(true,1500);
|
||||
check(!motion(p.left,p.a,{p.a.point.x-10,p.a.point.y},1501),"return movement gate always clears within bounded timeout");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.enter(1);
|
||||
for(auto vk:{VK_SHIFT,VK_LSHIFT,VK_RSHIFT,VK_CONTROL,VK_MENU})
|
||||
p.right.keyboard(key(vk),false,1200);
|
||||
check(p.right.mode()==ControlMode::Receiving&&p.ba.empty(),"layout-generated unpaired modifier releases cannot return remote ownership");
|
||||
auto own=key(VK_SHIFT);own.dwExtraInfo=inputMarker();
|
||||
p.right.keyboard(own,true,1201);p.right.keyboard(own,false,1202);
|
||||
check(p.right.mode()==ControlMode::Receiving&&p.ba.empty(),"own correction marker cannot trigger takeover even with injection flag stripped");
|
||||
p.right.keyboard(key('B'),true,1203);
|
||||
check(p.right.mode()==ControlMode::Local&&!p.ba.empty(),"real local key press still takes over after conversion release noise");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.enter(1);MSLLHOOKSTRUCT mouse{};mouse.pt=p.b.point;
|
||||
p.right.mouse(WM_MOUSEMOVE,mouse,1200);
|
||||
check(p.right.mode()==ControlMode::Receiving,"stationary untagged mouse notification does not cancel remote control");
|
||||
mouse.pt.x+=20;p.right.mouse(WM_MOUSEMOVE,mouse,1201);
|
||||
check(p.right.mode()==ControlMode::Local,"actual local mouse movement still takes control back");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.enter(1);p.b.delayed=true;auto before=p.b.events.back().mi.dx;
|
||||
for(int i=0;i<5;++i){auto point=p.a.point;point.x+=10;motion(p.left,p.a,point,1300+i);p.deliver(1300+i);}
|
||||
auto pixels=LONG(int64_t(p.b.events.back().mi.dx)*(p.b.rect.right-p.b.rect.left)/65536);
|
||||
check(pixels==58&&p.b.point.x==8&&p.b.events.back().mi.dx>before,
|
||||
"queued remote moves accumulate from entry edge even before Windows updates cursor position");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.b.delayed=true;
|
||||
motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);p.deliver(1100);
|
||||
check(p.left.mode()==ControlMode::Requesting,"source stays local until destination cursor actually reaches entry edge");
|
||||
p.right.tick(true,1400);p.deliver(1400);
|
||||
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"failed destination positioning returns both peers locally");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.a.delayed=true;p.enter(1);p.left.tick(true,1400);p.deliver(1400);
|
||||
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"missing source warp acknowledgement cannot suppress movement indefinitely");
|
||||
}
|
||||
{
|
||||
Pair p(1);p.a.delayed=true;p.enter(1);auto queued=p.ab.size();
|
||||
motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1200);
|
||||
check(p.ab.size()==queued,"pre-parking physical motion cannot become a giant centre-relative delta");
|
||||
MSLLHOOKSTRUCT warp{};warp.flags=LLMHF_INJECTED;warp.dwExtraInfo=cursorWarpMarker();
|
||||
p.left.mouse(WM_MOUSEMOVE,warp,1201);p.a.point={(p.a.rect.left+p.a.rect.right)/2,(p.a.rect.top+p.a.rect.bottom)/2};
|
||||
auto point=p.a.point;++point.x;motion(p.left,p.a,point,1202);
|
||||
check(p.ab.size()==queued+1,"source movement resumes after actual warp acknowledgement");
|
||||
}
|
||||
for(auto modifier:{VK_LCONTROL,VK_LMENU,VK_LSHIFT,VK_LWIN})
|
||||
{
|
||||
Pair p(1);p.enter(1);p.b.events.clear();
|
||||
auto mod=key(modifier,modifier==VK_LCONTROL?0x1d:modifier==VK_LMENU?0x38:modifier==VK_LSHIFT?0x2a:0x5b);
|
||||
if(modifier==VK_LWIN)mod.flags=LLKHF_EXTENDED;
|
||||
p.left.keyboard(mod,true,1200);p.left.keyboard(key('C',0x2e),true,1201);
|
||||
p.left.keyboard(key('C',0x2e),false,1202);p.left.keyboard(mod,false,1203);p.deliver(1204);
|
||||
check(p.b.events.size()==4&&p.b.events[0].ki.wVk==modifier&&p.b.events[1].ki.wVk=='C'&&
|
||||
!(p.b.events[0].ki.dwFlags&KEYEVENTF_KEYUP)&&(p.b.events[3].ki.dwFlags&KEYEVENTF_KEYUP),
|
||||
"modifier chord delivered in order with matching release");
|
||||
check(!(p.b.events[0].ki.dwFlags&KEYEVENTF_SCANCODE)&&!(p.b.events[3].ki.dwFlags&KEYEVENTF_SCANCODE),
|
||||
"modifier down and up use explicit Windows key identities");
|
||||
auto correction=key(VK_CONTROL,0x1d);correction.flags=LLKHF_INJECTED;correction.dwExtraInfo=ks::inputMarker();
|
||||
p.right.keyboard(correction,true,1210);p.right.keyboard(correction,false,1211);p.right.tick(true,1220);
|
||||
check(p.right.mode()==ControlMode::Receiving&&p.left.mode()==ControlMode::Sending,
|
||||
"generated correction keys do not take back remote ownership");
|
||||
}
|
||||
for(auto dpis:{std::pair{96,192},std::pair{240,96}})
|
||||
{
|
||||
Pair p(1);p.a.dpi=dpis.first;p.b.dpi=dpis.second;p.enter(1);auto before=p.b.point;
|
||||
for(int i=0;i<100;++i){auto point=p.a.point;++point.x;motion(p.left,p.a,point,1300+i);p.deliver(1300+i);}
|
||||
check(std::abs(p.b.point.x-before.x-100*dpis.second/dpis.first)<=1,"mouse movement scales between monitor DPIs without losing small deltas");
|
||||
}
|
||||
for(int side=0;side<4;++side)
|
||||
{
|
||||
Pair p(side);auto middle=edgePoint(p.a.rect,side,32768,0);
|
||||
@@ -145,6 +242,61 @@ void routerChecks()
|
||||
check(router.mode()==ControlMode::Local&&router.keyboard(key('Q'),false,1300),"queue failure returns control and consumes orphan release");
|
||||
}
|
||||
}
|
||||
|
||||
struct MeshFixture
|
||||
{
|
||||
struct Message{std::string from,to;InputFrame frame;};
|
||||
std::deque<Message> messages;Desktop desktops[3];std::unique_ptr<InputMesh> meshes[3];uint64_t now=1000;
|
||||
std::map<std::string,uint64_t> connections[3];
|
||||
MeshFixture(bool vertical=false){
|
||||
POINT cells[3]={{0,0},{1,0},vertical?POINT{1,-1}:POINT{2,0}};
|
||||
for(int i=0;i<3;++i){
|
||||
desktops[i].rect={0,0,800,600};desktops[i].point={400,300};std::map<std::string,POINT> positions;
|
||||
for(int j=0;j<3;++j)if(i!=j){auto name=std::to_string(j);positions[name]={cells[j].x-cells[i].x,cells[j].y-cells[i].y};connections[i][name]=1;}
|
||||
meshes[i]=std::make_unique<InputMesh>(std::to_string(i),desktops[i].environment(),positions,[this,i](auto &peer,auto f){messages.push_back({std::to_string(i),peer,f});return true;});
|
||||
}pump();
|
||||
}
|
||||
~MeshFixture(){for(auto &m:meshes)m.reset();}
|
||||
void pump(){for(int rounds=0;rounds<10;++rounds){
|
||||
while(!messages.empty()){auto m=messages.front();messages.pop_front();meshes[std::stoi(m.to)]->receive(m.from,m.frame,now);}
|
||||
for(int i=0;i<3;++i)meshes[i]->tick(connections[i],now);
|
||||
}}
|
||||
void move(int x,int y){++now;auto &d=desktops[0];auto old=d.point;MSLLHOOKSTRUCT m{};m.pt={old.x+x,old.y+y};d.point=m.pt;
|
||||
bool consumed=meshes[0]->mouse(WM_MOUSEMOVE,m,now);if(consumed&&meshes[0]->mode()==ControlMode::Sending)d.point=old;pump();}
|
||||
void enter(){desktops[0].point={798,300};move(1,0);check(meshes[0]->mode()==ControlMode::Sending&&meshes[1]->mode()==ControlMode::Receiving,"mesh enters first neighbor");}
|
||||
};
|
||||
void meshChecks()
|
||||
{
|
||||
{
|
||||
MeshFixture f;f.enter();f.move(900,0);
|
||||
check(f.meshes[0]->peer()=="2"&&f.meshes[2]->mode()==ControlMode::Receiving&&f.meshes[1]->mode()==ControlMode::Local,"A routes directly to C through B right edge");
|
||||
f.desktops[1].events.clear();f.desktops[2].events.clear();
|
||||
for(auto k:{VK_LSHIFT,int('A')})f.meshes[0]->keyboard(key(k),true,++f.now);
|
||||
for(auto k:{int('A'),VK_LSHIFT})f.meshes[0]->keyboard(key(k),false,++f.now);
|
||||
f.pump();
|
||||
check(f.desktops[1].keyEvents(true)==0&&f.desktops[2].keyEvents(true)==2&&f.desktops[2].keyEvents(false)==2,"modifier chord goes only to current destination C");
|
||||
f.move(-900,0);check(f.meshes[0]->peer()=="1"&&f.meshes[1]->mode()==ControlMode::Receiving,"C left edge returns through B");
|
||||
f.move(-900,0);check(f.meshes[0]->mode()==ControlMode::Local&&f.desktops[0].point.x>780,"B left edge restores source at its right edge");
|
||||
}
|
||||
{
|
||||
MeshFixture f(true);f.enter();f.move(0,-900);
|
||||
check(f.meshes[0]->peer()=="2"&&f.desktops[2].point.y>580,"vertical turn enters C at bottom edge");
|
||||
f.move(0,900);check(f.meshes[0]->peer()=="1","vertical return routes back to B");
|
||||
}
|
||||
{
|
||||
MeshFixture f;f.connections[0].erase("2");f.pump();f.enter();f.move(900,0);
|
||||
check(f.meshes[0]->peer()=="1"&&f.meshes[1]->mode()==ControlMode::Receiving,"offline neighbor blocks passage without losing current control");
|
||||
f.meshes[0]->keyboard(key(VK_LSHIFT),true,++f.now);f.pump();f.connections[0].erase("1");f.connections[1].erase("0");f.pump();
|
||||
check(f.meshes[0]->mode()==ControlMode::Local&&f.meshes[1]->mode()==ControlMode::Local&&f.desktops[1].keyEvents(false)==1,"disconnect restores local control and releases held modifier");
|
||||
}
|
||||
{
|
||||
MeshFixture f;f.enter();f.meshes[1]->receive("2",{InputType::Enter,99,1,20000},++f.now);
|
||||
check(f.meshes[1]->peer()=="0"&&f.meshes[1]->mode()==ControlMode::Receiving,"another controller cannot steal receiving desktop");f.pump();
|
||||
f.move(900,0);f.meshes[0]->receive("1",{InputType::Return,1,20000},++f.now);
|
||||
check(f.meshes[0]->mode()==ControlMode::Sending&&f.meshes[0]->peer()=="2","late previous peer return cannot cancel current destination");
|
||||
f.connections[0]["2"]=2;f.pump();check(f.meshes[0]->mode()==ControlMode::Local,"new connection generation does not continue stale ownership");
|
||||
}
|
||||
}
|
||||
uint16_t freePort()
|
||||
{
|
||||
SOCKET s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(s==INVALID_SOCKET)throw std::runtime_error("test socket");
|
||||
@@ -152,6 +304,46 @@ uint16_t freePort()
|
||||
if(bind(s,(sockaddr*)&a,sizeof(a))){closesocket(s);throw std::runtime_error("test bind");}
|
||||
int size=sizeof(a);getsockname(s,(sockaddr*)&a,&size);auto port=ntohs(a.sin_port);closesocket(s);return port;
|
||||
}
|
||||
|
||||
void hubChecks()
|
||||
{
|
||||
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
|
||||
struct Close{~Close(){WSACleanup();}} close;
|
||||
std::string ids[3]={std::string(32,'a'),std::string(32,'b'),std::string(32,'c')},secret(64,'f');
|
||||
uint16_t ports[3]={freePort(),freePort(),freePort()};
|
||||
std::unique_ptr<InputHub> hubs[3];std::vector<InputEndpoint> endpoints[3];std::vector<RoutedInput> incoming[3];
|
||||
for(int i=0;i<3;++i){hubs[i]=std::make_unique<InputHub>(ids[i],ports[i]);for(int j=0;j<3;++j)if(i!=j)endpoints[i].push_back({ids[j],secret,htonl(INADDR_LOOPBACK),ports[j]});}
|
||||
auto pump=[&](uint64_t offset=0){for(int i=2;i>=0;--i)hubs[i]->poll(endpoints[i],{},incoming[i],GetTickCount64()+offset);Sleep(1);};
|
||||
auto deadline=GetTickCount64()+5000;
|
||||
while((hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2||hubs[2]->ready().size()!=2)&&GetTickCount64()<deadline)pump();
|
||||
check(hubs[0]->ready().size()==2&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"three TCP hubs authenticate all independent pair channels on one listener per PC");
|
||||
auto generation=hubs[0]->ready();
|
||||
for(auto &list:endpoints)for(auto &e:list)e.address=0;
|
||||
for(int n=0;n<20;++n)pump();
|
||||
check(hubs[0]->ready()==generation&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"expired discovery does not destroy authenticated channels");
|
||||
for(auto &list:endpoints)for(auto &e:list)e.address=htonl(INADDR_LOOPBACK);
|
||||
InputFrame keyFrame{InputType::Key,7,'A',0,1},cross{InputType::Cross,8,2,30000};
|
||||
hubs[0]->poll(endpoints[0],{{ids[1],keyFrame},{ids[2],cross}},incoming[0],GetTickCount64());
|
||||
for(int n=0;n<100&&(incoming[1].empty()||incoming[2].empty());++n)pump();
|
||||
check(incoming[1].size()==1&&incoming[1][0].peer==ids[0]&&incoming[1][0].frame==keyFrame&&incoming[2].size()==1&&incoming[2][0].frame==cross,"hub routes encrypted frames only to their named destinations");
|
||||
for(auto &in:incoming)in.clear();
|
||||
auto survivor=hubs[0]->ready().at(ids[2]),old=hubs[0]->ready().at(ids[1]);
|
||||
hubs[1]->disconnect();for(int n=0;n<30;++n)pump();
|
||||
check(hubs[0]->ready().contains(ids[2])&&hubs[0]->ready().at(ids[2])==survivor,"one computer disconnect leaves other authenticated channel intact");
|
||||
for(int n=0;n<300&&(hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2);++n)pump(1800);
|
||||
check(hubs[0]->ready().size()==2&&hubs[0]->ready().at(ids[1])!=old,"mesh reconnect restores channel with fresh ownership generation");
|
||||
{
|
||||
auto serverPort=freePort();InputHub server(ids[2],serverPort),client(ids[0],freePort());
|
||||
std::vector<InputEndpoint> unknown{{ids[0],secret,0}},known{{ids[2],secret,htonl(INADDR_LOOPBACK),serverPort}};
|
||||
std::vector<RoutedInput> a,b;
|
||||
for(int n=0;n<300&&(server.ready().empty()||client.ready().empty());++n){
|
||||
server.poll(unknown,{},a,GetTickCount64());client.poll(known,{},b,GetTickCount64());Sleep(1);
|
||||
}
|
||||
check(server.ready().size()==1&&client.ready().size()==1,"saved pair listens and authenticates without receiving a discovery announcement");
|
||||
}
|
||||
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 channelChecks()
|
||||
{
|
||||
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
|
||||
@@ -167,6 +359,31 @@ void channelChecks()
|
||||
InputCipher otherSession(inputSessionKey(secret,a,b,randomHex(16),nb));rejects([&]{otherSession.decrypt(packet);},"old-session input rejected");
|
||||
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
|
||||
struct Close{~Close(){WSACleanup();}} close;
|
||||
{
|
||||
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||
std::vector<InputFrame> received;auto start=GetTickCount64();
|
||||
server.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||
auto generation=client.generation();
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,start+3000);
|
||||
check(client.generation()==generation,"initial handshake allows more than the active heartbeat timeout");
|
||||
for(int n=0;n<200&&(!client.ready()||!server.ready());++n)
|
||||
{
|
||||
auto now=GetTickCount64()+3000;
|
||||
server.poll(htonl(INADDR_LOOPBACK),{},received,now);client.poll(htonl(INADDR_LOOPBACK),{},received,now);Sleep(1);
|
||||
}
|
||||
check(client.ready()&&server.ready(),"delayed initial handshake still authenticates");
|
||||
}
|
||||
{
|
||||
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||
std::vector<InputFrame> received;auto start=GetTickCount64();
|
||||
server.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,start+100);
|
||||
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");
|
||||
}
|
||||
{
|
||||
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||
auto deadline=GetTickCount64()+5000;std::vector<InputFrame> received;
|
||||
@@ -182,7 +399,15 @@ void channelChecks()
|
||||
InputFrame reverse{InputType::Wheel,37,-120,1};server.poll(htonl(INADDR_LOOPBACK),{reverse},received,GetTickCount64());
|
||||
for(int n=0;n<100&&received.empty();++n){client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
|
||||
check(received.size()==1&&received[0]==reverse,"real input channel is bidirectional");
|
||||
auto generation=client.generation();client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64()+3000);
|
||||
received.clear();server.disconnect();
|
||||
client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());
|
||||
for(int n=0;n<300&&(!client.ready()||!server.ready());++n)
|
||||
{
|
||||
auto now=GetTickCount64()+1800;
|
||||
server.poll(htonl(INADDR_LOOPBACK),{},received,now);client.poll(htonl(INADDR_LOOPBACK),{},received,now);Sleep(1);
|
||||
}
|
||||
check(client.ready()&&server.ready(),"paired channel reconnects automatically after peer disconnect without new pairing");
|
||||
auto generation=client.generation();client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64()+5000);
|
||||
check(!client.ready()&&client.generation()!=generation,"heartbeat timeout closes channel and changes generation");
|
||||
}
|
||||
{
|
||||
@@ -201,6 +426,6 @@ void channelChecks()
|
||||
}
|
||||
int main()
|
||||
{
|
||||
try{channelChecks();routerChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
|
||||
try{channelChecks();hubChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
|
||||
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "settings_merge.h"
|
||||
#include <iostream>
|
||||
using namespace ks;
|
||||
int main(){try{
|
||||
Settings base;base.autoCorrect=false;base.toggle=L"Ctrl+Alt+F11";
|
||||
auto draft=base,current=base;draft.toggle=L"Ctrl+Alt+F12";current.autoCorrect=true;
|
||||
auto merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
|
||||
if(merged.toggle!=draft.toggle||!merged.autoCorrect)throw std::runtime_error("independent changes lost");
|
||||
current.toggle=L"Ctrl+Alt+F10";bool rejected=false;
|
||||
try{mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current));}
|
||||
catch(const std::exception &e){rejected=std::string(e.what()).starts_with("Settings conflict: ");}
|
||||
if(!rejected)throw std::runtime_error("same-field conflict silently overwritten");
|
||||
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current),true),{});
|
||||
if(merged.toggle!=draft.toggle||!merged.autoCorrect)throw std::runtime_error("confirmed edits discarded unrelated current changes");
|
||||
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(base),encodeSettings(current)),{});
|
||||
if(merged.toggle!=current.toggle||!merged.autoCorrect)throw std::runtime_error("unchanged draft reverted current settings");
|
||||
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";
|
||||
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "autocorrect.h"
|
||||
#include "history_preview.h"
|
||||
#include "ocr.h"
|
||||
#include "lan_clipboard_image.h"
|
||||
#include "richtext.h"
|
||||
#include "storage.h"
|
||||
#include "diagnostics.h"
|
||||
@@ -895,6 +896,33 @@ int main(int argc, char **argv)
|
||||
ks::validateTesseract(options);
|
||||
require(true, "Windows OCR works without optional installation");
|
||||
}
|
||||
{
|
||||
ks::History h(temp/L"computer-origins");auto clip=ks::textClip(L"shared text");
|
||||
h.add(clip,L"editor",settings);
|
||||
h.add(clip,L"editor",settings,"peer-a",L"Office PC");
|
||||
h.add(clip,L"editor",settings,"peer-a",L"Office PC");
|
||||
require(h.entries().size()==2,"duplicate suppression preserves different clipboard sources");
|
||||
ks::History restored(temp/L"computer-origins");
|
||||
require(restored.entries().front().computer=="peer-a"&&restored.entries().front().computerName==L"Office PC","computer metadata survives history reload");
|
||||
require(restored.entries().back().computer.empty(),"legacy and local records remain local");
|
||||
}
|
||||
{
|
||||
ks::Pixels pixels{1920,1080,ks::Bytes(1920*1080*4,255)};
|
||||
for(size_t i=0;i<pixels.bgra.size();i+=4){pixels.bgra[i]=static_cast<unsigned char>((i/4)%1920/16);pixels.bgra[i+1]=static_cast<unsigned char>((i/4)/1920/16);}
|
||||
ks::Clip original;original.formats.push_back(ks::imageDib(pixels));
|
||||
original.formats.push_back({RegisterClipboardFormatW(L"PNG"),L"PNG",ks::encodePng(pixels)});
|
||||
auto packed=ks::lan::compactClipboardImage(original);
|
||||
require(packed.formats.size()==1&&packed.formats[0].name==L"PNG","LAN sends only one image format");
|
||||
require(packed.size()*10<original.size(),"screen-like image LAN payload reduced by more than 90 percent");
|
||||
require(ks::decodeImage(packed).bgra==pixels.bgra,"LAN PNG preserves exact pixels");
|
||||
auto paste=ks::lan::clipboardImageForPaste(packed);
|
||||
require(std::any_of(paste.formats.begin(),paste.formats.end(),[](const auto &f){return f.id==CF_DIB||f.id==CF_DIBV5;}),"LAN receiver restores a DIB for ordinary paste");
|
||||
original.formats.pop_back();
|
||||
require(ks::decodeImage(ks::lan::compactClipboardImage(original)).bgra==pixels.bgra,"DIB-only clipboard is losslessly compressed");
|
||||
auto text=ks::textClip(L"plain text");
|
||||
require(ks::encodeClip(ks::lan::compactClipboardImage(text))==ks::encodeClip(text),"LAN text clipboard remains unchanged");
|
||||
std::cout<<"LAN screenshot bytes: "<<original.size()<<" -> "<<packed.size()<<"\n";
|
||||
}
|
||||
// Test-owned directory only; never touch the user's clipboard or stored history.
|
||||
std::filesystem::remove_all(temp);
|
||||
CoUninitialize();
|
||||
|
||||
Reference in new issue
Block a user