Add opt-in KSwitcher LAN discovery and mutual code pairing preview

This commit is contained in:
agrynco committed 2026-11-06 15:30:06 +02:00
1 parent 346af2256f
commit 9b565dd55e
17 files changed
+743 -21

No files matched your search

+10 -2
View File
@@ -19,10 +19,10 @@ foreach(utilityTarget StayHere StayHereTest)
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
endif()
endforeach()
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
add_library(kscore src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
target_link_libraries(kscore PUBLIC ws2_32 bcrypt iphlpapi winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
@@ -89,6 +89,14 @@ else()
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
enable_testing()
add_executable(KSwitcherLanTests tests/lan.cpp)
target_link_libraries(KSwitcherLanTests PRIVATE kscore)
if(MSVC)
set_property(TARGET KSwitcherLanTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherLanTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_test(NAME lan-pairing-regressions COMMAND KSwitcherLanTests)
add_executable(KSwitcherChatProfileTests tests/chat_profiles.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherChatProfileTests PRIVATE kscore)
if(MSVC)
+79
View File
@@ -0,0 +1,79 @@
# LAN discovery and pairing preview
This development branch starts the built-in replacement for Mouse Without Borders.
It adds discovery and authenticated pairing inside the existing KSwitcher resident
process. It is **not yet a mouse/keyboard or clipboard transport**. Do not disable
MWB on the strength of this preview.
## Try it on two Windows computers
1. Run this development build on both computers, on the same private IPv4 subnet.
2. Open Settings → Tools → KSwitcher computers.
3. Enable discovery on both. The list contains running LAN-enabled KSwitcher
instances, not computers returned by a generic Windows/network scan.
4. Match the computer name and eight-character instance-ID prefix displayed on
the other computer. Select each other in the lists.
5. Generate a code once. Enter that same 32-character code on both computers,
then click Pair on both within two minutes. Spaces, hyphens and uppercase
hexadecimal characters are accepted. A different code never establishes trust.
6. Both sides should report Paired; authenticated presence appears as
“Paired / verified”. An offline pair stays listed so it can be forgotten.
Discovery uses UDP 45883 on private IPv4 and link-local interfaces. Windows may
require an inbound firewall allowance for KSwitcher on the private network; the
preview does not modify firewall rules. Networks that block broadcasts, separate
VLANs, public IPv4 and IPv6-only networks are not supported yet. A second build
cannot listen on the same discovery port on the same computer.
Changes in this window apply immediately. Closing the window does not cancel a
pending pairing; use Cancel pairing. Disabling discovery stops its worker and
closes its socket. Forget pair removes local trust; forget it on both computers
for bilateral removal. No input can be sent or received in this milestone.
## Identity, pairing and storage
An instance ID belongs to a KSwitcher data directory, not a hostname/IP address.
It persists when the LAN configuration is first saved. Discovery announcements
contain only protocol/version, instance ID, computer name and a fresh challenge.
Discovered names and addresses are untrusted; they never authorize input.
The generated code has 128 bits of randomness from Windows CNG. It is deliberately
not a guessable short PIN. HMAC-SHA256 proofs bind the source/destination IDs and
fresh nonces; both endpoints confirm the handshake before saving the pair.
Replayed confirmations cannot complete a new attempt. The shared pair key is
separate from the setup code. The code is never transmitted or logged.
Local identity, enablement and trust records are stored in DPAPI-protected
`data/lan-pairing.bin`. They are separate from settings export and OpenBao sync.
Do not clone this file between installations: each instance needs its own identity.
Unverified/offline status does not remove a previously saved pair. Discovery is
bounded to 64 visible instances and stale discoveries expire after 12 seconds.
A newly started pairing window lasts two minutes. Presence challenges rotate so
captured replies cannot indefinitely keep a disconnected instance verified.
Disabled discovery starts no worker and opens no sockets. Enabling it adds one
worker thread within KSwitcher; the existing configurator uses local IPC. There
is no new executable, service, WebView or runtime dependency.
## Next integration milestone
- An authenticated encrypted input channel using the stored pair key, separate
from UDP discovery. Pairing alone must never inject input.
- Left/right/top/bottom screen placement and cursor crossing at the screen edge
from the first input prototype, not hotkey-based normal switching.
- Reuse the resident low-level hooks. Forwarded keys must not expand snippets or
invoke actions on the source PC; received keys run through the destination path.
- Release pressed keys/buttons and return local control on disconnection, timeout,
suspend, lock, disable or process shutdown. Keep an emergency return chord.
- Text/image clipboard transport with bounded sizes and loop suppression follows
reliable input handoff. File transfer and secure-desktop control are later work.
## Validation
`KSwitcherLanTests` covers cryptographic test vectors, wrong codes, identity and
protocol substitution, mutual confirmation, replay, expiry, DPAPI persistence,
and no worker while disabled. `--network` additionally exercises real discovery
start/stop/rebind. Settings UI tests cover the child window, disabled actions,
empty list, parent reactivation and unchanged Save state. Two physical computers
are still required to validate discovery across adapters/firewalls and pairing
under real packet loss; this preview does not claim that validation.
+13
View File
@@ -10,6 +10,19 @@ 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.
## Development preview: KSwitcher computers
The current development branch adds opt-in LAN discovery and same-code pairing
inside the resident process. See [LAN-PREVIEW.md](LAN-PREVIEW.md) for setup and
limits. This preview does not yet forward mouse/keyboard input or clipboard data.
The planned input mode switches both mouse and keyboard together at screen edges.
**Українською.** У поточній гілці розробки додано пошук запущених екземплярів
KSwitcher у локальній мережі та спарювання однаковим кодом на обох ПК. Модуль
вбудований у резидентний процес і за замовчуванням вимкнений. Передача миші,
клавіатури й буфера ще не підключена. Миша та клавіатура перемикатимуться разом
при переході курсора через край екрана.
## Run
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
+22 -18
View File
@@ -15,14 +15,14 @@ Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -
Push-Location $root
try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
$libs = @('-lws2_32','-lbcrypt','-liphlpapi','-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
if ($LASTEXITCODE) { throw 'StayHere build failed' }
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
foreach ($name in @('lan','lan_pairing','core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
@@ -35,18 +35,22 @@ try {
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
if ($Test) {
& $compiler @common -Werror tests/lan.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe
if ($LASTEXITCODE) { throw 'LAN test build failed' }
& .\build\KSwitcherLanTests.exe
if ($LASTEXITCODE) { throw 'LAN pairing tests failed' }
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
@@ -55,11 +59,11 @@ try {
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
& .\build\KSwitcherUtilitiesTests.exe
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
& $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
& $compiler @common tests/dictionary.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
& .\build\KSwitcherDictionaryTests.exe
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
& $compiler @common tests/sync.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
if ($LASTEXITCODE) { throw 'Sync test build failed' }
& .\build\KSwitcherSyncTests.exe
if ($LASTEXITCODE) { throw 'Sync tests failed' }
@@ -67,19 +71,19 @@ try {
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
& .\build\KSwitcherScrollTests.exe
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
& $compiler @common tests/tests.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
& $compiler @common tests/input.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/chat_profiles.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
& $compiler @common tests/chat_profiles.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
if ($LASTEXITCODE) { throw 'Chat profile test build failed' }
& .\build\KSwitcherChatProfileTests.exe
if ($LASTEXITCODE) { throw 'Chat profile tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
& $compiler @common tests/screenshot_ui.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
@@ -87,7 +91,7 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
try {
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
& $compiler @common tests/screenshot_registration.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
& $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
@@ -99,7 +103,7 @@ try {
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
}
if ($Far) {
& $compiler @common tests/far_snippets.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
& $compiler @common tests/far_snippets.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
if ($LASTEXITCODE) { throw 'Far snippet test build failed' }
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
@@ -111,28 +115,28 @@ try {
Copy-Item $executablePath,(Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe') -Destination $historyFixture
Set-Content (Join-Path $historyFixture '.local-dev') 'Isolated history integration test'
$historyTest = Join-Path $historyFixture 'KSwitcherHistoryHostTests.exe'
& $compiler @common tests/history_host.cpp build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
& $compiler @common tests/history_host.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
if ($LASTEXITCODE) { throw 'History host test build failed' }
& $historyTest
if ($LASTEXITCODE) { throw 'History host tests failed' }
$snapshotTest = Join-Path (Split-Path $executablePath) 'KSwitcherClipboardSnapshotTests.exe'
& $compiler @common tests/clipboard_snapshot.cpp build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
& $compiler @common tests/clipboard_snapshot.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
if ($LASTEXITCODE) { throw 'Clipboard snapshot test build failed' }
& $snapshotTest
if ($LASTEXITCODE) { throw 'Clipboard snapshot tests failed' }
$captureTest = Join-Path (Split-Path $executablePath) 'KSwitcherImageCaptureTests.exe'
& $compiler @common tests/image_capture.cpp build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
& $compiler @common tests/image_capture.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
if ($LASTEXITCODE) { throw 'Image capture test build failed' }
& $captureTest
if ($LASTEXITCODE) { throw 'Image capture tests failed' }
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
& $compiler @common tests/integration.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
}
if ($Ocr) {
$ocrTest = Join-Path (Split-Path $executablePath) 'KSwitcherOcrTests.exe'
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest
& $compiler @common tests/ocr.cpp build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest
if ($LASTEXITCODE) { throw 'OCR test build failed' }
& $ocrTest
if ($LASTEXITCODE) { throw 'OCR tests failed' }
+9
View File
@@ -559,6 +559,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
}
App::~App()
{
lanService.reset();
stopBrowserChatMonitor();
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
if (historyProcess) CloseHandle(historyProcess);
@@ -721,6 +722,11 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
return true;
}
Bytes App::lanCommand(const Bytes &command)
{
if (!lanService) lanService = std::make_unique<LanService>(data);
return lanService->command(command);
}
void App::applySettings(const Settings &requested)
{
Settings s = requested;
@@ -2332,6 +2338,9 @@ int App::execute()
scheduleHistoryOcr(settings);
addTray();
if (!smoke) startBrowserChatMonitor();
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
try { lanService = std::make_unique<LanService>(data); }
catch (const std::exception &e) { report(L"LAN module: " + wide(e.what())); }
SetTimer(window, 1, 60000, nullptr);
if (!smoke) SetTimer(window, 6, 1000, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
+3
View File
@@ -4,6 +4,7 @@
#include "update.h"
#include "sync.h"
#include "chat_context.h"
#include "lan.h"
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -92,6 +93,7 @@ class App
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
Bytes syncConflictData();
Bytes lanCommand(const Bytes &);
void pollSync();
static App *current;
@@ -99,6 +101,7 @@ class App
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
HHOOK keyboardHook{}, mouseHook{};
std::unique_ptr<LanService> lanService;
HANDLE historyProcess{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
+1
View File
@@ -112,6 +112,7 @@ void App::reloadLayouts()
}
void App::clearHistory() { request(*this, ConfigClearHistory); }
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
Bytes App::lanCommand(const Bytes &bytes) { return request(*this, ConfigLan, bytes); }
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
{
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
+7
View File
@@ -40,6 +40,13 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
{
switch (message.dwData)
{
case ConfigLan:
{
if (!message.cbData || message.cbData > 4096) throw std::runtime_error("Invalid LAN request.");
auto first = static_cast<const unsigned char *>(message.lpData);
reply = app.lanCommand(Bytes(first, first + message.cbData));
break;
}
case ConfigSyncConflicts:
reply = app.syncConflictData();
break;
+1 -1
View File
@@ -7,7 +7,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
ConfigSyncResolve = ConfigRead + 10, ConfigEnd = ConfigRead + 11;
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigEnd = ConfigRead + 12;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
+228
View File
@@ -0,0 +1,228 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iphlpapi.h>
#include "lan.h"
#include "lan_pairing.h"
#include <atomic>
#include <mutex>
#include <thread>
#include <map>
namespace ks
{
using lan::Json;
namespace
{
Bytes encoded(const Json &j) { auto s=j.dump(); return Bytes(s.begin(),s.end()); }
bool localAddress(ULONG network)
{
auto ip=ntohl(network);
return (ip>>24)==10 || (ip>>20)==0xac1 || (ip>>16)==0xc0a8 || (ip>>16)==0xa9fe;
}
std::string hostName()
{
wchar_t name[256]{}; DWORD size=256;
return GetComputerNameW(name,&size) ? utf8(std::wstring(name,size)) : "KSwitcher";
}
std::vector<ULONG> broadcasts()
{
ULONG size=16384; Bytes data(size);
auto flags=GAA_FLAG_SKIP_ANYCAST|GAA_FLAG_SKIP_MULTICAST|GAA_FLAG_SKIP_DNS_SERVER;
auto result=GetAdaptersAddresses(AF_INET,flags,nullptr,(IP_ADAPTER_ADDRESSES*)data.data(),&size);
if(result==ERROR_BUFFER_OVERFLOW) { data.resize(size); result=GetAdaptersAddresses(AF_INET,flags,nullptr,(IP_ADAPTER_ADDRESSES*)data.data(),&size); }
std::vector<ULONG> out;
if(result!=NO_ERROR) return out;
for(auto a=(IP_ADAPTER_ADDRESSES*)data.data();a;a=a->Next)
if(a->OperStatus==IfOperStatusUp)
for(auto u=a->FirstUnicastAddress;u;u=u->Next)
if(u->Address.lpSockaddr->sa_family==AF_INET && u->OnLinkPrefixLength>0 && u->OnLinkPrefixLength<31)
{
auto ip=((sockaddr_in*)u->Address.lpSockaddr)->sin_addr.s_addr;
if(localAddress(ip)) out.push_back(htonl(ntohl(ip) | (0xffffffffu >> u->OnLinkPrefixLength)));
}
return out;
}
}
struct LanService::Impl
{
struct Peer { std::string name,epoch; sockaddr_in address{}; uint64_t seen{},verified{}; };
std::filesystem::path path;
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
std::map<std::string,Peer> peers;
Json trusted=Json::object();
std::unique_ptr<lan::Pairing> pending;
bool enabled{};
std::atomic<bool> stopping{false};
std::mutex mutex;
std::thread worker;
explicit Impl(const std::filesystem::path &root) : path(root/L"lan-pairing.bin")
{
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");
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
for(auto &[key,entry]:trusted.items())
if(!lan::identifier(key)||key==id||entry.at("key").get<std::string>().size()!=64||
!entry.at("name").is_string()||entry.at("name").get<std::string>().size()>128)
throw std::runtime_error("Invalid paired computer key.");
}
else id=lan::randomHex(16);
if(enabled) start();
}
~Impl() { stop(); }
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted}}))); }
void start()
{
stopping=false; epoch=lan::randomHex(16); status="Searching for KSwitcher computers...";
worker=std::thread([this]{ run(); });
}
void stop()
{
stopping=true;
if(worker.joinable()) worker.join();
std::lock_guard lock(mutex); pending.reset(); peers.clear();
}
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
{
auto bytes=j.dump(); if(bytes.size()>2048) return;
sendto(socket,bytes.data(),int(bytes.size()),0,(const sockaddr*)&address,sizeof(address));
}
std::string heartbeat(const std::string &from,const std::string &to,const std::string &a,const std::string &b)
{ return "KSwitcher LAN v1|presence|"+from+"|"+to+"|"+a+"|"+b; }
void receive(SOCKET socket,const sockaddr_in &address,const Json &j,uint64_t now)
{
if(j.value("app","")!="KSwitcher-LAN" || j.value("version",0)!=1) return;
auto other=j.value("id",""); if(!lan::identifier(other)||other==id) return;
auto type=j.value("type","");
if(type=="hello")
{
auto display=j.value("name",""); auto session=j.value("epoch","");
if(display.empty()||display.size()>128||!lan::identifier(session)||
std::any_of(display.begin(),display.end(),[](unsigned char c){return c<32||c==127;})) return;
// Discovery is untrusted and never grants input access.
if(!peers.contains(other) && peers.size()>=64) return;
auto &peer=peers[other];
if(peer.epoch!=session || peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
if(trusted.contains(other))
{
auto response=lan::envelope("presence",id); response["to"]=other; response["epoch"]=epoch;
response["proof"]=lan::mac(trusted[other].at("key"),heartbeat(id,other,epoch,session));
send(socket,address,response);
}
return;
}
if(!peers.contains(other)||j.value("to","")!=id) return;
auto &peer=peers.at(other);
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
if(type=="presence" && trusted.contains(other) && j.value("epoch","")==peer.epoch &&
lan::equal(j.value("proof",""),lan::mac(trusted[other].at("key"),heartbeat(other,id,peer.epoch,epoch))))
{ peer.verified=now; return; }
if(pending && pending->peer==other)
{
for(const auto &response:pending->receive(j,now)) send(socket,address,response);
if(pending->confirmed && (!trusted.contains(other)||trusted[other].at("key")!=pending->derived))
{
auto previous=trusted;
trusted[other]={{"name",peer.name},{"key",pending->derived}};
try {save();} catch(...) {trusted=std::move(previous);throw;}
peer.verified=now; status="Paired. Input and clipboard sharing are not enabled in this preview.";
}
}
}
void run() noexcept
{
WSADATA startup{}; SOCKET socket=INVALID_SOCKET; bool winsock=false;
try
{
if(WSAStartup(MAKEWORD(2,2),&startup)) throw std::runtime_error("Cannot start Windows networking.");
winsock=true; socket=::socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
BOOL yes=TRUE; u_long nonblock=1;
if(socket==INVALID_SOCKET || setsockopt(socket,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes)) ||
setsockopt(socket,SOL_SOCKET,SO_BROADCAST,(char*)&yes,sizeof(yes)) || ioctlsocket(socket,FIONBIO,&nonblock))
throw std::runtime_error("Cannot open LAN discovery socket.");
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;
while(!stopping)
{
auto now=GetTickCount64();
{
std::lock_guard lock(mutex);
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;
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
nextHello=now+3000;
}
for(auto it=peers.begin();it!=peers.end();) {
if(now-it->second.seen>lan::PeerLifetime) it=peers.erase(it); else ++it;
}
if(pending && pending->expired(now)) {
status=pending->confirmed ? "Pair saved. Select a computer to manage it." : "Pairing expired. Enter the same code and click Pair on both computers.";
pending.reset();
}
if(pending && now>=nextOffer && peers.contains(pending->peer)) {
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
}
}
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,100000};
int ready=select(0,&read,nullptr,nullptr,&delay);
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
if(ready>0) for(int count=0;count<64;++count)
{
char buffer[2049]; sockaddr_in sender{}; int size=sizeof(sender);
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
if(received<=0) break;
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
try { auto j=Json::parse(buffer,buffer+received); std::lock_guard lock(mutex); receive(socket,sender,j,GetTickCount64()); }
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
}
}
}
catch(const std::exception &e) { std::lock_guard lock(mutex); status=std::string("LAN stopped: ")+e.what(); }
if(socket!=INVALID_SOCKET) closesocket(socket);
if(winsock) WSACleanup();
}
Bytes command(const Bytes &input)
{
auto j=Json::parse(input); auto action=j.value("action","status");
if(action=="enable")
{
stop();
bool previous=enabled; enabled=j.at("enabled").get<bool>();
try {save();} catch(...) {enabled=previous;if(enabled) start();throw;}
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
}
std::lock_guard lock(mutex);
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
if(action=="cancel") { pending.reset(); status="Pairing cancelled."; }
if(action=="pair")
{
auto peer=j.at("peer").get<std::string>();
if(!enabled||!peers.contains(peer)) throw std::runtime_error("Select an available KSwitcher computer first.");
if(!trusted.contains(peer)&&trusted.size()>=64) throw std::runtime_error("Remove an old pair before adding another computer.");
pending=std::make_unique<lan::Pairing>(id,peer,j.at("code").get<std::string>(),GetTickCount64());
status="Waiting for the same code and Pair on the other KSwitcher (2 minutes)...";
}
if(action=="forget") { auto peer=j.at("peer").get<std::string>(); trusted.erase(peer); if(pending&&pending->peer==peer) pending.reset(); save(); status="Pair removed on this computer."; }
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()}};
auto now=GetTickCount64();
for(const auto &[peer,p]:peers)
{
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
result["peers"].push_back({{"id",peer},{"name",p.name},{"address",ip},{"paired",trusted.contains(peer)},
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true}});
}
for(const auto &[peer,p]:trusted.items()) if(!peers.contains(peer))
result["peers"].push_back({{"id",peer},{"name",p.at("name")},{"address",""},{"paired",true},{"verified",false},{"online",false}});
return encoded(result);
}
};
LanService::LanService(const std::filesystem::path &path) : impl(std::make_unique<Impl>(path)) {}
LanService::~LanService()=default;
Bytes LanService::command(const Bytes &command) { return impl->command(command); }
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "platform.h"
#include <memory>
namespace ks
{
// Local-only discovery and pairing. No input/clipboard receiver is exposed yet.
class LanService
{
struct Impl;
std::unique_ptr<Impl> impl;
public:
explicit LanService(const std::filesystem::path &);
~LanService();
Bytes command(const Bytes &);
};
}
+97
View File
@@ -0,0 +1,97 @@
#include "lan_pairing.h"
#include <bcrypt.h>
#include <algorithm>
namespace ks::lan
{
namespace
{
void checked(NTSTATUS s) { if (s < 0) throw std::runtime_error("Windows cryptography failed."); }
std::string hex(const Bytes &data)
{
std::string out; out.reserve(data.size()*2);
for (auto b : data) { out += "0123456789abcdef"[b>>4]; out += "0123456789abcdef"[b&15]; }
return out;
}
std::string proof(const char *kind, const std::string &from, const std::string &to,
const std::string &a, const std::string &b = {})
{ return std::string("KSwitcher LAN v1|") + kind + "|" + from + "|" + to + "|" + a + "|" + b; }
}
std::string randomHex(size_t size)
{
if (size > 64) throw std::runtime_error("Invalid random size.");
Bytes bytes(size); checked(BCryptGenRandom(nullptr, bytes.data(), ULONG(size), BCRYPT_USE_SYSTEM_PREFERRED_RNG));
return hex(bytes);
}
bool identifier(const std::string &s)
{ return s.size()==32 && s.find_first_not_of("0123456789abcdef")==s.npos; }
std::string normalizeCode(const std::string &input)
{
std::string result;
for (auto c : input)
{
if (c=='-' || c==' ') continue;
if (c>='A' && c<='F') c+= 'a'-'A';
result+=c;
}
if (!identifier(result)) throw std::runtime_error("Use the same generated 32-character pairing code on both computers.");
return result;
}
std::string mac(const std::string &key, const std::string &text)
{
BCRYPT_ALG_HANDLE alg{}; BCRYPT_HASH_HANDLE hash{};
checked(BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, nullptr, BCRYPT_ALG_HANDLE_HMAC_FLAG));
struct Close { BCRYPT_ALG_HANDLE a; BCRYPT_HASH_HANDLE &h; ~Close(){if(h) BCryptDestroyHash(h); BCryptCloseAlgorithmProvider(a,0);} } close{alg,hash};
checked(BCryptCreateHash(alg,&hash,nullptr,0,(PUCHAR)key.data(),ULONG(key.size()),0));
checked(BCryptHashData(hash,(PUCHAR)text.data(),ULONG(text.size()),0));
Bytes out(32); checked(BCryptFinishHash(hash,out.data(),ULONG(out.size()),0)); return hex(out);
}
bool equal(const std::string &a, const std::string &b)
{
if (a.size()!=b.size()) return false;
unsigned diff=0; for(size_t i=0;i<a.size();++i) diff |= unsigned(a[i]^b[i]); return diff==0;
}
Json envelope(const std::string &type, const std::string &id)
{ return {{"app","KSwitcher-LAN"},{"version",1},{"type",type},{"id",id}}; }
Pairing::Pairing(std::string a,std::string b,const std::string &key,uint64_t now)
: local(std::move(a)),peer(std::move(b)),code(normalizeCode(key)),nonce(randomHex(16)),started(now)
{
if(!identifier(local)||!identifier(peer)||local==peer) throw std::runtime_error("Invalid KSwitcher peer.");
}
bool Pairing::expired(uint64_t now) const { return now-started >= PairLifetime; }
Json Pairing::offer() const
{
auto j=envelope("offer",local); j["to"]=peer; j["nonce"]=nonce;
j["proof"]=mac(code,proof("offer",local,peer,nonce)); return j;
}
std::vector<Json> Pairing::receive(const Json &j,uint64_t now)
{
if(expired(now)||j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||
j.value("id","")!=peer||j.value("to","")!=local) return {};
auto type=j.value("type",""); auto other=j.value("nonce","");
if(!identifier(other)) return {};
if(type=="offer")
{
if(!equal(j.value("proof",""),mac(code,proof("offer",peer,local,other)))) return {};
// A new attempt cannot replace a nonce already authenticated in this attempt.
if(!remoteNonce.empty() && remoteNonce!=other) return {};
remoteNonce=other;
derived=local<peer ? mac(code,proof("key",local,peer,nonce,other)) : mac(code,proof("key",peer,local,other,nonce));
auto ack=envelope("ack",local); ack["to"]=peer; ack["nonce"]=nonce; ack["echo"]=other;
ack["proof"]=mac(code,proof("ack",local,peer,other,nonce)); return {ack};
}
if(type=="ack" && j.value("echo","")==nonce)
{
if(!equal(j.value("proof",""),mac(code,proof("ack",peer,local,nonce,other)))) return {};
if(!remoteNonce.empty()&&remoteNonce!=other) return {};
remoteNonce=other;
derived=local<peer ? mac(code,proof("key",local,peer,nonce,other)) : mac(code,proof("key",peer,local,other,nonce));
acknowledged=true;
auto done=envelope("confirm",local); done["to"]=peer; done["nonce"]=nonce;
done["proof"]=mac(derived,proof("confirm",local,peer,nonce,other)); return {done};
}
if(type=="confirm" && acknowledged && other==remoteNonce &&
equal(j.value("proof",""),mac(derived,proof("confirm",peer,local,other,nonce)))) confirmed=true;
return {};
}
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "platform.h"
#include "../vendor/json.hpp"
namespace ks::lan
{
using Json = nlohmann::json;
inline constexpr unsigned Port = 45883;
inline constexpr uint64_t PairLifetime = 120000, PeerLifetime = 12000;
std::string randomHex(size_t bytes);
std::string normalizeCode(const std::string &);
std::string mac(const std::string &key, const std::string &text);
bool equal(const std::string &, const std::string &);
bool identifier(const std::string &);
Json envelope(const std::string &type, const std::string &id);
struct Pairing
{
std::string local, peer, code, nonce, remoteNonce, derived;
uint64_t started{};
bool acknowledged{}, confirmed{};
Pairing(std::string local, std::string peer, const std::string &code, uint64_t now);
bool expired(uint64_t now) const;
Json offer() const;
std::vector<Json> receive(const Json &, uint64_t now);
};
}
+3
View File
@@ -1528,6 +1528,7 @@ void showOcrOptions(HWND parent, SettingsView *v)
showRequestedWindow(w); SetForegroundWindow(w);
}
#include "ui_sync.inl"
#include "ui_lan.inl"
bool settingsChanged(HWND w, SettingsView *v)
{
try
@@ -1640,6 +1641,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
if (id == 262) { showLan(w, *v->app); return 0; }
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
if (id == 230)
{
@@ -2103,6 +2105,7 @@ void showSettings(App &app, bool visible)
button(w, L"View l&og...", 256, 24, 308, 210);
label(w, L"Inspect recent operations and diagnostics.", 254, 312, 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);
+105
View File
@@ -0,0 +1,105 @@
// Uses the resident service; the configurator never opens a discovery socket.
struct LanView
{
HWND parent{};
App *app{};
nlohmann::json snapshot;
std::vector<std::string> ids;
std::wstring error;
};
nlohmann::json lanRequest(App &app,const nlohmann::json &request)
{
auto text=request.dump(); auto reply=app.lanCommand(Bytes(text.begin(),text.end()));
return nlohmann::json::parse(reply);
}
std::string selectedLan(HWND w,LanView &v)
{
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
}
void refreshLan(HWND w,LanView &v)
{
auto selected=selectedLan(w,v);
auto next=lanRequest(*v.app,{{"action","status"}});
if(v.snapshot.is_null()||v.snapshot["peers"]!=next["peers"])
{
SendDlgItemMessageW(w,702,WM_SETREDRAW,FALSE,0);
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids.clear();
for(const auto &peer:next["peers"])
{
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);
}
SendDlgItemMessageW(w,702,WM_SETREDRAW,TRUE,0); InvalidateRect(GetDlgItem(w,702),nullptr,TRUE);
}
v.snapshot=std::move(next);
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
for(const auto &peer:v.snapshot["peers"]) if(peer.at("id")==selected) {online=peer.at("online");paired=peer.at("paired");}
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get<std::string>()) : v.error);
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending);
EnableWindow(GetDlgItem(w,706),pending);
EnableWindow(GetDlgItem(w,707),paired);
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending);
}
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_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)) {DestroyWindow(w);return 0;}
try
{
if(msg==WM_TIMER) {refreshLan(w,*v);return 0;}
if(msg==WM_COMMAND)
{
int id=LOWORD(wp); auto peer=selectedLan(w,*v);
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {refreshLan(w,*v);return 0;}
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
v->error.clear();
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
if(id==704)
{
auto code=lanRequest(*v->app,{{"action","code"}}).at("code").get<std::string>();
std::string grouped; for(size_t i=0;i<code.size();++i) {if(i&&i%4==0) grouped+='-';grouped+=code[i];}
value(w,703,wide(grouped)); SetFocus(GetDlgItem(w,703)); SendDlgItemMessageW(w,703,EM_SETSEL,0,-1);
}
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
refreshLan(w,*v); return 0;
}
}
catch(const std::exception &e) {v->error=wide(e.what());value(w,708,v->error);}
return DefWindowProcW(w,msg,wp,lp);
}
void showLan(HWND parent,App &app)
{
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — pairing preview",lanProc,740,600);
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"Changes apply immediately. This preview pairs PCs; mouse, keyboard and clipboard sharing comes next.",18,512,700);
button(w,L"&Close",IDCANCEL,618,550,100);
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);}
}
+16
View File
@@ -283,6 +283,22 @@ int main()
SetDlgItemTextW(snippets,315,L"chrome.exe");
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
ks::showSettings(app);parent=app.settingsWindow;
SendMessageW(parent,WM_COMMAND,262,0);
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
require(!IsWindowEnabled(GetDlgItem(lan,707)),"cannot forget nonexistent pair");
require(SendDlgItemMessageW(lan,702,LB_GETCOUNT,0,0)==0,"disabled discovery lists no generic network devices");
require(!std::filesystem::exists(root/L"lan-pairing.bin"),"opening LAN settings does not enable or save discovery");
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
}
std::filesystem::remove_all(root);
std::cout << "Passed " << passed << " chat profile checks\n";
+106
View File
@@ -0,0 +1,106 @@
#include "lan.h"
#include "lan_pairing.h"
#include <iostream>
#include <tlhelp32.h>
namespace
{
int passed{};
void check(bool ok,const char *name) {if(!ok) throw std::runtime_error(name);++passed;}
template<class F> void rejects(F f,const char *name) {bool failed=false;try{f();}catch(...){failed=true;}check(failed,name);}
unsigned threads()
{
HANDLE snapshot=CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD,0);
if(snapshot==INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot inspect test threads");
THREADENTRY32 entry{sizeof(entry)};unsigned count=0;
if(Thread32First(snapshot,&entry)) do {if(entry.th32OwnerProcessID==GetCurrentProcessId()) ++count;} while(Thread32Next(snapshot,&entry));
CloseHandle(snapshot);return count;
}
}
int main(int argc,char **argv)
{
using namespace ks;using namespace ks::lan;
try
{
const std::string a(32,'a'),b(32,'b'),c(32,'c'),code="0123456789abcdef0123456789abcdef";
check(normalizeCode("0123-4567-89AB-CDEF-0123-4567-89ab-cdef")==code,"code accepts groups and case");
rejects([]{normalizeCode("123456");},"short guessable codes rejected");
rejects([]{normalizeCode(std::string(32,'x'));},"invalid code rejected");
check(identifier(randomHex(16)),"cryptographic instance ID");
check(mac("key","The quick brown fox jumps over the lazy dog")=="f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", "HMAC SHA256 known vector");
check(equal("abc","abc")&&!equal("abc","abd")&&!equal("abc","ab"),"proof comparison");
Pairing left(a,b,code,100),right(b,a,code,100),wrong(b,a,std::string(32,'f'),100);
check(wrong.receive(left.offer(),101).empty(),"different code cannot acknowledge");
auto forged=left.offer();forged["id"]=c;
check(right.receive(forged,101).empty(),"identity substitution rejected");
forged=left.offer();forged["version"]=2;
check(right.receive(forged,101).empty(),"unsupported protocol rejected");
forged=left.offer();forged["to"]=c;
check(right.receive(forged,101).empty(),"wrong destination rejected");
auto ackR=right.receive(left.offer(),101),ackL=left.receive(right.offer(),101);
check(ackR.size()==1&&ackL.size()==1,"mutual offers acknowledged");
check(!left.confirmed&&!right.confirmed,"discovery and offers are not trust");
auto confirmL=left.receive(ackR[0],102),confirmR=right.receive(ackL[0],102);
check(confirmL.size()==1&&confirmR.size()==1,"authenticated challenges confirmed");
left.receive(confirmR[0],103);right.receive(confirmL[0],103);
check(left.confirmed&&right.confirmed&&left.derived==right.derived,"both endpoints derive same pair key");
check(left.derived!=code&&left.derived.size()==64,"pair key separate from setup code");
Pairing retry(a,b,code,200);
check(retry.receive(ackR[0],201).empty()&&!retry.confirmed,"old ACK cannot pair new attempt");
check(retry.receive(confirmR[0],201).empty()&&!retry.confirmed,"old confirmation cannot pair new attempt");
Pairing expired(b,a,code,100);
check(expired.receive(left.offer(),100+PairLifetime).empty(),"pairing window expires");
check(left.receive(right.offer(),104).size()==1,"duplicate UDP offer is safely acknowledged");
rejects([&]{Pairing self(a,a,code,0);},"self-pair rejected");
auto root=std::filesystem::temp_directory_path()/wide("ks-lan-test-"+randomHex(16));
std::filesystem::create_directory(root);
struct Cleanup{std::filesystem::path p;~Cleanup(){std::error_code ec;std::filesystem::remove_all(p,ec);}} cleanup{root};
auto request=[](LanService &service,Json j){auto s=j.dump();return Json::parse(service.command(Bytes(s.begin(),s.end())));};
std::string identity;
{
auto idleThreads=threads();
LanService service(root);
check(threads()==idleThreads,"disabled LAN creates no worker thread");
auto state=request(service,{{"action","status"}});
check(!state.at("enabled").get<bool>()&&state.at("peers").empty(),"disabled by default");
identity=state.at("id");
check(!std::filesystem::exists(root/L"lan-pairing.bin"),"viewing disabled status does not create config");
auto generated=request(service,{{"action","code"}}).at("code").get<std::string>();
check(identifier(generated),"generated pairing code has 128 bits");
rejects([&]{request(service,{{"action","pair"},{"peer",b},{"code",code}});},"cannot pair undiscovered peer while disabled");
request(service,{{"action","enable"},{"enabled",false}});
auto sealed=readFile(root/L"lan-pairing.bin",256*1024);
check(std::string(sealed.begin(),sealed.end()).find(identity)==std::string::npos,"local configuration protected by DPAPI");
if(argc>1&&std::string(argv[1])=="--network")
{
request(service,{{"action","enable"},{"enabled",true}});
Sleep(400);
auto active=request(service,{{"action","status"}});
check(active.at("enabled").get<bool>() && active.at("status").get<std::string>().find("LAN stopped:")==std::string::npos,"opt-in discovery starts successfully");
check(threads()>idleThreads,"enabled discovery has a worker");
request(service,{{"action","enable"},{"enabled",false}});
check(threads()==idleThreads,"disabling discovery joins its worker");
// Reopening immediately exercises socket cleanup and exclusive bind.
request(service,{{"action","enable"},{"enabled",true}});Sleep(200);
check(request(service,{{"action","status"}}).at("status").get<std::string>().find("LAN stopped:")==std::string::npos,"discovery port released and reusable");
request(service,{{"action","enable"},{"enabled",false}});
}
}
{
LanService restored(root);auto state=request(restored,{{"action","status"}});
check(state.at("id")==identity&&!state.at("enabled").get<bool>(),"stable local instance ID and disabled state restored");
}
{
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
saved["trusted"][b]={{"name","Other KSwitcher"},{"key",left.derived}};
auto json=saved.dump();atomicWrite(root/L"lan-pairing.bin",protect(Bytes(json.begin(),json.end())));
LanService restored(root);auto state=request(restored,{{"action","status"}});
check(state["peers"].size()==1 && state["peers"][0].at("paired").get<bool>() && !state["peers"][0].at("online").get<bool>(),"saved pair remains visible offline");
check(state.dump().find(left.derived)==std::string::npos,"status never exposes stored pair keys");
request(restored,{{"action","forget"},{"peer",b}});
check(request(restored,{{"action","status"}})["peers"].empty(),"forget removes offline pair");
}
std::cout<<"Passed "<<passed<<" LAN discovery/pairing checks\n";
}
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
}