Compare commits

...
39 changed files with 1766 additions and 324 deletions

No files matched your search

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