Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b8b8f26fe | ||
|
|
19f1e4531c | ||
|
|
a124e030ab | ||
|
|
c18148a574 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.50 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.51 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
|
||||
@@ -317,6 +317,26 @@ Telegram ігнорується. Teams підтримує зазначений
|
||||
Профілі локальні, входять до ручного експорту/імпорту, але не до
|
||||
хмарної синхронізації. Доступно з v0.6.48.
|
||||
|
||||
|
||||
### Telegram Web in Chrome — v0.6.51
|
||||
|
||||
Open a chat in Telegram Web K (`https://web.telegram.org/k/`) in Chrome, then use **Settings → Languages → Languages for a chat...** and select **Telegram Web: name**. Choose languages, then **Use languages → Save**. The same subset applies to Alt+Shift, Win+Space, Win+Shift+Space and Next layout. Web profiles are separate from desktop Telegram profiles. Same-name chats across browser profiles/accounts share a web profile.
|
||||
|
||||
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.
|
||||
|
||||
**Українською.** З v0.6.51 підтримано 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 — v0.6.51
|
||||
|
||||
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 v0.6.51 or newer 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 це весь браузер, а не окремий сайт. Обмеження входять до експорту/імпорту та синхронізації. Перед синхронізацією онови всі ПК до v0.6.51 або новішої: старі версії не розуміють обмеження та можуть застосувати сніпет глобально.
|
||||
### Existing features
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
|
||||
Binary file not shown.
+9
-7
@@ -1,20 +1,22 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.50 — 2026-11-06
|
||||
## v0.6.51 — 2026-11-06
|
||||
|
||||
## Changes
|
||||
- Disable experimental Viber chat OCR to remove repeated recognition and its language-switching delays. No Viber OCR runs in the background, on layout switching/conversion, or when opening the chat-profile dialog.
|
||||
- Keep saved Viber profiles and show them as inactive. Viber uses ordinary Windows layout switching and the global KSwitcher Next layout cycle. No settings reset is needed.
|
||||
- Preserve Telegram Desktop and Microsoft Teams chat profiles, their shortcuts and behavior. Clipboard/image OCR and Tesseract options remain available.
|
||||
- Add Telegram Web K chat language profiles in Google Chrome. Open a chat at `https://web.telegram.org/k/`, then **Settings → Languages → Languages for a chat...** and select **Telegram Web: name**. Choose languages, then **Use languages → Save**. Applies to Alt+Shift, Win+Space, reverse Win+Shift+Space and Next layout.
|
||||
- Read the trusted Telegram address and chat header through Chrome accessibility. Refresh on foreground/focus and address/header events, without OCR, a polling timer or a browser extension. Web profiles are separate from Telegram Desktop profiles; identical chat names across accounts/browser profiles share a web profile. Web A and other browsers are not advertised as supported. Telegram Desktop and Teams remain unchanged; Viber OCR remains disabled.
|
||||
- Add application-specific snippets. In **Tools → Snippets**, enable **Only in these applications**, enter comma-separated executable names or select an open application and choose **Add app**. Unchecked means everywhere. Matching ignores case and the optional `.exe` suffix. Chrome scope applies to the whole browser.
|
||||
- Prefer an application-specific snippet over a global snippet with the same trigger. Allow the same trigger in disjoint application scopes and reject overlapping scopes. Keep existing snippets global. Preserve scopes in manual import/export and synchronization, with scope-aware merge conflicts. Validate the target application before insertion.
|
||||
- Center **Add app** against its application selector.
|
||||
|
||||
## Validation
|
||||
Passed 653 chat-profile/UI checks including saved Viber-profile persistence, 332 native checks, 564 input checks, 105 sync checks, complete dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. The disabled Viber OCR module is not compiled into the application. Release versions, manifest, archive and published downloads are checked during publication.
|
||||
Passed 674 chat-profile/snippet UI checks, 568 input checks (including app-specific precedence and global fallback), 108 sync checks, 332 native checks, complete dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. Inspected the snippet editor at 250% scaling. Live Chrome checks read a Telegram chat name directly and observed cache activation/invalidation. Release binaries, package manifest, published assets and update selection are checked during publication.
|
||||
|
||||
## Upgrade
|
||||
## Upgrade and compatibility
|
||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
|
||||
|
||||
Chat profiles remain local, are included in manual export/import and excluded from cloud synchronization. Saved Viber profiles require v0.6.49 or newer to load, even while inactive; retain a backup before downgrading. Phrase replacement synchronization requires v0.6.47 or newer on participating PCs.
|
||||
**Update every synchronizing PC to v0.6.51 or newer before sharing application-specific snippets. Older versions do not understand application restrictions and may treat scoped snippets as global.** Keep a backup before downgrading. Settings with Telegram Web profiles require v0.6.51 or newer. Chat profiles remain local, included in manual export/import and excluded from cloud synchronization.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
250752bd26227f4ba43f956eda73ec6ab4d909a35300e6d1c72222892cd03db7 KSwitcher-win-x64.zip
|
||||
0ee6426bf1715ee3156a1f8dbb2d213a9e09a49b2fcfec553d6049dc6a868877 KSwitcher-win-x64.zip
|
||||
+35
-35
@@ -1,81 +1,81 @@
|
||||
{
|
||||
"version": "0.6.50",
|
||||
"version": "0.6.51",
|
||||
"schema": 1,
|
||||
"files": [
|
||||
{
|
||||
"sha256": "3c57a24174112daa795e10d1cbbd50d405d453bc93c0fa43e83788ffcca1de51",
|
||||
"size": 1834496,
|
||||
"path": "KSwitcher.exe"
|
||||
"size": 1847296,
|
||||
"path": "KSwitcher.exe",
|
||||
"sha256": "c40f90944169ec6f1b92803ccaec734573bf798939f13ab3553b982b709e479c"
|
||||
},
|
||||
{
|
||||
"sha256": "f569f21076f85993ed7a6d3f1fb48fbff26046b73215c2fd36d8b0afb8c0aafb",
|
||||
"size": 1698304,
|
||||
"path": "KSwitcher.Config.exe"
|
||||
"size": 1716224,
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"sha256": "8e8a5fee9be8f318b78516b82415c6b098ac67f7c374f4667ef271576a77f9fa"
|
||||
},
|
||||
{
|
||||
"sha256": "a6c3113d06185b4fd405d34618ba5fa1c0a1a9996d9288af04effb823c8c7635",
|
||||
"size": 2885120,
|
||||
"path": "KSwitcher.Screenshot.exe"
|
||||
"size": 2897408,
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"sha256": "bb5ffc3a26955c752d593ba06f69476985d9902f2ed0345fe3efe9bfc2eb63c8"
|
||||
},
|
||||
{
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
||||
"size": 4401,
|
||||
"path": "LICENSE"
|
||||
"path": "LICENSE",
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||
},
|
||||
{
|
||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
|
||||
"size": 1096,
|
||||
"path": "json-LICENSE.txt"
|
||||
"path": "json-LICENSE.txt",
|
||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
|
||||
},
|
||||
{
|
||||
"sha256": "41a4e4a38cb31fed543f11f92b554206ff1014b4fcd27431c518e488e5c5df1a",
|
||||
"size": 69865,
|
||||
"path": "README.md"
|
||||
"size": 74601,
|
||||
"path": "README.md",
|
||||
"sha256": "818f81e6c4bfaa6b014ac50351ec22f68ee6eb3c094ad5ecb7bac98d605e0918"
|
||||
},
|
||||
{
|
||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
|
||||
"size": 732485,
|
||||
"path": "dictionaries/en.txt"
|
||||
"path": "dictionaries/en.txt",
|
||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9"
|
||||
},
|
||||
{
|
||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
|
||||
"size": 20566,
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5"
|
||||
},
|
||||
{
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
||||
"size": 2265,
|
||||
"path": "dictionaries/README.md"
|
||||
"path": "dictionaries/README.md",
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311"
|
||||
},
|
||||
{
|
||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
|
||||
"size": 1109134,
|
||||
"path": "dictionaries/ru.txt"
|
||||
"path": "dictionaries/ru.txt",
|
||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849"
|
||||
},
|
||||
{
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
||||
"size": 1280,
|
||||
"path": "dictionaries/sources.json"
|
||||
"path": "dictionaries/sources.json",
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408"
|
||||
},
|
||||
{
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
||||
"size": 940799,
|
||||
"path": "dictionaries/uk.txt"
|
||||
"path": "dictionaries/uk.txt",
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf"
|
||||
},
|
||||
{
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
||||
"size": 993,
|
||||
"path": "tools/README.txt"
|
||||
"path": "tools/README.txt",
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||
},
|
||||
{
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
|
||||
"size": 134656,
|
||||
"path": "tools/StayHere.exe"
|
||||
"path": "tools/StayHere.exe",
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||
},
|
||||
{
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
||||
"size": 2796,
|
||||
"path": "tools/StayHere-README.md"
|
||||
"path": "tools/StayHere-README.md",
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,50,0
|
||||
PRODUCTVERSION 0,6,50,0
|
||||
FILEVERSION 0,6,51,0
|
||||
PRODUCTVERSION 0,6,51,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.50"
|
||||
VALUE "FileVersion", "0.6.51"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
@@ -559,6 +559,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
stopBrowserChatMonitor();
|
||||
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
|
||||
if (historyProcess) CloseHandle(historyProcess);
|
||||
if (notificationRegistration) CloseHandle(notificationRegistration);
|
||||
@@ -918,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)
|
||||
{
|
||||
@@ -978,6 +982,7 @@ void App::switchChatLayout(bool reverse)
|
||||
{
|
||||
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
|
||||
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
|
||||
if (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
|
||||
if (!valid()) return;
|
||||
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
||||
if (next) switchWindowLayout(target, next, valid);
|
||||
@@ -1275,6 +1280,7 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
} reset{busy, window, preDeferred};
|
||||
auto effectiveChat = requestedChat;
|
||||
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
||||
config.cycle = chatCycle(config, effectiveChat, available);
|
||||
auto start = GetTickCount64();
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
@@ -1470,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;
|
||||
@@ -2318,6 +2325,7 @@ int App::execute()
|
||||
// Resume pending OCR, including older images without a saved OCR state.
|
||||
scheduleHistoryOcr(settings);
|
||||
addTray();
|
||||
if (!smoke) startBrowserChatMonitor();
|
||||
SetTimer(window, 1, 60000, nullptr);
|
||||
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||
SetThreadExecutionState(ES_CONTINUOUS |
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Chrome Telegram Web integration: native accessibility, no OCR or polling timer.
|
||||
#include "chat_context.h"
|
||||
#include <uiautomation.h>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
namespace ks {
|
||||
namespace {
|
||||
template<class T> struct BrowserCom {
|
||||
T *p{}; ~BrowserCom(){if(p)p->Release();}
|
||||
T** out(){return &p;} T* operator->(){return p;}
|
||||
};
|
||||
std::wstring browserString(IUIAutomationElement* e, PROPERTYID id) {
|
||||
VARIANT v;VariantInit(&v);std::wstring s;
|
||||
if(SUCCEEDED(e->GetCurrentPropertyValue(id,&v)) && v.vt==VT_BSTR && v.bstrVal) s.assign(v.bstrVal,SysStringLen(v.bstrVal));
|
||||
VariantClear(&v);return s;
|
||||
}
|
||||
struct ChromeElements { BrowserCom<IUIAutomationElement> address,document,header; int addresses{},documents{}; };
|
||||
void findChromeElements(IUIAutomationTreeWalker* walker,IUIAutomationElement* e,ChromeElements& found,int depth,int& budget) {
|
||||
if(depth>12 || --budget<0)return;
|
||||
CONTROLTYPEID type{};BOOL offscreen=TRUE;e->get_CurrentControlType(&type);e->get_CurrentIsOffscreen(&offscreen);
|
||||
if(type==UIA_DocumentControlTypeId){
|
||||
if(!offscreen && browserString(e,UIA_AutomationIdPropertyId)==L"RootWebArea"){
|
||||
if(++found.documents==1){e->AddRef();found.document.p=e;}}
|
||||
return; // Never traverse message content.
|
||||
}
|
||||
if(type==UIA_EditControlTypeId && !offscreen && browserString(e,UIA_ClassNamePropertyId)==L"OmniboxViewViews"){
|
||||
if(++found.addresses==1){e->AddRef();found.address.p=e;}}
|
||||
BrowserCom<IUIAutomationElement> child;walker->GetFirstChildElement(e,child.out());
|
||||
while(child.p && budget>0){findChromeElements(walker,child.p,found,depth+1,budget);BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(child.p,next.out());child.p->Release();child.p=next.p;next.p=nullptr;}
|
||||
}
|
||||
IUIAutomationElement* chromeHeader(IUIAutomationTreeWalker* walker,IUIAutomationElement* document) {
|
||||
// Web K's center pane is separate from its chat list and message history.
|
||||
std::function<IUIAutomationElement*(IUIAutomationElement*,int)> center;
|
||||
center=[&](IUIAutomationElement* e,int depth)->IUIAutomationElement* {
|
||||
if(depth>3)return nullptr;
|
||||
if(browserString(e,UIA_AutomationIdPropertyId)==L"column-center"){e->AddRef();return e;}
|
||||
auto id=browserString(e,UIA_AutomationIdPropertyId);if(id==L"column-left"||id==L"column-right")return nullptr;
|
||||
BrowserCom<IUIAutomationElement> c;walker->GetFirstChildElement(e,c.out());int limit=12;
|
||||
while(c.p && --limit>0){if(auto result=center(c.p,depth+1))return result;BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}return nullptr;
|
||||
};
|
||||
BrowserCom<IUIAutomationElement> pane;pane.p=center(document,0);if(!pane.p)return nullptr;
|
||||
BrowserCom<IUIAutomationElement> header;walker->GetFirstChildElement(pane.p,header.out());if(!header.p)return nullptr;
|
||||
BrowserCom<IUIAutomationElement> c;walker->GetFirstChildElement(header.p,c.out());int limit=16,buttons=0;IUIAutomationElement* title{};
|
||||
while(c.p && --limit>0){CONTROLTYPEID type{};c->get_CurrentControlType(&type);if(type==UIA_ButtonControlTypeId)++buttons;
|
||||
if(type==UIA_TextControlTypeId && !title){title=c.p;title->AddRef();}
|
||||
BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}
|
||||
if(buttons<2 && title){title->Release();title=nullptr;}return title;
|
||||
}
|
||||
std::wstring chromeTitleKey(IUIAutomationElement* header){
|
||||
if(!header)return {};
|
||||
auto name=browserString(header,UIA_NamePropertyId);
|
||||
if(name.empty()||name.size()>512||name.find_first_of(L"\r\n")!=name.npos)return {};
|
||||
return L"telegram-web\n"+name;
|
||||
}
|
||||
std::wstring chromeSnapshot(IUIAutomation* uia,HWND window,ChromeElements& found) {
|
||||
if(!IsWindow(window)||IsIconic(window)||lower(processName(window))!=L"chrome")return {};
|
||||
BrowserCom<IUIAutomationElement> root;BrowserCom<IUIAutomationTreeWalker> walker;
|
||||
if(FAILED(uia->ElementFromHandle(window,root.out()))||!root.p||FAILED(uia->get_ControlViewWalker(walker.out())))return {};
|
||||
int budget=250;findChromeElements(walker.p,root.p,found,0,budget);
|
||||
if(found.addresses!=1||found.documents!=1)return {};
|
||||
auto address=telegramWebKey(browserString(found.address.p,UIA_ValueValuePropertyId));
|
||||
auto document=telegramWebKey(browserString(found.document.p,UIA_ValueValuePropertyId));
|
||||
BOOL editing=FALSE;found.address->get_CurrentHasKeyboardFocus(&editing);
|
||||
if(editing || address.empty() || address!=document)return {};
|
||||
found.header.p=chromeHeader(walker.p,found.document.p);
|
||||
return chromeTitleKey(found.header.p);
|
||||
}
|
||||
std::mutex browserMutex;
|
||||
HWND browserWindow{};std::wstring browserKey;
|
||||
std::atomic<DWORD> browserThreadId{};
|
||||
std::atomic<bool> browserPending{}, browserStopping{};
|
||||
std::thread browserThread;
|
||||
void invalidateBrowser(){std::lock_guard lock(browserMutex);browserKey.clear();}
|
||||
void requestBrowser(){invalidateBrowser();auto id=browserThreadId.load();if(id && !browserPending.exchange(true))PostThreadMessageW(id,WM_APP+61,0,0);}
|
||||
void CALLBACK browserForeground(HWINEVENTHOOK,DWORD,HWND,LONG,LONG,DWORD,DWORD){requestBrowser();}
|
||||
class BrowserChanged final: public IUIAutomationPropertyChangedEventHandler {
|
||||
std::atomic<ULONG> refs{1};
|
||||
public:
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void** out) override {if(!out)return E_POINTER;*out=nullptr;if(iid==IID_IUnknown||iid==IID_IUIAutomationPropertyChangedEventHandler){*out=static_cast<IUIAutomationPropertyChangedEventHandler*>(this);AddRef();return S_OK;}return E_NOINTERFACE;}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override{return ++refs;}
|
||||
ULONG STDMETHODCALLTYPE Release() override{auto n=--refs;if(!n)delete this;return n;}
|
||||
HRESULT STDMETHODCALLTYPE HandlePropertyChangedEvent(IUIAutomationElement*,PROPERTYID,VARIANT) override {requestBrowser();return S_OK;}
|
||||
};
|
||||
}
|
||||
std::wstring readChromeChat(HWND window) {
|
||||
auto hr=CoInitializeEx(nullptr,COINIT_MULTITHREADED);
|
||||
struct Apartment{bool own;~Apartment(){if(own)CoUninitialize();}} apartment{SUCCEEDED(hr)};
|
||||
BrowserCom<IUIAutomation2> uia;
|
||||
if(FAILED(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out())))return {};
|
||||
uia->put_ConnectionTimeout(150);uia->put_TransactionTimeout(150);
|
||||
ChromeElements elements;return chromeSnapshot(uia.p,window,elements);
|
||||
}
|
||||
std::wstring cachedChromeChat(HWND window){std::lock_guard lock(browserMutex);return window==browserWindow?browserKey:L"";}
|
||||
void startBrowserChatMonitor(){
|
||||
if(browserThread.joinable())return;
|
||||
browserStopping=false;
|
||||
browserThread=std::thread([] {
|
||||
CoInitializeEx(nullptr,COINIT_MULTITHREADED);MSG message{};PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
|
||||
browserThreadId=GetCurrentThreadId();
|
||||
auto foreground=SetWinEventHook(EVENT_SYSTEM_FOREGROUND,EVENT_SYSTEM_FOREGROUND,nullptr,browserForeground,0,0,WINEVENT_OUTOFCONTEXT);
|
||||
// Focus changes cover tab selection and entering/leaving the address bar.
|
||||
auto focus=SetWinEventHook(EVENT_OBJECT_FOCUS,EVENT_OBJECT_FOCUS,nullptr,browserForeground,0,0,WINEVENT_OUTOFCONTEXT);
|
||||
BrowserCom<IUIAutomation2> uia;CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out());
|
||||
BrowserCom<IUIAutomationElement> address,document,header;BrowserCom<BrowserChanged> handler;handler.p=new BrowserChanged;
|
||||
if(uia.p){uia->put_ConnectionTimeout(150);uia->put_TransactionTimeout(150);}
|
||||
requestBrowser();
|
||||
if(browserStopping)PostThreadMessageW(GetCurrentThreadId(),WM_QUIT,0,0);
|
||||
while(GetMessageW(&message,nullptr,0,0)>0){
|
||||
if(message.message!=WM_APP+61){TranslateMessage(&message);DispatchMessageW(&message);continue;}
|
||||
browserPending=false;
|
||||
if(!uia.p)continue;
|
||||
if(address.p){uia->RemovePropertyChangedEventHandler(address.p,handler.p);address.p->Release();address.p=nullptr;}
|
||||
if(document.p){uia->RemovePropertyChangedEventHandler(document.p,handler.p);document.p->Release();document.p=nullptr;}
|
||||
if(header.p){uia->RemovePropertyChangedEventHandler(header.p,handler.p);header.p->Release();header.p=nullptr;}
|
||||
auto window=GetForegroundWindow();ChromeElements elements;auto key=chromeSnapshot(uia.p,window,elements);
|
||||
PROPERTYID properties[]={UIA_ValueValuePropertyId};
|
||||
bool subscribed=elements.address.p && elements.document.p;
|
||||
if(subscribed){address.p=elements.address.p;elements.address.p=nullptr;document.p=elements.document.p;elements.document.p=nullptr;
|
||||
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(address.p,TreeScope_Element,nullptr,handler.p,properties,1));
|
||||
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(document.p,TreeScope_Element,nullptr,handler.p,properties,1)) && subscribed;
|
||||
if(elements.header.p){header.p=elements.header.p;elements.header.p=nullptr;PROPERTYID nameProperty=UIA_NamePropertyId;
|
||||
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(header.p,TreeScope_Element,nullptr,handler.p,&nameProperty,1)) && subscribed;}
|
||||
// Close the read/subscribe race without scanning the content tree.
|
||||
key=telegramWebKey(browserString(address.p,UIA_ValueValuePropertyId));
|
||||
if(key.empty() || key!=telegramWebKey(browserString(document.p,UIA_ValueValuePropertyId)))key.clear();
|
||||
else key=chromeTitleKey(header.p);
|
||||
BOOL editing=FALSE;address->get_CurrentHasKeyboardFocus(&editing);if(editing)key.clear();
|
||||
}
|
||||
std::lock_guard lock(browserMutex);browserWindow=window;browserKey=subscribed && !browserPending && window==GetForegroundWindow()?key:L"";
|
||||
}
|
||||
browserThreadId=0;
|
||||
if(foreground)UnhookWinEvent(foreground);
|
||||
if(focus)UnhookWinEvent(focus);
|
||||
if(uia.p){if(header.p)uia->RemovePropertyChangedEventHandler(header.p,handler.p);if(address.p)uia->RemovePropertyChangedEventHandler(address.p,handler.p);if(document.p)uia->RemovePropertyChangedEventHandler(document.p,handler.p);}
|
||||
invalidateBrowser();
|
||||
// COM objects release before uninitializing the apartment below.
|
||||
if(header.p){header.p->Release();header.p=nullptr;}
|
||||
if(address.p){address.p->Release();address.p=nullptr;}if(document.p){document.p->Release();document.p=nullptr;}
|
||||
if(uia.p){uia.p->Release();uia.p=nullptr;}CoUninitialize();
|
||||
});
|
||||
}
|
||||
void stopBrowserChatMonitor(){browserStopping=true;auto id=browserThreadId.load();if(id)PostThreadMessageW(id,WM_QUIT,0,0);if(browserThread.joinable())browserThread.join();browserPending=false;}
|
||||
}
|
||||
@@ -4,6 +4,31 @@
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline std::wstring telegramWebKey(std::wstring url)
|
||||
{
|
||||
if (url.starts_with(L"https://")) url.erase(0, 8);
|
||||
const std::wstring host = L"web.telegram.org/";
|
||||
if (!url.starts_with(host)) return {};
|
||||
url.erase(0, host.size());
|
||||
if (!(url.starts_with(L"k/#") || url.starts_with(L"a/#"))) return {};
|
||||
auto peer = url.substr(3);
|
||||
if (peer.empty() || peer.size() > 128) return {};
|
||||
if (peer.front() == L'@')
|
||||
{
|
||||
peer = lower(peer);
|
||||
if (peer.size() < 2 || peer.find_first_not_of(L"abcdefghijklmnopqrstuvwxyz0123456789_", 1) != peer.npos) return {};
|
||||
}
|
||||
else
|
||||
{
|
||||
auto first = peer.front() == L'-' ? 1u : 0u;
|
||||
if (first == peer.size() || peer.find_first_not_of(L"0123456789", first) != peer.npos) return {};
|
||||
}
|
||||
return L"telegram-web\n" + peer;
|
||||
}
|
||||
std::wstring readChromeChat(HWND);
|
||||
std::wstring cachedChromeChat(HWND);
|
||||
void startBrowserChatMonitor();
|
||||
void stopBrowserChatMonitor();
|
||||
struct ChatSwitchHook
|
||||
{
|
||||
bool pressed[256]{};
|
||||
@@ -64,6 +89,7 @@ inline std::wstring chatKey(std::wstring process, std::wstring title)
|
||||
}
|
||||
inline std::wstring windowChatKey(HWND window)
|
||||
{
|
||||
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
|
||||
return window ? chatKey(processName(window), chatTitle(window)) : L"";
|
||||
}
|
||||
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -733,3 +733,5 @@ void log(const std::wstring &message)
|
||||
}
|
||||
}
|
||||
} // namespace ks
|
||||
|
||||
#include "browser_chat.inc"
|
||||
+29
-4
@@ -67,7 +67,7 @@ void validateSettings(const Settings &s)
|
||||
auto separator = key.find(L'\n');
|
||||
if (separator == key.npos || separator + 1 == key.size() || key.size() > 1024 ||
|
||||
key.find(L'\n', separator + 1) != key.npos || key.find(L'\r') != key.npos ||
|
||||
(key.substr(0, separator) != L"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber"))
|
||||
(key.substr(0, separator) != L"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber" && key.substr(0, separator) != L"telegram-web"))
|
||||
throw std::runtime_error("Invalid chat language profile");
|
||||
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select at least one chat language");
|
||||
std::vector<std::wstring> seen;
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
+73
-18
@@ -1013,7 +1013,7 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||
if (selected < 0 || selected >= (int)v->keys.size())
|
||||
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog. Viber OCR is disabled.");
|
||||
throw std::runtime_error("Open Telegram, Telegram Web K in Chrome, or Teams, then reopen this dialog. Viber OCR is disabled.");
|
||||
auto next = v->settings->draft;
|
||||
if (id == 713) next.chatCycles.erase(v->keys[selected]);
|
||||
else
|
||||
@@ -1055,7 +1055,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
{
|
||||
if (!IsWindowVisible(candidate)) return TRUE;
|
||||
auto &data = *(Context *)context;
|
||||
auto key = windowChatKey(candidate);
|
||||
auto key = lower(processName(candidate)) == L"chrome" ? readChromeChat(candidate) : windowChatKey(candidate);
|
||||
if (!key.empty()) data.keys.insert(std::move(key));
|
||||
return TRUE;
|
||||
}, (LPARAM)&context);
|
||||
@@ -1063,13 +1063,13 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||
label(w, L"Telegram / Teams profiles; saved Viber profiles are inactive", 18, 16, 604);
|
||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||
710, 18, 44, 604, 140);
|
||||
for (const auto &key : v->keys)
|
||||
{
|
||||
auto split = key.find(L'\n');
|
||||
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"viber\n") ? L"Viber (inactive): " : L"Teams: ") + key.substr(split + 1);
|
||||
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"telegram-web\n") ? L"Telegram Web: " : key.starts_with(L"viber\n") ? L"Viber (inactive): " : L"Teams: ") + key.substr(split + 1);
|
||||
SendDlgItemMessageW(w, 710, LB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
}
|
||||
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
||||
@@ -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);
|
||||
@@ -1988,7 +2031,7 @@ void showSettings(App &app, bool visible)
|
||||
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
||||
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
||||
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
|
||||
label(w, L"Chat languages: Telegram / Teams. Viber OCR disabled.", 272, 382, 412);
|
||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams", 272, 382, 412);
|
||||
page();
|
||||
|
||||
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||
@@ -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, 145, 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);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.50";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.51";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -14,6 +14,30 @@ 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",
|
||||
L"http://web.telegram.org/k/#@example", L"https://evil/web.telegram.org/k/#@example",
|
||||
L"https://web.telegram.org/k/#@example?foo", L"https://web.telegram.org/k/#@",
|
||||
L"https://web.telegram.org/k/#-", L"https://web.telegram.org/k/#settings"})
|
||||
require(ks::telegramWebKey(url).empty(), "Reject non-chat or untrusted Telegram Web URL");
|
||||
const auto key = ks::chatKey(L"Telegram.exe", L"\u200eТестовий чат \u2013 (12)");
|
||||
require(key == L"telegram\nТестовий чат", "Telegram unread suffix");
|
||||
require(ks::chatKey(L"Telegram", L"Тестовий чат") == key, "stable unread profile");
|
||||
@@ -28,6 +52,7 @@ void contextChecks()
|
||||
settings.cycle = {L"win:04090409", L"win:04220422", L"win:04190419"};
|
||||
settings.chatCycles[key] = {settings.cycle[1], settings.cycle[0]};
|
||||
settings.chatCycles[L"viber\nТестовий контакт"] = {settings.cycle[1], settings.cycle[0]};
|
||||
settings.chatCycles[L"telegram-web\n@example"] = {settings.cycle[0], settings.cycle[1]};
|
||||
std::vector<ks::Layout> installed(3);
|
||||
for (size_t i = 0; i < installed.size(); ++i)
|
||||
{
|
||||
@@ -166,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";
|
||||
|
||||
@@ -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…");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user