From a124e030ab5cb3feddbed0b801e305365b0c0e15 Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Fri, 6 Nov 2026 10:37:01 +0200 Subject: [PATCH] Add application-scoped snippets with global fallback and scope-aware sync --- README.md | 12 ++++++ src/app.cpp | 4 ++ src/core.cpp | 15 ++++++++ src/core.h | 3 ++ src/storage.cpp | 31 ++++++++++++++-- src/sync_merge.inl | 10 ++++- src/ui.cpp | 81 ++++++++++++++++++++++++++++++++++------- tests/chat_profiles.cpp | 41 +++++++++++++++++++++ tests/input.cpp | 24 ++++++++++++ tests/sync.cpp | 12 ++++++ 10 files changed, 215 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b876b0a..60bf2af 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app.cpp b/src/app.cpp index 4611c01..54f44c7 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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 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; diff --git a/src/core.cpp b/src/core.cpp index 25d12df..678a979 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -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> &bindings) { diff --git a/src/core.h b/src/core.h index b43c101..e743173 100644 --- a/src/core.h +++ b/src/core.h @@ -32,7 +32,10 @@ struct Snippet { std::wstring trigger, text; std::wstring id; + std::vector 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 diff --git a/src/storage.cpp b/src/storage.cpp index b2f2563..f34a1d5 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -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 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 &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()))); + } + 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(); diff --git a/src/sync_merge.inl b/src/sync_merge.inl index b6f5fc2..cd9112e 100644 --- a/src/sync_merge.inl +++ b/src/sync_merge.inl @@ -158,8 +158,14 @@ void collisionPaths(const Json &j, std::set &paths) if (sn.is_null() || tombstone(sn)) continue; auto path = "/snippets/" + pointerKey(id); auto key = lower(wide(sn.at("trigger").get())); - auto [it, inserted] = triggers.emplace(key, path); - if (!inserted) { paths.insert(path); paths.insert(it->second); } + std::vector scopes; + if (sn.contains("apps")) for (const auto &app : sn.at("apps")) scopes.push_back(snippetAppName(wide(app.get()))); + 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::string> shortcuts; auto add = [&](const Json &text, const std::string &path) { diff --git a/src/ui.cpp b/src/ui.cpp index 718011c..0c24706 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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 apps; + EnumWindows([](HWND candidate, LPARAM context)->BOOL { + if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set*)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); diff --git a/tests/chat_profiles.cpp b/tests/chat_profiles.cpp index eba3cd5..982862b 100644 --- a/tests/chat_profiles.cpp +++ b/tests/chat_profiles.cpp @@ -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{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{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{L"notepad",L"ms-teams"}, "cancel preserves existing scope"); } std::filesystem::remove_all(root); std::cout << "Passed " << passed << " chat profile checks\n"; diff --git a/tests/input.cpp b/tests/input.cpp index f710775..05ccee1 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -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 &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 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…"); diff --git a/tests/sync.cpp b/tests/sync.cpp index a88a126..f8e980e 100644 --- a/tests/sync.cpp +++ b/tests/sync.cpp @@ -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{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");