Compare commits

...
45 changed files with 3239 additions and 80 deletions

No files matched your search

+37 -3
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.54 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.57 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -19,10 +19,10 @@ foreach(utilityTarget StayHere StayHereTest)
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
endif()
endforeach()
add_library(kscore 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 winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
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)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
@@ -89,6 +89,22 @@ else()
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
enable_testing()
add_executable(KSwitcherLanTests tests/lan.cpp)
target_link_libraries(KSwitcherLanTests PRIVATE kscore)
if(MSVC)
set_property(TARGET KSwitcherLanTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
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$<$<CONFIG:Debug>: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)
@@ -162,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)
+118
View File
@@ -0,0 +1,118 @@
# LAN mouse, keyboard and clipboard — v0.6.57 (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. 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 multiple computers
1. Install the v0.6.57 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. 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 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 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 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
retried when the ordinary desktop is available. Source key-ups for keys already
sent remotely are consumed after return, avoiding stray local modifier events.
## Layout and limitations
- 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
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.
## Network, authentication and storage
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.
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.
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 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
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.
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
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.
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.
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 three-machine hardware test.
+29
View File
@@ -10,6 +10,29 @@ 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.57 (experimental)
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.57:** 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.57, залишивши кожному
власну папку 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
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
@@ -346,6 +369,12 @@ Recognition uses Chrome accessibility: the address bar and active document must
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
### Desktop chat snippet selection — v0.6.55
ChatGPT and Codex desktop editors use paced selection for snippet triggers. If only a verified suffix is selected, KSwitcher extends the selection and verifies the full trigger before replacement. Unrelated text or changed focus cancels the operation. Triggers remain recognized across keyboard layouts. One snippet can target multiple applications: add each with **Add app**, or enter comma-separated process names. These existing application scopes also synchronize.
**Українською.** У настільних ChatGPT і Codex тригер виділяється з паузами між натисканнями. Якщо виділилася лише перевірена кінцева частина, KSwitcher доповнює виділення й перевіряє весь тригер перед заміною. Інший текст або зміна фокусу скасовують операцію. Тригери розпізнаються в різних розкладках. Один сніпет можна прив’язати до кількох програм через **Add app** або назви процесів через кому; ці прив’язки вже підтримують синхронізацію.
### Application-specific snippets — v0.6.51
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
Binary file not shown.
+10 -7
View File
@@ -1,16 +1,19 @@
# Latest Windows release
## v0.6.54 — 2026-11-06
## v0.6.57 — 2026-11-06
## Changes
- Add **Smoothing (0–100%)** to the screenshot editor toolbar. Adjust the selected pencil stroke, or set the default for new strokes when no pencil stroke is selected (initially 25%). **Apply to all strokes** updates all pencil strokes in the image and the default. Text, highlights and geometric shapes are unchanged.
- Retain original pencil samples: zero restores the original path, and reducing smoothing restores detail without accumulating distortion. Moving, endpoint editing, cloning, undo/redo and PNG export preserve the chosen geometry. Each slider drag or bulk change is one undo step. Very short strokes remain unchanged; Shift-constrained and linked strokes start unsmoothed but can be adjusted explicitly. Shape recognition remains separate.
- Show **No unsaved changes** in Settings with **Save** disabled until edits are pending. Show **Unsaved changes — click Save to apply** for pending edits, including accepted chat-language, OCR and automatic-correction preferences. Cancelling a child dialog or restoring the original values updates the state immediately. Separately saved snippets do not falsely mark Settings as unsaved.
- Avoid creating empty personal-dictionary entries when accepting unchanged automatic-correction options, preventing a false unsaved-state indication.
- Retain chat-language synchronization from v0.6.53 and **Arc (U)** from v0.6.52.
- Settings Save merges independent edits with newer resident settings. Same-field conflicts offer a choice; declining keeps the window and draft open.
- Multiple paired PCs on a draggable monitor grid; mouse and keyboard follow neighboring edges, including A → B → C and back. Connections reconnect independently.
- New text, formatted text and screenshots synchronize to all online paired PCs: history and active clipboard. Newer local copies are preserved during incoming transfers.
- Clipboard history has a persistent Computers checkbox filter and source labels. Lossless PNG transport avoids duplicate image payloads.
- LAN modifier chords, DPI-scaled motion, cursor parking/return, automatic conversion ownership and queued wheel reversal fixes. Local shortcut latches reset on ownership changes.
- Stable paired status, pairing/input button states, persistent selection and a fixed-size LAN window with scrolling where needed.
## Validation
Passed 774 settings/chat-profile checks, 568 input checks, 332 native checks, 126 sync checks, dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. Smoothing checks cover defaults, per-stroke and bulk edits, reversible geometry, movement/cloning/endpoints, undo grouping and exact PNG restoration at zero. Settings checks cover clean/dirty/reverted/invalid states and accepted/cancelled child changes. Inspected actual editor and Settings renderings, including Settings at 250% scaling. Published assets, checksums, executable versions, updater selection and bilingual Wiki pages are verified during publication.
Passed: 5 settings-save merge checks, 203 LAN input/three-peer TCP, 289 clipboard transfer, 43 pairing, 828 settings/chat UI, 570 input, 341 native and 126 sync checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Three physical PCs have not been verified together. LAN remains experimental; follow LAN-PREVIEW.md.
**Update ALL LAN participants to v0.6.57. Input protocol v2 cannot connect to older one-pair builds.** Pair every physical input source with each destination. Configure the same physical arrangement on each PC; layouts are not automatically synchronized. File transfer, offline clipboard backfill, cross-PC dragging and secure desktops remain unsupported.
## 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
View File
@@ -1 +1 @@
e7a0d76f89d7d15123378390e1b549000404c4be2463160de95f9c1626687a59 KSwitcher-win-x64.zip
f3ec4f7a62b4479929d94d6d6f7f68edb8b4a47b84edac7258afb64dbe07aef2 KSwitcher-win-x64.zip
+14 -9
View File
@@ -1,21 +1,21 @@
{
"version": "0.6.51",
"version": "0.6.57",
"schema": 1,
"files": [
{
"size": 1847296,
"size": 2106368,
"path": "KSwitcher.exe",
"sha256": "c40f90944169ec6f1b92803ccaec734573bf798939f13ab3553b982b709e479c"
"sha256": "3b7315e1545f70739fd1a7c099ddeab28362b323521d93879fa43fdd76133f8e"
},
{
"size": 1716224,
"size": 2003456,
"path": "KSwitcher.Config.exe",
"sha256": "8e8a5fee9be8f318b78516b82415c6b098ac67f7c374f4667ef271576a77f9fa"
"sha256": "a819ec42030990725326730396680388cbcb870dd00c0a97856cb1615f9db27b"
},
{
"size": 2897408,
"size": 3151360,
"path": "KSwitcher.Screenshot.exe",
"sha256": "bb5ffc3a26955c752d593ba06f69476985d9902f2ed0345fe3efe9bfc2eb63c8"
"sha256": "8473697245c2c961496135eaaff6de9e9a19e917f2fd7299a16068210223d396"
},
{
"size": 4401,
@@ -28,9 +28,14 @@
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
},
{
"size": 74601,
"size": 79896,
"path": "README.md",
"sha256": "818f81e6c4bfaa6b014ac50351ec22f68ee6eb3c094ad5ecb7bac98d605e0918"
"sha256": "8b4b1e905a2f0a39d532e61136ec7eca473492a39a538eb073ed829307adefae"
},
{
"size": 7921,
"path": "LAN-PREVIEW.md",
"sha256": "2467b86bbad7d7dce1e0c65c8c0faf2320e4804e90a85784a058afa357127b6b"
},
{
"size": 732485,
+34 -18
View File
@@ -15,14 +15,14 @@ Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -
Push-Location $root
try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
$libs = @('-lws2_32','-lbcrypt','-liphlpapi','-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
& $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 @('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,18 +35,34 @@ 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/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/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/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/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
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' }
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
@@ -55,11 +71,11 @@ try {
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
& .\build\KSwitcherUtilitiesTests.exe
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
& $compiler @common tests/dictionary.cpp 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/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' }
@@ -67,19 +83,19 @@ try {
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
& .\build\KSwitcherScrollTests.exe
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
& $compiler @common tests/tests.cpp 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/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/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/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' }
@@ -87,7 +103,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/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' }
@@ -99,7 +115,7 @@ try {
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
}
if ($Far) {
& $compiler @common tests/far_snippets.cpp 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')
@@ -111,28 +127,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/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/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/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/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/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' }
+2 -1
View File
@@ -9,13 +9,14 @@ Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe'
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LAN-PREVIEW.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,54,0
PRODUCTVERSION 0,6,54,0
FILEVERSION 0,6,57,0
PRODUCTVERSION 0,6,57,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.54"
VALUE "FileVersion", "0.6.57"
END
END
BLOCK "VarFileInfo"
+130 -6
View File
@@ -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"
@@ -559,6 +560,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
}
App::~App()
{
lanService.reset();
stopBrowserChatMonitor();
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
if (historyProcess) CloseHandle(historyProcess);
@@ -721,6 +723,15 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
return true;
}
Bytes App::lanCommand(const Bytes &command)
{
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()) resetLanInput();
return reply;
}
void App::applySettings(const Settings &requested)
{
Settings s = requested;
@@ -830,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);
@@ -1118,6 +1137,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->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
// 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.
@@ -1147,6 +1173,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->resetLanInput();
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)
@@ -1490,11 +1523,17 @@ void App::transform(int command, int snippet)
}
Run tail = recent;
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
if (!tx.select(sn.trigger.size()))
return;
autoSelected = true;
reason = L"snippet-verification-mismatch";
text = tx.copy();
if (farSnippet)
{
if (!tx.select(sn.trigger.size())) return;
text = tx.copy();
}
else
text = tx.copyRange(sn.trigger.size(), tail.keys,
[&](const std::wstring &candidate) { return tail.match(candidate, available, physicalLayout()) >= 0; },
[&](const std::wstring &candidate) { return tail.matchSuffix(candidate, available, physicalLayout()) >= 0; });
if (tail.match(text, available, physicalLayout()) < 0)
return;
automatic = true;
@@ -1775,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
{
@@ -1791,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);
}
@@ -1818,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);
}
@@ -2115,6 +2205,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;
@@ -2185,7 +2282,29 @@ 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)
{
// 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;
}
if (wp == 6)
{
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
@@ -2300,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)));
@@ -2326,6 +2446,10 @@ int App::execute()
scheduleHistoryOcr(settings);
addTray();
if (!smoke) startBrowserChatMonitor();
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
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);
if (!smoke) SetTimer(window, 6, 1000, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
+8 -1
View File
@@ -4,6 +4,7 @@
#include "update.h"
#include "sync.h"
#include "chat_context.h"
#include "lan.h"
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -39,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;
@@ -47,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{};
@@ -69,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 &);
@@ -89,9 +93,11 @@ 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();
Bytes lanCommand(const Bytes &);
void pollSync();
static App *current;
@@ -99,6 +105,7 @@ class App
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
HHOOK keyboardHook{}, mouseHook{};
std::unique_ptr<LanService> lanService;
HANDLE historyProcess{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
+1 -1
View File
@@ -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.
}
+11 -1
View File
@@ -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()
{
@@ -112,6 +121,7 @@ void App::reloadLayouts()
}
void App::clearHistory() { request(*this, ConfigClearHistory); }
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
Bytes App::lanCommand(const Bytes &bytes) { return request(*this, ConfigLan, bytes); }
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
{
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
+11 -3
View File
@@ -1,4 +1,5 @@
#include "configuration.h"
#include "settings_merge.h"
#include <cstring>
namespace ks
{
@@ -40,6 +41,13 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
{
switch (message.dwData)
{
case ConfigLan:
{
if (!message.cbData || message.cbData > 4096) throw std::runtime_error("Invalid LAN request.");
auto first = static_cast<const unsigned char *>(message.lpData);
reply = app.lanCommand(Bytes(first, first + message.cbData));
break;
}
case ConfigSyncConflicts:
reply = app.syncConflictData();
break;
@@ -60,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.");
@@ -67,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
View File
@@ -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, ConfigEnd = ConfigRead + 11;
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);
+401
View File
@@ -0,0 +1,401 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iphlpapi.h>
#include "lan.h"
#include "lan_pairing.h"
#include "lan_mesh.h"
#include "lan_clipboard.h"
#include "lan_layout.h"
#include <atomic>
#include <mutex>
#include <thread>
#include <map>
namespace ks
{
using lan::Json;
namespace
{
Bytes encoded(const Json &j) { auto s=j.dump(); return Bytes(s.begin(),s.end()); }
bool localAddress(ULONG network)
{
auto ip=ntohl(network);
return (ip>>24)==10 || (ip>>20)==0xac1 || (ip>>16)==0xc0a8 || (ip>>16)==0xa9fe;
}
std::string hostName()
{
wchar_t name[256]{}; DWORD size=256;
return GetComputerNameW(name,&size) ? utf8(std::wstring(name,size)) : "KSwitcher";
}
std::vector<ULONG> broadcasts()
{
ULONG size=16384; Bytes data(size);
auto flags=GAA_FLAG_SKIP_ANYCAST|GAA_FLAG_SKIP_MULTICAST|GAA_FLAG_SKIP_DNS_SERVER;
auto result=GetAdaptersAddresses(AF_INET,flags,nullptr,(IP_ADAPTER_ADDRESSES*)data.data(),&size);
if(result==ERROR_BUFFER_OVERFLOW) { data.resize(size); result=GetAdaptersAddresses(AF_INET,flags,nullptr,(IP_ADAPTER_ADDRESSES*)data.data(),&size); }
std::vector<ULONG> out;
if(result!=NO_ERROR) return out;
for(auto a=(IP_ADAPTER_ADDRESSES*)data.data();a;a=a->Next)
if(a->OperStatus==IfOperStatusUp)
for(auto u=a->FirstUnicastAddress;u;u=u->Next)
if(u->Address.lpSockaddr->sa_family==AF_INET && u->OnLinkPrefixLength>0 && u->OnLinkPrefixLength<31)
{
auto ip=((sockaddr_in*)u->Address.lpSockaddr)->sin_addr.s_addr;
if(localAddress(ip)) out.push_back(htonl(ntohl(ip) | (0xffffffffu >> u->OnLinkPrefixLength)));
}
return out;
}
}
struct LanService::Impl
{
struct Peer { std::string name,epoch; sockaddr_in address{}; uint64_t seen{},verified{}; };
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(),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;
std::thread worker;
bool shareInput{};
std::string inputPeer;
int inputEdge=1;
std::atomic<bool> inputConnected{false},abortInput{false};
std::atomic<uint64_t> inputEpoch{0};
std::atomic<uint64_t> inputConfiguration{0};
std::atomic<bool> layoutChanged{false};
uint64_t observedEpoch{},observedRevision{},observedConfiguration{};
std::mutex inputMutex;
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.";
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.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||
!entry.at("name").is_string()||entry.at("name").get<std::string>().size()>128)
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},{"positions",positions}}))); }
bool queue(const std::string &peer,lan::InputFrame frame)
{
std::lock_guard lock(inputMutex);
if(!inputReady.contains(peer))return false;
if(inputOut.size()>=512){abortInput=true;return false;}
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()
{
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;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;}
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()
{
stopping=false; epoch=lan::randomHex(16); status="Searching for KSwitcher computers...";
worker=std::thread([this]{ run(); });
}
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();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)
{
auto bytes=j.dump(); if(bytes.size()>2048) return;
sendto(socket,bytes.data(),int(bytes.size()),0,(const sockaddr*)&address,sizeof(address));
}
std::string heartbeat(const std::string &from,const std::string &to,const std::string &a,const std::string &b)
{ return "KSwitcher LAN v1|presence|"+from+"|"+to+"|"+a+"|"+b; }
void receive(SOCKET socket,const sockaddr_in &address,const Json &j,uint64_t now)
{
if(j.value("app","")!="KSwitcher-LAN" || j.value("version",0)!=1) return;
auto other=j.value("id",""); if(!lan::identifier(other)||other==id) return;
auto type=j.value("type","");
if(type=="hello")
{
auto display=j.value("name",""); auto session=j.value("epoch","");
if(display.empty()||display.size()>128||!lan::identifier(session)||
std::any_of(display.begin(),display.end(),[](unsigned char c){return c<32||c==127;})) return;
// Discovery is untrusted and never grants input access.
if(!peers.contains(other) && peers.size()>=64) return;
auto &peer=peers[other];
// 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))
{
auto response=lan::envelope("presence",id); response["to"]=other; response["epoch"]=epoch;
response["proof"]=lan::mac(trusted[other].at("key"),heartbeat(id,other,epoch,session));
send(socket,address,response);
}
return;
}
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; }
if(pending && pending->peer==other)
{
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 previousPositions=positions;
trusted[other]={{"name",peer.name},{"key",pending->derived}};
try {lan::fillPositions(positions,trusted,inputPeer,inputEdge);save();} catch(...) {trusted=std::move(previous);positions=std::move(previousPositions);throw;}
layoutChanged=true;peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
}
}
}
void run() noexcept
{
WSADATA startup{}; SOCKET socket=INVALID_SOCKET; bool winsock=false;
try
{
if(WSAStartup(MAKEWORD(2,2),&startup)) throw std::runtime_error("Cannot start Windows networking.");
winsock=true; socket=::socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
BOOL yes=TRUE; u_long nonblock=1;
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;
lan::InputHub hub(id);
while(!stopping)
{
auto now=GetTickCount64();bool clipboardActive=false;
std::vector<lan::InputEndpoint> endpoints;
{
std::lock_guard lock(mutex);
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())if(peers.contains(peer))
endpoints.push_back({peer,entry.at("key"),peers.at(peer).address.sin_addr.s_addr});
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;hello["clipboardSession"]=clipboard->session();
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
nextHello=now+3000;
}
for(auto it=peers.begin();it!=peers.end();) {
if(now-it->second.seen>lan::PeerLifetime) it=peers.erase(it); else ++it;
}
if(pending && pending->expired(now)) {
status=pending->confirmed ? "Pair saved. Select a computer to manage it." : "Pairing expired. Enter the same code and click Pair on both computers.";
pending.reset();
}
if(pending && now>=nextOffer && peers.contains(pending->peer)) {
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
}
}
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);
}
if(inputIn.size()>512){inputIn.clear();abortInput=true;}
}
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)
{
char buffer[2049]; sockaddr_in sender{}; int size=sizeof(sender);
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
if(received<=0) break;
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
try { auto j=Json::parse(buffer,buffer+received); std::lock_guard lock(mutex); receive(socket,sender,j,GetTickCount64()); }
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
}
}
}
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();
}
Bytes command(const Bytes &input)
{
auto j=Json::parse(input); auto action=j.value("action","status");
if(action=="enable")
{
stop();
bool previous=enabled; enabled=j.at("enabled").get<bool>();
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=="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.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;positions=std::move(beforePositions);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")
{
auto peer=j.at("peer").get<std::string>();
if(!enabled||!peers.contains(peer)) throw std::runtime_error("Select an available KSwitcher computer first.");
if(!trusted.contains(peer)&&trusted.size()>=64) throw std::runtime_error("Remove an old pair before adding another computer.");
pending=std::make_unique<lan::Pairing>(id,peer,j.at("code").get<std::string>(),GetTickCount64());
status="Waiting for the same code and Pair on the other KSwitcher (2 minutes)...";
}
if(action=="forget")
{
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();
configureRouter();
status="Pair removed on this computer.";
}
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"controlMode",router?int(router->mode()):0}};
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
auto now=GetTickCount64();
for(const auto &[peer,p]:peers)
{
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
result["peers"].push_back({{"id",peer},{"name",p.name},{"address",ip},{"paired",trusted.contains(peer)},
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true}});
}
for(const auto &[peer,p]:trusted.items()) if(!peers.contains(peer))
result["peers"].push_back({{"id",peer},{"name",p.at("name")},{"address",""},{"paired",true},{"verified",false},{"online",false}});
return encoded(result);
}
};
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());}
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;}
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "platform.h"
#include <memory>
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 &, 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);
bool mouse(UINT, const MSLLHOOKSTRUCT &, bool allowEnter);
bool takeInputChange();
void cancelInput();
};
}
+271
View File
@@ -0,0 +1,271 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include "lan_channel.h"
#include <bcrypt.h>
#include <limits>
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<unsigned char>(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<key.size();i+=2)bytes.push_back(static_cast<unsigned char>(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<uint64_t>::max())throw std::runtime_error("Input session exhausted.");
unsigned char nonce[12]{};for(int i=0;i<8;++i)nonce[11-i]=static_cast<unsigned char>(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<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));
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::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)
{
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{},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,bool multiple):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p),managed(multiple){}
~Impl(){closeSocket(socket);closeSocket(listener);}
void reset(uint64_t now,const std::string &reason)
{
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;state=reason;
}
void begin(uint64_t now)
{
nonce=randomHex(16);lastRead=lastPing=now;
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...";
}
void poll(uint32_t address,const std::vector<InputFrame> &frames,std::vector<InputFrame> &received,uint64_t now)
{
try
{
if(socket==INVALID_SOCKET&&now<retry)return;
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.");}
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(local<peer)
{
socket=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(socket==INVALID_SOCKET||!nonblocking(socket))throw std::runtime_error("Cannot open input connection.");
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);a.sin_addr.s_addr=address;
int result=connect(socket,(sockaddr*)&a,sizeof(a));
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
connecting=result!=0;begin(now);
if(connecting)state="Connecting to paired PC on TCP 45884...";
}
else if(!managed)
{
sockaddr_in a{};int length=sizeof(a);socket=accept(listener,(sockaddr*)&a,&length);
if(socket!=INVALID_SOCKET)
{
if(a.sin_addr.s_addr!=address){closeSocket(socket);return;}
if(!nonblocking(socket))throw std::runtime_error("Cannot configure input connection.");
begin(now);
}
}
}
if(socket==INVALID_SOCKET)return;
// 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=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)
{
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()<length+4)return;
auto hello=Json::parse(incoming.begin()+4,incoming.begin()+4+length);
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(!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,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());
}
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,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{}; };
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&&e.address==it->second.endpoint.address;});
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));
}
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)){
closeSocket(listener);retry=now+1500;error="TCP input port is unavailable.";
}else error.clear();
}
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.endpoint.address==it->address&&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);
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();}
}
+74
View File
@@ -0,0 +1,74 @@
#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, Cross };
struct InputFrame
{
InputType type{};
uint32_t token{};
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
{
struct Impl; std::unique_ptr<Impl> impl;
public:
explicit InputCipher(const std::string &hexKey);
~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);
// Network-thread-only nonblocking TCP channel. No Windows input is injected here.
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,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();
};
}
+133
View File
@@ -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.";
}
}
};
}
+25
View File
@@ -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;
}
}
+357
View File
@@ -0,0 +1,357 @@
#include "lan_input.h"
#include <algorithm>
#include <bit>
#include <atomic>
namespace ks::lan
{
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_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)
{
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 notice{},cover{};HCURSOR previous{};
~ControlOverlay(){hide();}
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(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);
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);
if(cover)SetCursor(parkedCursor());
}
};
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<ControlOverlay>();
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();},
monitorDpi
};
}
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>(int64_t(offset)*65535/std::max<LONG>(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<LONG>(0,r.right-r.left-1)/65535);
LONG y=r.top+LONG(int64_t(position)*std::max<LONG>(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<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&&!(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();
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();
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
{
return std::any_of(physical.begin(),physical.end(),[](bool b){return b;})||env.held();
}
void InputRouter::release()
{
for(size_t i=0;i<remoteKeys.size();++i)if(remoteKeys[i]&&emit(releases[i]))remoteKeys[i]=false;
for(unsigned i=0;i<5;++i)if(remoteButtons[i]&&emit(button(i,false)))remoteButtons[i]=false;
}
void InputRouter::local(uint64_t now,int position)
{
bool sending=mode_==ControlMode::Sending||transit;
if(position<0)position=edgePosition(returnPoint,env.bounds(),edge);
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);}
}
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)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)
{
if(!connected||!env.desktop()){cancel(now);return;}
if(f.type==InputType::Enter)
{
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);
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()||(!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){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;}
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;}))
{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);
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)
{
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,"Windows input injection failed");
}
bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
{
// 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;}
// 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;
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)&&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&&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,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});
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=ownershipToken();
returnPoint=m.pt;requested=now;mode(ControlMode::Requesting);
if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now);
}
return false;
}
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "lan_channel.h"
#include <array>
namespace ks::lan
{
enum class ControlMode { Local, Requesting, Sending, Receiving };
struct InputEnvironment
{
std::function<RECT()> bounds;
std::function<POINT()> cursor;
std::function<bool(const INPUT &)> inject;
std::function<bool()> desktop;
std::function<bool()> held;
std::function<POINT()> anchor;
std::function<void(ControlMode)> modeChanged;
std::function<int(POINT)> dpi;
};
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<bool(InputFrame)> send;
ControlMode mode_=ControlMode::Local;
int edge{};bool preferLocal{},connected{},multi{},transit{};
int crossEdge=-1;uint64_t crossAt{};
uint32_t token{};
uint64_t cooldown{},requested{},revision_{};
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{};
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<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,const char *reason="control cancelled");
};
}
+43
View File
@@ -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.");
}
}
}
+82
View File
@@ -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);
}
};
}
+97
View File
@@ -0,0 +1,97 @@
#include "lan_pairing.h"
#include <bcrypt.h>
#include <algorithm>
namespace ks::lan
{
namespace
{
void checked(NTSTATUS s) { if (s < 0) throw std::runtime_error("Windows cryptography failed."); }
std::string hex(const Bytes &data)
{
std::string out; out.reserve(data.size()*2);
for (auto b : data) { out += "0123456789abcdef"[b>>4]; out += "0123456789abcdef"[b&15]; }
return out;
}
std::string proof(const char *kind, const std::string &from, const std::string &to,
const std::string &a, const std::string &b = {})
{ return std::string("KSwitcher LAN v1|") + kind + "|" + from + "|" + to + "|" + a + "|" + b; }
}
std::string randomHex(size_t size)
{
if (size > 64) throw std::runtime_error("Invalid random size.");
Bytes bytes(size); checked(BCryptGenRandom(nullptr, bytes.data(), ULONG(size), BCRYPT_USE_SYSTEM_PREFERRED_RNG));
return hex(bytes);
}
bool identifier(const std::string &s)
{ return s.size()==32 && s.find_first_not_of("0123456789abcdef")==s.npos; }
std::string normalizeCode(const std::string &input)
{
std::string result;
for (auto c : input)
{
if (c=='-' || c==' ') continue;
if (c>='A' && c<='F') c+= 'a'-'A';
result+=c;
}
if (!identifier(result)) throw std::runtime_error("Use the same generated 32-character pairing code on both computers.");
return result;
}
std::string mac(const std::string &key, const std::string &text)
{
BCRYPT_ALG_HANDLE alg{}; BCRYPT_HASH_HANDLE hash{};
checked(BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, nullptr, BCRYPT_ALG_HANDLE_HMAC_FLAG));
struct Close { BCRYPT_ALG_HANDLE a; BCRYPT_HASH_HANDLE &h; ~Close(){if(h) BCryptDestroyHash(h); BCryptCloseAlgorithmProvider(a,0);} } close{alg,hash};
checked(BCryptCreateHash(alg,&hash,nullptr,0,(PUCHAR)key.data(),ULONG(key.size()),0));
checked(BCryptHashData(hash,(PUCHAR)text.data(),ULONG(text.size()),0));
Bytes out(32); checked(BCryptFinishHash(hash,out.data(),ULONG(out.size()),0)); return hex(out);
}
bool equal(const std::string &a, const std::string &b)
{
if (a.size()!=b.size()) return false;
unsigned diff=0; for(size_t i=0;i<a.size();++i) diff |= unsigned(a[i]^b[i]); return diff==0;
}
Json envelope(const std::string &type, const std::string &id)
{ return {{"app","KSwitcher-LAN"},{"version",1},{"type",type},{"id",id}}; }
Pairing::Pairing(std::string a,std::string b,const std::string &key,uint64_t now)
: local(std::move(a)),peer(std::move(b)),code(normalizeCode(key)),nonce(randomHex(16)),started(now)
{
if(!identifier(local)||!identifier(peer)||local==peer) throw std::runtime_error("Invalid KSwitcher peer.");
}
bool Pairing::expired(uint64_t now) const { return now-started >= PairLifetime; }
Json Pairing::offer() const
{
auto j=envelope("offer",local); j["to"]=peer; j["nonce"]=nonce;
j["proof"]=mac(code,proof("offer",local,peer,nonce)); return j;
}
std::vector<Json> Pairing::receive(const Json &j,uint64_t now)
{
if(expired(now)||j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||
j.value("id","")!=peer||j.value("to","")!=local) return {};
auto type=j.value("type",""); auto other=j.value("nonce","");
if(!identifier(other)) return {};
if(type=="offer")
{
if(!equal(j.value("proof",""),mac(code,proof("offer",peer,local,other)))) return {};
// A new attempt cannot replace a nonce already authenticated in this attempt.
if(!remoteNonce.empty() && remoteNonce!=other) return {};
remoteNonce=other;
derived=local<peer ? mac(code,proof("key",local,peer,nonce,other)) : mac(code,proof("key",peer,local,other,nonce));
auto ack=envelope("ack",local); ack["to"]=peer; ack["nonce"]=nonce; ack["echo"]=other;
ack["proof"]=mac(code,proof("ack",local,peer,other,nonce)); return {ack};
}
if(type=="ack" && j.value("echo","")==nonce)
{
if(!equal(j.value("proof",""),mac(code,proof("ack",peer,local,nonce,other)))) return {};
if(!remoteNonce.empty()&&remoteNonce!=other) return {};
remoteNonce=other;
derived=local<peer ? mac(code,proof("key",local,peer,nonce,other)) : mac(code,proof("key",peer,local,other,nonce));
acknowledged=true;
auto done=envelope("confirm",local); done["to"]=peer; done["nonce"]=nonce;
done["proof"]=mac(derived,proof("confirm",local,peer,nonce,other)); return {done};
}
if(type=="confirm" && acknowledged && other==remoteNonce &&
equal(j.value("proof",""),mac(derived,proof("confirm",peer,local,other,nonce)))) confirmed=true;
return {};
}
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "platform.h"
#include "../vendor/json.hpp"
namespace ks::lan
{
using Json = nlohmann::json;
inline constexpr unsigned Port = 45883;
inline constexpr uint64_t PairLifetime = 120000, PeerLifetime = 12000;
std::string randomHex(size_t bytes);
std::string normalizeCode(const std::string &);
std::string mac(const std::string &key, const std::string &text);
bool equal(const std::string &, const std::string &);
bool identifier(const std::string &);
Json envelope(const std::string &type, const std::string &id);
struct Pairing
{
std::string local, peer, code, nonce, remoteNonce, derived;
uint64_t started{};
bool acknowledged{}, confirmed{};
Pairing(std::string local, std::string peer, const std::string &code, uint64_t now);
bool expired(uint64_t now) const;
Json offer() const;
std::vector<Json> receive(const Json &, uint64_t now);
};
}
+1 -1
View File
@@ -435,7 +435,7 @@ bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
return name == L"ms-teams" || name == L"msedgewebview2" ||
name == L"chrome" || name == L"msedge";
name == L"chrome" || name == L"msedge" || name == L"chatgpt" || name == L"codex";
}
bool selectPrevious(size_t count, const std::function<bool()> &valid)
{
+23
View File
@@ -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 &current,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
View File
@@ -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
View File
@@ -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;
+94 -10
View File
@@ -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);
@@ -1528,6 +1605,7 @@ void showOcrOptions(HWND parent, SettingsView *v)
showRequestedWindow(w); SetForegroundWindow(w);
}
#include "ui_sync.inl"
#include "ui_lan.inl"
bool settingsChanged(HWND w, SettingsView *v)
{
try
@@ -1640,6 +1718,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
if (id == 262) { showLan(w, *v->app); return 0; }
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
if (id == 230)
{
@@ -1966,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,
@@ -2096,12 +2178,14 @@ 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);
page();
+157
View File
@@ -0,0 +1,157 @@
// Uses the resident service; the configurator never opens a discovery socket.
struct LanView
{
HWND parent{};
App *app{};
nlohmann::json snapshot=nlohmann::json::object();
std::vector<std::string> ids;
std::wstring error;
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)
{
auto text=request.dump(); auto reply=app.lanCommand(Bytes(text.begin(),text.end()));
return nlohmann::json::parse(reply);
}
std::string selectedLan(HWND w,LanView &v)
{
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
}
void refreshLan(HWND w,LanView &v)
{
auto selected=selectedLan(w,v);
auto next=lanRequest(*v.app,{{"action","status"}});
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);
auto state=peer.at("paired").get<bool>() ? L"Paired" : L"Not paired";
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=std::move(ids);v.rows=std::move(rows);
for(size_t i=0;i<v.ids.size();++i)
{
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");}
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get<std::string>()) : v.error);
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
EnableWindow(GetDlgItem(w,706),pending);
EnableWindow(GetDlgItem(w,707),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));
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) {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
{
if(msg==WM_TIMER) {refreshLan(w,*v);return 0;}
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(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)
{
auto code=lanRequest(*v->app,{{"action","code"}}).at("code").get<std::string>();
std::string grouped; for(size_t i=0;i<code.size();++i) {if(i&&i%4==0) grouped+='-';grouped+=code[i];}
value(w,703,wide(grouped)); SetFocus(GetDlgItem(w,703)); SendDlgItemMessageW(w,703,EM_SETSEL,0,-1);
}
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
if(id==715)
{
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
}
if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}});
refreshLan(w,*v); return 0;
}
}
catch(const std::exception &e) {v->error=wide(e.what());value(w,708,v->error);}
return DefWindowProcW(w,msg,wp,lp);
}
void showLan(HWND parent,App &app)
{
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,836,0,false);
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,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);}
}
+111
View File
@@ -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
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.54";
inline constexpr wchar_t AppVersion[] = L"0.6.57";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+82 -1
View File
@@ -1,6 +1,7 @@
#include "app.h"
#include "ocr.h"
#include "screenshot.h"
#include "lan_pairing.h"
#include <iostream>
#include <commctrl.h>
@@ -21,6 +22,7 @@ void contextChecks()
ks::validateSettings(scoped);
require(ks::snippetAppPriority(scoped.snippets[1], L"ms-teams") > ks::snippetAppPriority(scoped.snippets[0], L"ms-teams"), "app snippet precedes global regardless of list order");
require(ks::snippetAppPriority(scoped.snippets[1], L"Telegram.exe") == 0, "restricted snippet cannot run in another app");
require(ks::snippetAppPriority(scoped.snippets[1], L"Outlook.EXE") == 2, "restricted snippet also runs in its second application");
require(ks::snippetAppPriority(scoped.snippets[0], L"") == 1, "legacy/global snippets remain available");
auto scopedRoundtrip = ks::decodeSettings(ks::encodeSettings(scoped), {});
require(scopedRoundtrip.snippets[1].apps == std::vector<std::wstring>{L"ms-teams", L"outlook"}, "scopes normalize and persist");
@@ -257,7 +259,14 @@ int main()
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
CheckDlgButton(snippets,314,BST_CHECKED);
SendMessageW(snippets,WM_COMMAND,314,0);
SetDlgItemTextW(snippets,315,L"Notepad.exe, MS-Teams.EXE");
SetDlgItemTextW(snippets,315,L"Notepad.exe");
auto secondApp = SendDlgItemMessageW(snippets,316,CB_ADDSTRING,0,(LPARAM)L"MS-Teams.EXE");
SendDlgItemMessageW(snippets,316,CB_SETCURSEL,secondApp,0);
SendMessageW(snippets,WM_COMMAND,317,0);
SendMessageW(snippets,WM_COMMAND,317,0);
wchar_t appList[128]{};
GetDlgItemTextW(snippets,315,appList,128);
require(std::wstring(appList)==L"Notepad.exe, MS-Teams.EXE", "Add app appends a second application without duplicates");
SendMessageW(snippets,WM_COMMAND,IDOK,0);
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
require(!saveEnabled() && status()==L"No unsaved changes.","separately saved snippet edit leaves Settings clean");
@@ -275,6 +284,78 @@ int main()
SetDlgItemTextW(snippets,315,L"chrome.exe");
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
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);
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");
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);
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
}
{
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);
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
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(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;
while(GetTickCount64()<repaintUntil)
{
MSG message{};
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)){TranslateMessage(&message);DispatchMessageW(&message);}
Sleep(10);
}
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);
}
{
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=FindWindowW(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";
+13
View File
@@ -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;
+24 -3
View File
@@ -22,6 +22,7 @@ namespace
ks::App *app{};
HWND host{}, field{};
ks::Clip saved;
bool snippetRecoveryOnly = false;
bool captured = false, failed = false;
bool conversionOnly = false;
bool typosOnly = false;
@@ -588,10 +589,29 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
finish();
break;
case 62:
case 64:
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
app->settings.snippets = {{L";repo-changes-jira", L"expanded{cursor}"}};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"prefix ;repo-changes-jira", false);
record(L";repo-changes-jira");
droppedSelectionKeys = 10;
enterPresses = 0;
app->transform(0, 0);
break;
case 65:
require(text() == L"prefix expanded", "long snippet recovers partial selection and preserves prefix");
require(droppedSelectionKeys == 0 && enterPresses == 0, "snippet recovery consumes missing arrows without Enter");
require(ks::readClipboard(app->window).text() == sentinel, "snippet recovery restores clipboard");
if (snippetRecoveryOnly) { finish(); break; }
stage = 62;
break;
case 62:
if (!captured) saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
app->settings.snippets = {{L";r", L"unexpected{enter}"}};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"prefix ", false);
@@ -1038,8 +1058,9 @@ int main(int argc, char **argv)
SetWindowSubclass(field, observeEnter, 1, 0);
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
stage = 20;
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
stage = 62;
snippetRecoveryOnly = argc > 1 && std::string(argv[1]) == "--snippet-recovery-only";
if (snippetRecoveryOnly || (argc > 1 && std::string(argv[1]) == "--snippets-only"))
stage = 64;
SetTimer(host, 1, 100, nullptr);
auto forwarding = SetWindowsHookExW(WH_KEYBOARD_LL,forwardingHook,GetModuleHandleW(nullptr),0);
require(forwarding!=nullptr,"downstream forwarding fixture installed before application hook");
+168
View File
@@ -0,0 +1,168 @@
#include "lan.h"
#include "lan_pairing.h"
#include <iostream>
#include <tlhelp32.h>
namespace
{
int passed{};
void check(bool ok,const char *name) {if(!ok) throw std::runtime_error(name);++passed;}
template<class F> void rejects(F f,const char *name) {bool failed=false;try{f();}catch(...){failed=true;}check(failed,name);}
unsigned threads()
{
HANDLE snapshot=CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD,0);
if(snapshot==INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot inspect test threads");
THREADENTRY32 entry{sizeof(entry)};unsigned count=0;
if(Thread32First(snapshot,&entry)) do {if(entry.th32OwnerProcessID==GetCurrentProcessId()) ++count;} while(Thread32Next(snapshot,&entry));
CloseHandle(snapshot);return count;
}
}
int main(int argc,char **argv)
{
using namespace ks;using namespace ks::lan;
try
{
const std::string a(32,'a'),b(32,'b'),c(32,'c'),code="0123456789abcdef0123456789abcdef";
check(normalizeCode("0123-4567-89AB-CDEF-0123-4567-89ab-cdef")==code,"code accepts groups and case");
rejects([]{normalizeCode("123456");},"short guessable codes rejected");
rejects([]{normalizeCode(std::string(32,'x'));},"invalid code rejected");
check(identifier(randomHex(16)),"cryptographic instance ID");
check(mac("key","The quick brown fox jumps over the lazy dog")=="f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", "HMAC SHA256 known vector");
check(equal("abc","abc")&&!equal("abc","abd")&&!equal("abc","ab"),"proof comparison");
Pairing left(a,b,code,100),right(b,a,code,100),wrong(b,a,std::string(32,'f'),100);
check(wrong.receive(left.offer(),101).empty(),"different code cannot acknowledge");
auto forged=left.offer();forged["id"]=c;
check(right.receive(forged,101).empty(),"identity substitution rejected");
forged=left.offer();forged["version"]=2;
check(right.receive(forged,101).empty(),"unsupported protocol rejected");
forged=left.offer();forged["to"]=c;
check(right.receive(forged,101).empty(),"wrong destination rejected");
auto ackR=right.receive(left.offer(),101),ackL=left.receive(right.offer(),101);
check(ackR.size()==1&&ackL.size()==1,"mutual offers acknowledged");
check(!left.confirmed&&!right.confirmed,"discovery and offers are not trust");
auto confirmL=left.receive(ackR[0],102),confirmR=right.receive(ackL[0],102);
check(confirmL.size()==1&&confirmR.size()==1,"authenticated challenges confirmed");
left.receive(confirmR[0],103);right.receive(confirmL[0],103);
check(left.confirmed&&right.confirmed&&left.derived==right.derived,"both endpoints derive same pair key");
check(left.derived!=code&&left.derived.size()==64,"pair key separate from setup code");
Pairing retry(a,b,code,200);
check(retry.receive(ackR[0],201).empty()&&!retry.confirmed,"old ACK cannot pair new attempt");
check(retry.receive(confirmR[0],201).empty()&&!retry.confirmed,"old confirmation cannot pair new attempt");
Pairing expired(b,a,code,100);
check(expired.receive(left.offer(),100+PairLifetime).empty(),"pairing window expires");
check(left.receive(right.offer(),104).size()==1,"duplicate UDP offer is safely acknowledged");
rejects([&]{Pairing self(a,a,code,0);},"self-pair rejected");
auto root=std::filesystem::temp_directory_path()/wide("ks-lan-test-"+randomHex(16));
std::filesystem::create_directory(root);
struct Cleanup{std::filesystem::path p;~Cleanup(){std::error_code ec;std::filesystem::remove_all(p,ec);}} cleanup{root};
auto request=[](LanService &service,Json j){auto s=j.dump();return Json::parse(service.command(Bytes(s.begin(),s.end())));};
std::string identity;
{
auto idleThreads=threads();
LanService service(root);
check(threads()==idleThreads,"disabled LAN creates no worker thread");
auto state=request(service,{{"action","status"}});
check(!state.at("enabled").get<bool>()&&state.at("peers").empty(),"disabled by default");
identity=state.at("id");
check(!std::filesystem::exists(root/L"lan-pairing.bin"),"viewing disabled status does not create config");
auto generated=request(service,{{"action","code"}}).at("code").get<std::string>();
check(identifier(generated),"generated pairing code has 128 bits");
rejects([&]{request(service,{{"action","pair"},{"peer",b},{"code",code}});},"cannot pair undiscovered peer while disabled");
request(service,{{"action","enable"},{"enabled",false}});
auto sealed=readFile(root/L"lan-pairing.bin",256*1024);
check(std::string(sealed.begin(),sealed.end()).find(identity)==std::string::npos,"local configuration protected by DPAPI");
if(argc>1&&std::string(argv[1])=="--network")
{
request(service,{{"action","enable"},{"enabled",true}});
Sleep(400);
auto active=request(service,{{"action","status"}});
check(active.at("enabled").get<bool>() && active.at("status").get<std::string>().find("LAN stopped:")==std::string::npos,"opt-in discovery starts successfully");
check(threads()>idleThreads,"enabled discovery has a worker");
request(service,{{"action","enable"},{"enabled",false}});
check(threads()==idleThreads,"disabling discovery joins its worker");
// Reopening immediately exercises socket cleanup and exclusive bind.
request(service,{{"action","enable"},{"enabled",true}});Sleep(200);
check(request(service,{{"action","status"}}).at("status").get<std::string>().find("LAN stopped:")==std::string::npos,"discovery port released and reusable");
request(service,{{"action","enable"},{"enabled",false}});
}
}
{
LanService restored(root);auto state=request(restored,{{"action","status"}});
check(state.at("id")==identity&&!state.at("enabled").get<bool>(),"stable local instance ID and disabled state restored");
}
{
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
saved["trusted"][b]={{"name","Other KSwitcher"},{"key",left.derived}};
auto json=saved.dump();atomicWrite(root/L"lan-pairing.bin",protect(Bytes(json.begin(),json.end())));
LanService restored(root);auto state=request(restored,{{"action","status"}});
check(state["peers"].size()==1 && state["peers"][0].at("paired").get<bool>() && !state["peers"][0].at("online").get<bool>(),"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<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;}
}
+76
View File
@@ -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;}}
+417
View File
@@ -0,0 +1,417 @@
#include <winsock2.h>
#include "lan_input.h"
#include "lan_mesh.h"
#include <iostream>
#include <deque>
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;}
template<class F>void 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<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)&&!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](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)
{
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
{
Desktop a,b;std::deque<InputFrame> 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)
{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);
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()
{
{
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);
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<InputFrame> 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");
}
}
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");
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 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");
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");
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);
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);
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;
while((!client.ready()||!server.ready())&&GetTickCount64()<deadline)
{
server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);
}
check(client.ready()&&server.ready(),"real TCP peers mutually authenticate with paired key");
check(received.empty(),"handshake produces no input events");
client.poll(htonl(INADDR_LOOPBACK),{f},received,GetTickCount64());
for(int n=0;n<100&&received.empty();++n){server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
check(received.size()==1&&received[0]==f,"real TCP channel delivers one ordered authenticated key");received.clear();
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");
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");
}
{
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,std::string(64,'e'),0,port);
std::vector<InputFrame> 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<InputFrame> 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();hubChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
}
+21
View File
@@ -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;}}
+2 -2
View File
@@ -145,9 +145,9 @@ int main()
}
{
Settings scoped = local;
scoped.snippets.push_back({L"test", L"Only Teams", L"teams-scope", {L"ms-teams"}});
scoped.snippets.push_back({L"test", L"Work apps", L"teams-scope", {L"ms-teams", L"outlook"}});
auto wire=sharedSyncSettings(scoped);
require(restoreSyncSettings(wire, local, {}).snippets[1].apps==std::vector<std::wstring>{L"ms-teams"}, "sync preserves app scope");
require(restoreSyncSettings(wire, local, {}).snippets[1].apps==std::vector<std::wstring>{L"ms-teams", L"outlook"}, "sync preserves multiple app scopes");
auto leftScope=j,rightScope=j;
leftScope["snippets"].push_back({{"id","left-scope"},{"trigger","same"},{"text","left"},{"apps",Json::array({"notepad"})}});
rightScope["snippets"].push_back({{"id","right-scope"},{"trigger","same"},{"text","right"},{"apps",Json::array({"ms-teams"})}});
+28
View File
@@ -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();