diff --git a/README.md b/README.md index a4456de..b876b0a 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,14 @@ Telegram ігнорується. Teams підтримує зазначений Профілі локальні, входять до ручного експорту/імпорту, але не до хмарної синхронізації. Доступно з v0.6.48. + +### Telegram Web in Chrome — development build + +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. + +**Українською.** У дев-версії підтримано 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 та інші браузери не перевірені й не заявлені як підтримувані. ### 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 cfba9cc..4611c01 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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); @@ -978,6 +979,7 @@ void App::switchChatLayout(bool reverse) { struct Reset { std::atomic &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 +1277,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"; @@ -2318,6 +2321,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 | diff --git a/src/browser_chat.inc b/src/browser_chat.inc new file mode 100644 index 0000000..823009e --- /dev/null +++ b/src/browser_chat.inc @@ -0,0 +1,143 @@ +// Chrome Telegram Web integration: native accessibility, no OCR or polling timer. +#include "chat_context.h" +#include +#include +#include +namespace ks { +namespace { +template 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 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 child;walker->GetFirstChildElement(e,child.out()); + while(child.p && budget>0){findChromeElements(walker,child.p,found,depth+1,budget);BrowserCom 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 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 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 next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}return nullptr; + }; + BrowserCom pane;pane.p=center(document,0);if(!pane.p)return nullptr; + BrowserCom header;walker->GetFirstChildElement(pane.p,header.out());if(!header.p)return nullptr; + BrowserCom 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 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 root;BrowserCom 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 browserThreadId{}; +std::atomic 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 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(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 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 uia;CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out()); + BrowserCom address,document,header;BrowserCom 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;} +} diff --git a/src/chat_context.h b/src/chat_context.h index d019992..c6d6f2f 100644 --- a/src/chat_context.h +++ b/src/chat_context.h @@ -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 chatCycle(const Settings &settings, const std::wstring &key, diff --git a/src/platform.cpp b/src/platform.cpp index 71f2265..7eb0e6c 100644 --- a/src/platform.cpp +++ b/src/platform.cpp @@ -733,3 +733,5 @@ void log(const std::wstring &message) } } } // namespace ks + +#include "browser_chat.inc" diff --git a/src/storage.cpp b/src/storage.cpp index f9ae8a6..b2f2563 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -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 seen; diff --git a/src/ui.cpp b/src/ui.cpp index 34e991e..718011c 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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); @@ -1988,7 +1988,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); diff --git a/tests/chat_profiles.cpp b/tests/chat_profiles.cpp index 9437211..eba3cd5 100644 --- a/tests/chat_profiles.cpp +++ b/tests/chat_profiles.cpp @@ -14,6 +14,13 @@ void require(bool ok, const char *message) } void contextChecks() { + 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 +35,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 installed(3); for (size_t i = 0; i < installed.size(); ++i) {