diff --git a/CMakeLists.txt b/CMakeLists.txt index 404d8a3..141f188 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.58 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.59 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index f00e8c8..0c04a23 100644 --- a/README.md +++ b/README.md @@ -590,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. - **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. -- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download; - the existing stale-editor check prevents an old Settings window overwriting downloaded changes. +- 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 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 network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 6217f1c..33c3f6a 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index aec797b..aba7df0 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,16 +1,15 @@ # Latest Windows release -## v0.6.58 — 2026-11-06 +## v0.6.59 — 2026-11-09 ## Changes -- Keep trusted TCP input listeners and connections alive across missing discovery announcements. Retain the last known address for reconnection; authenticate incoming trusted peers even before discovery sees their address. -- Show saved pairing separately from actual input connectivity: connected, disabled here, offline or not connected. Background identity verification does not flash the list. -- Log TCP listener failures with the Windows error and peer connection state changes for troubleshooting. +- 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. +- 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. +- 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. +- Retain all v0.6.58 LAN, clipboard, screenshot, snippet and chat-profile behavior. No sync schema or compatibility change. ## Validation -Passed: 205 LAN input checks (including missed discovery and authentication before discovery), 289 clipboard transfer, 43 pairing, 829 settings/chat UI, 570 input, 341 native, 126 sync and 5 settings-save merge checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. LAN remains experimental; three physical PCs have not been verified together. - -Update both PCs to receive these fixes. Preserve each PC's own data directory and pairing identity. Input protocol v2 remains compatible with v0.6.57. A changed instance ID requires pairing again. The computer list refreshes automatically once per second while open. +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. ## 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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index f9bfc17..b72ae25 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -16d8138c63442931bf947707b28820d9b89943169ac9f25db3cb50d23c0ff319 KSwitcher-win-x64.zip +f0d7d15534db21c17b113a25cc07d59a68fa151ecf995ac360ac476751171a9a KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 1e6d169..75fbb63 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,6,58,0 - PRODUCTVERSION 0,6,58,0 + FILEVERSION 0,6,59,0 + PRODUCTVERSION 0,6,59,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -20,7 +20,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.6.58" + VALUE "FileVersion", "0.6.59" END END BLOCK "VarFileInfo" diff --git a/src/app_sync.inl b/src/app_sync.inl index f662c96..c94febc 100644 --- a/src/app_sync.inl +++ b/src/app_sync.inl @@ -13,7 +13,7 @@ std::wstring App::syncCommand(int action, const std::vector &choices validateSyncConfig(c); if (action == 4 && (c != conflictConnection || choices.empty())) 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. if (syncWorker.joinable()) syncWorker.join(); syncConnection = c; @@ -22,7 +22,8 @@ std::wstring App::syncCommand(int action, const std::vector &choices auto availableLayouts = layouts; syncRunning = true; 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; try { @@ -42,6 +43,9 @@ std::wstring App::syncCommand(int action, const std::vector &choices } catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; } 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); syncDone.store(true); }); @@ -76,8 +80,18 @@ void App::pollSync() { atomicWrite(data / L"settings-before-sync.bin", protect(snapshot)); 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); + 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); conflictConnection = syncConnection; SYSTEMTIME time{}; GetLocalTime(&time); diff --git a/src/config_main.cpp b/src/config_main.cpp index 5ac719f..54f7f5c 100644 --- a/src/config_main.cpp +++ b/src/config_main.cpp @@ -52,6 +52,19 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp) } 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) { try diff --git a/src/configuration.h b/src/configuration.h index cd6221b..36538a2 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -2,6 +2,7 @@ #include "app.h" namespace ks { +inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24; inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1, ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3, ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5, diff --git a/src/sync.cpp b/src/sync.cpp index 6d1e325..b998969 100644 --- a/src/sync.cpp +++ b/src/sync.cpp @@ -39,7 +39,7 @@ class OpenBao final : public SyncProvider SyncConfig config; SyncTransport transport; 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 (status == 404 && missing) @@ -50,7 +50,14 @@ class OpenBao final : public SyncProvider return nullptr; } 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) + "."); return result.empty() ? Json::object() : parse(result); } @@ -59,7 +66,7 @@ class OpenBao final : public SyncProvider if (transport) { 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.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."); result.insert(result.end(), chunk, chunk + received); } - return response(status, result, missing); + return response(status, result, missing, path, method); } public: 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; if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, 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).", - merged.base, merged.conflicts}; + merged.base, merged.conflicts, action == SyncAction::Download}; } Bytes encodeSyncConflicts(const std::vector &conflicts) { diff --git a/src/sync.h b/src/sync.h index 7daaba0..942609a 100644 --- a/src/sync.h +++ b/src/sync.h @@ -63,6 +63,7 @@ struct SyncResult std::wstring status; Bytes base; std::vector conflicts; + bool downloaded{}; }; SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction, const std::function &validate = {}, const std::vector &choices = {}, diff --git a/src/ui.cpp b/src/ui.cpp index 5fc70e1..630ffa0 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1688,6 +1688,10 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { + case ConfigSettingsDownloaded: + v->draft = v->app->settings; + fillSettings(w, v); + return 0; case WM_ENABLE: if (wp) updateSettingsState(w, v); break; diff --git a/src/ui_sync.inl b/src/ui_sync.inl index 2cbecb6..b85ee95 100644 --- a/src/ui_sync.inl +++ b/src/ui_sync.inl @@ -1,5 +1,5 @@ #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) { 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."; else status = app.syncCommand(); - if (value(w, 608) != status) value(w, 608, status); + auto *view = reinterpret_cast(GetWindowLongPtrW(w, GWLP_USERDATA)); + if ((!view || !view->actionError) && value(w, 608) != status) value(w, 608, status); } SyncConfig syncFields(HWND w) { @@ -47,6 +48,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) int id = LOWORD(wp); if (id == 601 && HIWORD(wp) == BN_CLICKED) { + v->actionError = false; updateSyncControls(w, *v->app); 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 == 609) { + v->actionError = false; auto c = loadSyncConfig(v->app->data); c.enabled = false; 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) { + v->actionError = false; if (!checked(w, 601)) { if (id != IDOK) return 0; @@ -80,7 +84,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) return 0; } 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"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0; auto c = syncFields(w); @@ -100,6 +104,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) } 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. value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration."); if (dynamic_cast(&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"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"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); value(w, 608, app.syncCommand()); checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622); diff --git a/src/update.h b/src/update.h index 72dd19f..313c4ac 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.58"; +inline constexpr wchar_t AppVersion[] = L"0.6.59"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; diff --git a/tests/input.cpp b/tests/input.cpp index 2fc742b..28c0048 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -1,4 +1,5 @@ #include "app.h" +#include "configuration.h" #include "ui_scroll.h" #include "ui_geometry.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"); 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); require(timer != 0, "settings close prompt responder"); SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0); diff --git a/tests/sync.cpp b/tests/sync.cpp index 91d6002..fc4922d 100644 --- a/tests/sync.cpp +++ b/tests/sync.cpp @@ -298,6 +298,9 @@ int main() require(provider.writes == 1 && result.revision == 1, "no-op avoids versions"); result = synchronize(provider, base, bytes(left), SyncAction::Download); 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; rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected"); 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"); readStatus = 302; 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; 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); ++checks; std::filesystem::remove_all(root);