Add application-scoped snippets with global fallback and scope-aware sync

This commit is contained in:
agrynco committed 2026-11-06 10:37:01 +02:00
1 parent c18148a574
commit a124e030ab
10 files changed
+215 -18

No files matched your search

+12
View File
@@ -325,6 +325,18 @@ Open a chat in Telegram Web K (`https://web.telegram.org/k/`) in Chrome, then us
Recognition uses Chrome accessibility: the address bar and active document must both identify a Telegram chat, then the visible header supplies the name. The app watches foreground/focus and address/header-change events; it does not use OCR, a polling timer, an extension, or message-history extraction. The address bar, home page and unsupported URLs do not match. Web A and other browsers have not been validated and are not advertised as supported. Page or accessibility changes can make a chat unavailable instead of guessing.
**Українською.** У дев-версії підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
### Application-specific snippets — development build
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
A matching application-specific snippet takes precedence over a global snippet with the same trigger. The same trigger can be used for different applications; overlapping scopes for the same trigger are rejected. Existing snippets without a scope remain global. Matching is case-insensitive by process name, with or without `.exe`, and is checked before insertion. No OCR or timer is involved. Chrome scope means the entire browser, not one website.
Scopes are saved with snippets and included in manual import/export and synchronization; merge conflicts consider the application scopes. Use the updated build on every synchronizing PC before sharing scoped snippets: older versions do not understand the restriction and may treat the snippet as global.
**Українською.** У **Tools → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. **Save** зберігає, **Cancel** відкидає зміни.
Сніпет конкретної програми має пріоритет над глобальним із тим самим тригером. Для різних програм однакові тригери дозволені, для перетинних областей — ні. Старі сніпети залишаються глобальними. Зіставлення за назвою процесу не залежить від регістру та суфікса `.exe`; OCR і таймерів немає. Для Chrome це весь браузер, а не окремий сайт. Обмеження входять до експорту/імпорту та синхронізації. Перед синхронізацією онови всі ПК: старі версії не розуміють обмеження та можуть застосувати сніпет глобально.
### Existing features
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
+4
View File
@@ -919,9 +919,12 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
if (busy || settings.snippets.empty())
return;
auto keys = lower(run.keys);
auto snippetProcess = processName(fg);
for (int priority : {2, 1})
for (size_t i = 0; i < settings.snippets.size(); ++i)
{
auto &sn = settings.snippets[i];
if (snippetAppPriority(sn, snippetProcess) != priority) continue;
std::vector<std::wstring> variants{lower(sn.trigger)};
for (auto &l : layouts)
{
@@ -1473,6 +1476,7 @@ void App::transform(int command, int snippet)
if (snippet >= (int)config.snippets.size() || recent.window != target)
return;
const auto &sn = config.snippets[snippet];
if (!snippetAppPriority(sn, processName(target))) return;
if (recent.keys.size() < sn.trigger.size())
return;
result = parseTemplate(sn.text).text;
+15
View File
@@ -6,6 +6,21 @@
namespace ks
{
std::wstring snippetAppName(std::wstring name)
{
auto first = name.find_first_not_of(L" \t"), last = name.find_last_not_of(L" \t");
if (first == name.npos) return {};
name = lower(name.substr(first, last - first + 1));
if (name.ends_with(L".exe")) name.resize(name.size() - 4);
return name;
}
int snippetAppPriority(const Snippet &snippet, const std::wstring &process)
{
if (snippet.apps.empty()) return 1;
auto name = snippetAppName(process);
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
return 0;
}
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
+3
View File
@@ -32,7 +32,10 @@ struct Snippet
{
std::wstring trigger, text;
std::wstring id;
std::vector<std::wstring> apps; // Empty means everywhere; executable names without .exe.
};
std::wstring snippetAppName(std::wstring);
int snippetAppPriority(const Snippet &, const std::wstring &process);
std::wstring snippetId(const Snippet &);
std::wstring newSnippetId();
struct Template
+28 -3
View File
@@ -169,8 +169,19 @@ void validateSettings(const Settings &s)
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
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");
if (!seen.insert(lower(snippet.trigger)).second)
throw std::runtime_error("Duplicate snippet trigger");
if (snippet.apps.size() > 64) throw std::runtime_error("Too many snippet applications");
std::vector<std::wstring> scopes;
for (const auto &app : snippet.apps)
{
auto normalized = snippetAppName(app);
if (normalized.empty() || normalized.size() > 250 || normalized.find_first_of(L"\\/:*?\"<>|,\r\n\t") != normalized.npos)
throw std::runtime_error("Use application names such as chrome.exe, not paths or wildcards");
scopes.push_back(normalized);
}
if (scopes.empty()) scopes.push_back(L"");
for (const auto &scope : scopes)
if (!seen.insert(lower(snippet.trigger) + L"\n" + scope).second)
throw std::runtime_error("Duplicate snippet trigger for the same application or global scope");
auto t = parseTemplate(snippet.text);
if (t.text.empty() || t.cursors > 1)
throw std::runtime_error("Each snippet needs text and at most one {cursor} marker");
@@ -281,6 +292,12 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
for (auto &sn : j.at("snippets"))
{
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
if (sn.contains("apps"))
{
if (!sn.at("apps").is_array()) throw std::runtime_error("Snippet apps must be a list");
for (const auto &app : sn.at("apps")) item.apps.push_back(snippetAppName(wide(app.get<std::string>())));
}
if (item.id.empty() && !item.apps.empty()) item.id = newSnippetId();
item.id = snippetId(item);
s.snippets.push_back(std::move(item));
}
@@ -354,7 +371,15 @@ Bytes encodeSettings(const Settings &s)
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
j["snippets"] = Json::array();
for (auto &sn : s.snippets)
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}});
{
Json item{{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}};
if (!sn.apps.empty())
{
item["apps"] = Json::array();
for (const auto &app : sn.apps) item["apps"].push_back(utf8(snippetAppName(app)));
}
j["snippets"].push_back(std::move(item));
}
j["snippetTombstones"] = Json::array();
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
j["excludedApps"] = Json::array();
+8 -2
View File
@@ -158,8 +158,14 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
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::vector<std::wstring> scopes;
if (sn.contains("apps")) for (const auto &app : sn.at("apps")) scopes.push_back(snippetAppName(wide(app.get<std::string>())));
if (scopes.empty()) scopes.push_back(L"");
for (const auto &scope : scopes)
{
auto [it, inserted] = triggers.emplace(key + L"\n" + scope, 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) {
+68 -13
View File
@@ -1712,14 +1712,27 @@ void updateSnippetEditor(HWND w, SnippetView *v)
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
value(w, 303, selected ? v->snippets[v->selection].text : L"");
for (int id : {302, 303, 311, 312, 313})
std::wstring apps;
if (selected) for (const auto &app : v->snippets[v->selection].apps)
{
if (!apps.empty()) apps += L", ";
if (!app.empty()) apps += app + L".exe";
}
value(w, 315, apps);
bool restricted = selected && !v->snippets[v->selection].apps.empty();
CheckDlgButton(w, 314, restricted ? BST_CHECKED : BST_UNCHECKED);
for (int id : {302, 303, 311, 312, 313, 314})
EnableWindow(GetDlgItem(w, id), selected);
for (int id : {315, 316, 317}) EnableWindow(GetDlgItem(w, id), selected && restricted);
}
void fillSnippets(HWND w, SnippetView *v)
{
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
for (auto &sn : v->snippets)
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
{
auto label = sn.trigger + (sn.apps.empty() ? L" [All apps]" : L" [" + sn.apps.front() + (sn.apps.size() > 1 ? L", ...]" : L"]"));
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)label.c_str());
}
if (v->selection >= 0)
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
updateSnippetEditor(w, v);
@@ -1732,6 +1745,18 @@ void collectSnippet(HWND w, SnippetView *v)
if (snippet.id.empty()) snippet.id = snippetId(snippet);
snippet.trigger = value(w, 302);
snippet.text = value(w, 303);
snippet.apps.clear();
if (IsDlgButtonChecked(w, 314) == BST_CHECKED)
{
std::wistringstream input(value(w, 315)); std::wstring item;
while (std::getline(input, item, L','))
{
auto app = snippetAppName(item);
if (!app.empty()) snippet.apps.push_back(app);
}
// An explicitly restricted but empty list must not become global.
if (snippet.apps.empty()) snippet.apps.push_back(L"");
}
}
}
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
@@ -1758,6 +1783,24 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case WM_COMMAND:
{
int id = LOWORD(wp);
if (id == 314)
{
bool restricted = IsDlgButtonChecked(w, 314) == BST_CHECKED;
for (int field : {315, 316, 317}) EnableWindow(GetDlgItem(w, field), restricted);
return 0;
}
if (id == 317)
{
auto app = value(w, 316);
if (!app.empty())
{
auto current = value(w, 315);
std::wistringstream input(current); std::wstring entry;
while (std::getline(input, entry, L',')) if (snippetAppName(entry) == snippetAppName(app)) return 0;
value(w, 315, current.empty() ? app : current + L", " + app);
}
return 0;
}
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
{
collectSnippet(w, v);
@@ -2046,22 +2089,34 @@ void showSnippets(App &app)
SetForegroundWindow(app.snippetsWindow);
return;
}
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 740, 520);
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 740, 620);
app.snippetsWindow = w;
label(w, L"Triggers", 16, 16);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
16, 44, 200, 380);
button(w, L"&Add", 310, 16, 438, 90);
button(w, L"&Delete", 311, 116, 438, 100);
16, 44, 200, 480);
button(w, L"&Add", 310, 16, 538, 90);
button(w, L"&Delete", 311, 116, 538, 100);
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
edit(w, 302, 236, 44, 488);
label(w, L"&Replacement text", 236, 82, 480);
edit(w, 303, 236, 110, 488, 270, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 392, 488, 36);
button(w, L"Insert cursor &marker", 312, 236, 430, 190);
button(w, L"Insert &Enter marker", 313, 436, 430, 190);
button(w, L"&Save", IDOK, 510, 478, 100);
button(w, L"&Cancel", IDCANCEL, 624, 478, 100);
control(w, L"BUTTON", L"Only in these &applications (unchecked: everywhere)", WS_TABSTOP | BS_AUTOCHECKBOX, 314, 236, 82, 488, 24);
edit(w, 315, 236, 112, 488);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, 316, 236, 148, 364, 200);
button(w, L"Add a&pp", 317, 612, 148, 112);
std::set<std::wstring> apps;
EnumWindows([](HWND candidate, LPARAM context)->BOOL {
if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set<std::wstring>*)context)->insert(lower(name) + L".exe"); }
return TRUE;
}, (LPARAM)&apps);
for (const auto &name : apps) SendDlgItemMessageW(w, 316, CB_ADDSTRING, 0, (LPARAM)name.c_str());
SendDlgItemMessageW(w, 316, CB_SETCURSEL, 0, 0);
label(w, L"Process names, comma-separated; e.g. chrome.exe, ms-teams.exe", 236, 182, 488);
label(w, L"&Replacement text", 236, 212, 480);
edit(w, 303, 236, 240, 488, 240, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 492, 488, 36);
button(w, L"Insert cursor &marker", 312, 236, 530, 190);
button(w, L"Insert &Enter marker", 313, 436, 530, 190);
button(w, L"&Save", IDOK, 510, 578, 100);
button(w, L"&Cancel", IDCANCEL, 624, 578, 100);
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
showRequestedWindow(w);
SetForegroundWindow(w);
+41
View File
@@ -14,6 +14,23 @@ void require(bool ok, const char *message)
}
void contextChecks()
{
ks::Settings scoped;
scoped.snippets = {{L";sig", L"Global", L"global"},
{L";sig", L"Work", L"teams", {L"MS-Teams.EXE", L"OUTLOOK.exe"}},
{L";sig", L"Personal", L"telegram", {L"telegram"}}};
ks::validateSettings(scoped);
require(ks::snippetAppPriority(scoped.snippets[1], L"ms-teams") > ks::snippetAppPriority(scoped.snippets[0], L"ms-teams"), "app snippet precedes global regardless of list order");
require(ks::snippetAppPriority(scoped.snippets[1], L"Telegram.exe") == 0, "restricted snippet cannot run in another app");
require(ks::snippetAppPriority(scoped.snippets[0], L"") == 1, "legacy/global snippets remain available");
auto scopedRoundtrip = ks::decodeSettings(ks::encodeSettings(scoped), {});
require(scopedRoundtrip.snippets[1].apps == std::vector<std::wstring>{L"ms-teams", L"outlook"}, "scopes normalize and persist");
require(scopedRoundtrip.snippets[0].apps.empty(), "absent scope remains global");
auto collision = scoped; collision.snippets.push_back({L";SIG", L"Ambiguous", L"other", {L"outlook.exe"}});
bool collisionRejected=false;try { ks::validateSettings(collision); } catch(...) { collisionRejected=true; }
require(collisionRejected, "overlapping app lists with same trigger rejected");
auto emptyScope = scoped; emptyScope.snippets[1].apps={L""};
bool emptyRejected=false;try { ks::validateSettings(emptyScope); } catch(...) { emptyRejected=true; }
require(emptyRejected, "empty explicit restriction cannot become global");
require(ks::telegramWebKey(L"https://web.telegram.org/k/#@Example") == L"telegram-web\n@example", "Telegram Web username normalized");
require(ks::telegramWebKey(L"web.telegram.org/a/#-123456") == L"telegram-web\n-123456", "Telegram Web numeric peer");
for (const auto *url : {L"https://web.telegram.org/k/", L"https://web.telegram.org.evil/k/#@example",
@@ -174,6 +191,30 @@ int main()
SendMessageW(dialog, WM_COMMAND, 713, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
ks::showSnippets(app);
HWND snippets = app.snippetsWindow;
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
CheckDlgButton(snippets,314,BST_CHECKED);
SendMessageW(snippets,WM_COMMAND,314,0);
SetDlgItemTextW(snippets,315,L"Notepad.exe, MS-Teams.EXE");
SendMessageW(snippets,WM_COMMAND,IDOK,0);
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
ks::showSnippets(app);snippets=app.snippetsWindow;
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
require(IsDlgButtonChecked(snippets,314)==BST_CHECKED, "scope restored when reopening editor");
RedrawWindow(snippets,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT snippetRect{};GetClientRect(snippets,&snippetRect);
HDC snippetDc=GetDC(snippets);
auto snippetImage=ks::screenshotClip(snippetDc,snippetRect.right,snippetRect.bottom,{});
ReleaseDC(snippets,snippetDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"snippet-scopes.png",ks::encodePng(ks::decodeImage(snippetImage)));
SetDlgItemTextW(snippets,315,L"chrome.exe");
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
}
std::filesystem::remove_all(root);
std::cout << "Passed " << passed << " chat profile checks\n";
+24
View File
@@ -26,6 +26,17 @@ LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, U
}
namespace ks {
struct InputRegressionAccess {
static int snippetChoice(App &a, const std::vector<Snippet> &snippets) {
a.settings.snippets=snippets; a.settings.autoCorrect=false; a.busy=false;
a.snippetPending=-1; a.typingTarget=nullptr; a.snippetTyping=false;
auto target=GetForegroundWindow();
a.run.keys=L";si"; a.run.window=target; a.run.layout=windowLayout(target); a.run.at=GetTickCount64();
KBDLLHOOKSTRUCT key{};key.vkCode='G';key.scanCode=0x22;
a.keyboard(key,0);
int chosen=a.snippetPending;
a.busy=false;a.snippetPending=-1;a.typingTarget=nullptr;a.snippetTyping=false;
return chosen;
}
static bool idleUndo(App &a) {
a.last=LastTransform{}; a.last->at=100;
a.releaseIdleMemory(120100);
@@ -130,6 +141,19 @@ int main(int argc, char **argv) {
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
{
ks::App app(GetModuleHandleW(nullptr),true,temp/L"snippet-scopes");
app.window=CreateWindowExW(0,L"STATIC",L"Snippet scope test",0,0,0,0,0,HWND_MESSAGE,nullptr,GetModuleHandleW(nullptr),nullptr);
auto process=ks::processName(GetForegroundWindow());
require(!process.empty(),"scope test has foreground process");
std::vector<ks::Snippet> snippets{{L";sig",L"global",L"global"},{L";sig",L"scoped",L"scoped",{process}}};
require(ks::InputRegressionAccess::snippetChoice(app,snippets)==1,"app-specific trigger wins even when global is first");
snippets[1].apps={L"kswitcher-no-such-process"};
require(ks::InputRegressionAccess::snippetChoice(app,snippets)==0,"unmatched app falls back to global snippet");
snippets.erase(snippets.begin());
require(ks::InputRegressionAccess::snippetChoice(app,snippets)==-1,"scoped snippet is not queued in another application");
DestroyWindow(app.window);app.window=nullptr;
}
{
HMENU menu = CreatePopupMenu(), child = CreatePopupMenu();
AppendMenuW(menu,MF_STRING | MF_CHECKED,1,L"&Settings…");
+12
View File
@@ -70,6 +70,18 @@ int main()
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);
{
Settings scoped = local;
scoped.snippets.push_back({L"test", L"Only Teams", L"teams-scope", {L"ms-teams"}});
auto wire=sharedSyncSettings(scoped);
require(restoreSyncSettings(wire, local, {}).snippets[1].apps==std::vector<std::wstring>{L"ms-teams"}, "sync preserves app scope");
auto leftScope=j,rightScope=j;
leftScope["snippets"].push_back({{"id","left-scope"},{"trigger","same"},{"text","left"},{"apps",Json::array({"notepad"})}});
rightScope["snippets"].push_back({{"id","right-scope"},{"trigger","same"},{"text","right"},{"apps",Json::array({"ms-teams"})}});
require(parse(mergeSyncSettings(base,bytes(leftScope),bytes(rightScope)))["snippets"].size()==3, "same trigger in different apps merges");
rightScope["snippets"].back()["apps"]=Json::array({"NOTEPAD.exe"});
rejects([&]{mergeSyncSettings(base,bytes(leftScope),bytes(rightScope));}, "sync detects overlapping application scopes");
}
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");