Add opt-in KSwitcher LAN discovery and mutual code pairing preview
This commit is contained in:
1 parent
346af2256f
commit
9b565dd55e
17 files changed
+743
-21
No files matched your search
+10
-2
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) :
|
||||
|
||||
@@ -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
@@ -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
@@ -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); }
|
||||
}
|
||||
@@ -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 &);
|
||||
};
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);}
|
||||
}
|
||||
@@ -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
@@ -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;}
|
||||
}
|
||||
Reference in new issue
Block a user