Release v0.6.28: typo correction, partial sync and selection diagnostics

This commit is contained in:
2026-11-02 11:19:13 +02:00
parent 738c81dce2
commit 55993c0f90
23 changed files with 676 additions and 76 deletions
+75 -7
View File
@@ -73,6 +73,8 @@ class Transaction
Clip snapshot;
DWORD expected{};
bool captured{};
mutable bool invalidLogged{};
unsigned copyNumber{};
std::wstring adopted;
public:
@@ -108,8 +110,18 @@ class Transaction
}
bool valid() const
{
return target && IsWindow(target) && GetForegroundWindow() == target &&
app.inputVersion.load() == version;
auto foreground = GetForegroundWindow();
auto currentVersion = app.inputVersion.load();
bool ok = target && IsWindow(target) && foreground == target && currentVersion == version;
if (!ok && !invalidLogged)
{
invalidLogged = true;
diagnosticEvent(L"Context invalid: target=" + std::to_wstring((uintptr_t)target) +
L" alive=" + std::to_wstring(IsWindow(target)) + L" foreground=" +
std::to_wstring((uintptr_t)foreground) + L" expectedVersion=" + std::to_wstring(version) +
L" currentVersion=" + std::to_wstring(currentVersion));
}
return ok;
}
void check()
{
@@ -144,10 +156,14 @@ class Transaction
}
std::wstring copy()
{
const auto started = GetTickCount64();
const auto number = ++copyNumber;
if (!valid())
return {};
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
DWORD before = expected;
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target));
if (!valid() || !chord(L'C', MOD_CONTROL))
{
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
@@ -166,6 +182,10 @@ class Transaction
DWORD now = GetClipboardSequenceNumber();
if (now != changed)
{
diagnosticEvent(L"Copy sequence number=" + std::to_wstring(number) + L" from=" +
std::to_wstring(changed) + L" to=" + std::to_wstring(now) + L" elapsedMs=" +
std::to_wstring(GetTickCount64() - started) + L" owner=" +
std::to_wstring((uintptr_t)GetClipboardOwner()));
changedAgain |= changed != before;
changed = now;
stable = GetTickCount64();
@@ -183,7 +203,8 @@ class Transaction
return {};
if (!chord(L'C', MOD_CONTROL))
return {};
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
++attempts;
retryAt = GetTickCount64() + 250;
}
@@ -191,6 +212,10 @@ class Transaction
}
if (changed == before || !valid())
{
diagnosticEvent(L"Copy end number=" + std::to_wstring(number) + L" attempts=" +
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
L" sequence=" + std::to_wstring(GetClipboardSequenceNumber()) + L" clipboardOpenBy=" +
std::to_wstring((uintptr_t)GetOpenClipboardWindow()));
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
L"Copy failed: context changed");
return {};
@@ -201,6 +226,9 @@ class Transaction
expected = changed;
auto clip = readClipboard(app.window);
auto text = clip.text();
diagnosticEvent(L"Copy result number=" + std::to_wstring(number) + L" attempts=" +
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
L" text=" + diagnosticText(text));
adopted = text;
check();
for (auto &f : clip.formats)
@@ -233,6 +261,27 @@ class Transaction
const std::function<bool(const std::wstring &)> &suffixMatches = {})
{
// WebView word boundaries are only candidates; exact verification is mandatory.
diagnosticEvent(L"Selection begin steps=" + std::to_wstring(count) + L" expected=" + diagnosticText(wordCandidate));
GUITHREADINFO gui{sizeof(gui)};
if (GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui))
{
wchar_t cls[128]{};
GetClassNameW(gui.hwndFocus, cls, 128);
std::wstring offsets = L"unavailable";
if (_wcsicmp(cls, L"Edit") == 0)
{
DWORD_PTR range{};
if (SendMessageTimeoutW(gui.hwndFocus, EM_GETSEL, 0, 0,
SMTO_ABORTIFHUNG | SMTO_BLOCK, 25, &range) && (DWORD)range != 0xffffffff)
offsets = std::to_wstring(LOWORD(range)) + L"," + std::to_wstring(HIWORD(range));
}
diagnosticEvent(L"Selection editor class=" + std::wstring(cls) + L" focus=" +
std::to_wstring((uintptr_t)gui.hwndFocus) + L" caretWindow=" + std::to_wstring((uintptr_t)gui.hwndCaret) +
L" caretRect=" + std::to_wstring(gui.rcCaret.left) + L"," + std::to_wstring(gui.rcCaret.top) +
L"," + std::to_wstring(gui.rcCaret.right) + L"," + std::to_wstring(gui.rcCaret.bottom) +
L" layout=" + std::to_wstring((uintptr_t)windowLayout(target)) + L" selectionOffsets=" + offsets);
}
else diagnosticEvent(L"Selection editor: focus/caret/selection unavailable");
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
std::all_of(wordCandidate.begin(), wordCandidate.end(),
[](wchar_t c) { return letter(c) || iswdigit(c); }))
@@ -241,6 +290,7 @@ class Transaction
return {};
Sleep(25);
auto text = copy();
diagnosticEvent(L"Selection word-boundary match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
if (valid() && matches(text))
{
diagnosticEvent(L"Selection: verified word boundary");
@@ -251,6 +301,7 @@ class Transaction
Sleep(25);
}
auto text = select(count) ? copy() : std::wstring{};
diagnosticEvent(L"Selection initial match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
// Chromium editors can apply only part of a burst of Shift+Left events.
// Extend only a verified suffix; never replace a partial or unrelated range.
if (valid() && !matches(text) && !text.empty() &&
@@ -263,10 +314,11 @@ class Transaction
{
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
return {};
diagnosticEvent(L"Selection recovery Shift+Left sent=" + std::to_wstring(i + 1) + L" total=" + std::to_wstring(missing));
Sleep(25);
}
text = copy();
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)) + L" copied=" + diagnosticText(text));
}
return text;
}
@@ -532,6 +584,7 @@ void App::applySettings(const Settings &requested)
run.reset();
last.reset();
++inputVersion;
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
}
// Clear on the worker so the next check reloads edits to dictionary files.
enqueue([this] { autoCorrector.configure({}, {}); });
@@ -560,6 +613,7 @@ void App::reloadLayouts()
run.reset();
last.reset();
++inputVersion;
if (busy) diagnosticEvent(L"Input context changed: layouts reloaded version=" + std::to_wstring(inputVersion.load()));
}
registerHotkeys(settings);
}
@@ -589,6 +643,7 @@ void App::resetInput()
{
std::lock_guard lock(stateMutex);
++inputVersion;
if (busy) diagnosticEvent(L"Input context changed: reset version=" + std::to_wstring(inputVersion.load()));
run.reset();
last.reset();
}
@@ -606,6 +661,9 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
if (isHotkey(vk, mods))
return;
++inputVersion;
if (busy) diagnosticEvent(L"Input context changed: keyboard vk=" + std::to_wstring(vk) +
L" modifiers=" + std::to_wstring(mods) + L" injected=" + std::to_wstring((k.flags & LLKHF_INJECTED) != 0) +
L" version=" + std::to_wstring(inputVersion.load()));
last.reset();
HWND fg = GetForegroundWindow();
HKL layout = windowLayout(fg);
@@ -839,7 +897,10 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
{
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
current->resetInput();
}
}
return CallNextHookEx(nullptr, code, wp, lp);
}
@@ -1010,9 +1071,10 @@ void App::transform(int command, int snippet)
!(command == 0 && reason == L"paste-sent-unverified")},
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
{"expected", bounded(result)}, {"actual", bounded(actual)},
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
{"selectionExpected", bounded(expected)},
{"originalUnits", text.size()}, {"expectedUnits", (verification == L"selection-mismatch" ? expected : result).size()}, {"actualUnits", actual.size()},
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096 || expected.size() > 4096}};
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
}
catch (...) {} // Logging must never abort a completed input transaction.
@@ -1135,6 +1197,8 @@ void App::transform(int command, int snippet)
if (newlines(text) != newlines(previous->result))
{
reason = L"undo-verification-mismatch";
actual = text;
verification = L"selection-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -1177,6 +1241,8 @@ void App::transform(int command, int snippet)
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
if (hint < 0)
{
actual = text;
verification = L"selection-mismatch";
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
@@ -1194,6 +1260,8 @@ void App::transform(int command, int snippet)
{ return newlines(candidate) == newlines(previous->result); });
if (newlines(text) != newlines(previous->result))
{
actual = text;
verification = L"selection-mismatch";
reason = L"previous-result-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
+5 -2
View File
@@ -35,7 +35,10 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
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);
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts, c.keepLocalLayouts); }, choices,
c.keepLocalLayouts);
if (result.success && c.keepLocalLayouts)
result.status += L" Partial sync: this PC's layouts and their shortcuts were kept; server layouts were preserved.";
}
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
catch (const std::exception &e) { result.status = wide(e.what()); }
@@ -65,7 +68,7 @@ void App::pollSync()
nextSync = GetTickCount64() + 2000;
return;
}
auto next = restoreSyncSettings(syncResult.settings, settings, layouts);
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
if (encodeSettings(next) != syncSnapshot)
{
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
+66 -1
View File
@@ -164,6 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage))
return {};
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
auto small = lower(word);
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
small.find_first_not_of(alphabet) == small.npos &&
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
auto sameLayout = [&](std::wstring text)
{
if (word != small) text[0] = upper(text.substr(0, 1))[0];
return AutoSuggestion{source, source, prefix + text + suffix};
};
if (settings.autoReplace && sameLanguage)
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
replacement->second.find_first_not_of(alphabet) == small.npos)
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
{
auto found = settings.autoWords.find(language);
@@ -203,7 +221,54 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
(!candidate || priority(i) < priority(candidate->target)))
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
}
return candidate;
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
return candidate;
// Generate only single edits. Stop at a second distinct dictionary hit,
// including rare hits: frequency limits must never hide ambiguity.
std::wstring found;
int foundRank = 0;
std::set<std::wstring> personalTargets;
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
for (const auto &text : it->second) personalTargets.insert(lower(text));
auto consider = [&](const std::wstring &text)
{
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
if (!rank) return true;
if (!found.empty()) return false;
found = text;
foundRank = rank;
return true;
};
for (size_t i = 0; i < small.size(); ++i)
{
auto text = small;
text.erase(i, 1);
if (!consider(text)) return {};
if (i + 1 < small.size())
{
text = small;
std::swap(text[i], text[i + 1]);
if (!consider(text)) return {};
}
for (wchar_t c : alphabet)
{
text = small;
text[i] = c;
if (!consider(text)) return {};
}
}
if (small.size() < 32)
for (size_t i = 0; i <= small.size(); ++i)
for (wchar_t c : alphabet)
{
auto text = small;
text.insert(i, 1, c);
if (!consider(text)) return {};
}
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
return sameLayout(found);
return {};
}
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
const Layout &physical, const Settings &settings)
+2
View File
@@ -54,6 +54,8 @@ struct Settings
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
bool autoCorrect = false;
bool autoReplace = false, autoTypos = false;
std::map<std::wstring, std::wstring> autoReplacements;
bool writeLog = true;
bool screenshotAutoOpenEditor = false;
bool useTesseract = false;
+2 -1
View File
@@ -37,7 +37,8 @@ inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &conten
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
field("original") + L"\r\n\r\nExpected selection:\r\n" + field("selectionExpected") +
L"\r\n\r\nExpected result / verification target:\r\n" + field("expected") +
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
+9 -2
View File
@@ -407,12 +407,16 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
// Shift+Left events can all use the same caret position and select only one
// character. Give each change time to settle before sending the next one.
const auto target = GetForegroundWindow();
diagnosticEvent(L"SelectPrevious requested=" + std::to_wstring(count) +
L" mode=" + (needsSelectionDelay(target) ? std::wstring(L"paced-25ms") : std::wstring(L"batch")));
if (needsSelectionDelay(target))
{
for (size_t i = 0; i < count; ++i)
{
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
return false;
if (i < 32 || i + 1 == count)
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
Sleep(25);
}
return valid() && GetForegroundWindow() == target;
@@ -434,7 +438,10 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
}
add(VK_SHIFT, KEYEVENTF_KEYUP);
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
auto sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
diagnosticEvent(L"SelectPrevious batch offset=" + std::to_wstring(offset) + L" eventsRequested=" +
std::to_wstring(inputs.size()) + L" eventsSent=" + std::to_wstring(sent));
if (sent != inputs.size())
return false;
}
return valid();
@@ -574,7 +581,7 @@ DiagnosticBuffer diagnosticBuffer;
}
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
{
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
while (!entries.empty() && (entries.size() >= 128 || now - entries.front().first > 30000))
entries.pop_front();
entries.emplace_back(now, message);
if (!problem)
+27
View File
@@ -108,6 +108,19 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions");
if (s.autoReplacements.size() > 10000)
throw std::runtime_error("Too many automatic replacements");
for (const auto &[from, to] : s.autoReplacements)
{
bool valid = false;
for (auto alphabet : {L"abcdefghijklmnopqrstuvwxyz", L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
from == lower(from) && to == lower(to) && from != to &&
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
if (!valid)
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
}
for (const auto &[language, words] : s.autoWords)
{
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
@@ -172,6 +185,15 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
s.autoCorrect = j.value("autoCorrectLayout", false);
s.autoReplace = j.value("autoCorrectReplace", false);
s.autoTypos = j.value("autoCorrectTypos", false);
if (j.contains("autoCorrectReplacements"))
for (auto &[from, to] : j.at("autoCorrectReplacements").items())
{
auto key = lower(wide(from));
if (!s.autoReplacements.emplace(key, lower(wide(to.get<std::string>()))).second)
throw std::runtime_error("Duplicate automatic replacement");
}
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
s.autoMinLength = j.value("autoCorrectMinLength", 3);
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
@@ -268,6 +290,11 @@ Bytes encodeSettings(const Settings &s)
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
j["autoCorrectLayout"] = s.autoCorrect;
j["autoCorrectReplace"] = s.autoReplace;
j["autoCorrectTypos"] = s.autoTypos;
j["autoCorrectReplacements"] = Json::object();
for (const auto &[from, to] : s.autoReplacements)
j["autoCorrectReplacements"][utf8(from)] = utf8(to);
j["autoCorrectMaxRank"] = s.autoMaxRank;
j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
+47 -10
View File
@@ -17,7 +17,7 @@ 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)}};
{"username", utf8(c.username)}, {"keepLocalLayouts", c.keepLocalLayouts}};
}
std::wstring credentialName(const SyncConfig &c)
{
@@ -154,6 +154,7 @@ SyncConfig loadSyncConfig(const std::filesystem::path &root)
if (!syncVisible(root)) return c;
auto j = parse(readFile(root / L"sync.json", 16384));
c.enabled = j.value("enabled", false);
c.keepLocalLayouts = j.value("keepLocalLayouts", false);
c.provider = wide(j.value("provider", "openbao"));
c.server = wide(j.value("server", ""));
c.mount = wide(j.value("mount", "kswitcher"));
@@ -221,30 +222,50 @@ Bytes sharedSyncSettings(const Settings &s)
"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)
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
{
auto j = parse(shared), original = parse(encodeSettings(local));
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
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));
std::set<std::string> missing;
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.");
if (!installed.contains(id.get<std::string>())) missing.insert(id.get<std::string>());
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.");
if (!installed.contains(key.at("layoutId").get<std::string>())) missing.insert(key.at("layoutId").get<std::string>());
if (!keepLocalLayouts && !missing.empty())
{
std::wstring message = L"Missing keyboard layouts on this PC: ";
for (const auto &id : missing)
{
auto code = id.starts_with("win:") ? id.substr(4) : id;
wchar_t language[128]{};
if (code.size() == 8 && code.find_first_not_of("0123456789abcdefABCDEF") == code.npos)
GetLocaleInfoW(MAKELCID(LOWORD(std::stoul(code, nullptr, 16)), SORT_DEFAULT), LOCALE_SENGLISHLANGUAGENAME,
language, 128);
if (message.back() != L' ') message += L"; ";
message += (language[0] ? std::wstring(language) : L"Unknown layout") + L" [" + wide(id) + L"]";
}
message += L". Install these exact layouts, or enable 'Keep this PC's layouts' below and sync other settings.";
throw std::runtime_error(utf8(message));
}
if (keepLocalLayouts)
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = original.at(key);
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)
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices, bool keepLocalLayouts)
{
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
if (validate) validate(local);
@@ -255,11 +276,25 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
}
if (validate && action != SyncAction::Upload) validate(remote.settings);
auto mergeLocal = local, mergeBase = base;
if (keepLocalLayouts)
{
// Never upload a locally filtered projection over another PC's layout setup.
const auto remoteJson = parse(remote.settings);
auto preserve = [&](const Bytes &profile)
{
auto j = parse(profile);
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = remoteJson.at(key);
return bytes(j);
};
mergeLocal = preserve(local);
if (!base.empty()) mergeBase = preserve(base);
}
SyncMerge merged;
if (action == SyncAction::Automatic) merged = mergeSyncProfile(base, local, remote.settings, choices);
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
else
{
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : local)));
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal)));
merged = {selected, selected, selected, {}};
}
if (validate) { validate(merged.local); validate(merged.remote); }
@@ -308,7 +343,9 @@ 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{};
auto connection = j.at("connection");
if (!connection.contains("keepLocalLayouts")) connection["keepLocalLayouts"] = false;
return connection == configJson(c) ? bytes(j.at("settings")) : Bytes{};
}
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
{
+4 -2
View File
@@ -7,6 +7,7 @@ namespace ks
struct SyncConfig
{
bool enabled{};
bool keepLocalLayouts{};
std::wstring provider = L"openbao", server, mount = L"kswitcher",
path = L"personal/settings", authMount = L"userpass", username;
bool operator==(const SyncConfig &) const = default;
@@ -52,7 +53,7 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r
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> &);
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &, bool keepLocalLayouts = false);
enum class SyncAction { Automatic, Download, Upload };
struct SyncResult
{
@@ -64,7 +65,8 @@ struct SyncResult
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 = {});
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
bool keepLocalLayouts = false);
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
}
+12 -2
View File
@@ -4,6 +4,9 @@ namespace
{
Json normalizeProfile(Json j)
{
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
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""}));
@@ -46,6 +49,10 @@ Json treeProfile(const Json &source)
}
Json flatProfile(Json j)
{
auto &replacements = j["autoCorrectReplacements"];
for (auto it = replacements.begin(); it != replacements.end();)
if (it.value().is_null()) it = replacements.erase(it);
else ++it;
Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items())
{
@@ -90,6 +97,8 @@ std::wstring fieldLabel(const std::string &key)
{"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"},
{"autoCorrectReplace", L"Custom automatic replacements"}, {"autoCorrectTypos", L"Dictionary typo correction"},
{"autoCorrectReplacements", L"Replacement pairs"},
{"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"}};
@@ -167,8 +176,9 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
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));
// With no common history, union independent entries; differing scalar values
// remain on their respective devices until the user resolves the conflict.
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
SyncMerge result;
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
std::set<std::string> collisions;
+39 -7
View File
@@ -1112,6 +1112,25 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
draft.autoExcluded = parseCommaList(value(w, 404));
draft.autoPrefixChars = value(w, 408);
draft.autoSuffixChars = value(w, 409);
draft.autoReplace = checked(w, 414);
draft.autoTypos = checked(w, 415);
draft.autoReplacements.clear();
std::wistringstream replacements(lower(value(w, 416)));
std::wstring line;
auto trim = [](std::wstring text)
{
auto first = text.find_first_not_of(L" \t\r\n");
return first == text.npos ? std::wstring{} : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
};
while (std::getline(replacements, line))
{
if (trim(line).empty()) continue;
auto equals = line.find(L'=');
if (equals == line.npos)
throw std::runtime_error("Enter one replacement per line: typo=correction");
if (!draft.autoReplacements.emplace(trim(line.substr(0, equals)), trim(line.substr(equals + 1))).second)
throw std::runtime_error("Each replacement typo must occur only once");
}
if (draft.autoLanguages.size() < 2)
throw std::runtime_error("Select at least two languages");
validateSettings(draft);
@@ -1123,6 +1142,9 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
view->settings->draft.autoMinLength = draft.autoMinLength;
view->settings->draft.autoPrefixChars = draft.autoPrefixChars;
view->settings->draft.autoSuffixChars = draft.autoSuffixChars;
view->settings->draft.autoReplace = draft.autoReplace;
view->settings->draft.autoTypos = draft.autoTypos;
view->settings->draft.autoReplacements = std::move(draft.autoReplacements);
DestroyWindow(w);
}
catch (const std::exception &e)
@@ -1153,8 +1175,8 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
void showAutoOptions(HWND parent, SettingsView *v)
{
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
560, 680);
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic correction", autoOptionsProc,
560, 880);
windowLayoutState(w)->autoOptions = true;
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
@@ -1192,10 +1214,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
value(w, 408, v->draft.autoPrefixChars);
value(w, 409, v->draft.autoSuffixChars);
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
button(w, L"Open dictionary &folder", 407, 18, 562, 220);
label(w, L"After editing files, click OK here, then Save in Settings to reload.", 18, 600, 525);
button(w, L"&OK", IDOK, 326, 632, 100);
button(w, L"&Cancel", IDCANCEL, 440, 632, 100);
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
check(w, 414, v->draft.autoReplace);
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
std::wstring pairs;
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
value(w, 416, pairs);
checkbox(w, L"Correct spelling &typos using dictionaries (experimental)", 415, 18, 700, 524);
check(w, 415, v->draft.autoTypos);
label(w, L"Single edits, 4+ letters; ambiguous words are left unchanged.", 18, 730, 525);
button(w, L"Open dictionary &folder", 407, 18, 762, 220);
label(w, L"Click OK, then Save in Settings. Enable automatic correction there.", 18, 800, 525);
button(w, L"&OK", IDOK, 326, 832, 100);
button(w, L"&Cancel", IDCANCEL, 440, 832, 100);
RECT initial{};
GetWindowRect(w, &initial);
initial.right += scale(w, 200);
@@ -1672,7 +1704,7 @@ void showSettings(App &app, bool visible)
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
}
checkbox(w, L"Open editor after screen capt&ure", 209, 420, 224, 340);
checkbox(w, L"Automatically correct layout (e&xperimental)", 207, 18, 210, 395);
checkbox(w, L"Automatic correction (e&xperimental)", 207, 18, 210, 395);
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
18, 262, 470, 172);
+52 -11
View File
@@ -1,9 +1,29 @@
#include "ui_sync_conflicts.inl"
struct SyncView { HWND parent; App *app; };
void updateSyncControls(HWND w, App &app)
{
bool enabled = checked(w, 601);
bool savedEnabled = loadSyncConfig(app.data).enabled;
for (int id : {602, 603, 604, 605, 606, 607, 610, 611, 613})
EnableWindow(GetDlgItem(w, id), enabled);
EnableWindow(GetDlgItem(w, 612), enabled && savedEnabled);
EnableWindow(GetDlgItem(w, IDOK), enabled || savedEnabled);
value(w, IDOK, enabled ? L"&Save / Sign in / Sync" : L"&Save");
std::wstring status;
if (!enabled)
status = savedEnabled ? L"Click Save to disable synchronization." :
L"Synchronization is disabled. Enable it above to configure and sync.";
else if (!savedEnabled)
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
else
status = app.syncCommand();
if (value(w, 608) != status) value(w, 608, status);
}
SyncConfig syncFields(HWND w)
{
SyncConfig c;
c.enabled = checked(w, 601);
c.keepLocalLayouts = checked(w, 613);
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);
@@ -19,12 +39,19 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_TIMER)
{
value(w, 608, v->app->syncCommand());
updateSyncControls(w, *v->app);
return 0;
}
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 601 && HIWORD(wp) == BN_CLICKED)
{
updateSyncControls(w, *v->app);
return 0;
}
if ((id == IDOK || id == 610 || id == 611 || id == 612) &&
!IsWindowEnabled(GetDlgItem(w, id))) return 0;
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
if (id == 609)
@@ -35,18 +62,28 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
forgetSyncPassword(c);
check(w, 601, false);
value(w, 604, L"");
updateSyncControls(w, *v->app);
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 (!checked(w, 601))
{
if (id != IDOK) return 0;
auto c = loadSyncConfig(v->app->data);
c.enabled = false;
saveSyncConfig(v->app->data, c);
value(w, 604, L"");
updateSyncControls(w, *v->app);
return 0;
}
if (id != IDOK && MessageBoxW(w, id == 610 ?
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
L"Replace 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); }
@@ -55,6 +92,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
value(w, 604, L"");
saveSyncConfig(v->app->data, c);
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
updateSyncControls(w, *v->app);
SetTimer(w, 1, 1000, nullptr);
return 0;
}
@@ -80,7 +118,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM 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);
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 672);
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);
@@ -93,15 +131,18 @@ void showSyncOptions(HWND parent, App &app)
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
control(w, L"STATIC", L"", 0, 608, 18, 362, 622, 76);
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
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);
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
check(w, 613, c.keepLocalLayouts);
button(w, L"&Save / Sign in / Sync", IDOK, 18, 484, 200);
button(w, L"&Download remote", 610, 230, 484, 195);
button(w, L"&Upload local", 611, 437, 484, 203);
button(w, L"Resolve &individual conflicts...", 612, 18, 530, 300);
label(w, L"Background check: every 10 minutes", 330, 534, 310);
button(w, L"Disconnect / &forget password", 609, 18, 582, 290);
button(w, L"&Close", IDCANCEL, 540, 582, 100);
updateSyncControls(w, app);
EnableWindow(parent, FALSE);
SetTimer(w, 1, 1000, nullptr);
showRequestedWindow(w); SetForegroundWindow(w);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.27";
inline constexpr wchar_t AppVersion[] = L"0.6.28";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;