Release v0.6.49: Viber chat language profiles with local OCR

This commit is contained in:
agrynco committed 2026-11-05 23:52:50 +02:00
1 parent 4e7a683c47
commit 178573233b
15 files changed
+181 -63

No files matched your search

+20 -4
View File
@@ -963,7 +963,7 @@ void App::refreshChatContext()
HWND target = GetForegroundWindow();
chatWindow = target;
activeChatTitle = chatTitle(target);
activeChat = settings.chatCycles.empty() ? L"" : chatKey(processName(target), activeChatTitle);
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
}
void App::switchChatLayout(bool reverse)
{
@@ -974,10 +974,11 @@ void App::switchChatLayout(bool reverse)
auto cycle = chatCycle(settings, key, layouts);
auto available = layouts;
resetInput();
enqueue([this, target, key, cycle, available, reverse]
enqueue([this, target, key, cycle, available, reverse, options = settings]
{
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
if (key.starts_with(L"viber\n") && recognizeViberChat(target, options) != key) return;
if (!valid()) return;
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
if (next) switchWindowLayout(target, next, valid);
@@ -1258,9 +1259,8 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
const auto requestedChat = config.chatCycles.empty() ? L"" : windowChatKey(target);
config.cycle = chatCycle(config, requestedChat, available);
enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred, requestedChat]() mutable
{
struct BusyReset
{
@@ -1275,6 +1275,10 @@ void App::transform(int command, int snippet)
b = false;
}
} reset{busy, window, preDeferred};
auto effectiveChat = requestedChat;
if (lower(processName(target)) == L"viber")
effectiveChat = recognizeViberChat(target, config);
config.cycle = chatCycle(config, effectiveChat, available);
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected, actual, verification = L"not-attempted";
@@ -2178,6 +2182,17 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_TIMER:
if (wp == 7)
{
static std::atomic<bool> pending{false};
auto target = GetForegroundWindow();
if (!app->settings.chatCycles.empty() && lower(processName(target)) == L"viber" && !pending.exchange(true))
{
auto options = app->settings;
app->enqueue([target, options] { recognizeViberChat(target, options); pending = false; });
}
return 0;
}
if (wp == 6)
{
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
@@ -2318,6 +2333,7 @@ int App::execute()
scheduleHistoryOcr(settings);
addTray();
SetTimer(window, 1, 60000, nullptr);
if (!smoke) SetTimer(window, 7, 750, nullptr);
if (!smoke) SetTimer(window, 6, 1000, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
+3
View File
@@ -4,6 +4,8 @@
namespace ks
{
std::wstring cachedViberChat(HWND);
std::wstring recognizeViberChat(HWND, const Settings &);
struct ChatSwitchHook
{
bool pressed[256]{};
@@ -64,6 +66,7 @@ inline std::wstring chatKey(std::wstring process, std::wstring title)
}
inline std::wstring windowChatKey(HWND window)
{
if (window && lower(processName(window)) == L"viber") return cachedViberChat(window);
return window ? chatKey(processName(window), chatTitle(window)) : L"";
}
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
+1
View File
@@ -429,3 +429,4 @@ std::wstring recognizeText(const Clip &clip, const Settings &s)
return wide(text.get());
}
} // namespace ks
#include "viber_ocr.inc"
+1 -1
View File
@@ -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"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber"))
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;
+10 -7
View File
@@ -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.");
throw std::runtime_error("Open a Telegram, Teams or Viber chat, then reopen this dialog. Viber requires Tesseract.");
auto next = v->settings->draft;
if (id == 713) next.chatCycles.erase(v->keys[selected]);
else
@@ -1050,24 +1050,27 @@ void showChatLanguages(HWND parent, SettingsView *settings)
{
std::set<std::wstring> keys;
for (const auto &[key, cycle] : settings->draft.chatCycles) keys.insert(key);
struct Context { std::set<std::wstring> &keys; const Settings &settings; } context{keys, settings->draft};
EnumWindows([](HWND candidate, LPARAM context) -> BOOL
{
if (!IsWindowVisible(candidate)) return TRUE;
auto key = windowChatKey(candidate);
if (!key.empty()) ((std::set<std::wstring> *)context)->insert(std::move(key));
auto &data = *(Context *)context;
auto key = lower(processName(candidate)) == L"viber"
? recognizeViberChat(candidate, data.settings) : windowChatKey(candidate);
if (!key.empty()) data.keys.insert(std::move(key));
return TRUE;
}, (LPARAM)&keys);
}, (LPARAM)&context);
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
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"Saved profiles and currently open Telegram / Teams chats", 18, 16, 604);
label(w, L"Telegram / Teams / Viber (experimental OCR) chat profiles", 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: " : L"Teams: ") + key.substr(split + 1);
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"viber\n") ? L"Viber: " : 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);
@@ -1986,7 +1989,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"Choose a separate set for each Telegram or Teams chat.", 272, 382, 412);
label(w, L"Chat languages: Telegram, Teams, Viber (OCR).", 272, 382, 412);
page();
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.48";
inline constexpr wchar_t AppVersion[] = L"0.6.49";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+82
View File
@@ -0,0 +1,82 @@
// Experimental Viber header OCR; never called on the keyboard hook thread.
#include <uiautomation.h>
#include <mutex>
namespace ks {
namespace {
std::mutex viberMutex;
HWND viberWindow{};
std::wstring viberKey;
ULONGLONG viberTime{};
std::wstring elementClass(IUIAutomationElement *e) {
BSTR s{}; e->get_CurrentClassName(&s); std::wstring out=s?s:L""; SysFreeString(s); return out;
}
bool findViberHeader(IUIAutomationTreeWalker *walker,IUIAutomationElement *e,RECT &pane,RECT &search,int depth,int &budget) {
if(depth>5 || --budget<0) return false;
bool stack=elementClass(e).starts_with(L"StackView_");
Com<IUIAutomationElement> child;walker->GetFirstChildElement(e,child.out());
bool found=false,info=false;
while(child.p && --budget>0) {
if(stack) {
auto name=elementClass(child.p);
if(name.starts_with(L"SearchButton_")){child->get_CurrentBoundingRectangle(&search);found=true;}
if(name.starts_with(L"InfoButton_")) info=true;
} else if(findViberHeader(walker,child.p,pane,search,depth+1,budget)) return true;
Com<IUIAutomationElement> next;walker->GetNextSiblingElement(child.p,next.out());
child.p->Release();child.p=next.p;next.p=nullptr;
}
if(found && info){e->get_CurrentBoundingRectangle(&pane);return true;}
return false;
}
Pixels viberHeader(HWND window) {
if(!IsWindow(window)||IsIconic(window)) return {};
Com<IUIAutomation2> automation;
check(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)automation.out()));
automation->put_ConnectionTimeout(200);automation->put_TransactionTimeout(200);
Com<IUIAutomationElement> root;check(automation->ElementFromHandle(window,root.out()));
Com<IUIAutomationTreeWalker> walker;check(automation->get_RawViewWalker(walker.out()));
RECT pane{},search{},rect{};int budget=160;
if(!findViberHeader(walker.p,root.p,pane,search,0,budget)||!GetWindowRect(window,&rect)) return {};
double scale=GetDpiForWindow(window)/96.0;
int x=pane.left+int(76*scale)-rect.left,y=search.top-int(12*scale)-rect.top;
int width=search.left-int(12*scale)-rect.left-x,height=int(28*scale);
int ww=rect.right-rect.left,wh=rect.bottom-rect.top;
if(width<30||height<10||x<0||y<0||x+width>ww||y+height>wh||uint64_t(ww)*wh>16000000) return {};
HDC dc=CreateCompatibleDC(nullptr);BITMAPINFO bi{};bi.bmiHeader.biSize=sizeof(BITMAPINFOHEADER);
bi.bmiHeader.biWidth=ww;bi.bmiHeader.biHeight=-wh;bi.bmiHeader.biPlanes=1;bi.bmiHeader.biBitCount=32;
void *bits{};HBITMAP bitmap=CreateDIBSection(dc,&bi,DIB_RGB_COLORS,&bits,nullptr,0);
if(!bitmap){DeleteDC(dc);return {};}
auto old=SelectObject(dc,bitmap);Pixels p;
if(PrintWindow(window,dc,2)) {
p.width=width;p.height=height;p.bgra.resize(size_t(width)*height*4);
for(int row=0;row<height;++row) memcpy(p.bgra.data()+size_t(row)*width*4,(BYTE*)bits+(size_t(y+row)*ww+x)*4,size_t(width)*4);
}
SelectObject(dc,old);DeleteObject(bitmap);DeleteDC(dc);return p;
}
}
std::wstring cachedViberChat(HWND window) {
std::lock_guard lock(viberMutex);
return window==viberWindow && GetTickCount64()-viberTime<2000 ? viberKey : L"";
}
std::wstring recognizeViberChat(HWND window,const Settings &settings) {
std::wstring key;
try {
auto hr=CoInitializeEx(nullptr,COINIT_MULTITHREADED);
struct Apartment {bool owned;~Apartment(){if(owned)CoUninitialize();}} apartment{SUCCEEDED(hr)};
auto context=SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
struct Dpi {DPI_AWARENESS_CONTEXT old;~Dpi(){if(old)SetThreadDpiAwarenessContext(old);}} dpi{context};
auto before=viberHeader(window);
if(!before.bgra.empty()) {
Settings options=settings;options.useTesseract=true;
Clip clip;clip.formats.push_back(imageDib(before));
auto title=recognizeText(clip,options);
while(!title.empty() && iswspace(title.back())) title.pop_back();
while(!title.empty() && iswspace(title.front())) title.erase(0,1);
auto after=viberHeader(window);
if(!title.empty() && title.size()<512 && title.find_first_of(L"\r\n")==title.npos && before.width==after.width && before.height==after.height && before.bgra==after.bgra)
key=L"viber\n"+title;
}
} catch(...) {}
{std::lock_guard lock(viberMutex);viberWindow=window;viberKey=key;viberTime=GetTickCount64();}
return key;
}
}