diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b6893f..17e1607 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ foreach(utilityTarget StayHere StayHereTest) target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++) endif() endforeach() -add_library(kscore src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp) +add_library(kscore src/lan_channel.cpp src/lan_input.cpp src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp) target_include_directories(kscore PUBLIC src PRIVATE vendor) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_link_libraries(kscore PUBLIC ws2_32 bcrypt iphlpapi winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) @@ -97,6 +97,14 @@ else() target_link_options(KSwitcherLanTests PRIVATE -static -static-libgcc -static-libstdc++) endif() add_test(NAME lan-pairing-regressions COMMAND KSwitcherLanTests) +add_executable(KSwitcherLanInputTests tests/lan_input.cpp) +target_link_libraries(KSwitcherLanInputTests PRIVATE kscore) +if(MSVC) + set_property(TARGET KSwitcherLanInputTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +else() + target_link_options(KSwitcherLanInputTests PRIVATE -static -static-libgcc -static-libstdc++) +endif() +add_test(NAME lan-input-regressions COMMAND KSwitcherLanInputTests) add_executable(KSwitcherChatProfileTests tests/chat_profiles.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherChatProfileTests PRIVATE kscore) if(MSVC) diff --git a/LAN-PREVIEW.md b/LAN-PREVIEW.md index 30a079f..a719dd0 100644 --- a/LAN-PREVIEW.md +++ b/LAN-PREVIEW.md @@ -1,79 +1,108 @@ -# LAN discovery and pairing preview +# LAN mouse and keyboard preview -This development branch starts the built-in replacement for Mouse Without Borders. -It adds discovery and authenticated pairing inside the existing KSwitcher resident -process. It is **not yet a mouse/keyboard or clipboard transport**. Do not disable -MWB on the strength of this preview. +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 preview 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. -## Try it on two Windows computers +## Set up two computers -1. Run this development build on both computers, on the same private IPv4 subnet. -2. Open Settings → Tools → KSwitcher computers. -3. Enable discovery on both. The list contains running LAN-enabled KSwitcher - instances, not computers returned by a generic Windows/network scan. -4. Match the computer name and eight-character instance-ID prefix displayed on - the other computer. Select each other in the lists. -5. Generate a code once. Enter that same 32-character code on both computers, - then click Pair on both within two minutes. Spaces, hyphens and uppercase - hexadecimal characters are accepted. A different code never establishes trust. -6. Both sides should report Paired; authenticated presence appears as - “Paired / verified”. An offline pair stays listed so it can be forgotten. +1. Extract this preview into a separate writable folder on each Windows PC. + Exit the regular KSwitcher before starting this copy; only one resident runs + per Windows session. Each copy keeps its own data beside the executable. +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. +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. -Discovery uses UDP 45883 on private IPv4 and link-local interfaces. Windows may -require an inbound firewall allowance for KSwitcher on the private network; the -preview does not modify firewall rules. Networks that block broadcasts, separate -VLANs, public IPv4 and IPv6-only networks are not supported yet. A second build -cannot listen on the same discovery port on the same computer. +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. -Changes in this window apply immediately. Closing the window does not cancel a -pending pairing; use Cancel pairing. Disabling discovery stops its worker and -closes its socket. Forget pair removes local trust; forget it on both computers -for bilateral removal. No input can be sent or received in this milestone. +**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 +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 +retried when the ordinary desktop is available. Source key-ups for keys already +sent remotely are consumed after return, avoiding stray local modifier events. -## Identity, pairing and storage +## Layout and limitations -An instance ID belongs to a KSwitcher data directory, not a hostname/IP address. -It persists when the LAN configuration is first saved. Discovery announcements -contain only protocol/version, instance ID, computer name and a fresh challenge. -Discovered names and addresses are untrusted; they never authorize input. +- 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. +- 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 + can have gaps on an outer edge; this is not a per-monitor arrangement editor. +- Transferring while a key or mouse button is held is intentionally blocked. + Dragging works within the controlled computer; dragging objects across PCs is + not implemented. There is a short return cooldown to avoid bouncing. +- Windows can block SendInput to elevated apps and secure desktops. Run both + applications at suitable privilege levels for the target programs. The preview + does not install a service, bypass UAC or implement remote Ctrl+Alt+Delete. +- This is Windows input injection, not a virtual USB HID device. Raw-input games, + exclusive full-screen applications and special device keys need separate testing. -The generated code has 128 bits of randomness from Windows CNG. It is deliberately -not a guessable short PIN. HMAC-SHA256 proofs bind the source/destination IDs and -fresh nonces; both endpoints confirm the handshake before saving the pair. -Replayed confirmations cannot complete a new attempt. The shared pair key is -separate from the setup code. The code is never transmitted or logged. +## Network, authentication and storage -Local identity, enablement and trust records are stored in DPAPI-protected -`data/lan-pairing.bin`. They are separate from settings export and OpenBao sync. -Do not clone this file between installations: each instance needs its own identity. -Unverified/offline status does not remove a previously saved pair. Discovery is -bounded to 64 visible instances and stale discoveries expire after 12 seconds. -A newly started pairing window lasts two minutes. Presence challenges rotate so -captured replies cannot indefinitely keep a disconnected instance verified. +Discovery broadcasts on private IPv4 and link-local interfaces using UDP 45883. +Input uses TCP 45884. Allow KSwitcher on your private network when Windows asks; +the preview does not change firewall rules. Broadcast-blocked networks, separate +VLANs, public IPv4 and IPv6-only networks are not supported by discovery yet. +A second LAN-enabled KSwitcher cannot use the same ports on one computer. -Disabled discovery starts no worker and opens no sockets. Enabling it adds one -worker thread within KSwitcher; the existing configurator uses local IPC. There -is no new executable, service, WebView or runtime dependency. +The instance ID belongs to a data directory, not a hostname/IP address. Discovered +names and addresses are untrusted. Pairing uses a generated 128-bit code and +mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never +transmitted or logged. The pair key is separate from the setup code. -## Next integration milestone +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 +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 +key-up and leaving a key pressed. The input worker never injects Windows input; +the resident's main thread owns the input state and hooks. -- An authenticated encrypted input channel using the stored pair key, separate - from UDP discovery. Pairing alone must never inject input. -- Left/right/top/bottom screen placement and cursor crossing at the screen edge - from the first input prototype, not hotkey-based normal switching. -- Reuse the resident low-level hooks. Forwarded keys must not expand snippets or - invoke actions on the source PC; received keys run through the destination path. -- Release pressed keys/buttons and return local control on disconnection, timeout, - suspend, lock, disable or process shutdown. Keep an emergency return chord. -- Text/image clipboard transport with bounded sizes and loop suppression follows - reliable input handoff. File transfer and secure-desktop control are later work. +Identity, trust, enablement and screen position stay in DPAPI-protected +`data/lan-pairing.bin`, outside settings export and OpenBao sync. Do not clone this +file between installations. Forget a pair on both PCs for bilateral removal. +Closing the settings window does not cancel pairing or disable sharing. + +Disabled discovery has no socket or worker. Input sharing reuses the single LAN +worker; there is no extra resident executable or service. The main input timer +runs only while sharing is enabled. The existing configurator talks to the +resident through local IPC. ## Validation -`KSwitcherLanTests` covers cryptographic test vectors, wrong codes, identity and -protocol substitution, mutual confirmation, replay, expiry, DPAPI persistence, -and no worker while disabled. `--network` additionally exercises real discovery -start/stop/rebind. Settings UI tests cover the child window, disabled actions, -empty list, parent reactivation and unchanged Save state. Two physical computers -are still required to validate discovery across adapters/firewalls and pairing -under real packet loss; this preview does not claim that validation. +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. + +Two 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. diff --git a/README.md b/README.md index 5349656..2121853 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,19 @@ The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data r ## Development preview: KSwitcher computers -The current development branch adds opt-in LAN discovery and same-code pairing -inside the resident process. See [LAN-PREVIEW.md](LAN-PREVIEW.md) for setup and -limits. This preview does not yet forward mouse/keyboard input or clipboard data. -The planned input mode switches both mouse and keyboard together at screen edges. +The development build 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. -**Українською.** У поточній гілці розробки додано пошук запущених екземплярів -KSwitcher у локальній мережі та спарювання однаковим кодом на обох ПК. Модуль -вбудований у резидентний процес і за замовчуванням вимкнений. Передача миші, -клавіатури й буфера ще не підключена. Миша та клавіатура перемикатимуться разом -при переході курсора через край екрана. +**Українською.** У dev-збірці є пошук KSwitcher у мережі, спарювання однаковим +кодом і передача миші та клавіатури для однієї активної пари ПК. Укажіть, де +стоїть сусідній ПК: ліворуч, праворуч, зверху або знизу. Курсор і клавіатура +перемикаються разом через відповідний край екрана. Ctrl+Alt+Esc аварійно повертає +керування. Передача буфера та файлів ще не реалізована; перевірка на двох +фізичних ПК ще потрібна. ## Run diff --git a/build.ps1 b/build.ps1 index 7b3f15f..f16fff3 100644 --- a/build.ps1 +++ b/build.ps1 @@ -22,7 +22,7 @@ try { & $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere if ($LASTEXITCODE) { throw 'StayHere build failed' } Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force - foreach ($name in @('lan','lan_pairing','core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) { + foreach ($name in @('lan_channel','lan_input','lan','lan_pairing','core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) { & $compiler @common -c "src/$name.cpp" -o "build/$name.o" if ($LASTEXITCODE) { throw "Compilation failed: $name" } } @@ -35,19 +35,23 @@ try { & windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' } New-Item -ItemType Directory -Force build/release | Out-Null - & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath if ($LASTEXITCODE) { throw 'Link failed' } Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force $configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe' - & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath if ($LASTEXITCODE) { throw 'Configurator link failed' } Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force $screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe' - & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath if ($LASTEXITCODE) { throw 'Screenshot link failed' } Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force if ($Test) { - & $compiler @common -Werror tests/lan.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe + & $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 + if ($LASTEXITCODE) { throw 'LAN input tests failed' } + & $compiler @common -Werror tests/lan.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe if ($LASTEXITCODE) { throw 'LAN test build failed' } & .\build\KSwitcherLanTests.exe if ($LASTEXITCODE) { throw 'LAN pairing tests failed' } @@ -59,11 +63,11 @@ try { if ($LASTEXITCODE) { throw 'Utilities test build failed' } & .\build\KSwitcherUtilitiesTests.exe if ($LASTEXITCODE) { throw 'Utilities tests failed' } - & $compiler @common tests/dictionary.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe + & $compiler @common tests/dictionary.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe if ($LASTEXITCODE) { throw 'Dictionary test build failed' } & .\build\KSwitcherDictionaryTests.exe if ($LASTEXITCODE) { throw 'Dictionary tests failed' } - & $compiler @common tests/sync.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe + & $compiler @common tests/sync.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe if ($LASTEXITCODE) { throw 'Sync test build failed' } & .\build\KSwitcherSyncTests.exe if ($LASTEXITCODE) { throw 'Sync tests failed' } @@ -71,19 +75,19 @@ try { if ($LASTEXITCODE) { throw 'Scroll test build failed' } & .\build\KSwitcherScrollTests.exe if ($LASTEXITCODE) { throw 'Scroll tests failed' } - & $compiler @common tests/tests.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe + & $compiler @common tests/tests.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe if ($LASTEXITCODE) { throw 'Test build failed' } & .\build\KSwitcherTests.exe if ($LASTEXITCODE) { throw 'Tests failed' } - & $compiler @common tests/input.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe + & $compiler @common tests/input.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input test build failed' } & .\build\KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input tests failed' } - & $compiler @common tests/chat_profiles.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe + & $compiler @common tests/chat_profiles.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe if ($LASTEXITCODE) { throw 'Chat profile test build failed' } & .\build\KSwitcherChatProfileTests.exe if ($LASTEXITCODE) { throw 'Chat profile tests failed' } - & $compiler @common tests/screenshot_ui.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe + & $compiler @common tests/screenshot_ui.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' } & .\build\KSwitcherScreenshotUITests.exe if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' } @@ -91,7 +95,7 @@ try { if ($LASTEXITCODE) { throw 'Screenshot save tests failed' } $registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe' try { - & $compiler @common tests/screenshot_registration.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest + & $compiler @common tests/screenshot_registration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' } & $registrationTest if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' } @@ -103,7 +107,7 @@ try { if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' } } if ($Far) { - & $compiler @common tests/far_snippets.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe + & $compiler @common tests/far_snippets.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe if ($LASTEXITCODE) { throw 'Far snippet test build failed' } $fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log') @@ -115,28 +119,28 @@ try { Copy-Item $executablePath,(Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe') -Destination $historyFixture Set-Content (Join-Path $historyFixture '.local-dev') 'Isolated history integration test' $historyTest = Join-Path $historyFixture 'KSwitcherHistoryHostTests.exe' - & $compiler @common tests/history_host.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest + & $compiler @common tests/history_host.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest if ($LASTEXITCODE) { throw 'History host test build failed' } & $historyTest if ($LASTEXITCODE) { throw 'History host tests failed' } $snapshotTest = Join-Path (Split-Path $executablePath) 'KSwitcherClipboardSnapshotTests.exe' - & $compiler @common tests/clipboard_snapshot.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest + & $compiler @common tests/clipboard_snapshot.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest if ($LASTEXITCODE) { throw 'Clipboard snapshot test build failed' } & $snapshotTest if ($LASTEXITCODE) { throw 'Clipboard snapshot tests failed' } $captureTest = Join-Path (Split-Path $executablePath) 'KSwitcherImageCaptureTests.exe' - & $compiler @common tests/image_capture.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest + & $compiler @common tests/image_capture.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest if ($LASTEXITCODE) { throw 'Image capture test build failed' } & $captureTest if ($LASTEXITCODE) { throw 'Image capture tests failed' } - & $compiler @common tests/integration.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe + & $compiler @common tests/integration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration test build failed' } & .\build\KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration tests failed' } } if ($Ocr) { $ocrTest = Join-Path (Split-Path $executablePath) 'KSwitcherOcrTests.exe' - & $compiler @common tests/ocr.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest + & $compiler @common tests/ocr.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest if ($LASTEXITCODE) { throw 'OCR test build failed' } & $ocrTest if ($LASTEXITCODE) { throw 'OCR tests failed' } diff --git a/src/app.cpp b/src/app.cpp index 9d836bf..40b9ea7 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -725,7 +725,11 @@ bool App::registerHotkeys(const Settings &s, bool rollback) Bytes App::lanCommand(const Bytes &command) { if (!lanService) lanService = std::make_unique(data); - return lanService->command(command); + auto reply = lanService->command(command); + if (lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr); + else KillTimer(window, 7); + if (lanService->takeInputChange()) resetInput(); + return reply; } void App::applySettings(const Settings &requested) { @@ -1124,6 +1128,13 @@ void App::replayTyping() } LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp) { + if (code == HC_ACTION && current && current->lanService && + (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 (consumed) return 1; // Forwarded keys must not reach local hotkeys/snippets or other routers. + } // MWB suppresses source-machine events that it forwards elsewhere. Let // downstream hooks route the event before tracking it or starting a snippet. // Call the chain exactly once; a local event can still be consumed below. @@ -1153,6 +1164,13 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp) { if (code == HC_ACTION && current) { + if (current->lanService) + { + bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp, + !current->busy && !current->suspended && !current->hotkeyCapture); + if (current->lanService->takeInputChange()) current->resetInput(); + if (consumed) return 1; + } // KSwitcher generates no mouse input. Remote clicks invalidate a typing // range just like local clicks, including while a transaction is pending. if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL) @@ -2127,6 +2145,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_HOTKEY: app->dispatch((int)wp); return 0; + case WM_POWERBROADCAST: + if (wp == PBT_APMSUSPEND && app->lanService) + { + app->lanService->cancelInput(); + app->resetInput(); + } + return TRUE; case ReplayTypingMessage: app->replayTyping(); return 0; @@ -2198,6 +2223,15 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) return TRUE; } 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(); + } + return 0; + } if (wp == 6) { if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0) @@ -2341,6 +2375,7 @@ int App::execute() if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin")) try { lanService = std::make_unique(data); } 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); if (!smoke) SetTimer(window, 6, 1000, nullptr); SetThreadExecutionState(ES_CONTINUOUS | diff --git a/src/lan.cpp b/src/lan.cpp index 8fb2e31..bef32b7 100644 --- a/src/lan.cpp +++ b/src/lan.cpp @@ -3,6 +3,7 @@ #include #include "lan.h" #include "lan_pairing.h" +#include "lan_input.h" #include #include #include @@ -55,6 +56,17 @@ struct LanService::Impl std::atomic stopping{false}; std::mutex mutex; std::thread worker; + bool shareInput{}; + std::string inputPeer; + int inputEdge=1; + std::atomic inputConnected{false},abortInput{false}; + std::atomic inputEpoch{0}; + std::atomic inputConfiguration{0}; + uint64_t observedEpoch{},observedRevision{}; + std::mutex inputMutex; + std::vector inputOut,inputIn; + std::unique_ptr router; + std::string inputStatus="Input sharing disabled."; explicit Impl(const std::filesystem::path &root) : path(root/L"lan-pairing.bin") { if(std::filesystem::exists(path)) @@ -62,6 +74,8 @@ struct LanService::Impl 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"); + 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(!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().size()!=64|| @@ -69,10 +83,43 @@ struct LanService::Impl throw std::runtime_error("Invalid paired computer key."); } else id=lan::randomHex(16); + configureRouter(); if(enabled) start(); } ~Impl() { stop(); } - void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted}}))); } + 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) + { + if(!inputConnected)return false; + std::lock_guard lock(inputMutex); + if(inputOut.size()>=512){abortInput=true;return false;} + inputOut.push_back(frame);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::windowsInputEnvironment(),[this](auto f){return queue(f);},inputEdge,idrevision():0;bool changed=revision!=observedRevision;observedRevision=revision;return changed; + } + bool tickInput(bool allowEnter) + { + if(!router)return false; + auto epoch=inputEpoch.load();if(epoch!=observedEpoch){router->cancel(GetTickCount64());observedEpoch=epoch;} + router->tick(inputConnected,GetTickCount64()); + std::vector 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()); + return takeChange(); + } void start() { stopping=false; epoch=lan::randomHex(16); status="Searching for KSwitcher computers..."; @@ -80,8 +127,11 @@ struct LanService::Impl } void stop() { + if(router)router->cancel(GetTickCount64()); 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(); } void send(SOCKET socket,const sockaddr_in &address,const Json &j) @@ -128,7 +178,7 @@ struct LanService::Impl auto previous=trusted; trusted[other]={{"name",peer.name},{"key",pending->derived}}; try {save();} catch(...) {trusted=std::move(previous);throw;} - peer.verified=now; status="Paired. Input and clipboard sharing are not enabled in this preview."; + peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs."; } } } @@ -145,12 +195,21 @@ struct LanService::Impl throw std::runtime_error("Cannot open LAN discovery socket."); 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; + uint64_t nextHello=0,nextOffer=0,lastChannelGeneration=0; + std::unique_ptr channel; + std::string channelPeer,channelSecret;int channelEdge=-1; while(!stopping) { auto now=GetTickCount64(); + std::string selectedPeer,selectedSecret;int selectedEdge=-1;uint32_t selectedAddress=0;uint64_t configuration=0; { 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; + } if(now>=nextHello) { epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online. @@ -169,7 +228,30 @@ struct LanService::Impl send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000; } } - fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,100000}; + 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(id,selectedPeer,selectedSecret,selectedEdge); + } + if(channel) + { + std::vector 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(); + } + 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();} + } + fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,channel?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) @@ -184,6 +266,8 @@ struct LanService::Impl } } catch(const std::exception &e) { std::lock_guard lock(mutex); status=std::string("LAN stopped: ")+e.what(); } + inputConnected=false;++inputEpoch; + {std::lock_guard lock(mutex);if(shareInput)inputStatus="Input connection closed.";} if(socket!=INVALID_SOCKET) closesocket(socket); if(winsock) WSACleanup(); } @@ -195,9 +279,19 @@ struct LanService::Impl stop(); bool previous=enabled; enabled=j.at("enabled").get(); try {save();} catch(...) {enabled=previous;if(enabled) start();throw;} + configureRouter(); if(enabled) start(); else status="Disabled. No discovery sockets or worker thread."; } std::lock_guard lock(mutex); + 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; + shareInput=active;inputPeer=peer;inputEdge=edge; + try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;throw;} + configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled."; + } if(action=="code") return encoded({{"code",lan::randomHex(16)}}); if(action=="cancel") { pending.reset(); status="Pairing cancelled."; } if(action=="pair") @@ -208,8 +302,18 @@ struct LanService::Impl pending=std::make_unique(id,peer,j.at("code").get(),GetTickCount64()); status="Waiting for the same code and Pair on the other KSwitcher (2 minutes)..."; } - if(action=="forget") { auto peer=j.at("peer").get(); trusted.erase(peer); if(pending&&pending->peer==peer) pending.reset(); save(); status="Pair removed on this computer."; } - Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()}}; + if(action=="forget") + { + auto peer=j.at("peer").get();auto oldTrusted=trusted;bool oldSharing=shareInput; + trusted.erase(peer);if(peer==inputPeer)shareInput=false; + try{save();}catch(...){trusted=std::move(oldTrusted);shareInput=oldSharing;throw;} + if(pending&&pending->peer==peer)pending.reset(); + if(peer==inputPeer)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()}, + {"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"controlMode",router?int(router->mode()):0}}; auto now=GetTickCount64(); for(const auto &[peer,p]:peers) { @@ -225,4 +329,10 @@ struct LanService::Impl LanService::LanService(const std::filesystem::path &path) : impl(std::make_unique(path)) {} LanService::~LanService()=default; Bytes LanService::command(const Bytes &command) { return impl->command(command); } +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());} +bool LanService::mouse(UINT message,const MSLLHOOKSTRUCT &m,bool allowEnter){return impl->router&&impl->router->mouse(message,m,GetTickCount64(),allowEnter);} +bool LanService::takeInputChange(){return impl->takeChange();} +void LanService::cancelInput(){if(impl->router)impl->router->cancel(GetTickCount64());impl->abortInput=true;} } diff --git a/src/lan.h b/src/lan.h index b38995c..089e1b0 100644 --- a/src/lan.h +++ b/src/lan.h @@ -4,7 +4,7 @@ namespace ks { -// Local-only discovery and pairing. No input/clipboard receiver is exposed yet. +// Discovery, pairing and optional encrypted input within the resident process. class LanService { struct Impl; @@ -13,5 +13,11 @@ class LanService explicit LanService(const std::filesystem::path &); ~LanService(); Bytes command(const Bytes &); + bool inputEnabled() const; + bool tickInput(bool allowEnter = true); + bool keyboard(const KBDLLHOOKSTRUCT &, bool down); + bool mouse(UINT, const MSLLHOOKSTRUCT &, bool allowEnter); + bool takeInputChange(); + void cancelInput(); }; } diff --git a/src/lan_channel.cpp b/src/lan_channel.cpp new file mode 100644 index 0000000..aa0867d --- /dev/null +++ b/src/lan_channel.cpp @@ -0,0 +1,185 @@ +#include +#include +#include "lan_channel.h" +#include +#include + +namespace ks::lan +{ +namespace +{ +void checked(NTSTATUS s){if(s<0) throw std::runtime_error("Input channel authentication failed.");} +void put32(Bytes &b,uint32_t v){for(int n=24;n>=0;n-=8)b.push_back(static_cast(v>>n));} +uint32_t get32(const unsigned char *p){return (uint32_t(p[0])<<24)|(uint32_t(p[1])<<16)|(uint32_t(p[2])<<8)|p[3];} +Bytes keyBytes(const std::string &key) +{ + if(key.size()!=64||key.find_first_not_of("0123456789abcdef")!=key.npos) throw std::runtime_error("Invalid input key."); + Bytes bytes;for(size_t i=0;i(std::stoul(key.substr(i,2),nullptr,16)));return bytes; +} +bool nonblocking(SOCKET s){u_long value=1;BOOL yes=TRUE;return !ioctlsocket(s,FIONBIO,&value)&&!setsockopt(s,IPPROTO_TCP,TCP_NODELAY,(char*)&yes,sizeof(yes));} +void closeSocket(SOCKET &s){if(s!=INVALID_SOCKET){closesocket(s);s=INVALID_SOCKET;}} +} +struct InputCipher::Impl +{ + BCRYPT_ALG_HANDLE alg{};BCRYPT_KEY_HANDLE key{};uint64_t sequence{}; + explicit Impl(const std::string &secret) + { + auto bytes=keyBytes(secret); + try { + checked(BCryptOpenAlgorithmProvider(&alg,BCRYPT_AES_ALGORITHM,nullptr,0)); + checked(BCryptSetProperty(alg,BCRYPT_CHAINING_MODE,(PUCHAR)BCRYPT_CHAIN_MODE_GCM,sizeof(BCRYPT_CHAIN_MODE_GCM),0)); + checked(BCryptGenerateSymmetricKey(alg,&key,nullptr,0,bytes.data(),ULONG(bytes.size()),0)); + } catch(...) {if(key)BCryptDestroyKey(key);if(alg)BCryptCloseAlgorithmProvider(alg,0);throw;} + SecureZeroMemory(bytes.data(),bytes.size()); + } + ~Impl(){if(key)BCryptDestroyKey(key);if(alg)BCryptCloseAlgorithmProvider(alg,0);} + Bytes crypt(Bytes payload,Bytes &tag,bool encrypt) + { + if(sequence==std::numeric_limits::max())throw std::runtime_error("Input session exhausted."); + unsigned char nonce[12]{};for(int i=0;i<8;++i)nonce[11-i]=static_cast(sequence>>(i*8)); + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info;BCRYPT_INIT_AUTH_MODE_INFO(info); + info.pbNonce=nonce;info.cbNonce=sizeof(nonce);info.pbTag=tag.data();info.cbTag=ULONG(tag.size()); + // Protocol/direction are bound by key derivation; sequence and payload length by GCM. + Bytes result(payload.size());ULONG written{}; + auto fn=encrypt?BCryptEncrypt:BCryptDecrypt; + checked(fn(key,payload.data(),ULONG(payload.size()),&info,nullptr,0,result.data(),ULONG(result.size()),&written,0)); + if(written!=payload.size())throw std::runtime_error("Invalid input frame size."); + ++sequence;return result; + } +}; +InputCipher::InputCipher(const std::string &key):impl(std::make_unique(key)){} +InputCipher::~InputCipher()=default; +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)); + Bytes tag(16);auto result=impl->crypt(std::move(plain),tag,true);result.insert(result.end(),tag.begin(),tag.end());return result; +} +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."); + 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) +{ + if(!identifier(from)||!identifier(to)||from==to||!identifier(a)||!identifier(b))throw std::runtime_error("Invalid session identity."); + return mac(secret,"KSwitcher input v1|"+from+"|"+to+"|"+a+"|"+b+"|"+std::to_string(edge)); +} +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{}; + uint64_t changed{},lastRead{},lastPing{},retry{}; + Bytes incoming,outgoing; + std::unique_ptr 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(){closeSocket(socket);closeSocket(listener);} + void reset(uint64_t now,const std::string &reason) + { + closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;state=reason; + } + 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(); + put32(outgoing,uint32_t(hello.size()));outgoing.insert(outgoing.end(),hello.begin(),hello.end()); + state="Authenticating paired KSwitcher..."; + } + void poll(uint32_t address,const std::vector &frames,std::vector &received,uint64_t now) + { + try + { + if(socket==INVALID_SOCKET&&nowpeer) + { + 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.");} + sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port); + if(bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,1)) {closeSocket(listener);throw std::runtime_error("TCP input port is unavailable.");} + } + if(socket==INVALID_SOCKET && address && now>=retry) + { + if(local2000)throw std::runtime_error("Input connection timed out; control returned locally."); + 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; + } + if(authenticated) + { + for(auto &f:frames){auto bytes=tx->encrypt(f);outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());} + if(now-lastPing>=400){auto bytes=tx->encrypt({InputType::Ping});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());lastPing=now;} + } + if(outgoing.size()>32768)throw std::runtime_error("Input queue overflow; control returned locally."); + if(!outgoing.empty()) + { + int count=::send(socket,(char*)outgoing.data(),int(outgoing.size()),0); + if(count>0)outgoing.erase(outgoing.begin(),outgoing.begin()+count); + else if(count==0||WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Input send failed."); + } + unsigned char data[4096]; + for(int batch=0;batch<8;++batch) + { + int count=recv(socket,(char*)data,sizeof(data),0); + if(count==0)throw std::runtime_error("Other KSwitcher disconnected."); + if(count<0){if(WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Input receive failed.");break;} + incoming.insert(incoming.end(),data,data+count); + } + if(incoming.size()>32768)throw std::runtime_error("Input receive queue overflow."); + if(!helloRead && incoming.size()>=4) + { + size_t length=get32(incoming.data());if(length>512||length<32)throw std::runtime_error("Invalid input handshake."); + if(incoming.size()(inputSessionKey(secret,local,peer,nonce,remoteNonce,edge)); + rx=std::make_unique(inputSessionKey(secret,peer,local,remoteNonce,nonce,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()); + } + while(helloRead && incoming.size()>=36) + { + auto f=rx->decrypt(Bytes(incoming.begin(),incoming.begin()+36));incoming.erase(incoming.begin(),incoming.begin()+36);lastRead=now; + if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;state="Input connected. Move through the configured screen edge.";} + else if(f.type==InputType::Ready)throw std::runtime_error("Repeated input handshake."); + else if(f.type!=InputType::Ping)received.push_back(f); + } + } + 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(std::move(a),std::move(b),std::move(key),edge,port)){} +InputChannel::~InputChannel()=default; +void InputChannel::poll(uint32_t ip,const std::vector&out,std::vector&in,uint64_t now){impl->poll(ip,out,in,now);} +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.");} +} diff --git a/src/lan_channel.h b/src/lan_channel.h new file mode 100644 index 0000000..f4c243c --- /dev/null +++ b/src/lan_channel.h @@ -0,0 +1,40 @@ +#pragma once +#include "lan_pairing.h" +#include +namespace ks::lan +{ +enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel }; +struct InputFrame +{ + InputType type{}; + uint32_t token{}; + int32_t a{},b{},c{}; + bool operator==(const InputFrame &) const = default; +}; +// One directional AES-256-GCM key per session. Strict sequence numbers prevent replay. +class InputCipher +{ + struct Impl; std::unique_ptr impl; + public: + explicit InputCipher(const std::string &hexKey); + ~InputCipher(); + Bytes encrypt(const InputFrame &); + InputFrame decrypt(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); +// Network-thread-only nonblocking TCP channel. No Windows input is injected here. +class InputChannel +{ + struct Impl; std::unique_ptr impl; + public: + InputChannel(std::string local,std::string peer,std::string secret,int edge, + uint16_t port=45884); + ~InputChannel(); + void poll(uint32_t peerAddress,const std::vector &out,std::vector &in,uint64_t now); + bool ready() const; + uint64_t generation() const; + std::string status() const; + void disconnect(); +}; +} diff --git a/src/lan_input.cpp b/src/lan_input.cpp new file mode 100644 index 0000000..55fa16a --- /dev/null +++ b/src/lan_input.cpp @@ -0,0 +1,242 @@ +#include "lan_input.h" +#include + +namespace ks::lan +{ +ULONG_PTR remoteInputMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e49);} +ULONG_PTR cursorWarpMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e57);} +namespace +{ +LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp) +{ + if(message==WM_SETCURSOR){SetCursor(nullptr);return TRUE;} + if(message==WM_MOUSEACTIVATE)return MA_NOACTIVATE; + if(message==WM_PAINT) + { + PAINTSTRUCT paint{};HDC dc=BeginPaint(w,&paint);RECT r{};GetClientRect(w,&r); + FillRect(dc,&r,(HBRUSH)(COLOR_INFOBK+1));SetBkMode(dc,TRANSPARENT);SetTextColor(dc,GetSysColor(COLOR_INFOTEXT)); + auto previous=SelectObject(dc,GetStockObject(DEFAULT_GUI_FONT)); + DrawTextW(dc,L"KSwitcher: mouse + keyboard on the other PC\nCtrl+Alt+Esc returns control here",-1,&r,DT_CENTER|DT_WORDBREAK); + SelectObject(dc,previous);EndPaint(w,&paint);return 0; + } + return DefWindowProcW(w,message,wp,lp); +} +struct ControlOverlay +{ + HWND cover{},notice{}; + ~ControlOverlay(){hide();} + void hide(){if(notice)DestroyWindow(notice);if(cover)DestroyWindow(cover);notice=cover=nullptr;} + void show() + { + if(cover)return; + WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay"; + 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); + 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); + } +}; +bool ordinaryDesktop() +{ + HDESK d=OpenInputDesktop(0,FALSE,DESKTOP_READOBJECTS);if(!d)return false; + wchar_t name[256]{};DWORD count{};bool ok=GetUserObjectInformationW(d,UOI_NAME,name,sizeof(name),&count)!=0; + CloseDesktop(d);return ok&&_wcsicmp(name,L"Default")==0; +} +INPUT button(unsigned index,bool down) +{ + INPUT input{};input.type=INPUT_MOUSE; + static constexpr DWORD flags[5][2]={{MOUSEEVENTF_LEFTUP,MOUSEEVENTF_LEFTDOWN},{MOUSEEVENTF_RIGHTUP,MOUSEEVENTF_RIGHTDOWN}, + {MOUSEEVENTF_MIDDLEUP,MOUSEEVENTF_MIDDLEDOWN},{MOUSEEVENTF_XUP,MOUSEEVENTF_XDOWN},{MOUSEEVENTF_XUP,MOUSEEVENTF_XDOWN}}; + input.mi.dwFlags=flags[index][down];if(index>=3)input.mi.mouseData=index==3?XBUTTON1:XBUTTON2;return input; +} +} +InputEnvironment windowsInputEnvironment() +{ + auto overlay=std::make_shared(); + return { + []{LONG x=GetSystemMetrics(SM_XVIRTUALSCREEN),y=GetSystemMetrics(SM_YVIRTUALSCREEN);return RECT{x,y,x+GetSystemMetrics(SM_CXVIRTUALSCREEN),y+GetSystemMetrics(SM_CYVIRTUALSCREEN)};}, + []{POINT p{};GetCursorPos(&p);return p;}, + [](const INPUT &i){INPUT input=i;return SendInput(1,&input,sizeof(input))==1;}, + ordinaryDesktop, + []{for(int key=1;key<256;++key)if(GetAsyncKeyState(key)&0x8000)return true;return false;}, + []{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();} + }; +} +bool atEdge(POINT p,RECT r,int side) +{return side==0?p.x<=r.left:side==1?p.x>=r.right-1:side==2?p.y<=r.top:p.y>=r.bottom-1;} +int edgePosition(POINT p,RECT r,int side) +{ + auto length=side<2?r.bottom-r.top:r.right-r.left; + auto offset=side<2?p.y-r.top:p.x-r.left; + return int(std::clamp(int64_t(offset)*65535/std::max(1,length-1),0,65535)); +} +POINT edgePoint(RECT r,int side,int position,int inset) +{ + position=std::clamp(position,0,65535); + LONG x=r.left+LONG(int64_t(position)*std::max(0,r.right-r.left-1)/65535); + LONG y=r.top+LONG(int64_t(position)*std::max(0,r.bottom-r.top-1)/65535); + 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 s,int side,bool prefer) + :env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),nextToken(prefer?1:2) +{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);}} +bool InputRouter::emit(INPUT i) +{ + if(i.type==INPUT_KEYBOARD)i.ki.dwExtraInfo=remoteInputMarker(); + else i.mi.dwExtraInfo=remoteInputMarker(); + return env.desktop()&&env.inject(i); +} +bool InputRouter::move(POINT p,bool warp) +{ + auto r=env.bounds();if(r.right<=r.left||r.bottom<=r.top)return false; + INPUT input{};input.type=INPUT_MOUSE;input.mi.dwFlags=MOUSEEVENTF_MOVE|MOUSEEVENTF_ABSOLUTE|MOUSEEVENTF_VIRTUALDESK; + // 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); +} +bool InputRouter::post(InputFrame f){return connected&&send(f);} +bool InputRouter::held()const +{ + return std::any_of(physical.begin(),physical.end(),[](bool b){return b;})||env.held(); +} +void InputRouter::release() +{ + for(size_t i=0;i1000))cancel(now); + if(mode_==ControlMode::Local)release(); // Retry releases blocked by a temporarily secure desktop. +} +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()|| + 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); + 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); + return; + } + if(f.token!=token||!token)return; + if(f.type==InputType::Return){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(p.x)+f.a,r.left,r.right-1));p.y=LONG(std::clamp(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;})&& + !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); + } + 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; + 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) + { + if(f.a<0||f.a>4||f.b<0||f.b>1){cancel(now);return;} + ok=emit(button(unsigned(f.a),f.b!=0));if(ok)remoteButtons[size_t(f.a)]=f.b!=0; + } + else if(f.type==InputType::Wheel) + { + 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); +} +bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now) +{ + if(k.flags&LLKHF_INJECTED||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); + if(mode_==ControlMode::Sending) + { + outKeys[key]=down; + if(!post({InputType::Key,token,int32_t(k.vkCode),int32_t(k.scanCode),int32_t((down?1:0)|((k.flags&LLKHF_EXTENDED)?2:0))}))cancel(now); + return true; + } + if(outKeys[key]){if(!down)outKeys[key]=false;return true;} + return false; +} +bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool allowEnter) +{ + if(m.flags&LLMHF_INJECTED)return false; + 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::Sending) + { + bool ok=true; + if(message==WM_MOUSEMOVE) + { + 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; + } + 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}); + if(!ok)cancel(now); + return true; + } + if(index>=0&&outButtons[size_t(index)]){if(!down)outButtons[size_t(index)]=false;return true;} + 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; + returnPoint=m.pt;requested=now;mode(ControlMode::Requesting); + if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now); + } + return false; +} +} diff --git a/src/lan_input.h b/src/lan_input.h new file mode 100644 index 0000000..6cc6f93 --- /dev/null +++ b/src/lan_input.h @@ -0,0 +1,53 @@ +#pragma once +#include "lan_channel.h" +#include +namespace ks::lan +{ +enum class ControlMode { Local, Requesting, Sending, Receiving }; +struct InputEnvironment +{ + std::function bounds; + std::function cursor; + std::function inject; + std::function desktop; + std::function held; + std::function anchor; + std::function modeChanged; +}; +InputEnvironment windowsInputEnvironment(); +ULONG_PTR remoteInputMarker(); +ULONG_PTR cursorWarpMarker(); +bool atEdge(POINT,RECT,int edge); +int edgePosition(POINT,RECT,int edge); +POINT edgePoint(RECT,int edge,int position,int inset=8); +class InputRouter +{ + InputEnvironment env; + std::function send; + ControlMode mode_=ControlMode::Local; + int edge{};bool preferLocal{},connected{}; + uint32_t nextToken{},token{}; + uint64_t cooldown{},requested{},revision_{}; + POINT anchor{},returnPoint{}; + std::array physical{},outKeys{},remoteKeys{}; + std::array releases{}; + std::array outButtons{},remoteButtons{}; + void mode(ControlMode); + bool emit(INPUT); + bool move(POINT,bool warp=false); + bool post(InputFrame); + bool held() const; + void release(); + void local(uint64_t now,int position=-1); + public: + InputRouter(InputEnvironment,std::function,int edge,bool preferLocal); + ~InputRouter(); + 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); +}; +} diff --git a/src/ui_lan.inl b/src/ui_lan.inl index 637f874..ee9c50e 100644 --- a/src/ui_lan.inl +++ b/src/ui_lan.inl @@ -6,6 +6,7 @@ struct LanView nlohmann::json snapshot; std::vector ids; std::wstring error; + bool positionEdited{}; }; nlohmann::json lanRequest(App &app,const nlohmann::json &request) { @@ -47,6 +48,18 @@ void refreshLan(HWND w,LanView &v) 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); + 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 inputStatus=wide(v.snapshot.value("inputStatus",std::string("Input sharing disabled."))); + 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); } LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp) { @@ -61,7 +74,7 @@ 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) {refreshLan(w,*v);return 0;} + if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {v->positionEdited=false;refreshLan(w,*v);return 0;} if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp); v->error.clear(); if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}}); @@ -74,6 +87,14 @@ 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; + } + if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}}); refreshLan(w,*v); return 0; } } @@ -82,7 +103,7 @@ 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 — pairing preview",lanProc,740,600); + HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,790); 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); @@ -97,8 +118,15 @@ void showLan(HWND parent,App &app) 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"Changes apply immediately. This preview pairs PCs; mouse, keyboard and clipboard sharing comes next.",18,512,700); - button(w,L"&Close",IDCANCEL,618,550,100); + 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); 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);} diff --git a/tests/chat_profiles.cpp b/tests/chat_profiles.cpp index 5e310e6..92f0273 100644 --- a/tests/chat_profiles.cpp +++ b/tests/chat_profiles.cpp @@ -1,6 +1,7 @@ #include "app.h" #include "ocr.h" #include "screenshot.h" +#include "lan_pairing.h" #include #include @@ -290,6 +291,10 @@ int main() 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(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); @@ -300,6 +305,27 @@ int main() require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state"); SendMessageW(parent,WM_COMMAND,IDCANCEL,0); } + { + ks::lan::Json fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false}, + {"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"); + SendMessageW(lan,WM_TIMER,1,0); + require(IsDlgButtonChecked(lan,713)==BST_CHECKED,"status refresh preserves unsaved position choice"); + require(!IsWindowEnabled(GetDlgItem(lan,715)),"input cannot start while discovery is disabled"); + RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN); + RECT r{};GetClientRect(lan,&r);HDC 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-screen-layout.png",ks::encodePng(ks::decodeImage(screenshot))); + SendMessageW(lan,WM_COMMAND,IDCANCEL,0); + SendMessageW(pairedApp.settingsWindow,WM_COMMAND,IDCANCEL,0); + } std::filesystem::remove_all(root); std::cout << "Passed " << passed << " chat profile checks\n"; CoUninitialize(); diff --git a/tests/lan.cpp b/tests/lan.cpp index 7e01460..fd1d6e9 100644 --- a/tests/lan.cpp +++ b/tests/lan.cpp @@ -97,8 +97,22 @@ int main(int argc,char **argv) LanService restored(root);auto state=request(restored,{{"action","status"}}); check(state["peers"].size()==1 && state["peers"][0].at("paired").get() && !state["peers"][0].at("online").get(),"saved pair remains visible offline"); check(state.dump().find(left.derived)==std::string::npos,"status never exposes stored pair keys"); + rejects([&]{request(restored,{{"action","input"},{"enabled",true},{"peer",b},{"edge",1}});},"input cannot be enabled without discovery"); + rejects([&]{request(restored,{{"action","input"},{"enabled",false},{"edge",4}});},"invalid screen edge rejected"); + auto position=request(restored,{{"action","input"},{"enabled",false},{"peer",b},{"edge",2}}); + check(position.at("inputEdge")==2&&!restored.inputEnabled(),"screen position saved without enabling input"); + if(argc>1&&std::string(argv[1])=="--network") + { + request(restored,{{"action","enable"},{"enabled",true}}); + rejects([&]{request(restored,{{"action","input"},{"enabled",true},{"peer",c},{"edge",1}});},"discovered but unpaired peer cannot receive input"); + request(restored,{{"action","input"},{"enabled",true},{"peer",b},{"edge",2}}); + check(restored.inputEnabled(),"paired input enabled with explicit screen position"); + request(restored,{{"action","enable"},{"enabled",false}}); + check(!restored.inputEnabled(),"disabling discovery also stops input runtime"); + } request(restored,{{"action","forget"},{"peer",b}}); check(request(restored,{{"action","status"}})["peers"].empty(),"forget removes offline pair"); + check(!request(restored,{{"action","status"}}).at("shareInput").get(),"forgetting the input peer revokes persisted input permission"); } std::cout<<"Passed "< +#include "lan_input.h" +#include +#include + +using namespace ks;using namespace ks::lan; +namespace +{ +int passed{}; +void check(bool value,const char *text){if(!value)throw std::runtime_error(text);++passed;} +templatevoid rejects(F f,const char *text){bool failed=false;try{f();}catch(...){failed=true;}check(failed,text);} +struct Desktop +{ + RECT rect{-1920,-200,0,880};POINT point{-1000,300};bool available=true,physicalHeld=false,allow=true; + std::vector events; + 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)) + 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)}; + return true; + },[this]{return available;},[this]{return physicalHeld;}}; + } + 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);} +void exchange(InputRouter &source,InputRouter &target,std::deque &out,uint64_t now) +{(void)source;while(!out.empty()){auto f=out.front();out.pop_front();target.receive(f,now);}} +struct Pair +{ + Desktop a,b;std::deque ab,ba; + 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);} + void enter(int edge) + { + motion(left,a,edgePoint(a.rect,edge,30000,0),1000); + check(left.mode()==ControlMode::Requesting,"crossing edge requests ownership, not immediate suppression"); + deliver();check(left.mode()==ControlMode::Sending&&right.mode()==ControlMode::Receiving,"mouse and keyboard ownership acknowledged together"); + } +}; +void routerChecks() +{ + for(int side=0;side<4;++side) + { + Pair p(side);auto middle=edgePoint(p.a.rect,side,32768,0); + check(atEdge(middle,p.a.rect,side),"all four virtual-screen edges supported"); + check(std::abs(edgePosition(middle,p.a.rect,side)-32768)<100,"negative monitor coordinates normalize"); + p.enter(side); + auto old=p.b.point; + for(int step=0;step<20;++step) + { + auto point=p.a.point;if(side==0)--point.x;else if(side==1)++point.x;else if(side==2)--point.y;else ++point.y; + motion(p.left,p.a,point,1110+step);p.deliver(1110+step); + } + check(side<2 ? std::abs(p.b.point.x-old.x)==20 : std::abs(p.b.point.y-old.y)==20,"one-pixel mouse movements have no accumulating warp bias"); + check(p.left.keyboard(key('A',0x1e),true,1200),"source key down suppressed");p.deliver(1200); + check(p.a.keyEvents(true)==0&&p.b.keyEvents(true)==1,"key only injected on computer with cursor"); + check(p.b.events.back().ki.dwFlags&KEYEVENTF_SCANCODE,"physical scan code preserved across layouts"); + check(p.left.keyboard(key('A',0x1e),false,1210),"source key up suppressed");p.deliver(1210); + check(p.b.keyEvents(false)==1,"remote key released"); + auto injected=key('A',0x1e);injected.flags=LLKHF_INJECTED;injected.dwExtraInfo=remoteInputMarker(); + check(!p.right.keyboard(injected,true,1220),"received input continues to destination hotkeys/snippets, no routing loop"); + MSLLHOOKSTRUCT click{};check(p.left.mouse(WM_LBUTTONDOWN,click,1230),"source click suppressed");p.deliver(1230); + check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_LEFTDOWN,"remote left button pressed"); + p.left.mouse(WM_LBUTTONUP,click,1240);p.deliver(1240); + check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_LEFTUP,"remote left button released"); + click.mouseData=DWORD(120)<<16;p.left.mouse(WM_MOUSEWHEEL,click,1250);p.deliver(1250); + check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_WHEEL&&p.b.events.back().mi.mouseData==120,"wheel delta forwarded"); + auto centre=p.a.point;POINT moved=centre; + if(side==0)moved.x-=30;else if(side==1)moved.x+=30;else if(side==2)moved.y-=30;else moved.y+=30; + check(motion(p.left,p.a,moved,1300),"remote mouse move suppressed locally");p.deliver(1300); + check(p.b.point.x!=old.x||p.b.point.y!=old.y,"cursor moves on target"); + // Repeated source movement towards the return edge. + for(int n=0;n<5&&p.left.mode()==ControlMode::Sending;++n) + { + moved=p.a.point;if(side==0)moved.x+=400;else if(side==1)moved.x-=400;else if(side==2)moved.y+=400;else moved.y-=400; + motion(p.left,p.a,moved,1400+n);p.deliver(1400+n); + } + check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"crossing target return edge restores local ownership"); + check(!atEdge(p.a.point,p.a.rect,side),"return cursor inset avoids immediate bounce"); + check(!p.left.keyboard(key('B'),true,1500),"normal local typing resumes");p.left.keyboard(key('B'),false,1501); + } + { + Pair p(1);p.enter(1);p.left.keyboard(key(VK_LCONTROL),true,1200);p.left.keyboard(key(VK_LMENU),true,1201);p.deliver(); + check(p.left.keyboard(key(VK_ESCAPE),true,1202),"emergency return chord consumed");p.deliver(); + check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"emergency chord returns both sides"); + check(p.b.keyEvents(false)==2,"emergency return releases remote modifiers"); + check(p.left.keyboard(key(VK_LCONTROL),false,1203)&&p.left.keyboard(key(VK_LMENU),false,1204),"stray source modifier releases consumed after return"); + } + { + Pair p(1);p.enter(1);p.left.keyboard(key('A'),true,1200);p.deliver(); + p.left.tick(false,1300);p.right.tick(false,1300); + check(p.b.keyEvents(false)==1&&p.left.mode()==ControlMode::Local,"disconnect releases held remote key"); + check(p.left.keyboard(key('A'),false,1400),"source does not leak orphaned key-up after disconnect"); + } + { + Pair p(1);p.enter(1);p.left.keyboard(key('A'),true,1200);p.deliver();p.b.available=false;p.right.tick(true,1300); + check(p.right.mode()==ControlMode::Local&&p.b.keyEvents(false)==0,"secure desktop stops ownership without injecting there"); + p.b.available=true;p.right.tick(true,1400);check(p.b.keyEvents(false)==1,"blocked release retried when normal desktop returns"); + } + { + Pair p(1);p.a.physicalHeld=true;motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000); + check(p.left.mode()==ControlMode::Local,"no cross-screen handoff with physically held key/button"); + p.a.physicalHeld=false;motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1100); + p.left.keyboard(key('X'),true,1101);p.deliver(1200); + check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"typing during handoff cancels stale accept"); + } + { + Pair p(1);motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);motion(p.right,p.b,edgePoint(p.b.rect,0,30000,0),1000); + p.deliver();p.deliver();check(p.left.mode()==ControlMode::Sending&&p.right.mode()==ControlMode::Receiving,"simultaneous crossing has deterministic ownership"); + } + { + Pair p(1);p.enter(1);p.left.keyboard(key('A'),true,1200);p.deliver(); + check(!p.right.keyboard(key('B'),true,1300),"local physical keyboard takes over target");p.deliver(); + check(p.left.mode()==ControlMode::Local&&p.b.keyEvents(false)==1,"local takeover returns source and releases remote keys"); + } + { + Pair p(1);p.b.allow=false;motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);p.deliver(); + check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"blocked SendInput fails closed without capturing source"); + } + { + Pair p(1);p.enter(1);MSLLHOOKSTRUCT m{};m.mouseData=DWORD(XBUTTON2)<<16; + p.left.mouse(WM_XBUTTONDOWN,m,1200);p.deliver();p.right.tick(false,1300); + check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_XUP&&p.b.events.back().mi.mouseData==XBUTTON2,"disconnect releases held extra mouse button"); + } + { + Pair p(1);motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);p.left.tick(true,2101); + check(p.left.mode()==ControlMode::Local,"unanswered ownership request times out locally");p.deliver(2200);p.deliver(2200); + check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"late handoff acknowledgement cannot steal control"); + } + { + Desktop d;bool accept=true;std::deque frames; + InputRouter router(d.environment(),[&](auto f){frames.push_back(f);return accept;},1,true); + router.tick(true,1000);motion(router,d,edgePoint(d.rect,1,30000,0),1000); + auto token=frames.front().token;router.receive({InputType::Accept,token},1100); + accept=false;check(router.keyboard(key('Q'),true,1200),"failed queue still consumes the key rather than typing locally"); + check(router.mode()==ControlMode::Local&&router.keyboard(key('Q'),false,1300),"queue failure returns control and consumes orphan release"); + } +} +uint16_t freePort() +{ + SOCKET s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(s==INVALID_SOCKET)throw std::runtime_error("test socket"); + sockaddr_in a{};a.sin_family=AF_INET;a.sin_addr.s_addr=htonl(INADDR_LOOPBACK); + 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 channelChecks() +{ + std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16); + auto k=inputSessionKey(secret,a,b,na,nb);check(k!=inputSessionKey(secret,b,a,nb,na),"directional session keys differ"); + check(inputSessionKey(secret,a,b,na,nb,0)!=inputSessionKey(secret,a,b,na,nb,1),"screen geometry bound into authenticated session"); + InputCipher tx(k),rx(k);InputFrame f{InputType::Key,37,'A',0x1e,1};auto packet=tx.encrypt(f); + check(packet.size()==36&&rx.decrypt(packet)==f,"AES-GCM authenticated input roundtrip"); + rejects([&]{rx.decrypt(packet);},"encrypted frame replay rejected"); + auto next=tx.encrypt({InputType::Move,37,-12,400});auto tampered=next;tampered[4]^=1; + rejects([&]{rx.decrypt(tampered);},"ciphertext modification rejected"); + check(rx.decrypt(next)==InputFrame{InputType::Move,37,-12,400},"failed authentication does not expose or advance data"); + InputCipher wrong(inputSessionKey(secret,b,a,nb,na));rejects([&]{wrong.decrypt(packet);},"reflection across directions rejected"); + 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); + auto deadline=GetTickCount64()+5000;std::vector received; + while((!client.ready()||!server.ready())&&GetTickCount64() received;auto start=GetTickCount64(); + while(GetTickCount64()-start<400){server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);} + check(!client.ready()&&!server.ready()&&received.empty(),"wrong saved pair key never authorizes input"); + } + { + auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,1,port); + std::vector received;auto start=GetTickCount64(); + while(GetTickCount64()-start<400){server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);} + check(!client.ready()&&!server.ready()&&received.empty(),"conflicting screen arrangement rejected before input"); + } +} +} +int main() +{ + try{channelChecks();routerChecks();std::cout<<"Passed "<