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

+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");