diff --git a/CMakeLists.txt b/CMakeLists.txt index c6892bd..3b6893f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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$<$: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) diff --git a/LAN-PREVIEW.md b/LAN-PREVIEW.md new file mode 100644 index 0000000..30a079f --- /dev/null +++ b/LAN-PREVIEW.md @@ -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. diff --git a/README.md b/README.md index be26e2b..5349656 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.ps1 b/build.ps1 index b460f36..7b3f15f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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' } diff --git a/src/app.cpp b/src/app.cpp index ca3710f..9d836bf 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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(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(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 | diff --git a/src/app.h b/src/app.h index b9814fa..8598de2 100644 --- a/src/app.h +++ b/src/app.h @@ -4,6 +4,7 @@ #include "update.h" #include "sync.h" #include "chat_context.h" +#include "lan.h" #include #include #include @@ -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 &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; HANDLE historyProcess{}; std::mutex jobsMutex; std::condition_variable jobsReady; diff --git a/src/config_main.cpp b/src/config_main.cpp index dacdbc2..ec81c29 100644 --- a/src/config_main.cpp +++ b/src/config_main.cpp @@ -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 &choices) { auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) : diff --git a/src/configuration.cpp b/src/configuration.cpp index 323975a..c775289 100644 --- a/src/configuration.cpp +++ b/src/configuration.cpp @@ -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(message.lpData); + reply = app.lanCommand(Bytes(first, first + message.cbData)); + break; + } case ConfigSyncConflicts: reply = app.syncConflictData(); break; diff --git a/src/configuration.h b/src/configuration.h index 82a2fb1..0110a20 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -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); diff --git a/src/lan.cpp b/src/lan.cpp new file mode 100644 index 0000000..8fb2e31 --- /dev/null +++ b/src/lan.cpp @@ -0,0 +1,228 @@ +#include +#include +#include +#include "lan.h" +#include "lan_pairing.h" +#include +#include +#include +#include + +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 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 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 peers; + Json trusted=Json::object(); + std::unique_ptr pending; + bool enabled{}; + std::atomic 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().size()!=64|| + !entry.at("name").is_string()||entry.at("name").get().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(); + 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(); + 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(id,peer,j.at("code").get(),GetTickCount64()); + status="Waiting for the same code and Pair on the other KSwitcher (2 minutes)..."; + } + if(action=="forget") { auto peer=j.at("peer").get(); trusted.erase(peer); if(pending&&pending->peer==peer) pending.reset(); save(); status="Pair removed on this computer."; } + Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()}}; + 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(path)) {} +LanService::~LanService()=default; +Bytes LanService::command(const Bytes &command) { return impl->command(command); } +} diff --git a/src/lan.h b/src/lan.h new file mode 100644 index 0000000..b38995c --- /dev/null +++ b/src/lan.h @@ -0,0 +1,17 @@ +#pragma once +#include "platform.h" +#include + +namespace ks +{ +// Local-only discovery and pairing. No input/clipboard receiver is exposed yet. +class LanService +{ + struct Impl; + std::unique_ptr impl; + public: + explicit LanService(const std::filesystem::path &); + ~LanService(); + Bytes command(const Bytes &); +}; +} diff --git a/src/lan_pairing.cpp b/src/lan_pairing.cpp new file mode 100644 index 0000000..0a16da9 --- /dev/null +++ b/src/lan_pairing.cpp @@ -0,0 +1,97 @@ +#include "lan_pairing.h" +#include +#include + +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= 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 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 receive(const Json &, uint64_t now); +}; +} diff --git a/src/ui.cpp b/src/ui.cpp index f86a07b..339c7ab 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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); diff --git a/src/ui_lan.inl b/src/ui_lan.inl new file mode 100644 index 0000000..637f874 --- /dev/null +++ b/src/ui_lan.inl @@ -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 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.push_back(id); + std::wstring state=!peer.at("online").get() ? L"Offline" : peer.at("verified").get() ? L"Paired / verified" : + peer.at("paired").get() ? L"Paired / checking identity" : L"Available / not paired"; + auto text=wide(peer.at("name").get())+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())+L" ["+wide(v.snapshot.at("id").get().substr(0,8))+L"]"); + value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get()) : 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 grouped; for(size_t i=0;iapp,{{"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);} +} diff --git a/tests/chat_profiles.cpp b/tests/chat_profiles.cpp index 1bcbe1e..5e310e6 100644 --- a/tests/chat_profiles.cpp +++ b/tests/chat_profiles.cpp @@ -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{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"; diff --git a/tests/lan.cpp b/tests/lan.cpp new file mode 100644 index 0000000..7e01460 --- /dev/null +++ b/tests/lan.cpp @@ -0,0 +1,106 @@ +#include "lan.h" +#include "lan_pairing.h" +#include +#include + +namespace +{ +int passed{}; +void check(bool ok,const char *name) {if(!ok) throw std::runtime_error(name);++passed;} +template 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()&&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(); + 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() && active.at("status").get().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().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(),"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() && !state["peers"][0].at("online").get(),"saved pair remains visible offline"); + check(state.dump().find(left.derived)==std::string::npos,"status never exposes stored pair keys"); + request(restored,{{"action","forget"},{"peer",b}}); + check(request(restored,{{"action","status"}})["peers"].empty(),"forget removes offline pair"); + } + std::cout<<"Passed "<