Release v0.6.26: OpenBao settings synchronization with per-setting conflicts
This commit is contained in:
+12
-3
@@ -1,14 +1,14 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.25 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.26 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()
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
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)
|
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)
|
||||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||||
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
target_link_libraries(kscore PUBLIC 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)
|
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_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||||
@@ -17,6 +17,14 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
|||||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||||
add_executable(KSwitcherTests tests/tests.cpp)
|
add_executable(KSwitcherTests tests/tests.cpp)
|
||||||
|
add_executable(KSwitcherSyncTests tests/sync.cpp)
|
||||||
|
target_include_directories(KSwitcherSyncTests PRIVATE vendor)
|
||||||
|
target_link_libraries(KSwitcherSyncTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherSyncTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherSyncTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
||||||
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
||||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||||
@@ -57,6 +65,7 @@ else()
|
|||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||||
|
|||||||
@@ -383,6 +383,105 @@ This is a working native implementation, not a claim of complete UI/release pari
|
|||||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
||||||
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
||||||
|
|
||||||
|
## OpenBao synchronization (experimental, opt-in)
|
||||||
|
|
||||||
|
The **Sync...** button is available in Settings for everyone. Synchronization remains disabled
|
||||||
|
until you configure your own OpenBao and enable it. Connection configuration is saved separately
|
||||||
|
in `sync.json`. The portable data directory is normally `data/` beside the executable; installations
|
||||||
|
without a writable application directory use the existing local-app-data fallback.
|
||||||
|
No server address or account is included in the application.
|
||||||
|
|
||||||
|
The first provider is **OpenBao KV v2**. The dialog accepts an HTTPS server origin, username,
|
||||||
|
password, KV mount (default `kswitcher`), profile path (default `personal/settings`) and userpass
|
||||||
|
authentication mount (default `userpass`). Use the same profile on each of your PCs. This is
|
||||||
|
administrator-provisioned private access, not public account registration. Non-root namespaces,
|
||||||
|
reverse-proxy URL prefixes, MFA login flows and other providers are not implemented yet.
|
||||||
|
|
||||||
|
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
|
||||||
|
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
|
||||||
|
A blank password keeps the existing Windows credential for that server, login mount and username.
|
||||||
|
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
||||||
|
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
||||||
|
kept in memory for one sync attempt, with best-effort self-revocation afterwards. Configure bounded
|
||||||
|
token lifetimes on the server as well (for example a 5-minute default and 10-minute maximum).
|
||||||
|
**Disconnect / forget password** disables synchronization and removes the current connection's
|
||||||
|
saved credential. A request already sent to the server may still complete; its downloaded settings
|
||||||
|
will not be applied after the connection changes. Old credentials for other connection identities
|
||||||
|
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
|
||||||
|
|
||||||
|
First connection is deliberately explicit:
|
||||||
|
|
||||||
|
- **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 both profiles are identical, ordinary Sync can establish the baseline without a replacement.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
continues offline. Independent changes to different settings fields are merged against the last
|
||||||
|
successful baseline. **Resolve individual conflicts...** shows the name and local/server values
|
||||||
|
for each conflict. Choose either value for selected settings and apply your choices; unselected
|
||||||
|
conflicts stay pending. Other, nonconflicting changes continue to synchronize. Choices are checked
|
||||||
|
against freshly read values before writing, so a stale dialog cannot overwrite a newer edit.
|
||||||
|
Conflicting shortcuts are separate settings, including bindings for individual keyboard layouts.
|
||||||
|
Exclusions and personal dictionary words merge by membership. Ordered layout/dictionary lists
|
||||||
|
remain single settings because their ordering affects behavior.
|
||||||
|
|
||||||
|
Snippets have persistent IDs that survive renaming. Different snippets merge independently;
|
||||||
|
trigger and text edits within a snippet can merge too. Deleting a snippet retains a deletion
|
||||||
|
marker, so an unchanged offline copy cannot bring it back. Deletion versus a concurrent edit
|
||||||
|
is an explicit conflict, offering the deleted or edited version. Duplicate triggers and shortcut
|
||||||
|
collisions created by independent edits are also presented for resolution. Deletion markers are
|
||||||
|
retained without automatic expiry; recreating a snippet in the editor gives it a new ID.
|
||||||
|
|
||||||
|
Existing profiles migrate automatically: legacy snippet IDs derive deterministically from the
|
||||||
|
original trigger, then are stored permanently. Sync writes use envelope version 2; older clients
|
||||||
|
refuse this version instead of dropping IDs/deletions. Update all participating PCs before using
|
||||||
|
the new merge behavior. Existing version-1 profiles and local baselines remain readable.
|
||||||
|
|
||||||
|
Download/Upload are still available for deliberate whole-profile recovery/initialization, not
|
||||||
|
required for ordinary conflicts. Every server write uses the version just read as a CAS
|
||||||
|
condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
|
||||||
|
Deleted server versions are not silently recreated.
|
||||||
|
|
||||||
|
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
||||||
|
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
||||||
|
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
||||||
|
history, screenshots and dictionary files are never uploaded. Missing keyboard layouts block
|
||||||
|
applying a remote profile until those Windows layouts are installed, preventing lost bindings.
|
||||||
|
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
||||||
|
|
||||||
|
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||||
|
change (`settings-before-sync.bin`) are protected with Windows DPAPI and belong to this Windows
|
||||||
|
account/machine. Reinstallation recovery uses the remote profile and a fresh login; copying these
|
||||||
|
local DPAPI files to a new machine does not recover them. For an older server snapshot, use OpenBao
|
||||||
|
KV version history to restore that snapshot as the latest version, then choose Download remote.
|
||||||
|
The app does not yet provide a server-history browser. Keep ordinary server backups too.
|
||||||
|
|
||||||
|
OpenBao administrator setup:
|
||||||
|
|
||||||
|
1. Enable a KV **version 2** engine at `kswitcher`; enable **Require Check-and-Set** and set
|
||||||
|
maximum versions to a suitable value, such as 30.
|
||||||
|
2. Create a dedicated userpass account, with a policy restricted to the chosen profile. Do not
|
||||||
|
use the administrator account. For the defaults, the application needs only:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
path "kswitcher/data/personal/settings" {
|
||||||
|
capabilities = ["create", "read", "update"]
|
||||||
|
}
|
||||||
|
path "auth/token/revoke-self" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set bounded token lifetimes on that account/auth mount and enter its credentials in KSwitcher.
|
||||||
|
Keep the server's address and TLS certificate reachable/trusted on each participating PC.
|
||||||
|
|
||||||
|
`SyncProvider` separates versioned read/conditional-write operations from merging, local state
|
||||||
|
and UI integration, so another service can be added without replacing the synchronization logic.
|
||||||
|
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||||
|
|||||||
Binary file not shown.
+10
-7
@@ -1,22 +1,25 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.25 — 2026-11-01
|
## v0.6.26 — 2026-11-02
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Pencil (F) automatically recognizes nearly straight strokes on mouse release and turns them into editable straight lines at the drawn angle.
|
- Settings → Sync… is available to everyone. Connect your own OpenBao KV v2 with a username and password; synchronization is disabled by default and no server is built in.
|
||||||
- Preserve endpoints, color, thickness, line style and arrowheads. Ctrl+Z restores the exact original stroke; a second undo removes it, and Ctrl+Y reapplies recognition.
|
- Synchronize at startup, approximately two seconds after saving settings, and every 10 minutes in the background. Local operation continues offline.
|
||||||
- Reject short gestures, noticeable curves, spikes and substantial backtracking. Existing shape recognition, smoothing, linked strokes and Shift constraints retain their behavior.
|
- Merge independent settings changes and resolve conflicts individually, with local/server values shown side by side. Unresolved values stay pending while independent changes synchronize. Every server write uses CAS to protect concurrent changes.
|
||||||
|
- Snippets have stable IDs and retained deletion records. Independent snippets, renames and text edits can merge; deletion versus editing and duplicate shortcuts/triggers receive explicit conflict handling.
|
||||||
|
- Personal dictionaries, exclusions, shortcuts and layout preferences synchronize. Startup, history, local paths, connection details and credentials stay on the PC. Passwords use Windows Credential Manager; cached sync state uses DPAPI.
|
||||||
|
- Existing profiles migrate to sync envelope version 2. Update every participating PC before using the new synchronization behavior.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
423 native regressions, 473 input/UI checks, line/shape recognition and screenshot editor regressions, PNG save regressions, shared wheel handling, notification registration, 48 updater checks and updater progress checks. The user also confirmed the feature in the development editor.
|
91 sync/protocol/conflict checks, 423 native regressions, 510 input/UI checks, screenshot editor and PNG save regressions, shared wheel handling, notification registration, 48 updater checks and updater progress checks. Initial upload and personal-dictionary synchronization were confirmed against the user's OpenBao. Two-physical-PC production testing is still pending.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
Recognition is heuristic and conservative. Endpoints must be at least 24 source-image pixels apart; uncertain strokes may remain freehand. Use Straight line (L) when an exact line is needed. Linked strokes and strokes drawn partly with Shift are not automatically straightened. Existing browser-selection and clipboard/accessibility limitations remain unchanged.
|
OpenBao synchronization is experimental and requires a reachable HTTPS server with a trusted certificate, KV v2 and a provisioned userpass account/policy. It does not provide account registration, MFA login, non-root namespaces or reverse-proxy URL prefixes. Ordered layout/language lists are resolved as whole settings. Deletion records are retained without expiry. Missing keyboard layouts block applying a profile. Download/Upload replace a whole profile and are intended for initial setup or deliberate recovery; use Resolve individual conflicts for normal conflicts. Existing browser-selection and clipboard/accessibility limitations remain unchanged.
|
||||||
|
|
||||||
**Upgrade:** 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.
|
**Upgrade:** 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.
|
||||||
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, passwords, server address, clipboard history, Tesseract installation or language models.
|
||||||
|
|
||||||
Build and package:
|
Build and package:
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
69efef3571e2836875fc36dbd42a62d642471e7cd275343959dfb575e5848bd5 KSwitcher-win-x64.zip
|
99a863ddb82d3ec868d874ba5986d6552ab8e5b2fd61d0e227577b35f2657383 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -
|
|||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
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')
|
$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 = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
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')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
}
|
}
|
||||||
@@ -29,11 +29,11 @@ try {
|
|||||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
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/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/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' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
$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/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/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' }
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||||
@@ -41,15 +41,19 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $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
|
||||||
|
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||||
|
& .\build\KSwitcherSyncTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
||||||
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||||
& .\build\KSwitcherScrollTests.exe
|
& .\build\KSwitcherScrollTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
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/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
& $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
|
||||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
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/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/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' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
@@ -73,13 +77,13 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||||
}
|
}
|
||||||
if ($Integration) {
|
if ($Integration) {
|
||||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.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/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' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
}
|
}
|
||||||
if ($Ocr) {
|
if ($Ocr) {
|
||||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.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/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 build/KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||||
& .\build\KSwitcherOcrTests.exe
|
& .\build\KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||||
|
|||||||
+3
-3
@@ -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,25,0
|
FILEVERSION 0,6,26,0
|
||||||
PRODUCTVERSION 0,6,25,0
|
PRODUCTVERSION 0,6,26,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.25"
|
VALUE "FileVersion", "0.6.26"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+19
-1
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "../vendor/json.hpp"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
#include "app_sync.inl"
|
||||||
App *App::current = nullptr;
|
App *App::current = nullptr;
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -322,6 +324,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
if (updateWorker.joinable())
|
if (updateWorker.joinable())
|
||||||
updateWorker.join();
|
updateWorker.join();
|
||||||
if (keyboardHook)
|
if (keyboardHook)
|
||||||
@@ -476,8 +479,16 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::applySettings(const Settings &s)
|
void App::applySettings(const Settings &requested)
|
||||||
{
|
{
|
||||||
|
Settings s = requested;
|
||||||
|
std::set<std::wstring> present, removed(s.snippetTombstones.begin(), s.snippetTombstones.end());
|
||||||
|
removed.insert(settings.snippetTombstones.begin(), settings.snippetTombstones.end());
|
||||||
|
for (auto &snippet : s.snippets) { snippet.id = snippetId(snippet); present.insert(snippet.id); }
|
||||||
|
for (const auto &snippet : settings.snippets)
|
||||||
|
if (!present.contains(snippetId(snippet))) removed.insert(snippetId(snippet));
|
||||||
|
for (const auto &id : present) removed.erase(id);
|
||||||
|
s.snippetTombstones.assign(removed.begin(), removed.end());
|
||||||
bool autostartChanged = false;
|
bool autostartChanged = false;
|
||||||
AutoCorrector predictor;
|
AutoCorrector predictor;
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
@@ -525,6 +536,7 @@ void App::applySettings(const Settings &s)
|
|||||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
scheduleHistoryOcr(s);
|
scheduleHistoryOcr(s);
|
||||||
|
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
}
|
}
|
||||||
@@ -1693,6 +1705,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
|
if (wp == 6)
|
||||||
|
{
|
||||||
|
app->pollSync();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (wp == 5)
|
if (wp == 5)
|
||||||
{
|
{
|
||||||
// After the initial 5-second check, repeat while the resident stays open.
|
// After the initial 5-second check, repeat while the resident stays open.
|
||||||
@@ -1795,6 +1812,7 @@ int App::execute()
|
|||||||
scheduleHistoryOcr(settings);
|
scheduleHistoryOcr(settings);
|
||||||
addTray();
|
addTray();
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
|
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
if (!registrationWarning.empty())
|
if (!registrationWarning.empty())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include "update.h"
|
#include "update.h"
|
||||||
|
#include "sync.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -78,6 +79,9 @@ class App
|
|||||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||||
void resetInput();
|
void resetInput();
|
||||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||||
|
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
||||||
|
Bytes syncConflictData();
|
||||||
|
void pollSync();
|
||||||
static App *current;
|
static App *current;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -96,6 +100,16 @@ class App
|
|||||||
Settings historyOcrSettings;
|
Settings historyOcrSettings;
|
||||||
void scheduleHistoryOcr(const Settings &);
|
void scheduleHistoryOcr(const Settings &);
|
||||||
std::thread updateWorker;
|
std::thread updateWorker;
|
||||||
|
std::thread syncWorker;
|
||||||
|
std::atomic<bool> syncDone{false};
|
||||||
|
bool syncRunning{};
|
||||||
|
ULONGLONG nextSync{}, syncRetryAfter{};
|
||||||
|
SyncResult syncResult;
|
||||||
|
SyncConfig syncConnection;
|
||||||
|
SyncConfig conflictConnection;
|
||||||
|
std::vector<SyncConflict> pendingSyncConflicts;
|
||||||
|
Bytes syncSnapshot;
|
||||||
|
std::wstring syncStatus = L"Not connected.";
|
||||||
bool updateRunning{}, installingUpdate{};
|
bool updateRunning{}, installingUpdate{};
|
||||||
std::wstring availableUpdate, notifiedUpdate;
|
std::wstring availableUpdate, notifiedUpdate;
|
||||||
bool (*updateNotifier)(const std::wstring &){};
|
bool (*updateNotifier)(const std::wstring &){};
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Included by app.cpp; all App mutations happen on its window thread.
|
||||||
|
Bytes App::syncConflictData()
|
||||||
|
{
|
||||||
|
if (loadSyncConfig(data) != conflictConnection) return encodeSyncConflicts({});
|
||||||
|
return encodeSyncConflicts(pendingSyncConflicts);
|
||||||
|
}
|
||||||
|
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
if (action == 0) return syncStatus;
|
||||||
|
if (syncRunning) return L"Synchronization is already running.";
|
||||||
|
auto c = loadSyncConfig(data);
|
||||||
|
if (!c.enabled) return syncStatus = L"Synchronization is disabled.";
|
||||||
|
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.";
|
||||||
|
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
|
||||||
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
|
syncConnection = c;
|
||||||
|
syncSnapshot = encodeSettings(settings);
|
||||||
|
auto snapshot = settings;
|
||||||
|
auto availableLayouts = layouts;
|
||||||
|
syncRunning = true;
|
||||||
|
syncDone = false;
|
||||||
|
syncStatus = L"Connecting to OpenBao...";
|
||||||
|
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
syncWorker = std::thread([this, c, snapshot, availableLayouts, action, choices] {
|
||||||
|
SyncResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto provider = makeSyncProvider(c);
|
||||||
|
// An explicit choice also repairs a missing/corrupt local baseline.
|
||||||
|
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
|
||||||
|
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
||||||
|
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
|
||||||
|
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts); }, 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()); }
|
||||||
|
syncResult = std::move(result);
|
||||||
|
syncDone.store(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (...) { syncRunning = false; throw; }
|
||||||
|
return syncStatus;
|
||||||
|
}
|
||||||
|
void App::pollSync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (syncRunning && syncDone.load())
|
||||||
|
{
|
||||||
|
if (busy) return;
|
||||||
|
syncWorker.join();
|
||||||
|
syncRunning = false;
|
||||||
|
syncDone = false;
|
||||||
|
syncStatus = syncResult.status;
|
||||||
|
if (syncResult.success)
|
||||||
|
{
|
||||||
|
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != syncSnapshot)
|
||||||
|
{
|
||||||
|
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
||||||
|
nextSync = GetTickCount64() + 2000;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto next = restoreSyncSettings(syncResult.settings, settings, layouts);
|
||||||
|
if (encodeSettings(next) != syncSnapshot)
|
||||||
|
{
|
||||||
|
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
||||||
|
applySettings(next);
|
||||||
|
}
|
||||||
|
saveSyncBase(data, syncConnection, syncResult.base.empty() ? syncResult.settings : syncResult.base);
|
||||||
|
pendingSyncConflicts = syncResult.conflicts;
|
||||||
|
conflictConnection = syncConnection;
|
||||||
|
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||||
|
wchar_t stamp[32]{};
|
||||||
|
swprintf(stamp, std::size(stamp), L" (%02u:%02u:%02u)", time.wHour, time.wMinute, time.wSecond);
|
||||||
|
syncStatus += stamp;
|
||||||
|
}
|
||||||
|
// Back off after errors (including bad credentials) instead of repeatedly
|
||||||
|
// hitting userpass lockout. The explicit Sync button remains available.
|
||||||
|
syncRetryAfter = syncResult.success ? 0 : GetTickCount64() + 15 * 60000;
|
||||||
|
nextSync = syncResult.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
||||||
|
}
|
||||||
|
if (!syncRunning && GetTickCount64() >= nextSync)
|
||||||
|
{
|
||||||
|
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||||
|
if (loadSyncConfig(data).enabled) syncCommand(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const nlohmann::json::exception &) { syncStatus = L"Invalid local sync configuration or cached profile."; nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||||
|
catch (const std::exception &e) { syncStatus = wide(e.what()); nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
||||||
{
|
{
|
||||||
current = this;
|
current = this;
|
||||||
|
data = dataDirectory();
|
||||||
layouts = readLayouts();
|
layouts = readLayouts();
|
||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
@@ -110,6 +111,13 @@ void App::reloadLayouts()
|
|||||||
settings = decodeSettings(bytes, layouts);
|
settings = decodeSettings(bytes, layouts);
|
||||||
}
|
}
|
||||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
||||||
|
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
|
||||||
|
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
|
||||||
|
request(*this, ConfigSync, Bytes{static_cast<unsigned char>(action)});
|
||||||
|
return wide(std::string(result.begin(), result.end()));
|
||||||
|
}
|
||||||
void App::setHotkeyCapture(HWND field)
|
void App::setHotkeyCapture(HWND field)
|
||||||
{
|
{
|
||||||
hotkeyCapture = field;
|
hotkeyCapture = field;
|
||||||
@@ -180,6 +188,12 @@ int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
|||||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||||
{
|
{
|
||||||
HWND popup = GetLastActivePopup(app.settingsWindow);
|
HWND popup = GetLastActivePopup(app.settingsWindow);
|
||||||
|
while (!IsWindowEnabled(popup))
|
||||||
|
{
|
||||||
|
HWND next = GetLastActivePopup(popup);
|
||||||
|
if (next == popup) break;
|
||||||
|
popup = next;
|
||||||
|
}
|
||||||
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
||||||
}
|
}
|
||||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
{
|
{
|
||||||
switch (message.dwData)
|
switch (message.dwData)
|
||||||
{
|
{
|
||||||
|
case ConfigSyncConflicts:
|
||||||
|
reply = app.syncConflictData();
|
||||||
|
break;
|
||||||
|
case ConfigSyncResolve:
|
||||||
|
{
|
||||||
|
if (!message.cbData) throw std::runtime_error("Empty conflict choices.");
|
||||||
|
auto first = static_cast<const unsigned char *>(message.lpData);
|
||||||
|
auto status = utf8(app.syncCommand(4, decodeSyncChoices(Bytes(first, first + message.cbData))));
|
||||||
|
reply.assign(status.begin(), status.end());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ConfigSync:
|
||||||
|
{
|
||||||
|
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 3)
|
||||||
|
throw std::runtime_error("Invalid sync command.");
|
||||||
|
auto status = utf8(app.syncCommand(*static_cast<const unsigned char *>(message.lpData)));
|
||||||
|
reply.assign(status.begin(), status.end());
|
||||||
|
break;
|
||||||
|
}
|
||||||
case ConfigSave:
|
case ConfigSave:
|
||||||
{
|
{
|
||||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@ namespace ks
|
|||||||
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,
|
||||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
|
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||||
|
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||||
|
ConfigSyncResolve = ConfigRead + 10, ConfigEnd = ConfigRead + 11;
|
||||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
bool configurationPeer(HWND, const wchar_t *filename);
|
||||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ std::wstring transliterate(const std::wstring &);
|
|||||||
struct Snippet
|
struct Snippet
|
||||||
{
|
{
|
||||||
std::wstring trigger, text;
|
std::wstring trigger, text;
|
||||||
|
std::wstring id;
|
||||||
};
|
};
|
||||||
|
std::wstring snippetId(const Snippet &);
|
||||||
|
std::wstring newSnippetId();
|
||||||
struct Template
|
struct Template
|
||||||
{
|
{
|
||||||
std::wstring text, suffix;
|
std::wstring text, suffix;
|
||||||
@@ -73,6 +76,7 @@ struct Settings
|
|||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
|
std::vector<std::wstring> snippetTombstones;
|
||||||
};
|
};
|
||||||
// Consume repeats and key-up for intercepted chords, even if settings change.
|
// Consume repeats and key-up for intercepted chords, even if settings change.
|
||||||
struct HotkeyHook
|
struct HotkeyHook
|
||||||
|
|||||||
+32
-2
@@ -8,6 +8,22 @@
|
|||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
using Json = nlohmann::json;
|
using Json = nlohmann::json;
|
||||||
|
std::wstring snippetId(const Snippet &snippet)
|
||||||
|
{
|
||||||
|
if (!snippet.id.empty()) return snippet.id;
|
||||||
|
// Deterministic migration of pre-ID profiles on every existing device.
|
||||||
|
std::wstring id = L"legacy-";
|
||||||
|
const wchar_t *hex = L"0123456789abcdef";
|
||||||
|
for (unsigned char c : utf8(lower(snippet.trigger))) { id += hex[c >> 4]; id += hex[c & 15]; }
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
std::wstring newSnippetId()
|
||||||
|
{
|
||||||
|
GUID guid{}; wchar_t text[40]{};
|
||||||
|
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create snippet ID.");
|
||||||
|
StringFromGUID2(guid, text, 40);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
Bytes jsonBytes(const Json &j)
|
Bytes jsonBytes(const Json &j)
|
||||||
@@ -107,8 +123,14 @@ void validateSettings(const Settings &s)
|
|||||||
s.maxImageMiB > 64)
|
s.maxImageMiB > 64)
|
||||||
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
||||||
std::set<std::wstring> seen;
|
std::set<std::wstring> seen;
|
||||||
|
std::set<std::wstring> ids;
|
||||||
|
for (const auto &id : s.snippetTombstones)
|
||||||
|
if (id.empty() || id.size() > 600 || !ids.insert(id).second)
|
||||||
|
throw std::runtime_error("Invalid snippet deletion record.");
|
||||||
for (const auto &snippet : s.snippets)
|
for (const auto &snippet : s.snippets)
|
||||||
{
|
{
|
||||||
|
auto id = snippetId(snippet);
|
||||||
|
if (id.size() > 600 || !ids.insert(id).second) throw std::runtime_error("Duplicate snippet ID or deleted snippet.");
|
||||||
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
||||||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
||||||
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
||||||
@@ -203,7 +225,13 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
}
|
}
|
||||||
if (j.contains("snippets"))
|
if (j.contains("snippets"))
|
||||||
for (auto &sn : j.at("snippets"))
|
for (auto &sn : j.at("snippets"))
|
||||||
s.snippets.push_back({str(sn, "trigger"), str(sn, "text")});
|
{
|
||||||
|
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
|
||||||
|
item.id = snippetId(item);
|
||||||
|
s.snippets.push_back(std::move(item));
|
||||||
|
}
|
||||||
|
if (j.contains("snippetTombstones"))
|
||||||
|
for (const auto &id : j.at("snippetTombstones")) s.snippetTombstones.push_back(wide(id.get<std::string>()));
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -264,7 +292,9 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
||||||
j["snippets"] = Json::array();
|
j["snippets"] = Json::array();
|
||||||
for (auto &sn : s.snippets)
|
for (auto &sn : s.snippets)
|
||||||
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}});
|
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}});
|
||||||
|
j["snippetTombstones"] = Json::array();
|
||||||
|
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
|
||||||
j["excludedApps"] = Json::array();
|
j["excludedApps"] = Json::array();
|
||||||
for (auto &name : s.excluded)
|
for (auto &name : s.excluded)
|
||||||
j["excludedApps"].push_back(utf8(name));
|
j["excludedApps"].push_back(utf8(name));
|
||||||
|
|||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
#include "sync.h"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include <winhttp.h>
|
||||||
|
#include <wincred.h>
|
||||||
|
#include <set>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr size_t Limit = 4 * 1024 * 1024;
|
||||||
|
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
||||||
|
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
||||||
|
Json configJson(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)},
|
||||||
|
{"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)},
|
||||||
|
{"username", utf8(c.username)}};
|
||||||
|
}
|
||||||
|
std::wstring credentialName(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
return L"KSwitcher.Sync/" + c.server + L"/" + c.authMount + L"/" + c.username;
|
||||||
|
}
|
||||||
|
bool safePath(const std::wstring &s)
|
||||||
|
{
|
||||||
|
if (s.empty() || s.size() > 512 || s.front() == '/' || s.back() == '/' ||
|
||||||
|
s.find(L"//") != s.npos) return false;
|
||||||
|
return s.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-/") == s.npos;
|
||||||
|
}
|
||||||
|
struct HttpHandle
|
||||||
|
{
|
||||||
|
HINTERNET h{};
|
||||||
|
~HttpHandle() { if (h) WinHttpCloseHandle(h); }
|
||||||
|
};
|
||||||
|
class OpenBao final : public SyncProvider
|
||||||
|
{
|
||||||
|
SyncConfig config;
|
||||||
|
SyncTransport transport;
|
||||||
|
std::wstring token;
|
||||||
|
Json response(unsigned status, const Bytes &result, bool missing)
|
||||||
|
{
|
||||||
|
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||||
|
if (status == 404 && missing)
|
||||||
|
{
|
||||||
|
auto j = result.empty() ? Json::object() : parse(result);
|
||||||
|
if (j.contains("data") && !j["data"].is_null())
|
||||||
|
throw std::runtime_error("Remote profile is deleted. Restore it in OpenBao first.");
|
||||||
|
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 < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
|
||||||
|
return result.empty() ? Json::object() : parse(result);
|
||||||
|
}
|
||||||
|
Json request(const wchar_t *method, const std::wstring &path, const Json *body = nullptr, bool missing = false)
|
||||||
|
{
|
||||||
|
if (transport)
|
||||||
|
{
|
||||||
|
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
|
||||||
|
return response(reply.status, reply.body, missing);
|
||||||
|
}
|
||||||
|
URL_COMPONENTS url{sizeof(url)};
|
||||||
|
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
|
||||||
|
if (!WinHttpCrackUrl(config.server.c_str(), 0, 0, &url)) throw std::runtime_error("Invalid server URL.");
|
||||||
|
std::wstring host(url.lpszHostName, url.dwHostNameLength);
|
||||||
|
HttpHandle session{WinHttpOpen(L"KSwitcher Sync/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||||
|
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)};
|
||||||
|
if (!session.h || !WinHttpSetTimeouts(session.h, 5000, 5000, 5000, 5000))
|
||||||
|
throw std::runtime_error("Cannot initialize sync connection.");
|
||||||
|
HttpHandle connection{WinHttpConnect(session.h, host.c_str(), url.nPort, 0)};
|
||||||
|
HttpHandle req{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr,
|
||||||
|
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) : nullptr};
|
||||||
|
DWORD redirects = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
|
||||||
|
if (!req.h || !WinHttpSetOption(req.h, WINHTTP_OPTION_REDIRECT_POLICY, &redirects, sizeof(redirects)))
|
||||||
|
throw std::runtime_error("Cannot initialize sync request.");
|
||||||
|
std::wstring headers = L"Content-Type: application/json\r\n";
|
||||||
|
if (!token.empty()) headers += L"X-Vault-Token: " + token + L"\r\n";
|
||||||
|
std::string payload = body ? body->dump() : "";
|
||||||
|
bool sent = WinHttpSendRequest(req.h, headers.c_str(), (DWORD)-1,
|
||||||
|
payload.empty() ? WINHTTP_NO_REQUEST_DATA : payload.data(), (DWORD)payload.size(), (DWORD)payload.size(), 0);
|
||||||
|
if (!payload.empty()) SecureZeroMemory(payload.data(), payload.size());
|
||||||
|
if (!sent || !WinHttpReceiveResponse(req.h, nullptr))
|
||||||
|
throw std::runtime_error("OpenBao connection failed. Check network, HTTPS certificate and server address.");
|
||||||
|
DWORD status{}, length = sizeof(status);
|
||||||
|
if (!WinHttpQueryHeaders(req.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||||
|
WINHTTP_HEADER_NAME_BY_INDEX, &status, &length, WINHTTP_NO_HEADER_INDEX))
|
||||||
|
throw std::runtime_error("Cannot read OpenBao response.");
|
||||||
|
Bytes result;
|
||||||
|
ULONGLONG deadline = GetTickCount64() + 15000;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
unsigned char chunk[8192]; DWORD received{};
|
||||||
|
if (GetTickCount64() > deadline || !WinHttpReadData(req.h, chunk, sizeof(chunk), &received))
|
||||||
|
throw std::runtime_error("OpenBao response timed out.");
|
||||||
|
if (!received) break;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
|
||||||
|
{
|
||||||
|
validateSyncConfig(c);
|
||||||
|
PCREDENTIALW credential{};
|
||||||
|
if (!CredReadW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0, &credential))
|
||||||
|
throw std::runtime_error("Sign in: no saved password for this server and user.");
|
||||||
|
std::string password(reinterpret_cast<char *>(credential->CredentialBlob), credential->CredentialBlobSize);
|
||||||
|
SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize);
|
||||||
|
CredFree(credential);
|
||||||
|
Json login{{"password", password}};
|
||||||
|
SecureZeroMemory(password.data(), password.size());
|
||||||
|
Json response;
|
||||||
|
try { response = request(L"POST", L"/v1/auth/" + c.authMount + L"/login/" + c.username, &login); }
|
||||||
|
catch (...) { auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size()); throw; }
|
||||||
|
auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size());
|
||||||
|
token = wide(response.at("auth").at("client_token").get<std::string>());
|
||||||
|
if (token.empty() || token.find_first_of(L"\r\n") != token.npos)
|
||||||
|
throw std::runtime_error("Invalid OpenBao authentication response.");
|
||||||
|
}
|
||||||
|
~OpenBao() override
|
||||||
|
{
|
||||||
|
if (token.empty()) return;
|
||||||
|
// Sessions are scoped to a sync attempt; do not accumulate login tokens.
|
||||||
|
try { request(L"POST", L"/v1/auth/token/revoke-self"); } catch (...) {}
|
||||||
|
SecureZeroMemory(token.data(), token.size() * sizeof(wchar_t));
|
||||||
|
}
|
||||||
|
SyncDocument read() override
|
||||||
|
{
|
||||||
|
auto j = request(L"GET", L"/v1/" + config.mount + L"/data/" + config.path, nullptr, true);
|
||||||
|
if (j.is_null()) return {};
|
||||||
|
const auto &data = j.at("data");
|
||||||
|
if (!data.at("metadata").value("deletion_time", std::string()).empty() ||
|
||||||
|
data.at("metadata").value("destroyed", false))
|
||||||
|
throw std::runtime_error("Remote profile is deleted or destroyed.");
|
||||||
|
auto payload = data.at("data");
|
||||||
|
if (payload.value("syncSchema", 0) != 1 && payload.value("syncSchema", 0) != 2) throw std::runtime_error("Unsupported remote sync format.");
|
||||||
|
return {data.at("metadata").at("version").get<uint64_t>(), bytes(payload.at("settings"))};
|
||||||
|
}
|
||||||
|
uint64_t write(const Bytes &settings, uint64_t revision) override
|
||||||
|
{
|
||||||
|
Json body{{"options", {{"cas", revision}}}, {"data", {{"syncSchema", 2}, {"settings", parse(settings)}}}};
|
||||||
|
auto response = request(L"POST", L"/v1/" + config.mount + L"/data/" + config.path, &body);
|
||||||
|
return response.at("data").at("version").get<uint64_t>();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bool syncVisible(const std::filesystem::path &root) { return std::filesystem::exists(root / L"sync.json"); }
|
||||||
|
SyncConfig loadSyncConfig(const std::filesystem::path &root)
|
||||||
|
{
|
||||||
|
SyncConfig c;
|
||||||
|
if (!syncVisible(root)) return c;
|
||||||
|
auto j = parse(readFile(root / L"sync.json", 16384));
|
||||||
|
c.enabled = j.value("enabled", false);
|
||||||
|
c.provider = wide(j.value("provider", "openbao"));
|
||||||
|
c.server = wide(j.value("server", ""));
|
||||||
|
c.mount = wide(j.value("mount", "kswitcher"));
|
||||||
|
c.path = wide(j.value("path", "personal/settings"));
|
||||||
|
c.authMount = wide(j.value("authMount", "userpass"));
|
||||||
|
c.username = wide(j.value("username", ""));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
void validateSyncConfig(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
if (c.provider != L"openbao") throw std::runtime_error("Unsupported sync provider.");
|
||||||
|
URL_COMPONENTS url{sizeof(url)};
|
||||||
|
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = url.dwUserNameLength = url.dwPasswordLength = (DWORD)-1;
|
||||||
|
if (c.server.size() > 1024 || !WinHttpCrackUrl(c.server.c_str(), 0, 0, &url) ||
|
||||||
|
url.nScheme != INTERNET_SCHEME_HTTPS || !url.dwHostNameLength || url.dwUserNameLength ||
|
||||||
|
url.dwPasswordLength || url.dwExtraInfoLength ||
|
||||||
|
(url.dwUrlPathLength && std::wstring(url.lpszUrlPath, url.dwUrlPathLength) != L"/"))
|
||||||
|
throw std::runtime_error("Server must be an HTTPS origin, for example https://bao.example.com (no path or credentials).");
|
||||||
|
bool userValid = !c.username.empty() && c.username.size() <= 256 && c.username != L"." && c.username != L".." &&
|
||||||
|
c.username.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.@") == c.username.npos;
|
||||||
|
if (!safePath(c.mount) || !safePath(c.path) || !safePath(c.authMount) || !userValid)
|
||||||
|
throw std::runtime_error("Use letters, numbers, underscores and hyphens in paths and login; / is allowed only in paths.");
|
||||||
|
if (credentialName(c).size() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH)
|
||||||
|
throw std::runtime_error("Sync connection name is too long.");
|
||||||
|
}
|
||||||
|
void saveSyncConfig(const std::filesystem::path &root, const SyncConfig &c)
|
||||||
|
{
|
||||||
|
if (c.enabled) validateSyncConfig(c);
|
||||||
|
atomicWrite(root / L"sync.json", bytes(configJson(c)));
|
||||||
|
}
|
||||||
|
void saveSyncPassword(const SyncConfig &c, const std::wstring &password)
|
||||||
|
{
|
||||||
|
validateSyncConfig(c);
|
||||||
|
auto secret = utf8(password);
|
||||||
|
if (secret.empty() || secret.size() > CRED_MAX_CREDENTIAL_BLOB_SIZE) throw std::runtime_error("Password is empty or too long.");
|
||||||
|
auto target = credentialName(c);
|
||||||
|
CREDENTIALW credential{}; credential.Type = CRED_TYPE_GENERIC; credential.TargetName = target.data();
|
||||||
|
credential.UserName = const_cast<wchar_t *>(c.username.c_str());
|
||||||
|
credential.CredentialBlob = reinterpret_cast<BYTE *>(secret.data());
|
||||||
|
credential.CredentialBlobSize = (DWORD)secret.size(); credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
|
||||||
|
bool ok = CredWriteW(&credential, 0) != 0;
|
||||||
|
SecureZeroMemory(secret.data(), secret.size());
|
||||||
|
if (!ok) throw std::runtime_error("Cannot save password in Windows Credential Manager.");
|
||||||
|
}
|
||||||
|
void forgetSyncPassword(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
if (!CredDeleteW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0) && GetLastError() != ERROR_NOT_FOUND)
|
||||||
|
throw std::runtime_error("Cannot remove saved sync password.");
|
||||||
|
}
|
||||||
|
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTransport transport)
|
||||||
|
{
|
||||||
|
return std::make_unique<OpenBao>(c, std::move(transport));
|
||||||
|
}
|
||||||
|
namespace { Json canonicalProfile(Json); }
|
||||||
|
Bytes sharedSyncSettings(const Settings &s)
|
||||||
|
{
|
||||||
|
auto j = parse(encodeSettings(s));
|
||||||
|
// Windows may enumerate the same installed layouts in a different order on
|
||||||
|
// another PC. Binding order is not user intent (unlike the layout cycle).
|
||||||
|
auto &bindings = j["layoutHotkeys"];
|
||||||
|
std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) {
|
||||||
|
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
||||||
|
});
|
||||||
|
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
||||||
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
||||||
|
return bytes(canonicalProfile(j));
|
||||||
|
}
|
||||||
|
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts)
|
||||||
|
{
|
||||||
|
auto j = parse(shared), original = parse(encodeSettings(local));
|
||||||
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
|
auto allowed = parse(sharedSyncSettings(local));
|
||||||
|
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||||
|
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||||
|
for (auto it = j.begin(); it != j.end(); ++it)
|
||||||
|
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
|
||||||
|
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
|
||||||
|
// decodeSettings intentionally ignores hotkeys for missing layouts during import.
|
||||||
|
// Sync must stop instead, otherwise the next upload would lose those bindings.
|
||||||
|
std::set<std::string> installed;
|
||||||
|
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
||||||
|
for (const auto &id : j.at("layouts"))
|
||||||
|
if (!installed.contains(id.get<std::string>())) throw std::runtime_error("Install the remote profile's keyboard layouts before syncing.");
|
||||||
|
for (const auto &key : j.at("layoutHotkeys"))
|
||||||
|
if (!installed.contains(key.at("layoutId").get<std::string>())) throw std::runtime_error("Install the remote profile's keyboard layouts before syncing.");
|
||||||
|
original.update(j);
|
||||||
|
return decodeSettings(bytes(original), layouts);
|
||||||
|
}
|
||||||
|
#include "sync_merge.inl"
|
||||||
|
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
|
||||||
|
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
|
||||||
|
if (validate) validate(local);
|
||||||
|
auto remote = provider.read();
|
||||||
|
if (!remote.revision)
|
||||||
|
{
|
||||||
|
if (action != SyncAction::Upload) throw std::runtime_error("No remote profile. Choose Upload local to create it.");
|
||||||
|
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
|
||||||
|
}
|
||||||
|
if (validate && action != SyncAction::Upload) validate(remote.settings);
|
||||||
|
SyncMerge merged;
|
||||||
|
if (action == SyncAction::Automatic) merged = mergeSyncProfile(base, local, remote.settings, choices);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : local)));
|
||||||
|
merged = {selected, selected, selected, {}};
|
||||||
|
}
|
||||||
|
if (validate) { validate(merged.local); validate(merged.remote); }
|
||||||
|
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"." :
|
||||||
|
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
|
||||||
|
merged.base, merged.conflicts};
|
||||||
|
}
|
||||||
|
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
|
||||||
|
{
|
||||||
|
Json j = Json::array();
|
||||||
|
for (const auto &c : conflicts) j.push_back({{"key", c.key}, {"label", utf8(c.label)}, {"local", parse(c.local)}, {"remote", parse(c.remote)}});
|
||||||
|
return bytes(j);
|
||||||
|
}
|
||||||
|
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &data)
|
||||||
|
{
|
||||||
|
std::vector<SyncConflict> result;
|
||||||
|
for (const auto &c : parse(data)) result.push_back({c.at("key"), wide(c.at("label").get<std::string>()), bytes(c.at("local")), bytes(c.at("remote"))});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Bytes encodeSyncChoices(const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
Json j = Json::array();
|
||||||
|
for (const auto &c : choices) j.push_back({{"conflict", parse(encodeSyncConflicts({c.conflict})).at(0)}, {"useLocal", c.useLocal}});
|
||||||
|
return bytes(j);
|
||||||
|
}
|
||||||
|
std::vector<SyncChoice> decodeSyncChoices(const Bytes &data)
|
||||||
|
{
|
||||||
|
auto j = parse(data);
|
||||||
|
if (!j.is_array() || j.size() > 10000) throw std::runtime_error("Invalid conflict choices.");
|
||||||
|
std::vector<SyncChoice> result;
|
||||||
|
for (const auto &c : j) result.push_back({decodeSyncConflicts(bytes(Json::array({c.at("conflict")}))).at(0), c.at("useLocal").get<bool>()});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::wstring syncValueText(const Bytes &data)
|
||||||
|
{
|
||||||
|
auto j = parse(data);
|
||||||
|
if (j.is_null()) return L"(not present)";
|
||||||
|
if (tombstone(j)) return L"(deleted)";
|
||||||
|
return wide(j.is_string() ? j.get<std::string>() : j.dump(2));
|
||||||
|
}
|
||||||
|
Bytes loadSyncBase(const std::filesystem::path &root, const SyncConfig &c)
|
||||||
|
{
|
||||||
|
auto path = root / L"sync-base.bin";
|
||||||
|
if (!std::filesystem::exists(path)) return {};
|
||||||
|
auto j = parse(unprotect(readFile(path, Limit + 65536)));
|
||||||
|
return j.at("connection") == configJson(c) ? bytes(j.at("settings")) : Bytes{};
|
||||||
|
}
|
||||||
|
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
|
||||||
|
{
|
||||||
|
atomicWrite(root / L"sync-base.bin", protect(bytes({{"connection", configJson(c)}, {"settings", parse(base)}})));
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "storage.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
struct SyncConfig
|
||||||
|
{
|
||||||
|
bool enabled{};
|
||||||
|
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
||||||
|
path = L"personal/settings", authMount = L"userpass", username;
|
||||||
|
bool operator==(const SyncConfig &) const = default;
|
||||||
|
};
|
||||||
|
bool syncVisible(const std::filesystem::path &);
|
||||||
|
SyncConfig loadSyncConfig(const std::filesystem::path &);
|
||||||
|
void saveSyncConfig(const std::filesystem::path &, const SyncConfig &);
|
||||||
|
void validateSyncConfig(const SyncConfig &);
|
||||||
|
void saveSyncPassword(const SyncConfig &, const std::wstring &);
|
||||||
|
void forgetSyncPassword(const SyncConfig &);
|
||||||
|
struct SyncDocument { uint64_t revision{}; Bytes settings; };
|
||||||
|
class SyncProvider
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~SyncProvider() = default;
|
||||||
|
virtual SyncDocument read() = 0;
|
||||||
|
virtual uint64_t write(const Bytes &, uint64_t expectedRevision) = 0;
|
||||||
|
};
|
||||||
|
struct SyncHttpResponse { unsigned status{}; Bytes body; };
|
||||||
|
using SyncTransport = std::function<SyncHttpResponse(const std::wstring &method, const std::wstring &path,
|
||||||
|
const std::wstring &token, const Bytes &body)>;
|
||||||
|
// An injectable transport permits protocol regression tests without a live vault.
|
||||||
|
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &, SyncTransport transport = {});
|
||||||
|
inline constexpr ULONGLONG SyncIntervalMs = 10 * 60 * 1000;
|
||||||
|
struct SyncConflict
|
||||||
|
{
|
||||||
|
std::string key;
|
||||||
|
std::wstring label;
|
||||||
|
Bytes local, remote;
|
||||||
|
};
|
||||||
|
struct SyncChoice { SyncConflict conflict; bool useLocal{}; };
|
||||||
|
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &);
|
||||||
|
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &);
|
||||||
|
Bytes encodeSyncChoices(const std::vector<SyncChoice> &);
|
||||||
|
std::vector<SyncChoice> decodeSyncChoices(const Bytes &);
|
||||||
|
std::wstring syncValueText(const Bytes &);
|
||||||
|
struct SyncMerge
|
||||||
|
{
|
||||||
|
Bytes local, remote, base;
|
||||||
|
std::vector<SyncConflict> conflicts;
|
||||||
|
};
|
||||||
|
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote,
|
||||||
|
const std::vector<SyncChoice> &choices = {});
|
||||||
|
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
|
||||||
|
Bytes sharedSyncSettings(const Settings &);
|
||||||
|
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &);
|
||||||
|
enum class SyncAction { Automatic, Download, Upload };
|
||||||
|
struct SyncResult
|
||||||
|
{
|
||||||
|
bool success{};
|
||||||
|
Bytes settings;
|
||||||
|
uint64_t revision{};
|
||||||
|
std::wstring status;
|
||||||
|
Bytes base;
|
||||||
|
std::vector<SyncConflict> conflicts;
|
||||||
|
};
|
||||||
|
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
||||||
|
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {});
|
||||||
|
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
|
||||||
|
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Three-way merge operates on individual values; unresolved values retain their
|
||||||
|
// respective local/remote versions and their old common ancestor.
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
Json normalizeProfile(Json j)
|
||||||
|
{
|
||||||
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
|
for (auto &sn : j.at("snippets"))
|
||||||
|
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
Json treeProfile(const Json &source)
|
||||||
|
{
|
||||||
|
auto j = normalizeProfile(source);
|
||||||
|
Json items = Json::object();
|
||||||
|
for (const auto &sn : j.at("snippets"))
|
||||||
|
{
|
||||||
|
auto id = sn.at("id").get<std::string>();
|
||||||
|
if (items.contains(id)) throw std::runtime_error("Duplicate snippet ID.");
|
||||||
|
items[id] = sn;
|
||||||
|
}
|
||||||
|
for (const auto &id : j.at("snippetTombstones"))
|
||||||
|
{
|
||||||
|
auto key = id.get<std::string>();
|
||||||
|
if (items.contains(key)) throw std::runtime_error("Duplicate or deleted snippet ID.");
|
||||||
|
items[key] = {{"$deleted", true}};
|
||||||
|
}
|
||||||
|
j["snippets"] = items;
|
||||||
|
j.erase("snippetTombstones");
|
||||||
|
items = Json::object();
|
||||||
|
for (const auto &binding : j.at("layoutHotkeys")) items[binding.at("layoutId").get<std::string>()] = binding.at("hotkey");
|
||||||
|
j["layoutHotkeys"] = items;
|
||||||
|
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
|
||||||
|
{
|
||||||
|
items = Json::object();
|
||||||
|
for (const auto &item : j.at(key)) items[item.get<std::string>()] = true;
|
||||||
|
j[key] = items;
|
||||||
|
}
|
||||||
|
for (auto &language : j.at("autoCorrectWords").items())
|
||||||
|
{
|
||||||
|
items = Json::object();
|
||||||
|
for (const auto &word : language.value()) items[word.get<std::string>()] = true;
|
||||||
|
language.value() = items;
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
Json flatProfile(Json j)
|
||||||
|
{
|
||||||
|
Json snippets = Json::array(), deleted = Json::array();
|
||||||
|
for (const auto &[id, item] : j.at("snippets").items())
|
||||||
|
{
|
||||||
|
if (item.is_null()) continue;
|
||||||
|
if (item.value("$deleted", false)) deleted.push_back(id);
|
||||||
|
else snippets.push_back(item);
|
||||||
|
}
|
||||||
|
j["snippets"] = snippets; j["snippetTombstones"] = deleted;
|
||||||
|
Json bindings = Json::array();
|
||||||
|
for (const auto &[id, hotkey] : j.at("layoutHotkeys").items())
|
||||||
|
if (!hotkey.is_null()) bindings.push_back({{"layoutId", id}, {"hotkey", hotkey}});
|
||||||
|
j["layoutHotkeys"] = bindings;
|
||||||
|
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
|
||||||
|
{
|
||||||
|
Json list = Json::array();
|
||||||
|
for (const auto &[item, present] : j.at(key).items()) if (present == true) list.push_back(item);
|
||||||
|
j[key] = list;
|
||||||
|
}
|
||||||
|
for (auto &language : j.at("autoCorrectWords").items())
|
||||||
|
{
|
||||||
|
Json list = Json::array();
|
||||||
|
if (language.value().is_object())
|
||||||
|
for (const auto &[word, present] : language.value().items()) if (present == true) list.push_back(word);
|
||||||
|
language.value() = list;
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
std::string pointerKey(const std::string &s)
|
||||||
|
{
|
||||||
|
std::string out;
|
||||||
|
for (char c : s) out += c == '~' ? "~0" : c == '/' ? "~1" : std::string(1, c);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
Json canonicalProfile(Json j) { return flatProfile(treeProfile(j)); }
|
||||||
|
std::wstring fieldLabel(const std::string &key)
|
||||||
|
{
|
||||||
|
static const std::map<std::string, std::wstring> labels{
|
||||||
|
{"toggleHotkey", L"Next layout shortcut"}, {"caseHotkey", L"Change case shortcut"},
|
||||||
|
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
|
||||||
|
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
|
||||||
|
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
|
||||||
|
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
||||||
|
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
||||||
|
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
||||||
|
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
|
||||||
|
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
||||||
|
{"trigger", L"Trigger"}, {"text", L"Text"}};
|
||||||
|
auto it = labels.find(key); return it == labels.end() ? wide(key) : it->second;
|
||||||
|
}
|
||||||
|
struct MergeNode { Json local, remote, base; };
|
||||||
|
bool tombstone(const Json &j) { return j.is_object() && j.value("$deleted", false); }
|
||||||
|
MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::string &path,
|
||||||
|
const std::wstring &label, const std::vector<SyncChoice> &choices, std::vector<SyncConflict> &conflicts,
|
||||||
|
const std::set<std::string> &forced)
|
||||||
|
{
|
||||||
|
if (l == r) return {l, l, l};
|
||||||
|
for (const auto &choice : choices)
|
||||||
|
if (choice.conflict.key == path && parse(choice.conflict.local) == l && parse(choice.conflict.remote) == r)
|
||||||
|
{
|
||||||
|
auto selected = choice.useLocal ? l : r;
|
||||||
|
if (selected.is_null() && path.starts_with("/snippets/") && path.find('/', 10) == std::string::npos)
|
||||||
|
selected = {{"$deleted", true}};
|
||||||
|
return {selected, selected, selected};
|
||||||
|
}
|
||||||
|
if (forced.contains(path))
|
||||||
|
{
|
||||||
|
conflicts.push_back({path, label + L" (duplicate trigger or shortcut)", bytes(l), bytes(r)});
|
||||||
|
return {l, r, b};
|
||||||
|
}
|
||||||
|
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
|
||||||
|
if (!forcedChild && l == b) return {r, r, r};
|
||||||
|
if (!forcedChild && r == b) return {l, l, l};
|
||||||
|
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
|
||||||
|
{
|
||||||
|
MergeNode out{Json::object(), Json::object(), Json::object()};
|
||||||
|
std::set<std::string> keys;
|
||||||
|
for (const auto *j : {&b, &l, &r}) if (j->is_object()) for (auto it = j->begin(); it != j->end(); ++it) keys.insert(it.key());
|
||||||
|
for (const auto &key : keys)
|
||||||
|
{
|
||||||
|
auto get = [&](const Json &j) { return j.is_object() && j.contains(key) ? j.at(key) : Json(); };
|
||||||
|
auto childLabel = label.empty() ? fieldLabel(key) : label + L" / " + fieldLabel(key);
|
||||||
|
if (path == "/snippets")
|
||||||
|
{
|
||||||
|
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
|
||||||
|
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
|
||||||
|
}
|
||||||
|
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
|
||||||
|
// Keep nulls in the ancestor so deletion conflicts survive restarts.
|
||||||
|
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
conflicts.push_back({path, label, bytes(l), bytes(r)});
|
||||||
|
return {l, r, b};
|
||||||
|
}
|
||||||
|
void collisionPaths(const Json &j, std::set<std::string> &paths)
|
||||||
|
{
|
||||||
|
std::map<std::wstring, std::string> triggers;
|
||||||
|
for (const auto &[id, sn] : j.at("snippets").items())
|
||||||
|
{
|
||||||
|
if (sn.is_null() || tombstone(sn)) continue;
|
||||||
|
auto path = "/snippets/" + pointerKey(id);
|
||||||
|
auto key = lower(wide(sn.at("trigger").get<std::string>()));
|
||||||
|
auto [it, inserted] = triggers.emplace(key, path);
|
||||||
|
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||||
|
}
|
||||||
|
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
|
||||||
|
auto add = [&](const Json &text, const std::string &path) {
|
||||||
|
if (!text.is_string()) return;
|
||||||
|
auto hotkey = Hotkey::parse(wide(text.get<std::string>()));
|
||||||
|
if (!hotkey || !hotkey->key) return;
|
||||||
|
auto [it, inserted] = shortcuts.emplace(std::make_pair(hotkey->modifiers, hotkey->key), path);
|
||||||
|
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||||
|
};
|
||||||
|
for (const auto *key : {"toggleHotkey", "caseHotkey", "translitHotkey", "undoHotkey", "clipboardHotkey", "screenshotHotkey"}) add(j.at(key), std::string("/") + key);
|
||||||
|
for (const auto &[id, key] : j.at("layoutHotkeys").items()) add(key, "/layoutHotkeys/" + pointerKey(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
|
||||||
|
if (base.empty() && l != r) throw std::runtime_error("First connection: choose Download remote or Upload local.");
|
||||||
|
auto b = base.empty() ? l : treeProfile(parse(base));
|
||||||
|
SyncMerge result;
|
||||||
|
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
||||||
|
std::set<std::string> collisions;
|
||||||
|
collisionPaths(merged.local, collisions); collisionPaths(merged.remote, collisions);
|
||||||
|
if (!collisions.empty())
|
||||||
|
{
|
||||||
|
result.conflicts.clear();
|
||||||
|
merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, collisions);
|
||||||
|
}
|
||||||
|
result.local = bytes(flatProfile(merged.local));
|
||||||
|
result.remote = bytes(flatProfile(merged.remote));
|
||||||
|
result.base = bytes(flatProfile(merged.base));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote)
|
||||||
|
{
|
||||||
|
auto result = mergeSyncProfile(base, local, remote);
|
||||||
|
if (!result.conflicts.empty()) throw std::runtime_error("Conflicting setting: " + result.conflicts.front().key);
|
||||||
|
return result.local;
|
||||||
|
}
|
||||||
+8
-2
@@ -1298,6 +1298,7 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
|||||||
EnableWindow(parent, FALSE);
|
EnableWindow(parent, FALSE);
|
||||||
showRequestedWindow(w); SetForegroundWindow(w);
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
}
|
}
|
||||||
|
#include "ui_sync.inl"
|
||||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (msg == WM_DPICHANGED)
|
if (msg == WM_DPICHANGED)
|
||||||
@@ -1334,6 +1335,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||||
|
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
collectLayout(w, v);
|
collectLayout(w, v);
|
||||||
@@ -1455,7 +1457,10 @@ void collectSnippet(HWND w, SnippetView *v)
|
|||||||
{
|
{
|
||||||
if (v->selection >= 0 && v->selection < (int)v->snippets.size())
|
if (v->selection >= 0 && v->selection < (int)v->snippets.size())
|
||||||
{
|
{
|
||||||
v->snippets[v->selection] = {value(w, 302), value(w, 303)};
|
auto &snippet = v->snippets[v->selection];
|
||||||
|
if (snippet.id.empty()) snippet.id = snippetId(snippet);
|
||||||
|
snippet.trigger = value(w, 302);
|
||||||
|
snippet.text = value(w, 303);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
@@ -1492,7 +1497,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (id == 310)
|
if (id == 310)
|
||||||
{
|
{
|
||||||
collectSnippet(w, v);
|
collectSnippet(w, v);
|
||||||
v->snippets.push_back({L";new", L""});
|
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||||
v->selection = (int)v->snippets.size() - 1;
|
v->selection = (int)v->snippets.size() - 1;
|
||||||
fillSnippets(w, v);
|
fillSnippets(w, v);
|
||||||
SetFocus(GetDlgItem(w, 302));
|
SetFocus(GetDlgItem(w, 302));
|
||||||
@@ -1694,6 +1699,7 @@ void showSettings(App &app, bool visible)
|
|||||||
button(w, L"Import settings…", 250, 18, 606, 145);
|
button(w, L"Import settings…", 250, 18, 606, 145);
|
||||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||||
|
button(w, L"Sync...", 258, 448, 606, 90);
|
||||||
button(w, L"Save", IDOK, 548, 606, 100);
|
button(w, L"Save", IDOK, 548, 606, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#include "ui_sync_conflicts.inl"
|
||||||
|
struct SyncView { HWND parent; App *app; };
|
||||||
|
SyncConfig syncFields(HWND w)
|
||||||
|
{
|
||||||
|
SyncConfig c;
|
||||||
|
c.enabled = checked(w, 601);
|
||||||
|
c.server = value(w, 602);
|
||||||
|
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
||||||
|
c.username = lower(value(w, 603)); c.mount = value(w, 605);
|
||||||
|
c.path = value(w, 606); c.authMount = value(w, 607);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
auto *v = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == WM_TIMER)
|
||||||
|
{
|
||||||
|
value(w, 608, v->app->syncCommand());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id = LOWORD(wp);
|
||||||
|
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
if (id == 609)
|
||||||
|
{
|
||||||
|
auto c = loadSyncConfig(v->app->data);
|
||||||
|
c.enabled = false;
|
||||||
|
saveSyncConfig(v->app->data, c);
|
||||||
|
forgetSyncPassword(c);
|
||||||
|
check(w, 601, false);
|
||||||
|
value(w, 604, L"");
|
||||||
|
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
||||||
|
KillTimer(w, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDOK || id == 610 || id == 611)
|
||||||
|
{
|
||||||
|
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 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);
|
||||||
|
if (id != IDOK && !c.enabled) throw std::runtime_error("Enable synchronization first.");
|
||||||
|
if (c.enabled) validateSyncConfig(c);
|
||||||
|
auto password = value(w, 604);
|
||||||
|
try { if (!password.empty()) saveSyncPassword(c, password); }
|
||||||
|
catch (...) { SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t)); throw; }
|
||||||
|
SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t));
|
||||||
|
value(w, 604, L"");
|
||||||
|
saveSyncConfig(v->app->data, c);
|
||||||
|
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
|
||||||
|
SetTimer(w, 1, 1000, nullptr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
// 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<const std::runtime_error *>(&e))
|
||||||
|
value(w, 608, wide(e.what()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
KillTimer(w, 1);
|
||||||
|
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void showSyncOptions(HWND parent, App &app)
|
||||||
|
{
|
||||||
|
auto c = loadSyncConfig(app.data);
|
||||||
|
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 640);
|
||||||
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
|
||||||
|
checkbox(w, L"Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
|
||||||
|
check(w, 601, c.enabled);
|
||||||
|
label(w, L"Server (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
|
||||||
|
label(w, L"Username", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
|
||||||
|
label(w, L"Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
|
||||||
|
label(w, L"Leave password blank to use the saved Windows credential.", 190, 166, 450);
|
||||||
|
label(w, L"KV v2 mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
|
||||||
|
label(w, L"Profile path", 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);
|
||||||
|
control(w, L"STATIC", L"", 0, 608, 18, 362, 622, 76);
|
||||||
|
value(w, 608, app.syncCommand());
|
||||||
|
button(w, L"Save / Sign in / Sync", IDOK, 18, 452, 200);
|
||||||
|
button(w, L"Download remote", 610, 230, 452, 195);
|
||||||
|
button(w, L"Upload local", 611, 437, 452, 203);
|
||||||
|
button(w, L"Resolve individual conflicts...", 612, 18, 498, 300);
|
||||||
|
label(w, L"Background check: every 10 minutes", 330, 502, 310);
|
||||||
|
button(w, L"Disconnect / forget password", 609, 18, 550, 290);
|
||||||
|
button(w, L"Close", IDCANCEL, 540, 550, 100);
|
||||||
|
EnableWindow(parent, FALSE);
|
||||||
|
SetTimer(w, 1, 1000, nullptr);
|
||||||
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
struct SyncConflictView
|
||||||
|
{
|
||||||
|
HWND parent;
|
||||||
|
App *app;
|
||||||
|
std::vector<SyncConflict> conflicts;
|
||||||
|
std::map<size_t, bool> choices;
|
||||||
|
};
|
||||||
|
void showConflictValues(HWND w, SyncConflictView *v)
|
||||||
|
{
|
||||||
|
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
|
||||||
|
if (selected < 0 || selected >= (int)v->conflicts.size()) return;
|
||||||
|
const auto &c = v->conflicts[selected];
|
||||||
|
value(w, 621, syncValueText(c.local)); value(w, 622, syncValueText(c.remote));
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK syncConflictsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
auto *v = reinterpret_cast<SyncConflictView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id = LOWORD(wp);
|
||||||
|
if (id == 620 && HIWORD(wp) == LBN_SELCHANGE) { showConflictValues(w, v); return 0; }
|
||||||
|
if (id == 623 || id == 624)
|
||||||
|
{
|
||||||
|
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
|
||||||
|
if (selected < 0 || selected >= (int)v->conflicts.size()) return 0;
|
||||||
|
v->choices[selected] = id == 623;
|
||||||
|
auto text = v->conflicts[selected].label + (id == 623 ? L" [This PC]" : L" [Server]");
|
||||||
|
SendDlgItemMessageW(w, 620, LB_DELETESTRING, selected, 0);
|
||||||
|
SendDlgItemMessageW(w, 620, LB_INSERTSTRING, selected, (LPARAM)text.c_str());
|
||||||
|
SendDlgItemMessageW(w, 620, LB_SETCURSEL, selected, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDOK)
|
||||||
|
{
|
||||||
|
if (v->choices.empty()) throw std::runtime_error("Select a setting and choose This PC or Server first.");
|
||||||
|
std::vector<SyncChoice> choices;
|
||||||
|
for (const auto &[index, local] : v->choices) choices.push_back({v->conflicts.at(index), local});
|
||||||
|
auto status = v->app->syncCommand(4, choices);
|
||||||
|
value(v->parent, 608, status);
|
||||||
|
if (status != L"Connecting to OpenBao...") throw std::runtime_error(utf8(status));
|
||||||
|
DestroyWindow(w); return 0;
|
||||||
|
}
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); return 0; }
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void showSyncConflicts(HWND parent, App &app)
|
||||||
|
{
|
||||||
|
auto conflicts = decodeSyncConflicts(app.syncConflictData());
|
||||||
|
if (conflicts.empty()) { value(parent, 608, L"No pending conflicts. Run Sync to check for changes."); return; }
|
||||||
|
HWND w = makeWindow(app, L"KSwitcher.SyncConflicts", L"Resolve individual settings", syncConflictsProc, 800, 560);
|
||||||
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
|
auto *v = new SyncConflictView{parent, &app, std::move(conflicts), {}};
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||||
|
label(w, L"Choose a value for each conflicting setting. Other changes are already synchronized.", 18, 16, 760);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | WS_BORDER, 620, 18, 48, 760, 130);
|
||||||
|
for (const auto &c : v->conflicts) SendDlgItemMessageW(w, 620, LB_ADDSTRING, 0, (LPARAM)c.label.c_str());
|
||||||
|
label(w, L"This PC", 18, 190, 360); label(w, L"Server", 410, 190, 368);
|
||||||
|
edit(w, 621, 18, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
|
edit(w, 622, 410, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
|
button(w, L"Use this PC for selected setting", 623, 18, 412, 368);
|
||||||
|
button(w, L"Use server for selected setting", 624, 410, 412, 368);
|
||||||
|
label(w, L"Unselected conflicts stay pending. Changed values will be checked again before saving.", 18, 454, 760);
|
||||||
|
button(w, L"Apply choices", IDOK, 526, 488, 140);
|
||||||
|
button(w, L"Cancel", IDCANCEL, 678, 488, 100);
|
||||||
|
SendDlgItemMessageW(w, 620, LB_SETCURSEL, 0, 0); showConflictValues(w, v);
|
||||||
|
EnableWindow(parent, FALSE); showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.25";
|
inline constexpr wchar_t AppVersion[] = L"0.6.26";
|
||||||
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;
|
||||||
|
|||||||
@@ -15,6 +15,37 @@ struct InputRegressionAccess {
|
|||||||
static inline bool delivery = true;
|
static inline bool delivery = true;
|
||||||
static inline int notifications = 0;
|
static inline int notifications = 0;
|
||||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||||
|
static void sampleConflicts(App &a) {
|
||||||
|
a.conflictConnection=loadSyncConfig(a.data);
|
||||||
|
a.pendingSyncConflicts={{"/autoCorrectMinLength",L"Minimum word length",{'4'},{'5'}}};
|
||||||
|
}
|
||||||
|
static bool staleSync(App &a, bool connectionChanged) {
|
||||||
|
auto before=encodeSettings(a.settings);
|
||||||
|
a.syncConnection=loadSyncConfig(a.data);
|
||||||
|
a.syncSnapshot=before;
|
||||||
|
if(connectionChanged) a.syncConnection.enabled=!a.syncConnection.enabled;
|
||||||
|
else a.syncSnapshot.clear();
|
||||||
|
auto remote=a.settings; remote.recent=!remote.recent;
|
||||||
|
a.syncResult={true,sharedSyncSettings(remote),2,L"test"};
|
||||||
|
a.syncWorker=std::thread([]{});
|
||||||
|
a.syncRunning=true; a.syncDone=true;
|
||||||
|
a.pollSync();
|
||||||
|
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
||||||
|
!std::filesystem::exists(a.data/L"sync-base.bin");
|
||||||
|
}
|
||||||
|
static bool syncBackoff(App &a) {
|
||||||
|
a.syncRetryAfter=GetTickCount64()+15*60000;
|
||||||
|
auto previous=a.settings;
|
||||||
|
auto next=previous;
|
||||||
|
next.toggle=next.casing=next.translit=next.undo=next.clipboard=next.screenshot=L"";
|
||||||
|
next.direct.clear(); next.directIds.clear();
|
||||||
|
// This harness deliberately has no global keyboard hook.
|
||||||
|
a.applySettings(next);
|
||||||
|
bool preserved=a.nextSync==a.syncRetryAfter;
|
||||||
|
a.settings=previous;
|
||||||
|
a.syncRetryAfter=0;
|
||||||
|
return preserved;
|
||||||
|
}
|
||||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||||
static bool updateTimer(App &a) {
|
static bool updateTimer(App &a) {
|
||||||
@@ -595,6 +626,55 @@ int main(int argc, char **argv) {
|
|||||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||||
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);
|
||||||
|
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
|
||||||
|
DestroyWindow(app.settingsWindow);
|
||||||
|
ks::saveSyncConfig(app.data,ks::SyncConfig{});
|
||||||
|
ks::showSettings(app,false);
|
||||||
|
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync button available after local opt-in");
|
||||||
|
SendMessageW(app.settingsWindow,WM_COMMAND,258,0);
|
||||||
|
auto syncWindow=FindWindowW(L"KSwitcher.Sync",nullptr);
|
||||||
|
require(syncWindow!=nullptr,"private sync dialog opens");
|
||||||
|
ShowWindow(syncWindow,SW_HIDE);
|
||||||
|
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
|
||||||
|
"sync password is masked");
|
||||||
|
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
|
||||||
|
ks::InputRegressionAccess::sampleConflicts(app);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||||
|
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
||||||
|
require(conflictsWindow!=nullptr,"per-setting conflict dialog opens");
|
||||||
|
ShowWindow(conflictsWindow,SW_HIDE);
|
||||||
|
require(SendDlgItemMessageW(conflictsWindow,620,LB_GETCOUNT,0,0)==1,"conflict shown as separate setting");
|
||||||
|
wchar_t conflictValue[20]{}; GetDlgItemTextW(conflictsWindow,621,conflictValue,20);
|
||||||
|
require(std::wstring(conflictValue)==L"4","local conflict value shown");
|
||||||
|
GetDlgItemTextW(conflictsWindow,622,conflictValue,20);
|
||||||
|
require(std::wstring(conflictValue)==L"5","remote conflict value shown");
|
||||||
|
for(int id:{620,621,622,623,624,IDOK,IDCANCEL}) {
|
||||||
|
RECT control{},client{};
|
||||||
|
GetWindowRect(GetDlgItem(conflictsWindow,id),&control);
|
||||||
|
MapWindowPoints(nullptr,conflictsWindow,reinterpret_cast<POINT*>(&control),2);
|
||||||
|
GetClientRect(conflictsWindow,&client);
|
||||||
|
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||||
|
"conflict controls fit dialog");
|
||||||
|
}
|
||||||
|
SendMessageW(conflictsWindow,WM_COMMAND,623,0);
|
||||||
|
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
|
||||||
|
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
|
||||||
|
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,IDOK,IDCANCEL}) {
|
||||||
|
RECT control{},client{};
|
||||||
|
GetWindowRect(GetDlgItem(syncWindow,id),&control);
|
||||||
|
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
|
||||||
|
GetClientRect(syncWindow,&client);
|
||||||
|
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||||
|
"sync controls fit dialog");
|
||||||
|
}
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
|
||||||
|
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||||
|
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||||
|
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
||||||
|
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
require(autoOptions!=nullptr,"auto-correction options open");
|
require(autoOptions!=nullptr,"auto-correction options open");
|
||||||
|
|||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
#include "sync.h"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include <iostream>
|
||||||
|
using namespace ks;
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int checks{};
|
||||||
|
void require(bool condition, const char *message)
|
||||||
|
{
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
++checks;
|
||||||
|
}
|
||||||
|
template<class F> void rejects(F f, const char *message)
|
||||||
|
{
|
||||||
|
bool rejected = false;
|
||||||
|
try { f(); } catch (const std::exception &) { rejected = true; }
|
||||||
|
require(rejected, message);
|
||||||
|
}
|
||||||
|
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
||||||
|
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
||||||
|
struct FakeProvider : SyncProvider
|
||||||
|
{
|
||||||
|
SyncDocument remote;
|
||||||
|
int writes{};
|
||||||
|
bool race{};
|
||||||
|
SyncDocument read() override { return remote; }
|
||||||
|
uint64_t write(const Bytes &b, uint64_t expected) override
|
||||||
|
{
|
||||||
|
if (race) ++remote.revision;
|
||||||
|
if (expected != remote.revision) throw std::runtime_error("CAS conflict");
|
||||||
|
++writes; remote = {remote.revision + 1, b}; return remote.revision;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
auto root = std::filesystem::temp_directory_path() / (L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
std::filesystem::create_directories(root);
|
||||||
|
require(!syncVisible(root), "hidden without opt-in file");
|
||||||
|
require(!loadSyncConfig(root).enabled, "disabled by default");
|
||||||
|
SyncConfig config; config.server = L"https://bao.example.com"; config.username = L"person"; config.enabled = true;
|
||||||
|
validateSyncConfig(config);
|
||||||
|
for (const auto *server : {L"http://bao.example.com", L"https://user:pass@bao.example.com", L"https://bao.example.com/x", L"https://bao.example.com?x=1"})
|
||||||
|
{
|
||||||
|
auto invalid = config; invalid.server = server;
|
||||||
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsafe endpoint");
|
||||||
|
}
|
||||||
|
auto invalid = config; invalid.path = L"../other";
|
||||||
|
rejects([&] { validateSyncConfig(invalid); }, "reject traversal");
|
||||||
|
invalid = config; invalid.provider = L"unknown";
|
||||||
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
||||||
|
saveSyncConfig(root, config);
|
||||||
|
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
|
||||||
|
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
||||||
|
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
||||||
|
Settings local;
|
||||||
|
local.autostart = true; local.elevatedAutostart = true; local.tesseractPath = L"C:\\private\\tesseract.exe";
|
||||||
|
local.history = false;
|
||||||
|
local.snippets.push_back({L"test", L"private snippet"});
|
||||||
|
auto base = sharedSyncSettings(local);
|
||||||
|
Settings ordered = local, reordered = local;
|
||||||
|
ordered.directIds = {L"00000409", L"00000422"}; ordered.direct = {L"Ctrl+1", L"Ctrl+2"};
|
||||||
|
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
||||||
|
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
||||||
|
auto j = parse(base);
|
||||||
|
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
|
||||||
|
auto restored = restoreSyncSettings(base, local, {});
|
||||||
|
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
|
||||||
|
auto hostile = j; hostile["startWithSystem"] = false;
|
||||||
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
|
||||||
|
hostile = j; hostile["schemaVersion"] = 2;
|
||||||
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
|
||||||
|
hostile = j; hostile["layouts"] = Json::array({"00000422"});
|
||||||
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "missing layouts block destructive normalization");
|
||||||
|
auto left = j, right = j;
|
||||||
|
left["convertLastWord"] = false; right["switchSystemLayout"] = false;
|
||||||
|
auto merged = parse(mergeSyncSettings(base, bytes(left), bytes(right)));
|
||||||
|
require(merged["convertLastWord"] == false && merged["switchSystemLayout"] == false, "merge independent edits");
|
||||||
|
left = right = j; left["autoCorrectMinLength"] = 4; right["autoCorrectMinLength"] = 5;
|
||||||
|
rejects([&] { mergeSyncSettings(base, bytes(left), bytes(right)); }, "same field conflict");
|
||||||
|
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
||||||
|
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
||||||
|
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
|
||||||
|
rejects([&] { mergeSyncSettings({}, bytes(left), base); }, "first sync needs explicit choice");
|
||||||
|
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
||||||
|
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||||
|
l["convertLastWord"] = false; r["switchSystemLayout"] = false;
|
||||||
|
auto plan = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(plan.conflicts.size() == 1, "one conflict reported independently");
|
||||||
|
require(parse(plan.local)["switchSystemLayout"] == false && parse(plan.remote)["convertLastWord"] == false,
|
||||||
|
"nonconflicting changes reach both copies");
|
||||||
|
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
||||||
|
"unresolved scalar retains both values");
|
||||||
|
require(parse(plan.base)["autoCorrectMinLength"] == j["autoCorrectMinLength"], "ancestor retained for unresolved setting");
|
||||||
|
saveSyncBase(root, config, plan.base);
|
||||||
|
auto again = mergeSyncProfile(loadSyncBase(root, config), plan.local, plan.remote);
|
||||||
|
require(again.conflicts.size() == 1, "conflict survives restart checkpoint");
|
||||||
|
auto choice = SyncChoice{plan.conflicts.front(), true};
|
||||||
|
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {choice});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.remote)["autoCorrectMinLength"] == 4,
|
||||||
|
"resolve only selected setting");
|
||||||
|
require(parse(resolved.remote)["switchSystemLayout"] == false, "choice preserves independent server change");
|
||||||
|
auto changed = parse(plan.remote); changed["autoCorrectMinLength"] = 6;
|
||||||
|
require(mergeSyncProfile(plan.base, plan.local, bytes(changed), {choice}).conflicts.size() == 1,
|
||||||
|
"stale conflict choice never overwrites new server value");
|
||||||
|
FakeProvider partial; partial.remote = {7, bytes(r)};
|
||||||
|
auto result = synchronize(partial, base, bytes(l), SyncAction::Automatic);
|
||||||
|
require(result.success && result.conflicts.size() == 1 && partial.writes == 1,
|
||||||
|
"partial synchronization commits independent fields with CAS");
|
||||||
|
auto encoded = encodeSyncChoices({choice});
|
||||||
|
require(decodeSyncChoices(encoded).front().conflict.key == choice.conflict.key, "choice IPC round trip");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto id = j["snippets"][0]["id"].get<std::string>();
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["snippets"][0]["trigger"] = "renamed";
|
||||||
|
r["snippets"][0]["text"] = "edited remotely";
|
||||||
|
auto merged = parse(mergeSyncSettings(base, bytes(l), bytes(r)));
|
||||||
|
require(merged["snippets"][0]["id"] == id && merged["snippets"][0]["trigger"] == "renamed" &&
|
||||||
|
merged["snippets"][0]["text"] == "edited remotely", "stable ID merges rename and text edit");
|
||||||
|
l = r = j; l["snippets"] = Json::array(); l["snippetTombstones"] = Json::array({id});
|
||||||
|
auto deletion = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(deletion.conflicts.empty() && parse(deletion.remote)["snippets"].empty(), "delete beats unchanged snippet");
|
||||||
|
require(parse(deletion.remote)["snippetTombstones"] == Json::array({id}), "deletion marker retained");
|
||||||
|
auto offline = mergeSyncProfile(base, bytes(r), deletion.remote);
|
||||||
|
require(offline.conflicts.empty() && parse(offline.local)["snippets"].empty(), "offline copy cannot resurrect deletion");
|
||||||
|
r["snippets"][0]["text"] = "offline edit";
|
||||||
|
auto conflict = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(conflict.conflicts.size() == 1, "delete versus edit is one snippet conflict");
|
||||||
|
auto remove = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], true}});
|
||||||
|
require(remove.conflicts.empty() && parse(remove.remote)["snippets"].empty(), "choose deletion");
|
||||||
|
auto keep = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], false}});
|
||||||
|
require(keep.conflicts.empty() && parse(keep.local)["snippetTombstones"].empty() &&
|
||||||
|
parse(keep.local)["snippets"][0]["text"] == "offline edit", "choose edited snippet removes tombstone");
|
||||||
|
require(mergeSyncProfile(base, bytes(l), bytes(l)).conflicts.empty(), "both delete converges");
|
||||||
|
auto legacy = j; legacy.erase("snippetTombstones"); legacy["snippets"][0].erase("id");
|
||||||
|
auto migrated = parse(mergeSyncSettings(bytes(legacy), base, bytes(legacy)));
|
||||||
|
require(migrated["snippets"][0]["id"] == id, "legacy profiles migrate to identical IDs");
|
||||||
|
auto roundtrip = decodeSettings(encodeSettings(restored), {});
|
||||||
|
require(snippetId(roundtrip.snippets.front()) == wide(id), "ID survives settings serialization");
|
||||||
|
require(newSnippetId() != newSnippetId(), "new snippet IDs are unique");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["snippets"].push_back({{"id", "new-a"}, {"trigger", "same"}, {"text", "A"}});
|
||||||
|
r["snippets"].push_back({{"id", "new-b"}, {"trigger", "same"}, {"text", "B"}});
|
||||||
|
auto collision = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(collision.conflicts.size() == 2, "duplicate new triggers expose individual choices");
|
||||||
|
auto resolved = mergeSyncProfile(collision.base, collision.local, collision.remote,
|
||||||
|
{{collision.conflicts[0], true}, {collision.conflicts[1], true}});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.remote)["snippets"].size() == 2, "choose one of colliding additions");
|
||||||
|
l = r = j; l["toggleHotkey"] = "Ctrl+Alt+F11"; r["caseHotkey"] = "Ctrl+Alt+F11";
|
||||||
|
require(mergeSyncProfile(base, bytes(l), bytes(r)).conflicts.size() == 2, "cross-field shortcut collisions exposed");
|
||||||
|
l = r = j; l["excludedApps"].push_back("local-app"); r["excludedApps"].push_back("remote-app");
|
||||||
|
require(parse(mergeSyncSettings(base, bytes(l), bytes(r)))["excludedApps"].size() == j["excludedApps"].size() + 2,
|
||||||
|
"independent exclusion edits merge");
|
||||||
|
}
|
||||||
|
FakeProvider provider;
|
||||||
|
rejects([&] { synchronize(provider, {}, base, SyncAction::Automatic); }, "empty server never auto-populated");
|
||||||
|
auto result = synchronize(provider, {}, base, SyncAction::Upload);
|
||||||
|
require(result.revision == 1 && provider.writes == 1, "initial upload CAS zero");
|
||||||
|
result = synchronize(provider, base, base, SyncAction::Automatic);
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
provider.race = false;
|
||||||
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
||||||
|
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
||||||
|
require(provider.writes == 1, "invalid data not uploaded");
|
||||||
|
saveSyncBase(root, config, base);
|
||||||
|
require(parse(loadSyncBase(root, config)) == j, "encrypted baseline round trip");
|
||||||
|
auto other = config; other.path = L"other/settings";
|
||||||
|
require(loadSyncBase(root, other).empty(), "baseline bound to connection");
|
||||||
|
auto encrypted = readFile(root / L"sync-base.bin", 1024 * 1024);
|
||||||
|
std::string disk(encrypted.begin(), encrypted.end());
|
||||||
|
require(disk.find("private snippet") == disk.npos, "baseline encrypted on disk");
|
||||||
|
// Exercise real Credential Manager with an isolated test target, never a user endpoint.
|
||||||
|
config.username = L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId());
|
||||||
|
saveSyncPassword(config, L"test-only-password");
|
||||||
|
int logins{}, revokes{}, reads{}, writes{};
|
||||||
|
unsigned readStatus = 200, writeStatus = 200;
|
||||||
|
bool deleted = false;
|
||||||
|
auto transport = [&](const std::wstring &method, const std::wstring &path,
|
||||||
|
const std::wstring &token, const Bytes &body) -> SyncHttpResponse {
|
||||||
|
if (path == L"/v1/auth/userpass/login/" + config.username)
|
||||||
|
{
|
||||||
|
require(method == L"POST" && token.empty(), "login route and no bearer token");
|
||||||
|
require(parse(body)["password"] == "test-only-password", "password sourced from credential manager");
|
||||||
|
++logins;
|
||||||
|
return {200, bytes({{"auth", {{"client_token", "test-token"}}}})};
|
||||||
|
}
|
||||||
|
require(token == L"test-token", "authenticated requests use returned token");
|
||||||
|
if (path == L"/v1/auth/token/revoke-self")
|
||||||
|
{
|
||||||
|
require(method == L"POST", "revoke method"); ++revokes; return {204, {}};
|
||||||
|
}
|
||||||
|
require(path == L"/v1/kswitcher/data/personal/settings", "KV v2 data route");
|
||||||
|
if (method == L"GET")
|
||||||
|
{
|
||||||
|
++reads;
|
||||||
|
if (readStatus != 200 && !deleted) return {readStatus, bytes({{"errors", Json::array()}})};
|
||||||
|
return {readStatus, bytes({{"data", {
|
||||||
|
{"metadata", {{"version", 7}, {"deletion_time", deleted ? "2026-01-01" : ""}, {"destroyed", false}}},
|
||||||
|
{"data", {{"syncSchema", 1}, {"settings", j}}}}}})};
|
||||||
|
}
|
||||||
|
require(method == L"POST", "write method");
|
||||||
|
auto request = parse(body);
|
||||||
|
require(request["options"]["cas"] == 7, "write includes exact CAS revision");
|
||||||
|
require(request["data"]["syncSchema"] == 2 && request["data"]["settings"] == j, "versioned settings envelope");
|
||||||
|
++writes;
|
||||||
|
return {writeStatus, bytes({{"data", {{"version", 8}}}})};
|
||||||
|
};
|
||||||
|
{
|
||||||
|
auto bao = makeSyncProvider(config, transport);
|
||||||
|
auto doc = bao->read();
|
||||||
|
require(doc.revision == 7 && parse(doc.settings) == j, "read protocol response");
|
||||||
|
require(bao->write(base, 7) == 8, "write protocol response");
|
||||||
|
readStatus = 404;
|
||||||
|
require(bao->read().revision == 0, "missing profile");
|
||||||
|
deleted = true;
|
||||||
|
rejects([&] { bao->read(); }, "soft deleted is not treated as absent");
|
||||||
|
deleted = false; readStatus = 403;
|
||||||
|
rejects([&] { bao->read(); }, "permission denied is not absent");
|
||||||
|
readStatus = 302;
|
||||||
|
rejects([&] { bao->read(); }, "redirect response rejected");
|
||||||
|
writeStatus = 400;
|
||||||
|
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
|
||||||
|
}
|
||||||
|
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
|
||||||
|
forgetSyncPassword(config); forgetSyncPassword(config);
|
||||||
|
++checks;
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
std::cout << checks << " sync checks passed\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
std::cerr << e.what() << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user