Compare commits

...
23 changed files with 216 additions and 90 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.57 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.59 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+6 -2
View File
@@ -1,4 +1,4 @@
# LAN mouse, keyboard and clipboard — v0.6.57 (experimental) # LAN mouse, keyboard and clipboard — v0.6.58 (experimental)
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
code on both computers, and shares mouse and keyboard through a screen edge. code on both computers, and shares mouse and keyboard through a screen edge.
@@ -10,7 +10,7 @@ is not implemented by clipboard synchronization.
## Set up multiple computers ## Set up multiple computers
1. Install the v0.6.57 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs. 1. Install the v0.6.58 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs. 2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs.
Only running LAN-enabled KSwitcher instances appear, not generic network devices. Only running LAN-enabled KSwitcher instances appear, not generic network devices.
3. Match the computer name and instance-ID prefix displayed on the other PC. 3. Match the computer name and instance-ID prefix displayed on the other PC.
@@ -116,3 +116,7 @@ Three physical computers still need an end-to-end trial for their adapters,
firewalls, displays and application privilege levels. The automated TCP tests firewalls, displays and application privilege levels. The automated TCP tests
use local sockets, and input-state tests use simulated desktop environments; use local sockets, and input-state tests use simulated desktop environments;
they do not claim a completed three-machine hardware test. they do not claim a completed three-machine hardware test.
## v0.6.58 connection fixes
Saved trust keeps TCP listeners available even when discovery announcements are missed. Working authenticated input sessions no longer depend on discovery presence. Pair saved is distinct from input connected; the list refreshes every second. A changed instance ID requires a new pairing. Keep each PC's own data folder during upgrades. Connection state changes and listener Windows errors are written to the diagnostic log.
+9 -5
View File
@@ -10,14 +10,18 @@ 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. [Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
## KSwitcher computers — v0.6.57 (experimental) ## KSwitcher computers — v0.6.58 (experimental)
TCP input listeners and authenticated connections survive missed discovery announcements.
The list distinguishes saved pairing from connected input and refreshes every second.
Keep each PC’s own data folder during updates; a different instance ID requires new pairing.
Arrange multiple paired PCs on a monitor grid. Mouse and keyboard follow shared Arrange multiple paired PCs on a monitor grid. Mouse and keyboard follow shared
edges, including A → B → C and back. New text and images go to every online paired edges, including A → B → C and back. New text and images go to every online paired
PC's history and active clipboard. The history Computers menu filters by multiple PC's history and active clipboard. The history Computers menu filters by multiple
sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md). sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md).
**Update every participating PC to v0.6.57:** input protocol v2 is incompatible **Update every participating PC to v0.6.58:** input protocol v2 is incompatible
with older one-pair builds. Keep each PC's own data directory. Pair every input with older one-pair builds. Keep each PC's own data directory. Pair every input
source with all destinations, arrange the same physical layout on each PC and source with all destinations, arrange the same physical layout on each PC and
enable input sharing. Empty cells and offline neighbors block passage. enable input sharing. Empty cells and offline neighbors block passage.
@@ -25,7 +29,7 @@ enable input sharing. Empty cells and offline neighbors block passage.
**Українською.** Розставте кілька спарованих ПК по сітці: миша й клавіатура **Українською.** Розставте кілька спарованих ПК по сітці: миша й клавіатура
переходять через спільний край, зокрема A → B → C і назад. Нові текст та зображення переходять через спільний край, зокрема A → B → C і назад. Нові текст та зображення
передаються всім доступним спарованим ПК в історію й активний буфер. Фільтр передаються всім доступним спарованим ПК в історію й активний буфер. Фільтр
Computers в історії має чекбокси. Оновіть усі ПК до v0.6.57, залишивши кожному Computers в історії має чекбокси. Оновіть усі ПК до v0.6.58, залишивши кожному
власну папку data. Спаруйте джерело вводу з усіма потрібними ПК й задайте власну папку data. Спаруйте джерело вводу з усіма потрібними ПК й задайте
однакове фізичне розташування на кожному. Передача файлів не реалізована. однакове фізичне розташування на кожному. Передача файлів не реалізована.
@@ -586,8 +590,8 @@ Nonconflicting changes synchronize immediately, and unresolved conflicts survive
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings. - **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
- **Download remote** replaces this PC's shared settings with the server profile. - **Download remote** replaces this PC's shared settings with the server profile.
- If the server has no profile yet, choose **Upload local** to create it. - If the server has no profile yet, choose **Upload local** to create it.
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download; - Unsaved edits in Settings are not synchronized. After a successful **Download remote**, the open Settings window refreshes automatically and keeps the selected tab. The download confirmation warns that unsaved edits will be discarded.
the existing stale-editor check prevents an old Settings window overwriting downloaded changes. - The status distinguishes downloading, downloaded settings, no local changes and failure. HTTP 400 errors identify authentication, download or upload; field errors remain visible until the next action.
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
Binary file not shown.
+6 -10
View File
@@ -1,19 +1,15 @@
# Latest Windows release # Latest Windows release
## v0.6.57 — 2026-11-06 ## v0.6.59 — 2026-11-09
## Changes ## Changes
- Settings Save merges independent edits with newer resident settings. Same-field conflicts offer a choice; declining keeps the window and draft open. - Show clear download/upload progress and results, including the server version, applied changes or unchanged local settings. Preserve action errors instead of replacing them with an older status.
- Multiple paired PCs on a draggable monitor grid; mouse and keyboard follow neighboring edges, including A → B → C and back. Connections reconnect independently. - Identify OpenBao HTTP 400 failures as authentication, download or write failures instead of labeling every failure as a rejected write. Failed downloads do not apply remote settings.
- New text, formatted text and screenshots synchronize to all online paired PCs: history and active clipboard. Newer local copies are preserved during incoming transfers. - Refresh the already-open Settings window after a successful Download remote, preserving its selected tab. This also works if the Sync dialog was closed while the request was running. Confirmation explains that unsaved Settings edits will be discarded.
- Clipboard history has a persistent Computers checkbox filter and source labels. Lossless PNG transport avoids duplicate image payloads. - Retain all v0.6.58 LAN, clipboard, screenshot, snippet and chat-profile behavior. No sync schema or compatibility change.
- LAN modifier chords, DPI-scaled motion, cursor parking/return, automatic conversion ownership and queued wheel reversal fixes. Local shortcut latches reset on ownership changes.
- Stable paired status, pairing/input button states, persistent selection and a fixed-size LAN window with scrolling where needed.
## Validation ## Validation
Passed: 5 settings-save merge checks, 203 LAN input/three-peer TCP, 289 clipboard transfer, 43 pairing, 828 settings/chat UI, 570 input, 341 native and 126 sync checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Three physical PCs have not been verified together. LAN remains experimental; follow LAN-PREVIEW.md. Passed: 132 sync, 571 input/UI, 341 native, 829 chat-profile/UI, 205 LAN input, 289 LAN clipboard, 43 LAN pairing and 5 settings-save merge checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Regression tests use an isolated mock OpenBao transport, not a live user account.
**Update ALL LAN participants to v0.6.57. Input protocol v2 cannot connect to older one-pair builds.** Pair every physical input source with each destination. Configure the same physical arrangement on each PC; layouts are not automatically synchronized. File transfer, offline clipboard backfill, cross-PC dragging and secure desktops remain unsupported.
## Upgrade and compatibility ## Upgrade and compatibility
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation. Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
+1 -1
View File
@@ -1 +1 @@
f3ec4f7a62b4479929d94d6d6f7f68edb8b4a47b84edac7258afb64dbe07aef2 KSwitcher-win-x64.zip f0d7d15534db21c17b113a25cc07d59a68fa151ecf995ac360ac476751171a9a KSwitcher-win-x64.zip
+35 -35
View File
@@ -1,86 +1,86 @@
{ {
"version": "0.6.57",
"schema": 1,
"files": [ "files": [
{ {
"size": 2106368, "sha256": "a7be2f749082bb6417b4cf7e8c8ea221ef4f7ba63dabc3cbbe8694ab1a3d942c",
"path": "KSwitcher.exe", "path": "KSwitcher.exe",
"sha256": "3b7315e1545f70739fd1a7c099ddeab28362b323521d93879fa43fdd76133f8e" "size": 2108416
}, },
{ {
"size": 2003456, "sha256": "c8a88364c9967ce5fd150519d632b14460e225a0cfae51a8de50f39782d8010c",
"path": "KSwitcher.Config.exe", "path": "KSwitcher.Config.exe",
"sha256": "a819ec42030990725326730396680388cbcb870dd00c0a97856cb1615f9db27b" "size": 2005504
}, },
{ {
"size": 3151360, "sha256": "68eed5abefed0208ef7bdd431094c7357c53bd3ca03dfd91aaeb7dbb4f291e06",
"path": "KSwitcher.Screenshot.exe", "path": "KSwitcher.Screenshot.exe",
"sha256": "8473697245c2c961496135eaaff6de9e9a19e917f2fd7299a16068210223d396" "size": 3153408
}, },
{ {
"size": 4401, "sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"path": "LICENSE", "path": "LICENSE",
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c" "size": 4401
}, },
{ {
"size": 1096, "sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
"path": "json-LICENSE.txt", "path": "json-LICENSE.txt",
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831" "size": 1096
}, },
{ {
"size": 79896, "sha256": "87f396a6bf988725b14bf5b6eb7c2b60ce33ff51828290ce40e136c66942cc09",
"path": "README.md", "path": "README.md",
"sha256": "8b4b1e905a2f0a39d532e61136ec7eca473492a39a538eb073ed829307adefae" "size": 80168
}, },
{ {
"size": 7921, "sha256": "72e131bdf46d32a8a17fb352292bc8983c0e5a7ed84812218b11cf6b69c9afc4",
"path": "LAN-PREVIEW.md", "path": "LAN-PREVIEW.md",
"sha256": "2467b86bbad7d7dce1e0c65c8c0faf2320e4804e90a85784a058afa357127b6b" "size": 8376
}, },
{ {
"size": 732485, "sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
"path": "dictionaries/en.txt", "path": "dictionaries/en.txt",
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9" "size": 732485
}, },
{ {
"size": 20566, "sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt", "path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5" "size": 20566
}, },
{ {
"size": 2265, "sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
"path": "dictionaries/README.md", "path": "dictionaries/README.md",
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311" "size": 2265
}, },
{ {
"size": 1109134, "sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
"path": "dictionaries/ru.txt", "path": "dictionaries/ru.txt",
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849" "size": 1109134
}, },
{ {
"size": 1280, "sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
"path": "dictionaries/sources.json", "path": "dictionaries/sources.json",
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408" "size": 1280
}, },
{ {
"size": 940799, "sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
"path": "dictionaries/uk.txt", "path": "dictionaries/uk.txt",
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf" "size": 940799
}, },
{ {
"size": 993, "sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"path": "tools/README.txt", "path": "tools/README.txt",
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a" "size": 993
}, },
{ {
"size": 134656, "sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
"path": "tools/StayHere.exe", "path": "tools/StayHere.exe",
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376" "size": 134656
}, },
{ {
"size": 2796, "sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md", "path": "tools/StayHere-README.md",
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413" "size": 2796
} }
] ],
"schema": 1,
"version": "0.6.58"
} }
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,57,0 FILEVERSION 0,6,59,0
PRODUCTVERSION 0,6,57,0 PRODUCTVERSION 0,6,59,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.57" VALUE "FileVersion", "0.6.59"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+16 -2
View File
@@ -13,7 +13,7 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
validateSyncConfig(c); validateSyncConfig(c);
if (action == 4 && (c != conflictConnection || choices.empty())) if (action == 4 && (c != conflictConnection || choices.empty()))
throw std::runtime_error("Connection changed or no choices selected. Sync again."); throw std::runtime_error("Connection changed or no choices selected. Sync again.");
if (busy) return L"Wait for the current text operation to finish."; if (busy) return syncStatus = L"Sync did not start. Wait for the current text operation to finish, then try again.";
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff. syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
if (syncWorker.joinable()) syncWorker.join(); if (syncWorker.joinable()) syncWorker.join();
syncConnection = c; syncConnection = c;
@@ -22,7 +22,8 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
auto availableLayouts = layouts; auto availableLayouts = layouts;
syncRunning = true; syncRunning = true;
syncDone = false; syncDone = false;
syncStatus = L"Connecting to OpenBao..."; syncStatus = action == 2 ? L"Downloading remote settings from OpenBao..." :
action == 3 ? L"Uploading local settings to OpenBao..." : L"Connecting to OpenBao...";
nextSync = GetTickCount64() + SyncIntervalMs; nextSync = GetTickCount64() + SyncIntervalMs;
try try
{ {
@@ -42,6 +43,9 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
} }
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; } catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
catch (const std::exception &e) { result.status = wide(e.what()); } catch (const std::exception &e) { result.status = wide(e.what()); }
if (!result.success)
result.status = std::wstring(action == 2 ? L"Download failed. Local settings were not changed. " :
action == 3 ? L"Upload failed. " : L"Synchronization failed. ") + result.status;
syncResult = std::move(result); syncResult = std::move(result);
syncDone.store(true); syncDone.store(true);
}); });
@@ -76,8 +80,18 @@ void App::pollSync()
{ {
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot)); atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
applySettings(next); applySettings(next);
syncStatus += L" Changes applied on this PC; local backup saved.";
} }
else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." :
L" No local settings were changed; conflicts still need your choice.";
saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base); saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base);
if (result.downloaded)
{
if (settingsWindow) SendMessageW(settingsWindow, ConfigSettingsDownloaded, 0, 0);
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
config && configurationPeer(config, L"KSwitcher.Config.exe"))
PostMessageW(config, ConfigSettingsDownloaded, 0, 0);
}
pendingSyncConflicts = std::move(result.conflicts); pendingSyncConflicts = std::move(result.conflicts);
conflictConnection = syncConnection; conflictConnection = syncConnection;
SYSTEMTIME time{}; GetLocalTime(&time); SYSTEMTIME time{}; GetLocalTime(&time);
+13
View File
@@ -52,6 +52,19 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
} }
return FALSE; return FALSE;
} }
if (message == ConfigSettingsDownloaded)
{
try
{
app->settings = decodeSettings(request(*app, ConfigRead), app->layouts);
if (app->settingsWindow) SendMessageW(app->settingsWindow, ConfigSettingsDownloaded, 0, 0);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"Unable to refresh downloaded settings", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_APP + 20) if (message == WM_APP + 20)
{ {
try try
+1
View File
@@ -2,6 +2,7 @@
#include "app.h" #include "app.h"
namespace ks namespace ks
{ {
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1, inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3, ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5, ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
+2 -2
View File
@@ -242,8 +242,8 @@ struct LanService::Impl
if(peers.contains(target))send(socket,peers.at(target).address,packet); if(peers.contains(target))send(socket,peers.at(target).address,packet);
}); });
clipboardActive=clipboard->active(); clipboardActive=clipboard->active();
if(shareInput)for(auto &[peer,entry]:trusted.items())if(peers.contains(peer)) if(shareInput)for(auto &[peer,entry]:trusted.items())
endpoints.push_back({peer,entry.at("key"),peers.at(peer).address.sin_addr.s_addr}); endpoints.push_back({peer,entry.at("key"),peers.contains(peer)?peers.at(peer).address.sin_addr.s_addr:0});
if(now>=nextHello) if(now>=nextHello)
{ {
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online. epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
+16 -5
View File
@@ -207,7 +207,7 @@ std::string InputChannel::status()const{return impl->state;}
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");} void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
struct InputHub::Impl struct InputHub::Impl
{ {
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{}; }; struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{};std::string lastState; };
struct Pending {SOCKET socket;uint32_t address;uint64_t since;}; struct Pending {SOCKET socket;uint32_t address;uint64_t since;};
std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET; std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET;
std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{}; std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{};
@@ -217,19 +217,29 @@ struct InputHub::Impl
void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now) void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now)
{ {
for(auto it=links.begin();it!=links.end();){ for(auto it=links.begin();it!=links.end();){
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port&&e.address==it->second.endpoint.address;}); auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port;});
if(match==endpoints.end())it=links.erase(it);else ++it; if(match==endpoints.end())it=links.erase(it);else ++it;
} }
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){ for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);link.epoch=++epoch;links.emplace(e.id,std::move(link)); Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);link.epoch=++epoch;links.emplace(e.id,std::move(link));
} }
// Discovery is a hint, not the lifetime of authenticated input sessions.
// Keep the last usable address across missed announcements. A new address
// is used only once a channel is down, never to tear down working input.
for(auto &e:endpoints){if(e.id==local)continue;auto &link=links.at(e.id);
if(e.address&&!link.channel->ready()&&e.address!=link.endpoint.address){
link.channel->disconnect();link.endpoint.address=e.address;
}
}
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;}); bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();} if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();}
if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){ if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port); listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){ if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){
closeSocket(listener);retry=now+1500;error="TCP input port is unavailable."; auto detail="TCP input port "+std::to_string(port)+" is unavailable (Windows error "+std::to_string(WSAGetLastError())+").";
}else error.clear(); if(error!=detail)log(L"LAN input: "+wide(detail));
closeSocket(listener);retry=now+1500;error=std::move(detail);
}else {error.clear();log(L"LAN input listener ready on TCP "+std::to_wstring(port));}
} }
if(listener!=INVALID_SOCKET)for(int n=0;n<16&&pending.size()<64;++n){ if(listener!=INVALID_SOCKET)for(int n=0;n<16&&pending.size()<64;++n){
sockaddr_in a{};int size=sizeof(a);SOCKET sock=accept(listener,(sockaddr*)&a,&size);if(sock==INVALID_SOCKET)break; sockaddr_in a{};int size=sizeof(a);SOCKET sock=accept(listener,(sockaddr*)&a,&size);if(sock==INVALID_SOCKET)break;
@@ -243,7 +253,7 @@ struct InputHub::Impl
else if(count>=int(length+4)){ else if(count>=int(length+4)){
done=true; done=true;
try{auto hello=Json::parse(bytes+4,bytes+4+length);auto id=hello.value("id","");auto link=links.find(id); try{auto hello=Json::parse(bytes+4,bytes+4+length);auto id=hello.value("id","");auto link=links.find(id);
if(id<local&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&link->second.endpoint.address==it->address&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET; if(id<local&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
}catch(const std::exception &){} }catch(const std::exception &){}
} }
} }
@@ -252,6 +262,7 @@ struct InputHub::Impl
for(auto &[id,link]:links){ for(auto &[id,link]:links){
std::vector<InputFrame> frames,received;for(auto &f:out)if(f.peer==id&&(!f.epoch||f.epoch==link.epoch))enqueueInput(frames,f.frame); std::vector<InputFrame> frames,received;for(auto &f:out)if(f.peer==id&&(!f.epoch||f.epoch==link.epoch))enqueueInput(frames,f.frame);
link.channel->poll(link.endpoint.address,frames,received,now); link.channel->poll(link.endpoint.address,frames,received,now);
auto state=link.channel->status();if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
if(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;} if(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;}
for(auto &f:received)in.push_back({id,f,link.epoch}); for(auto &f:received)in.push_back({id,f,link.epoch});
} }
+14 -6
View File
@@ -39,7 +39,7 @@ class OpenBao final : public SyncProvider
SyncConfig config; SyncConfig config;
SyncTransport transport; SyncTransport transport;
std::wstring token; std::wstring token;
Json response(unsigned status, const Bytes &result, bool missing) Json response(unsigned status, const Bytes &result, bool missing, const std::wstring &path, const wchar_t *method)
{ {
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large."); if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
if (status == 404 && missing) if (status == 404 && missing)
@@ -50,7 +50,14 @@ class OpenBao final : public SyncProvider
return nullptr; return nullptr;
} }
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy."); if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
if (status == 400) throw std::runtime_error("OpenBao rejected the write or its version changed. Sync again; check KV v2 configuration."); if (status == 400)
{
if (path.starts_with(L"/v1/auth/"))
throw std::runtime_error("OpenBao rejected the authentication request (HTTP 400). Check username, password and login mount.");
if (std::wstring_view(method) == L"GET")
throw std::runtime_error("OpenBao rejected the download request (HTTP 400). Check KV v2 mount and profile path.");
throw std::runtime_error("OpenBao rejected the write (HTTP 400) or its version changed. Sync again; check KV v2 configuration.");
}
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + "."); if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
return result.empty() ? Json::object() : parse(result); return result.empty() ? Json::object() : parse(result);
} }
@@ -59,7 +66,7 @@ class OpenBao final : public SyncProvider
if (transport) if (transport)
{ {
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{}); auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
return response(reply.status, reply.body, missing); return response(reply.status, reply.body, missing, path, method);
} }
URL_COMPONENTS url{sizeof(url)}; URL_COMPONENTS url{sizeof(url)};
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1; url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
@@ -98,7 +105,7 @@ class OpenBao final : public SyncProvider
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large."); if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
result.insert(result.end(), chunk, chunk + received); result.insert(result.end(), chunk, chunk + received);
} }
return response(status, result, missing); return response(status, result, missing, path, method);
} }
public: public:
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t)) explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
@@ -306,9 +313,10 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
auto revision = remote.revision; auto revision = remote.revision;
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision); if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
return {true, merged.local, revision, return {true, merged.local, revision,
merged.conflicts.empty() ? L"Synchronized. Server version " + std::to_wstring(revision) + L"." : merged.conflicts.empty() ? std::wstring(action == SyncAction::Download ? L"Downloaded remote settings. Server version " :
action == SyncAction::Upload ? L"Uploaded local settings. Server version " : L"Synchronized. Server version ") + std::to_wstring(revision) + L"." :
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).", L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
merged.base, merged.conflicts}; merged.base, merged.conflicts, action == SyncAction::Download};
} }
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts) Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
{ {
+1
View File
@@ -63,6 +63,7 @@ struct SyncResult
std::wstring status; std::wstring status;
Bytes base; Bytes base;
std::vector<SyncConflict> conflicts; std::vector<SyncConflict> conflicts;
bool downloaded{};
}; };
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction, SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {}, const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
+4
View File
@@ -1688,6 +1688,10 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
switch (msg) switch (msg)
{ {
case ConfigSettingsDownloaded:
v->draft = v->app->settings;
fillSettings(w, v);
return 0;
case WM_ENABLE: case WM_ENABLE:
if (wp) updateSettingsState(w, v); if (wp) updateSettingsState(w, v);
break; break;
+7 -1
View File
@@ -36,7 +36,13 @@ void refreshLan(HWND w,LanView &v)
for(const auto &peer:next["peers"]) for(const auto &peer:next["peers"])
{ {
auto id=peer.at("id").get<std::string>();ids.push_back(id); auto id=peer.at("id").get<std::string>();ids.push_back(id);
auto state=peer.at("paired").get<bool>() ? L"Paired" : L"Not paired"; std::wstring state=L"Not paired";
if(peer.at("paired").get<bool>()){
bool connected=next.value("inputReady",nlohmann::json::object()).contains(id);
state=connected?L"Pair saved — input connected":
!next.value("shareInput",false)?L"Pair saved — input disabled here":
!peer.value("online",false)?L"Pair saved — computer offline":L"Pair saved — input not connected";
}
rows.push_back(wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state); rows.push_back(wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state);
} }
// Presence/challenge refreshes must not repaint or reset the pairing list. // Presence/challenge refreshes must not repaint or reset the pairing list.
+9 -4
View File
@@ -1,5 +1,5 @@
#include "ui_sync_conflicts.inl" #include "ui_sync_conflicts.inl"
struct SyncView { HWND parent; App *app; }; struct SyncView { HWND parent; App *app; bool actionError{}; };
void updateSyncControls(HWND w, App &app) void updateSyncControls(HWND w, App &app)
{ {
bool enabled = checked(w, 601); bool enabled = checked(w, 601);
@@ -17,7 +17,8 @@ void updateSyncControls(HWND w, App &app)
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization."; status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
else else
status = app.syncCommand(); status = app.syncCommand();
if (value(w, 608) != status) value(w, 608, status); auto *view = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if ((!view || !view->actionError) && value(w, 608) != status) value(w, 608, status);
} }
SyncConfig syncFields(HWND w) SyncConfig syncFields(HWND w)
{ {
@@ -47,6 +48,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
int id = LOWORD(wp); int id = LOWORD(wp);
if (id == 601 && HIWORD(wp) == BN_CLICKED) if (id == 601 && HIWORD(wp) == BN_CLICKED)
{ {
v->actionError = false;
updateSyncControls(w, *v->app); updateSyncControls(w, *v->app);
return 0; return 0;
} }
@@ -56,6 +58,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (id == IDCANCEL) { DestroyWindow(w); return 0; } if (id == IDCANCEL) { DestroyWindow(w); return 0; }
if (id == 609) if (id == 609)
{ {
v->actionError = false;
auto c = loadSyncConfig(v->app->data); auto c = loadSyncConfig(v->app->data);
c.enabled = false; c.enabled = false;
saveSyncConfig(v->app->data, c); saveSyncConfig(v->app->data, c);
@@ -69,6 +72,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} }
if (id == IDOK || id == 610 || id == 611) if (id == IDOK || id == 610 || id == 611)
{ {
v->actionError = false;
if (!checked(w, 601)) if (!checked(w, 601))
{ {
if (id != IDOK) return 0; if (id != IDOK) return 0;
@@ -80,7 +84,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0; return 0;
} }
if (id != IDOK && MessageBoxW(w, id == 610 ? if (id != IDOK && MessageBoxW(w, id == 610 ?
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." : L"Replace this PC's shared settings with the remote profile? A local backup will be saved. The open Settings window will refresh automatically; its unsaved edits will be discarded." :
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.", L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0; L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
auto c = syncFields(w); auto c = syncFields(w);
@@ -100,6 +104,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} }
catch (const std::exception &e) catch (const std::exception &e)
{ {
v->actionError = true; // Keep this action's error visible across status polling.
// Never display parser exceptions containing connection file contents. // Never display parser exceptions containing connection file contents.
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration."); value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
if (dynamic_cast<const std::runtime_error *>(&e)) if (dynamic_cast<const std::runtime_error *>(&e))
@@ -130,7 +135,7 @@ void showSyncOptions(HWND parent, App &app)
label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount); label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path); label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount); label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622); label(w, L"Sync uses saved settings. Download remote refreshes the open Settings window.", 18, 330, 622);
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL); edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 608, app.syncCommand()); value(w, 608, app.syncCommand());
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622); checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.57"; inline constexpr wchar_t AppVersion[] = L"0.6.59";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+22 -11
View File
@@ -7,6 +7,15 @@
namespace namespace
{ {
HWND findTestWindow(LPCWSTR className, LPCWSTR title)
{
HWND window=nullptr;
while((window=FindWindowExW(nullptr,window,className,title))) {
DWORD process{}; GetWindowThreadProcessId(window,&process);
if(process==GetCurrentProcessId()) return window;
}
return nullptr;
}
int passed{}; int passed{};
void require(bool ok, const char *message) void require(bool ok, const char *message)
{ {
@@ -141,31 +150,31 @@ int main()
SetDlgItemTextW(parent,240,originalLimit); SetDlgItemTextW(parent,240,originalLimit);
require(!saveEnabled(),"restoring valid original text clears dirty state"); require(!saveEnabled(),"restoring valid original text clears dirty state");
SendMessageW(parent,WM_COMMAND,257,0); SendMessageW(parent,WM_COMMAND,257,0);
HWND ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr); HWND ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
require(ocr!=nullptr,"OCR preferences open"); require(ocr!=nullptr,"OCR preferences open");
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128); wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
SetDlgItemTextW(ocr,503,L"eng"); SetDlgItemTextW(ocr,503,L"eng");
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0); SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
require(!saveEnabled(),"cancelled child changes leave Settings clean"); require(!saveEnabled(),"cancelled child changes leave Settings clean");
SendMessageW(parent,WM_COMMAND,257,0); SendMessageW(parent,WM_COMMAND,257,0);
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr); ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng"); SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng");
SendMessageW(ocr,WM_COMMAND,IDOK,0); SendMessageW(ocr,WM_COMMAND,IDOK,0);
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save"); require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
SendMessageW(parent,WM_COMMAND,257,0); SendMessageW(parent,WM_COMMAND,257,0);
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr); ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
SetDlgItemTextW(ocr,503,languages); SetDlgItemTextW(ocr,503,languages);
SendMessageW(ocr,WM_COMMAND,IDOK,0); SendMessageW(ocr,WM_COMMAND,IDOK,0);
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state"); require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
SendMessageW(parent,WM_COMMAND,255,0); SendMessageW(parent,WM_COMMAND,255,0);
HWND automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr); HWND automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
require(automatic!=nullptr,"automatic correction preferences open"); require(automatic!=nullptr,"automatic correction preferences open");
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED; bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED); CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
SendMessageW(automatic,WM_COMMAND,IDOK,0); SendMessageW(automatic,WM_COMMAND,IDOK,0);
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save"); require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
SendMessageW(parent,WM_COMMAND,255,0); SendMessageW(parent,WM_COMMAND,255,0);
automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr); automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED); CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED);
SendMessageW(automatic,WM_COMMAND,IDOK,0); SendMessageW(automatic,WM_COMMAND,IDOK,0);
require(!saveEnabled(),"reverting auto-correction draft disables Save"); require(!saveEnabled(),"reverting auto-correction draft disables Save");
@@ -201,7 +210,7 @@ int main()
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)&notification); SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)&notification);
require(!saveEnabled(),"changing tabs leaves Settings clean"); require(!saveEnabled(),"changing tabs leaves Settings clean");
SendMessageW(parent, WM_COMMAND, 259, 0); SendMessageW(parent, WM_COMMAND, 259, 0);
HWND dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr); HWND dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent"); require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat"); int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
require(index >= 0, "saved chat listed"); require(index >= 0, "saved chat listed");
@@ -220,7 +229,7 @@ int main()
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog"); require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
require(!saveEnabled(),"cancelled chat edits do not enable Save"); require(!saveEnabled(),"cancelled chat edits do not enable Save");
SendMessageW(parent, WM_COMMAND, 259, 0); SendMessageW(parent, WM_COMMAND, 259, 0);
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr); dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat"); index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0); SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0); SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
@@ -236,7 +245,7 @@ int main()
parent = app.settingsWindow; parent = app.settingsWindow;
require(!saveEnabled(),"reopened saved Settings is clean"); require(!saveEnabled(),"reopened saved Settings is clean");
SendMessageW(parent, WM_COMMAND, 259, 0); SendMessageW(parent, WM_COMMAND, 259, 0);
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr); dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat"); index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0); SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0); SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
@@ -286,7 +295,7 @@ int main()
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope"); 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; ks::showSettings(app);parent=app.settingsWindow;
SendMessageW(parent,WM_COMMAND,262,0); SendMessageW(parent,WM_COMMAND,262,0);
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr); HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window"); require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer"); require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing"); require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
@@ -312,8 +321,10 @@ int main()
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end()))); auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp); ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp);
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0); SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0);
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr); HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings"); require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
wchar_t pairState[512]{};SendDlgItemMessageW(lan,702,LB_GETTEXT,0,(LPARAM)pairState);
require(std::wstring(pairState).find(L"Pair saved — input disabled here")!=std::wstring::npos,"saved pairing does not imply an active input connection");
require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls"); require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls");
SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0); SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0);
SendMessageW(lan,WM_TIMER,1,0); SendMessageW(lan,WM_TIMER,1,0);
@@ -346,7 +357,7 @@ int main()
{"positions",{{b,J::array({-1,0})},{c,J::array({1,0})}}}}; {"positions",{{b,J::array({-1,0})},{c,J::array({1,0})}}}};
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end()))); auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
ks::App layoutApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(layoutApp); ks::App layoutApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(layoutApp);
SendMessageW(layoutApp.settingsWindow,WM_COMMAND,262,0);auto lan=FindWindowW(L"KSwitcher.LAN",nullptr);auto grid=GetDlgItem(lan,721); SendMessageW(layoutApp.settingsWindow,WM_COMMAND,262,0);auto lan=findTestWindow(L"KSwitcher.LAN",nullptr);auto grid=GetDlgItem(lan,721);
RECT r{};GetClientRect(grid,&r); RECT r{};GetClientRect(grid,&r);
auto point=[&](int column,int row){return MAKELPARAM((2*column+1)*r.right/10,(2*row+1)*r.bottom/6);}; auto point=[&](int column,int row){return MAKELPARAM((2*column+1)*r.right/10,(2*row+1)*r.bottom/6);};
SendMessageW(grid,WM_LBUTTONDOWN,MK_LBUTTON,point(1,1));SendMessageW(grid,WM_MOUSEMOVE,MK_LBUTTON,point(1,0));SendMessageW(grid,WM_LBUTTONUP,0,point(1,0)); SendMessageW(grid,WM_LBUTTONDOWN,MK_LBUTTON,point(1,1));SendMessageW(grid,WM_MOUSEMOVE,MK_LBUTTON,point(1,0));SendMessageW(grid,WM_LBUTTONUP,0,point(1,0));
+16
View File
@@ -1,4 +1,5 @@
#include "app.h" #include "app.h"
#include "configuration.h"
#include "ui_scroll.h" #include "ui_scroll.h"
#include "ui_geometry.h" #include "ui_geometry.h"
#include "screenshot.h" #include "screenshot.h"
@@ -752,6 +753,21 @@ int main(int argc, char **argv) {
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported"); require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false); ks::showSettings(app,false);
{ {
auto window = app.settingsWindow;
auto previous = app.settings;
TabCtrl_SetCurSel(GetDlgItem(window,260),3);
app.settings.awake = !previous.awake;
app.settings.maxItems = previous.maxItems + 1;
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
wchar_t count[32]{};
GetDlgItemTextW(window,240,count,32);
require(app.settingsWindow == window &&
SendDlgItemMessageW(window,205,BM_GETCHECK,0,0) == (app.settings.awake ? BST_CHECKED : BST_UNCHECKED) &&
std::stoi(count) == app.settings.maxItems && TabCtrl_GetCurSel(GetDlgItem(window,260)) == 3,
"download refreshes existing settings controls while preserving window and active tab");
app.settings = previous;
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
TabCtrl_SetCurSel(GetDlgItem(window,260),0);
auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose); auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose);
require(timer != 0, "settings close prompt responder"); require(timer != 0, "settings close prompt responder");
SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0); SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0);
+14
View File
@@ -317,6 +317,11 @@ void hubChecks()
auto deadline=GetTickCount64()+5000; auto deadline=GetTickCount64()+5000;
while((hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2||hubs[2]->ready().size()!=2)&&GetTickCount64()<deadline)pump(); while((hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2||hubs[2]->ready().size()!=2)&&GetTickCount64()<deadline)pump();
check(hubs[0]->ready().size()==2&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"three TCP hubs authenticate all independent pair channels on one listener per PC"); check(hubs[0]->ready().size()==2&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"three TCP hubs authenticate all independent pair channels on one listener per PC");
auto generation=hubs[0]->ready();
for(auto &list:endpoints)for(auto &e:list)e.address=0;
for(int n=0;n<20;++n)pump();
check(hubs[0]->ready()==generation&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"expired discovery does not destroy authenticated channels");
for(auto &list:endpoints)for(auto &e:list)e.address=htonl(INADDR_LOOPBACK);
InputFrame keyFrame{InputType::Key,7,'A',0,1},cross{InputType::Cross,8,2,30000}; InputFrame keyFrame{InputType::Key,7,'A',0,1},cross{InputType::Cross,8,2,30000};
hubs[0]->poll(endpoints[0],{{ids[1],keyFrame},{ids[2],cross}},incoming[0],GetTickCount64()); hubs[0]->poll(endpoints[0],{{ids[1],keyFrame},{ids[2],cross}},incoming[0],GetTickCount64());
for(int n=0;n<100&&(incoming[1].empty()||incoming[2].empty());++n)pump(); for(int n=0;n<100&&(incoming[1].empty()||incoming[2].empty());++n)pump();
@@ -327,6 +332,15 @@ void hubChecks()
check(hubs[0]->ready().contains(ids[2])&&hubs[0]->ready().at(ids[2])==survivor,"one computer disconnect leaves other authenticated channel intact"); check(hubs[0]->ready().contains(ids[2])&&hubs[0]->ready().at(ids[2])==survivor,"one computer disconnect leaves other authenticated channel intact");
for(int n=0;n<300&&(hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2);++n)pump(1800); for(int n=0;n<300&&(hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2);++n)pump(1800);
check(hubs[0]->ready().size()==2&&hubs[0]->ready().at(ids[1])!=old,"mesh reconnect restores channel with fresh ownership generation"); check(hubs[0]->ready().size()==2&&hubs[0]->ready().at(ids[1])!=old,"mesh reconnect restores channel with fresh ownership generation");
{
auto serverPort=freePort();InputHub server(ids[2],serverPort),client(ids[0],freePort());
std::vector<InputEndpoint> unknown{{ids[0],secret,0}},known{{ids[2],secret,htonl(INADDR_LOOPBACK),serverPort}};
std::vector<RoutedInput> a,b;
for(int n=0;n<300&&(server.ready().empty()||client.ready().empty());++n){
server.poll(unknown,{},a,GetTickCount64());client.poll(known,{},b,GetTickCount64());Sleep(1);
}
check(server.ready().size()==1&&client.ready().size()==1,"saved pair listens and authenticates without receiving a discovery announcement");
}
endpoints[0].erase(endpoints[0].begin());hubs[0]->poll(endpoints[0],{},incoming[0],GetTickCount64()+1800); endpoints[0].erase(endpoints[0].begin());hubs[0]->poll(endpoints[0],{},incoming[0],GetTickCount64()+1800);
check(!hubs[0]->ready().contains(ids[1])&&hubs[0]->ready().contains(ids[2]),"forgetting one pair revokes only its channel"); check(!hubs[0]->ready().contains(ids[1])&&hubs[0]->ready().contains(ids[2]),"forgetting one pair revokes only its channel");
} }
+19 -1
View File
@@ -298,6 +298,9 @@ int main()
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions"); require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
result = synchronize(provider, base, bytes(left), SyncAction::Download); result = synchronize(provider, base, bytes(left), SyncAction::Download);
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes"); require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
require(result.downloaded, "explicit download requests a settings window refresh");
require(result.status.find(L"Downloaded remote settings. Server version 1.") != std::wstring::npos,
"explicit download reports its result and server version");
provider.race = true; provider.race = true;
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected"); rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote"); require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
@@ -395,10 +398,25 @@ int main()
rejects([&] { bao->read(); }, "permission denied is not absent"); rejects([&] { bao->read(); }, "permission denied is not absent");
readStatus = 302; readStatus = 302;
rejects([&] { bao->read(); }, "redirect response rejected"); rejects([&] { bao->read(); }, "redirect response rejected");
readStatus = 400;
try { bao->read(); require(false, "download HTTP 400 must fail"); }
catch (const std::runtime_error &e) {
require(std::string(e.what()).find("download request (HTTP 400)") != std::string::npos,
"download failure is not mislabeled as a write failure");
}
writeStatus = 400; writeStatus = 400;
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated"); rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
} }
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle"); require(logins == 1 && revokes == 1 && reads == 6 && writes == 2, "temporary login lifecycle");
try {
makeSyncProvider(config, [](const std::wstring &, const std::wstring &, const std::wstring &, const Bytes &) {
return SyncHttpResponse{400, {}};
});
require(false, "login HTTP 400 must fail");
} catch (const std::runtime_error &e) {
require(std::string(e.what()).find("authentication request (HTTP 400)") != std::string::npos,
"login failure identifies authentication instead of a write");
}
forgetSyncPassword(config); forgetSyncPassword(config); forgetSyncPassword(config); forgetSyncPassword(config);
++checks; ++checks;
std::filesystem::remove_all(root); std::filesystem::remove_all(root);