Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178573233b | ||
|
|
4e7a683c47 | ||
|
|
e33387c2d5 | ||
|
|
5de5b791bf |
No files matched your search
+25
-1
@@ -1,10 +1,24 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.45 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.49 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
add_executable(StayHere WIN32 utilities/StayHere/main.cpp)
|
||||||
|
add_executable(StayHereTest WIN32 utilities/StayHere/main.cpp)
|
||||||
|
target_compile_definitions(StayHereTest PRIVATE STAYHERE_TEST_INSTANCE)
|
||||||
|
set_target_properties(StayHere PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tools")
|
||||||
|
foreach(utilityTarget StayHere StayHereTest)
|
||||||
|
target_compile_definitions(${utilityTarget} PRIVATE UNICODE _UNICODE _WIN32_WINNT=0x0A00)
|
||||||
|
target_link_libraries(${utilityTarget} PRIVATE user32 shell32 gdi32 wtsapi32)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(${utilityTarget} PRIVATE /utf-8)
|
||||||
|
set_property(TARGET ${utilityTarget} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
||||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||||
@@ -75,6 +89,15 @@ else()
|
|||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_executable(KSwitcherChatProfileTests tests/chat_profiles.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
|
target_link_libraries(KSwitcherChatProfileTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(KSwitcherChatProfileTests PRIVATE /utf-8)
|
||||||
|
set_property(TARGET KSwitcherChatProfileTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherChatProfileTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME chat-profile-regressions COMMAND KSwitcherChatProfileTests)
|
||||||
add_executable(KSwitcherHistoryHostTests tests/history_host.cpp)
|
add_executable(KSwitcherHistoryHostTests tests/history_host.cpp)
|
||||||
target_link_libraries(KSwitcherHistoryHostTests PRIVATE kscore psapi)
|
target_link_libraries(KSwitcherHistoryHostTests PRIVATE kscore psapi)
|
||||||
set_target_properties(KSwitcherHistoryHostTests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/history-host-fixture-$<CONFIG>")
|
set_target_properties(KSwitcherHistoryHostTests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/history-host-fixture-$<CONFIG>")
|
||||||
@@ -127,6 +150,7 @@ else()
|
|||||||
target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests)
|
add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests)
|
||||||
|
add_test(NAME stayhere-lifecycle COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/stayhere.ps1" -Executable "$<TARGET_FILE:StayHereTest>")
|
||||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||||
|
|||||||
@@ -270,6 +270,60 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
|
|
||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
|
### Chat language profiles — v0.6.48
|
||||||
|
|
||||||
|
Settings separates General, Languages, Shortcuts, Clipboard and Tools into native tabs.
|
||||||
|
Switching tabs preserves edits; Save, Cancel, Import and Export stay available below every page.
|
||||||
|
|
||||||
|
Open the desired chat in Telegram Desktop or Microsoft Teams, then open Settings → Languages →
|
||||||
|
**Languages for a chat…**. Select the chat and click the languages to include. Choose
|
||||||
|
**Use languages**, then **Save** in Settings. The subset order is saved from the main
|
||||||
|
layout list when editing the profile; one-language profiles are supported. **Remove profile** restores default behavior.
|
||||||
|
|
||||||
|
For a matching chat, **Alt+Shift**, **Win+Space** (and reverse **Win+Shift+Space**) cycle
|
||||||
|
through that subset. KSwitcher's **Next layout** conversion uses the same subset.
|
||||||
|
Explicit KSwitcher hotkey bindings take precedence. Without a matching profile,
|
||||||
|
Windows shortcuts keep their normal behavior and Next layout uses the global cycle.
|
||||||
|
Unavailable layouts are skipped; if none remain installed, the global cycle is used.
|
||||||
|
Direct layout shortcuts are unchanged. Profiles do not change Windows' installed languages.
|
||||||
|
|
||||||
|
Matching uses the application and visible chat name, not a unique account/contact ID.
|
||||||
|
Identically named chats in one application share a profile; renamed chats need a new
|
||||||
|
profile. Telegram unread counters are ignored. Teams currently recognizes the desktop
|
||||||
|
`Chat | name | Microsoft Teams` title format; other Teams title formats are not supported.
|
||||||
|
From v0.6.49, experimental Viber header OCR is supported using an installed
|
||||||
|
Tesseract 5 and the languages selected in OCR options. Open a non-minimized Viber chat
|
||||||
|
before opening the profile dialog. Recognition runs locally; message text is not sent
|
||||||
|
to OCR and screenshots are not saved. Allow about two seconds after entering a chat.
|
||||||
|
An unreadable or changing header does not produce a profile match. OCR mistakes,
|
||||||
|
truncated names and Viber UI changes can affect matching. Viber support starts with v0.6.49.
|
||||||
|
Profiles stay on this PC (excluded from cloud sync), and are included in manual settings
|
||||||
|
export/import. Available from v0.6.48.
|
||||||
|
|
||||||
|
**Українською.** Налаштування розділено на вкладки General, Languages, Shortcuts, Clipboard
|
||||||
|
і Tools. Перехід між ними зберігає незбережені зміни; Save, Cancel, Import і Export доступні
|
||||||
|
на кожній вкладці. Відкрий потрібний чат у Telegram або Teams, потім **Settings → Languages → Languages
|
||||||
|
for a chat…**. Вибери чат і потрібні мови, натисни **Use languages**, а потім **Save**.
|
||||||
|
Набір діє для **Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** KSwitcher.
|
||||||
|
Можна вибрати й одну мову; порядок зберігається із загального списку під час редагування профілю. **Remove profile**
|
||||||
|
повертає звичайну поведінку. Без профілю системні комбінації працюють як раніше,
|
||||||
|
а Next layout використовує загальний набір. Видалені з Windows розкладки пропускаються;
|
||||||
|
якщо доступних мов профілю не залишилося, використовується загальний набір.
|
||||||
|
Прямі комбінації мов не змінюються; явно задані комбінації KSwitcher мають пріоритет.
|
||||||
|
Зіставлення виконується за програмою та назвою чату: однакові назви в одній програмі
|
||||||
|
мають спільний профіль, після перейменування потрібен новий. Лічильник непрочитаних
|
||||||
|
Telegram ігнорується. Teams підтримує зазначений вище формат заголовка.
|
||||||
|
З v0.6.49 Viber експериментально визначається через локальний OCR заголовка:
|
||||||
|
потрібен Tesseract 5 із мовами, вибраними в OCR options. Перед відкриттям профілів
|
||||||
|
відкрий чат у незгорнутому Viber. Після переходу в чат зачекай приблизно дві секунди.
|
||||||
|
Текст повідомлень не передається в OCR, знімки не зберігаються. Нечитабельний або
|
||||||
|
мінливий заголовок не дає збігу; помилки OCR, обрізані імена та зміни інтерфейсу Viber
|
||||||
|
можуть впливати на визначення. Підтримка Viber доступна з v0.6.49.
|
||||||
|
Профілі локальні, входять до ручного експорту/імпорту, але не до
|
||||||
|
хмарної синхронізації. Доступно з v0.6.48.
|
||||||
|
|
||||||
|
### Existing features
|
||||||
|
|
||||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||||
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||||
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||||
@@ -544,9 +598,9 @@ Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-re
|
|||||||
|
|
||||||
## Portable utilities
|
## Portable utilities
|
||||||
|
|
||||||
The tray menu's **Utilities** submenu lists `.exe` files in a `tools` folder beside
|
The tray menu's **Tools** submenu lists `.exe` files in a `tools` folder beside
|
||||||
`KSwitcher.exe`, sorted by filename. For example, place `StayHere.exe` at
|
`KSwitcher.exe`, sorted by filename. For example, place `StayHere.exe` at
|
||||||
`tools/StayHere.exe` and choose **Utilities → StayHere** to launch it manually.
|
`tools/StayHere.exe` and choose **Tools → StayHere** to launch it manually.
|
||||||
The folder is rescanned whenever the menu opens; no configuration or restart is needed.
|
The folder is rescanned whenever the menu opens; no configuration or restart is needed.
|
||||||
Only files directly in `tools` are listed (not subfolders, shortcuts, or KSwitcher's
|
Only files directly in `tools` are listed (not subfolders, shortcuts, or KSwitcher's
|
||||||
own executables beside the main program). Utilities start with `tools` as their working
|
own executables beside the main program). Utilities start with `tools` as their working
|
||||||
@@ -554,10 +608,18 @@ directory and are never started automatically. Their lifetime is independent of
|
|||||||
An absent or empty folder displays a disabled placeholder; launch errors are reported.
|
An absent or empty folder displays a disabled placeholder; launch errors are reported.
|
||||||
From v0.6.45, the release includes `tools/README.txt`, so extracting the ZIP or
|
From v0.6.45, the release includes `tools/README.txt`, so extracting the ZIP or
|
||||||
installing an update creates the folder. Add your own trusted portable executables;
|
installing an update creates the folder. Add your own trusted portable executables;
|
||||||
none are bundled or transferred by settings synchronization. Updates preserve your
|
StayHere is bundled from v0.6.46, and settings synchronization does not transfer
|
||||||
utility files and may refresh the bundled README. Repository development scripts
|
utility files. Updates preserve personal utilities and refresh bundled StayHere and
|
||||||
|
documentation. Repository development scripts
|
||||||
are not included in the portable tools folder.
|
are not included in the portable tools folder.
|
||||||
|
|
||||||
|
Choose **Tools → StayHere** to start the bundled activity utility. It starts
|
||||||
|
enabled; left-click its tray icon to pause/resume or right-click for Exit. While
|
||||||
|
enabled it prevents automatic sleep and, after three minutes without input, sends
|
||||||
|
F15 on its 15-second check. It does not move the mouse, bypass locking, start with
|
||||||
|
KSwitcher automatically, or directly control Teams status. Locked/disconnected
|
||||||
|
sessions do not receive simulated activity. See `tools/StayHere-README.md`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||||
@@ -591,8 +653,10 @@ default and require the main **Automatic correction (experimental)** checkbox to
|
|||||||
|
|
||||||
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||||
are rejected. Each side must contain 1–32 letters of the same supported language, without
|
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
||||||
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
|
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
|
||||||
|
spaces. Both sides must use the same supported language, without punctuation; leading,
|
||||||
|
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
|
||||||
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
||||||
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
||||||
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||||
@@ -608,6 +672,8 @@ ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped.
|
|||||||
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
||||||
are included in settings export/import and synchronization. Undo restores the original word
|
are included in settings export/import and synchronization. Undo restores the original word
|
||||||
and suppresses its correction until restart.
|
and suppresses its correction until restart.
|
||||||
|
When synchronizing phrase replacements, update all participating PCs to v0.6.47 or newer;
|
||||||
|
older clients reject spaces in replacement pairs.
|
||||||
|
|
||||||
### Layout correction and shared options
|
### Layout correction and shared options
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+11
-9
@@ -1,21 +1,23 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.45 — 2026-11-05
|
## v0.6.49 — 2026-11-05
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Fix Far Manager snippet expansion and caret placement. Far uses Alt+Shift+Arrow selection, Ctrl+Insert copy and Shift+Insert paste, with exact trigger and inserted-text verification. Cancellation restores the full temporary selection. Both visible and hidden panels are supported.
|
- Add experimental Viber chat language profiles using local OCR of the chat header. Requires a separate Tesseract 5 installation and the language packs selected in OCR options; Tesseract is not bundled.
|
||||||
- Run Far `{cursor}` and `{enter}` actions only after verified insertion. Far replacements must be nonempty, single-line and no longer than 4096 caret steps. Unsupported templates are rejected before selection; multiline clipboard pastes could execute commands before verification. Other applications retain accessibility verification.
|
- Open a non-minimized Viber chat, then **Settings → Languages → Languages for a chat...**. Select its Viber entry, choose languages, and click **Use languages → Save**.
|
||||||
- Add the tray **Utilities** menu. It scans trusted `.exe` files directly in `tools` beside the running KSwitcher.exe, sorted by filename and refreshed whenever the menu opens. Utilities run only when selected, use `tools` as their working directory and remain running independently.
|
- Apply the profile to **Alt+Shift**, **Win+Space**, reverse **Win+Shift+Space**, and KSwitcher's **Next layout** conversion. Allow about 1–2 seconds after switching chats. OCR runs outside the keyboard hook; the header is checked again before a profile-based system switch.
|
||||||
- Include **tools/README.txt** in the release ZIP and manifest. Both extraction and automatic updates create the folder. Personal utility executables are preserved by updates; none are bundled or transferred through settings synchronization. Source-repository development scripts are excluded.
|
- Match by application and recognized name. Identical names share a profile; renamed chats need a new profile. Unreadable or changing headers produce no match. OCR errors, truncated names, scaling and future Viber UI changes can affect recognition. Telegram and Teams profiles retain their existing behavior.
|
||||||
- Retain v0.6.44's newest-item selection when reopening clipboard history, including after a screenshot. Saved search/pinned filters remain active; background refreshes preserve the selected record while browsing.
|
- Recognition is local: only the cropped header is passed to OCR, screenshots are not saved, and no message text is submitted to OCR. Profiles remain local, are included in manual export/import, and excluded from OpenBao synchronization.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Passed Utilities, dictionary equivalence (140,939 words), 105 sync checks, shared wheel regressions, 319 native checks, 574 input checks, screenshot UI/save/registration, 52 updater checks and updater progress UI. Updater tests cover creation of tools, preserving personal utilities and rollback. The actual ZIP was validated and installed through the unchanged v0.6.44 updater; all installed manifest hashes matched, tools/README.txt was created, and a staging-only utility was excluded from the ZIP. An 18-check real-Far integration run passed during development (visible/hidden panels, caret restoration, cursor markers and verified Enter). The final release-build Far run was canceled by an unrelated foreground change before expansion; an uninterrupted final-build desktop run remains outstanding.
|
Passed 653 chat-profile/UI checks (including 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. Live Viber header recognition correctly identified a Ukrainian contact; the user confirmed the development integration works. Release binaries, archive, manifest and published downloads are checked during publication.
|
||||||
|
|
||||||
## Upgrade
|
## Upgrade
|
||||||
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.
|
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. Look for `tools` beside the executable actually running.
|
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.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together; history uses another instance of KSwitcher.exe. The package contains no personal settings, credentials or clipboard history.
|
When synchronizing phrase replacements across PCs, update each participating PC to v0.6.47 or newer. Chat language profiles do not synchronize. A settings file containing Viber profiles requires v0.6.49 or newer; retain a backup before downgrading.
|
||||||
|
|
||||||
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
||||||
|
|
||||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
@@ -1 +1 @@
|
|||||||
94b4a3e86c218a42ef4fe0c75c1a621af62e1dfd6b395eea2040d6c1e7f138a8 KSwitcher-win-x64.zip
|
3c99e7d6b7f3ea059bf9cbff2266eb89f7cbba32287b7ce52a3ac65596131962 KSwitcher-win-x64.zip
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"version": "0.6.49",
|
||||||
|
"schema": 1,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"size": 1841152,
|
||||||
|
"path": "KSwitcher.exe",
|
||||||
|
"sha256": "e64518d5224055c4bf09df0e32493ecfe50a1d225436117e56d102cddd7506e0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 1704960,
|
||||||
|
"path": "KSwitcher.Config.exe",
|
||||||
|
"sha256": "c3cb3163c9bd4683362154ec2be1d3fb95e72df879a18b356ad89d37bfdd444f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 2892800,
|
||||||
|
"path": "KSwitcher.Screenshot.exe",
|
||||||
|
"sha256": "4785c3cfb4b3ee9783703040237a000a387569100828d239615039fd417f1855"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 4401,
|
||||||
|
"path": "LICENSE",
|
||||||
|
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 1096,
|
||||||
|
"path": "json-LICENSE.txt",
|
||||||
|
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 69715,
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "2ac72789dc57c42303859956eb3f2a31b69bc5eea06e71ccf0588d6f08135948"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 732485,
|
||||||
|
"path": "dictionaries/en.txt",
|
||||||
|
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 20566,
|
||||||
|
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||||
|
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 2265,
|
||||||
|
"path": "dictionaries/README.md",
|
||||||
|
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 1109134,
|
||||||
|
"path": "dictionaries/ru.txt",
|
||||||
|
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 1280,
|
||||||
|
"path": "dictionaries/sources.json",
|
||||||
|
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 940799,
|
||||||
|
"path": "dictionaries/uk.txt",
|
||||||
|
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 993,
|
||||||
|
"path": "tools/README.txt",
|
||||||
|
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 134656,
|
||||||
|
"path": "tools/StayHere.exe",
|
||||||
|
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 2796,
|
||||||
|
"path": "tools/StayHere-README.md",
|
||||||
|
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ Push-Location $root
|
|||||||
try {
|
try {
|
||||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||||
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
|
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
|
||||||
|
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
|
||||||
|
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
|
||||||
|
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
|
||||||
|
if ($LASTEXITCODE) { throw 'StayHere build failed' }
|
||||||
|
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
@@ -41,6 +47,10 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
|
||||||
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
|
||||||
|
if ($LASTEXITCODE) { throw 'StayHere lifecycle tests failed' }
|
||||||
& $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe
|
& $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
|
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
|
||||||
& .\build\KSwitcherUtilitiesTests.exe
|
& .\build\KSwitcherUtilitiesTests.exe
|
||||||
@@ -65,6 +75,10 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
|
& $compiler @common tests/chat_profiles.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Chat profile test build failed' }
|
||||||
|
& .\build\KSwitcherChatProfileTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Chat profile tests failed' }
|
||||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||||
& .\build\KSwitcherScreenshotUITests.exe
|
& .\build\KSwitcherScreenshotUITests.exe
|
||||||
|
|||||||
+3
-1
@@ -12,8 +12,10 @@ Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
|||||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||||
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
|
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
|
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
|
||||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries','tools\README.txt') | ForEach-Object { Join-Path $destination $_ }
|
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||||
$entries = foreach ($path in $files) {
|
$entries = foreach ($path in $files) {
|
||||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
||||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,45,0
|
FILEVERSION 0,6,49,0
|
||||||
PRODUCTVERSION 0,6,45,0
|
PRODUCTVERSION 0,6,49,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.45"
|
VALUE "FileVersion", "0.6.49"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
KSwitcher portable utilities
|
KSwitcher portable utilities
|
||||||
|
|
||||||
Put your trusted portable .exe utilities directly in this tools folder,
|
Put your trusted portable .exe utilities directly in this tools folder,
|
||||||
beside this README.txt. Right-click the KSwitcher tray icon and open Utilities.
|
beside this README.txt. Right-click the KSwitcher tray icon and open Tools.
|
||||||
For example: tools/StayHere.exe appears as Utilities > StayHere.
|
For example: tools/StayHere.exe appears as Tools > StayHere.
|
||||||
|
|
||||||
The list refreshes each time the tray menu opens. Subfolders and shortcuts
|
The list refreshes each time the tray menu opens. Subfolders and shortcuts
|
||||||
are not listed. Utilities start only when you choose them, with this folder
|
are not listed. Utilities start only when you choose them, with this folder
|
||||||
as their working directory. Closing KSwitcher does not close a utility.
|
as their working directory. Closing KSwitcher does not close a utility.
|
||||||
|
|
||||||
No utility executables are bundled. Supply them separately on each computer.
|
StayHere.exe is bundled. Choose Tools > StayHere to start it; it starts
|
||||||
Normal KSwitcher updates preserve your own files in this folder; this README
|
enabled, prevents automatic sleep, and sends F15 after 3 minutes of inactivity.
|
||||||
may be refreshed. Utilities are not transferred by settings synchronization.
|
Left-click its tray icon to pause/resume; right-click for Exit. See StayHere-README.md.
|
||||||
|
It does not start automatically with KSwitcher or bypass Windows locking.
|
||||||
|
|
||||||
|
Add other utilities separately. Updates preserve your own files in this folder;
|
||||||
|
bundled StayHere.exe and documentation may be refreshed. Utilities are not
|
||||||
|
transferred by settings synchronization.
|
||||||
+64
-4
@@ -958,6 +958,32 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void App::refreshChatContext()
|
||||||
|
{
|
||||||
|
HWND target = GetForegroundWindow();
|
||||||
|
chatWindow = target;
|
||||||
|
activeChatTitle = chatTitle(target);
|
||||||
|
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
|
||||||
|
}
|
||||||
|
void App::switchChatLayout(bool reverse)
|
||||||
|
{
|
||||||
|
HWND target = chatWindow;
|
||||||
|
auto key = activeChat;
|
||||||
|
if (!target || target != GetForegroundWindow() || key.empty() || windowChatKey(target) != key ||
|
||||||
|
!settings.chatCycles.contains(key) || busy.exchange(true)) return;
|
||||||
|
auto cycle = chatCycle(settings, key, layouts);
|
||||||
|
auto available = layouts;
|
||||||
|
resetInput();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||||
{
|
{
|
||||||
// Filter before capture, hotkey latching and typing history. External injected
|
// Filter before capture, hotkey latching and typing history. External injected
|
||||||
@@ -1008,6 +1034,23 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
return 1; // Record the chord without executing a global shortcut or typing into an editor.
|
return 1; // Record the chord without executing a global shortcut or typing into an editor.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bool chatEnabled = false;
|
||||||
|
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && !settings.chatCycles.empty() &&
|
||||||
|
!isHotkey(key.vkCode, mods))
|
||||||
|
{
|
||||||
|
refreshChatContext();
|
||||||
|
chatEnabled = !activeChat.empty() && settings.chatCycles.contains(activeChat);
|
||||||
|
}
|
||||||
|
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
|
||||||
|
if (chatAction)
|
||||||
|
{
|
||||||
|
if (chatAction > 0)
|
||||||
|
{
|
||||||
|
chord(0xE8); // Suppress the shell's bare Alt/Win action.
|
||||||
|
switchChatLayout(chatAction == 2); // Captures this chat before another keyboard event.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (down)
|
if (down)
|
||||||
{
|
{
|
||||||
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
|
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
|
||||||
@@ -1215,8 +1258,9 @@ void App::transform(int command, int snippet)
|
|||||||
available = layouts;
|
available = layouts;
|
||||||
version = inputVersion;
|
version = inputVersion;
|
||||||
}
|
}
|
||||||
|
const auto requestedChat = config.chatCycles.empty() ? L"" : windowChatKey(target);
|
||||||
enqueue(
|
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
|
struct BusyReset
|
||||||
{
|
{
|
||||||
@@ -1231,6 +1275,10 @@ void App::transform(int command, int snippet)
|
|||||||
b = false;
|
b = false;
|
||||||
}
|
}
|
||||||
} reset{busy, window, preDeferred};
|
} 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();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||||
@@ -1520,9 +1568,9 @@ void App::transform(int command, int snippet)
|
|||||||
for (int i = 0; i < (int)available.size(); ++i)
|
for (int i = 0; i < (int)available.size(); ++i)
|
||||||
if (available[i].id == id && available[i].convertible)
|
if (available[i].id == id && available[i].convertible)
|
||||||
cycle.push_back(i);
|
cycle.push_back(i);
|
||||||
if (cycle.size() < 2)
|
if (cycle.empty())
|
||||||
{
|
{
|
||||||
reason = L"cycle-has-fewer-than-two-layouts";
|
reason = L"cycle-has-no-layouts";
|
||||||
if (automatic && tx.valid())
|
if (automatic && tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -1839,7 +1887,7 @@ void App::trayMenu()
|
|||||||
if (utilities.empty())
|
if (utilities.empty())
|
||||||
AppendMenuW(utilityMenu, MF_STRING | MF_GRAYED, 0,
|
AppendMenuW(utilityMenu, MF_STRING | MF_GRAYED, 0,
|
||||||
utilitiesError ? L"Cannot read tools folder" : L"No utilities in tools folder");
|
utilitiesError ? L"Cannot read tools folder" : L"No utilities in tools folder");
|
||||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)utilityMenu, L"&Utilities");
|
AppendMenuW(menu, MF_POPUP, (UINT_PTR)utilityMenu, L"&Tools");
|
||||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
||||||
HMENU pause = CreatePopupMenu();
|
HMENU pause = CreatePopupMenu();
|
||||||
@@ -2134,6 +2182,17 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
case WM_TIMER:
|
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 (wp == 6)
|
||||||
{
|
{
|
||||||
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
|
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
|
||||||
@@ -2274,6 +2333,7 @@ int App::execute()
|
|||||||
scheduleHistoryOcr(settings);
|
scheduleHistoryOcr(settings);
|
||||||
addTray();
|
addTray();
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
|
if (!smoke) SetTimer(window, 7, 750, nullptr);
|
||||||
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include "update.h"
|
#include "update.h"
|
||||||
#include "sync.h"
|
#include "sync.h"
|
||||||
|
#include "chat_context.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -134,6 +135,11 @@ class App
|
|||||||
int previousAttemptCommand{}, repeatAttempts{};
|
int previousAttemptCommand{}, repeatAttempts{};
|
||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
|
ChatSwitchHook chatSwitchHook;
|
||||||
|
HWND chatWindow{};
|
||||||
|
std::wstring activeChat, activeChatTitle;
|
||||||
|
void refreshChatContext();
|
||||||
|
void switchChatLayout(bool reverse);
|
||||||
HWND typingTarget{};
|
HWND typingTarget{};
|
||||||
bool autoPending{};
|
bool autoPending{};
|
||||||
int snippetPending = -1;
|
int snippetPending = -1;
|
||||||
|
|||||||
+1
-1
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
{
|
{
|
||||||
auto replacement = settings.autoReplacements.find(small);
|
auto replacement = settings.autoReplacements.find(small);
|
||||||
if (replacement != settings.autoReplacements.end() &&
|
if (replacement != settings.autoReplacements.end() &&
|
||||||
replacement->second.find_first_not_of(alphabet) == small.npos)
|
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
|
||||||
return sameLayout(replacement->second);
|
return sameLayout(replacement->second);
|
||||||
}
|
}
|
||||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
std::wstring cachedViberChat(HWND);
|
||||||
|
std::wstring recognizeViberChat(HWND, const Settings &);
|
||||||
|
struct ChatSwitchHook
|
||||||
|
{
|
||||||
|
bool pressed[256]{};
|
||||||
|
static bool candidate(UINT key, UINT mods)
|
||||||
|
{
|
||||||
|
bool shift = key == VK_SHIFT || key == VK_LSHIFT || key == VK_RSHIFT;
|
||||||
|
bool alt = key == VK_MENU || key == VK_LMENU || key == VK_RMENU;
|
||||||
|
return (key == VK_SPACE && (mods == MOD_WIN || mods == (MOD_WIN | MOD_SHIFT))) ||
|
||||||
|
(shift && mods == MOD_ALT) || (alt && mods == MOD_SHIFT);
|
||||||
|
}
|
||||||
|
// 1 forward, 2 backward, -1 consumed repeat/release, 0 ordinary input.
|
||||||
|
int update(UINT key, bool down, UINT mods, bool enabled)
|
||||||
|
{
|
||||||
|
if (key >= 256) return 0;
|
||||||
|
if (pressed[key]) { if (!down) pressed[key] = false; return -1; }
|
||||||
|
if (!down || !enabled) return 0;
|
||||||
|
if (!candidate(key, mods)) return 0;
|
||||||
|
pressed[key] = true;
|
||||||
|
return key == VK_SPACE && (mods & MOD_SHIFT) ? 2 : 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
inline std::wstring chatTitle(HWND window)
|
||||||
|
{
|
||||||
|
wchar_t title[1024]{};
|
||||||
|
if (!window) return {};
|
||||||
|
int length = GetWindowTextW(window, title, std::size(title));
|
||||||
|
if (!length || length >= (int)std::size(title) - 1) return {};
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
inline std::wstring chatKey(std::wstring process, std::wstring title)
|
||||||
|
{
|
||||||
|
process = lower(process);
|
||||||
|
if (process.ends_with(L".exe")) process.resize(process.size() - 4);
|
||||||
|
while (!title.empty() && (title.front() == 0x200e || title.front() == 0x200f)) title.erase(0, 1);
|
||||||
|
if (process == L"telegram")
|
||||||
|
{
|
||||||
|
// Telegram's unread badge is not part of the contact name.
|
||||||
|
auto badge = title.rfind(L" \u2013 (");
|
||||||
|
if (badge != title.npos && title.back() == L')')
|
||||||
|
{
|
||||||
|
auto digits = title.substr(badge + 4, title.size() - badge - 5);
|
||||||
|
if (!digits.empty() && digits.find_first_not_of(L"0123456789") == digits.npos)
|
||||||
|
title.resize(badge);
|
||||||
|
}
|
||||||
|
if (title.empty() || title == L"Telegram" || title.starts_with(L"Telegram (")) return {};
|
||||||
|
}
|
||||||
|
else if (process == L"ms-teams")
|
||||||
|
{
|
||||||
|
// Only the observed chat title format; meetings/search are not chats.
|
||||||
|
const std::wstring prefix = L"Chat | ", suffix = L" | Microsoft Teams";
|
||||||
|
if (!title.starts_with(prefix) || !title.ends_with(suffix) ||
|
||||||
|
title.size() <= prefix.size() + suffix.size()) return {};
|
||||||
|
title = title.substr(prefix.size(), title.size() - prefix.size() - suffix.size());
|
||||||
|
if (title.empty()) return {};
|
||||||
|
}
|
||||||
|
else return {};
|
||||||
|
return process + L"\n" + 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,
|
||||||
|
const std::vector<Layout> &layouts)
|
||||||
|
{
|
||||||
|
auto found = settings.chatCycles.find(key);
|
||||||
|
if (key.empty() || found == settings.chatCycles.end()) return settings.cycle;
|
||||||
|
std::vector<std::wstring> result;
|
||||||
|
for (const auto &id : found->second)
|
||||||
|
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
|
||||||
|
result.push_back(id);
|
||||||
|
return result.empty() ? settings.cycle : result;
|
||||||
|
}
|
||||||
|
inline HKL nextChatLayout(const std::vector<std::wstring> &cycle, const std::vector<Layout> &layouts,
|
||||||
|
HKL current, bool reverse)
|
||||||
|
{
|
||||||
|
std::vector<HKL> handles;
|
||||||
|
for (const auto &id : cycle)
|
||||||
|
for (const auto &layout : layouts)
|
||||||
|
if (layout.id == id) handles.push_back(layout.handle);
|
||||||
|
if (handles.empty()) return nullptr;
|
||||||
|
auto found = std::find(handles.begin(), handles.end(), current);
|
||||||
|
if (found == handles.end()) return handles.front();
|
||||||
|
auto next = (size_t(found - handles.begin()) + (reverse ? handles.size() - 1 : 1)) % handles.size();
|
||||||
|
return handles[next];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,8 @@ struct Settings
|
|||||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
||||||
screenshot = L"Win+Shift+S";
|
screenshot = L"Win+Shift+S";
|
||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
|
// Exact application + visible chat name; an absent entry uses the global cycle.
|
||||||
|
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
std::vector<std::wstring> snippetTombstones;
|
std::vector<std::wstring> snippetTombstones;
|
||||||
|
|||||||
@@ -429,3 +429,4 @@ std::wstring recognizeText(const Clip &clip, const Settings &s)
|
|||||||
return wide(text.get());
|
return wide(text.get());
|
||||||
}
|
}
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
#include "viber_ocr.inc"
|
||||||
+36
-2
@@ -61,6 +61,25 @@ bool validId(const std::wstring &id)
|
|||||||
} // namespace
|
} // namespace
|
||||||
void validateSettings(const Settings &s)
|
void validateSettings(const Settings &s)
|
||||||
{
|
{
|
||||||
|
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
|
||||||
|
for (const auto &[key, cycle] : s.chatCycles)
|
||||||
|
{
|
||||||
|
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"))
|
||||||
|
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;
|
||||||
|
for (const auto &id : cycle)
|
||||||
|
{
|
||||||
|
if (id.size() != 12 || !id.starts_with(L"win:") ||
|
||||||
|
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos ||
|
||||||
|
std::find(seen.begin(), seen.end(), id) != seen.end())
|
||||||
|
throw std::runtime_error("Invalid or duplicate chat layout");
|
||||||
|
seen.push_back(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
|
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
|
||||||
throw std::runtime_error("Invalid Tesseract executable path");
|
throw std::runtime_error("Invalid Tesseract executable path");
|
||||||
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
|
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
|
||||||
@@ -118,9 +137,11 @@ void validateSettings(const Settings &s)
|
|||||||
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||||
from == lower(from) && to == lower(to) && from != to &&
|
from == lower(from) && to == lower(to) && from != to &&
|
||||||
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
|
from.find_first_not_of(alphabet) == from.npos &&
|
||||||
|
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
|
||||||
|
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
|
||||||
if (!valid)
|
if (!valid)
|
||||||
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
|
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between correction words are allowed.");
|
||||||
}
|
}
|
||||||
for (const auto &[language, words] : s.autoWords)
|
for (const auto &[language, words] : s.autoWords)
|
||||||
{
|
{
|
||||||
@@ -232,6 +253,16 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.cycle.push_back(w);
|
s.cycle.push_back(w);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (j.contains("chatCycles"))
|
||||||
|
{
|
||||||
|
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
|
||||||
|
for (const auto &[key, cycle] : j.at("chatCycles").items())
|
||||||
|
{
|
||||||
|
if (!cycle.is_array() || cycle.empty()) throw std::runtime_error("Empty chat language profile");
|
||||||
|
for (const auto &id : cycle)
|
||||||
|
s.chatCycles[wide(key)].push_back(wide(id.get<std::string>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (j.contains("layoutHotkeys"))
|
if (j.contains("layoutHotkeys"))
|
||||||
{
|
{
|
||||||
std::fill(s.direct.begin(), s.direct.end(), L"");
|
std::fill(s.direct.begin(), s.direct.end(), L"");
|
||||||
@@ -288,6 +319,9 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"screenshotHotkey", utf8(s.screenshot)},
|
{"screenshotHotkey", utf8(s.screenshot)},
|
||||||
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
||||||
j["layouts"] = Json::array();
|
j["layouts"] = Json::array();
|
||||||
|
j["chatCycles"] = Json::object();
|
||||||
|
for (const auto &[key, cycle] : s.chatCycles)
|
||||||
|
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
|
||||||
for (auto &id : s.cycle)
|
for (auto &id : s.cycle)
|
||||||
j["layouts"].push_back(utf8(id));
|
j["layouts"].push_back(utf8(id));
|
||||||
j["autoCorrectLayout"] = s.autoCorrect;
|
j["autoCorrectLayout"] = s.autoCorrect;
|
||||||
|
|||||||
+2
-1
@@ -219,7 +219,8 @@ Bytes sharedSyncSettings(const Settings &s)
|
|||||||
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
||||||
});
|
});
|
||||||
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
||||||
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
|
||||||
|
"chatCycles"}) j.erase(key); // Chat names and matching rules stay on this PC.
|
||||||
return bytes(canonicalProfile(j));
|
return bytes(canonicalProfile(j));
|
||||||
}
|
}
|
||||||
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
||||||
|
|||||||
+216
-47
@@ -967,7 +967,130 @@ struct SettingsView
|
|||||||
std::vector<int> order;
|
std::vector<int> order;
|
||||||
int selection = -1;
|
int selection = -1;
|
||||||
bool filling{};
|
bool filling{};
|
||||||
|
std::vector<std::vector<HWND>> pages;
|
||||||
};
|
};
|
||||||
|
void selectSettingsPage(HWND w, SettingsView *v)
|
||||||
|
{
|
||||||
|
int selected = TabCtrl_GetCurSel(GetDlgItem(w, 260));
|
||||||
|
for (size_t page = 0; page < v->pages.size(); ++page)
|
||||||
|
for (HWND child : v->pages[page]) ShowWindow(child, (int)page == selected ? SW_SHOWNA : SW_HIDE);
|
||||||
|
InvalidateRect(w, nullptr, TRUE);
|
||||||
|
}
|
||||||
|
struct ChatLanguagesView
|
||||||
|
{
|
||||||
|
HWND parent{};
|
||||||
|
SettingsView *settings{};
|
||||||
|
std::vector<std::wstring> keys;
|
||||||
|
};
|
||||||
|
void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
||||||
|
{
|
||||||
|
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||||
|
if (index < 0 || index >= (int)v->keys.size()) return;
|
||||||
|
const auto &draft = v->settings->draft;
|
||||||
|
auto saved = draft.chatCycles.find(v->keys[index]);
|
||||||
|
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
{
|
||||||
|
auto &layout = v->settings->app->layouts[v->settings->order[i]];
|
||||||
|
SendDlgItemMessageW(w, 711, LB_SETSEL,
|
||||||
|
std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end(), i);
|
||||||
|
}
|
||||||
|
value(w, 712, saved == draft.chatCycles.end() ? L"No profile yet. Select the languages for this chat." :
|
||||||
|
L"Saved profile. Select languages, or remove it to use the defaults.");
|
||||||
|
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *v = (ChatLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||||
|
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id = LOWORD(wp);
|
||||||
|
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE) { fillChatLanguages(w, v); return 0; }
|
||||||
|
if (id == IDOK || id == 713)
|
||||||
|
{
|
||||||
|
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, 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
|
||||||
|
{
|
||||||
|
std::vector<std::wstring> cycle;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
|
||||||
|
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
|
||||||
|
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
|
||||||
|
next.chatCycles[v->keys[selected]] = std::move(cycle);
|
||||||
|
}
|
||||||
|
validateSettings(next);
|
||||||
|
v->settings->draft.chatCycles = std::move(next.chatCycles);
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
EnableWindow(v->parent, TRUE);
|
||||||
|
SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w);
|
||||||
|
delete v;
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
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 &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)&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"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: " : 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);
|
||||||
|
label(w, L"Click languages to include (saved in the order shown)", 18, 228, 604);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
|
711, 18, 256, 604, 132);
|
||||||
|
for (int i : settings->order)
|
||||||
|
SendDlgItemMessageW(w, 711, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
|
||||||
|
label(w, L"Applies to Alt+Shift, Win+Space and KSwitcher Next layout.", 18, 402, 604);
|
||||||
|
label(w, L"Matched by chat name. Identical names share a profile. Then Save settings.", 18, 430, 604);
|
||||||
|
button(w, L"&Remove profile", 713, 18, 476, 170);
|
||||||
|
button(w, L"&Use languages", IDOK, 300, 476, 170);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
|
||||||
|
EnableWindow(GetDlgItem(w, IDOK), !keys.empty());
|
||||||
|
EnableWindow(GetDlgItem(w, 713), FALSE);
|
||||||
|
if (!keys.empty()) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, 0, 0); fillChatLanguages(w, v); }
|
||||||
|
EnableWindow(parent, FALSE);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
SetFocus(GetDlgItem(w, 710));
|
||||||
|
}
|
||||||
void collectLayout(HWND w, SettingsView *v)
|
void collectLayout(HWND w, SettingsView *v)
|
||||||
{
|
{
|
||||||
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
||||||
@@ -1285,7 +1408,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
||||||
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
||||||
check(w, 414, v->draft.autoReplace);
|
check(w, 414, v->draft.autoReplace);
|
||||||
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
|
label(w, L"One pair per line: typo=correction (same language; correction may contain spaces)", 18, 592, 525);
|
||||||
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||||
std::wstring pairs;
|
std::wstring pairs;
|
||||||
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
|
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
|
||||||
@@ -1461,6 +1584,13 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
switch (msg)
|
switch (msg)
|
||||||
{
|
{
|
||||||
|
case WM_NOTIFY:
|
||||||
|
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
|
||||||
|
{
|
||||||
|
selectSettingsPage(w, v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
@@ -1477,6 +1607,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||||
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||||
|
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
collectLayout(w, v);
|
collectLayout(w, v);
|
||||||
@@ -1815,56 +1946,94 @@ void showSettings(App &app, bool visible)
|
|||||||
activateWindow(target);
|
activateWindow(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660, 0, false);
|
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 720, 510, 0, false);
|
||||||
app.settingsWindow = w;
|
app.settingsWindow = w;
|
||||||
label(w, L"Text conversion", 18, 16);
|
auto *v = (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||||
checkbox(w, L"Con&vert recently typed text when nothing is selected", 201, 18, 43, 390);
|
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
|
||||||
checkbox(w, L"Switch Windows input &layout after conversion", 202, 18, 70, 390);
|
InitCommonControlsEx(&tabsInit);
|
||||||
checkbox(w, L"Record clip&board history", 203, 18, 106);
|
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
|
||||||
checkbox(w, L"Start with &Windows", 204, 18, 133);
|
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools"};
|
||||||
checkbox(w, L"Start with Windows as ad&ministrator", 206, 18, 160, 395);
|
for (int i = 0; i < 5; ++i)
|
||||||
checkbox(w, L"&Keep screen awake", 205, 18, 184, 210);
|
{
|
||||||
checkbox(w, L"Write lo&g", 208, 240, 184, 160);
|
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
|
||||||
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
|
TabCtrl_InsertItem(tabs, i, &item);
|
||||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
|
}
|
||||||
L"Clipboard history", L"Screen capture"};
|
size_t start = windowLayoutState(w)->controls.size();
|
||||||
|
auto page = [&]
|
||||||
|
{
|
||||||
|
std::vector<HWND> children;
|
||||||
|
const auto &controls = windowLayoutState(w)->controls;
|
||||||
|
for (size_t i = start; i < controls.size(); ++i) children.push_back(controls[i].first);
|
||||||
|
v->pages.push_back(std::move(children));
|
||||||
|
start = controls.size();
|
||||||
|
};
|
||||||
|
label(w, L"Text conversion", 24, 76, 650);
|
||||||
|
checkbox(w, L"Con&vert recently typed text when nothing is selected", 201, 24, 110, 660);
|
||||||
|
checkbox(w, L"Switch Windows input &layout after conversion", 202, 24, 146, 660);
|
||||||
|
checkbox(w, L"Automatic correction (e&xperimental)", 207, 24, 182, 390);
|
||||||
|
button(w, L"&Correction options...", 255, 454, 180, 230);
|
||||||
|
label(w, L"Windows integration", 24, 254, 650);
|
||||||
|
checkbox(w, L"Start with &Windows", 204, 24, 288, 660);
|
||||||
|
checkbox(w, L"Start with Windows as ad&ministrator", 206, 24, 324, 660);
|
||||||
|
checkbox(w, L"&Keep screen awake", 205, 24, 360, 660);
|
||||||
|
page();
|
||||||
|
|
||||||
|
label(w, L"Default language cycle — top to bottom", 24, 76, 650);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
|
220, 24, 108, 410, 240);
|
||||||
|
button(w, L"Move u&p", 232, 454, 108, 110);
|
||||||
|
button(w, L"Move &down", 233, 574, 108, 110);
|
||||||
|
checkbox(w, L"&Include in cycle", 230, 454, 154, 230);
|
||||||
|
label(w, L"Direct layout shortcut", 454, 206, 230);
|
||||||
|
hotkeyEdit(app, w, 231, 454, 234, 230);
|
||||||
|
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).", 272, 382, 412);
|
||||||
|
page();
|
||||||
|
|
||||||
|
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||||
|
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture"};
|
||||||
for (int i = 0; i < 6; ++i)
|
for (int i = 0; i < 6; ++i)
|
||||||
{
|
{
|
||||||
label(w, names[i], 420, 48 + i * 30, 115);
|
label(w, names[i], 24, 116 + i * 40, 180);
|
||||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
hotkeyEdit(app, w, 210 + i, 230, 112 + i * 40, 454);
|
||||||
}
|
}
|
||||||
checkbox(w, L"Open editor after screen capt&ure", 209, 420, 224, 340);
|
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 378, 660);
|
||||||
checkbox(w, L"Automatic correction (e&xperimental)", 207, 18, 210, 395);
|
page();
|
||||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
checkbox(w, L"Record clip&board history", 203, 24, 80, 660);
|
||||||
18, 262, 470, 172);
|
label(w, L"History limits", 24, 140, 660);
|
||||||
button(w, L"Move u&p", 232, 504, 262, 120);
|
label(w, L"Items", 24, 176, 180);
|
||||||
button(w, L"Move &down", 233, 632, 262, 128);
|
label(w, L"Total MiB", 254, 176, 180);
|
||||||
checkbox(w, L"&Include in cycle", 230, 504, 300, 240);
|
label(w, L"Per item MiB", 484, 176, 180);
|
||||||
label(w, L"Direct layout shortcut", 504, 332, 240);
|
edit(w, 240, 24, 204, 200);
|
||||||
hotkeyEdit(app, w, 231, 504, 356, 256);
|
edit(w, 241, 254, 204, 200);
|
||||||
label(w, L"Backspace / Delete clears a shortcut", 504, 382, 256);
|
edit(w, 242, 484, 204, 200);
|
||||||
button(w, L"Re&fresh layouts", 253, 504, 408, 150);
|
button(w, L"Clear &history...", 254, 24, 258, 200);
|
||||||
label(w, L"History limits", 18, 444);
|
label(w, L"Exclude applications (process names, comma-separated)", 24, 330, 660);
|
||||||
label(w, L"Items", 18, 478, 50);
|
edit(w, 243, 24, 362, 660);
|
||||||
edit(w, 240, 70, 474, 90);
|
page();
|
||||||
label(w, L"Total MiB", 180, 478, 75);
|
|
||||||
edit(w, 241, 260, 474, 90);
|
label(w, L"Additional tools and preferences", 24, 76, 660);
|
||||||
label(w, L"Per item MiB", 370, 478, 100);
|
button(w, L"S&nippets...", 252, 24, 116, 210);
|
||||||
edit(w, 242, 475, 474, 80);
|
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
|
||||||
button(w, L"Clear &history…", 254, 580, 474, 180);
|
button(w, L"OCR op&tions...", 257, 24, 180, 210);
|
||||||
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
label(w, L"Configure text recognition from images.", 254, 184, 430);
|
||||||
edit(w, 243, 18, 544, 742);
|
button(w, L"S&ync...", 258, 24, 244, 210);
|
||||||
button(w, L"&Auto-correction options…", 255, 18, 575, 240);
|
label(w, L"Synchronize preferences between computers.", 254, 248, 430);
|
||||||
button(w, L"View l&og…", 256, 278, 575, 220);
|
button(w, L"View l&og...", 256, 24, 308, 210);
|
||||||
button(w, L"OCR op&tions…", 257, 518, 575, 242);
|
label(w, L"Inspect recent operations and diagnostics.", 254, 312, 430);
|
||||||
button(w, L"Impo&rt settings…", 250, 18, 606, 145);
|
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
|
||||||
button(w, L"&Export settings…", 251, 173, 606, 145);
|
page();
|
||||||
button(w, L"S&nippets…", 252, 328, 606, 110);
|
|
||||||
button(w, L"S&ync...", 258, 448, 606, 90);
|
button(w, L"Impo&rt...", 250, 18, 466, 125);
|
||||||
button(w, L"&Save", IDOK, 548, 606, 100);
|
button(w, L"&Export...", 251, 155, 466, 125);
|
||||||
button(w, L"&Cancel", IDCANCEL, 660, 606, 100);
|
button(w, L"&Save", IDOK, 490, 466, 100);
|
||||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
button(w, L"&Cancel", IDCANCEL, 602, 466, 100);
|
||||||
|
fillSettings(w, v);
|
||||||
|
TabCtrl_SetCurSel(tabs, 0);
|
||||||
|
selectSettingsPage(w, v);
|
||||||
if (visible)
|
if (visible)
|
||||||
{
|
{
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.45";
|
inline constexpr wchar_t AppVersion[] = L"0.6.49";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#include "app.h"
|
||||||
|
#include "ocr.h"
|
||||||
|
#include "screenshot.h"
|
||||||
|
#include <iostream>
|
||||||
|
#include <commctrl.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int passed{};
|
||||||
|
void require(bool ok, const char *message)
|
||||||
|
{
|
||||||
|
if (!ok) throw std::runtime_error(message);
|
||||||
|
++passed;
|
||||||
|
}
|
||||||
|
void contextChecks()
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
require(ks::chatKey(L"Telegram", L"Team (2026)") == L"telegram\nTeam (2026)", "preserve parentheses");
|
||||||
|
require(ks::chatKey(L"Telegram", L"Telegram").empty(), "no profile on home");
|
||||||
|
require(ks::chatKey(L"Viber", L"Rakuten Viber").empty(), "Viber generic window title is not a chat");
|
||||||
|
require(ks::chatKey(L"ms-teams", L"Chat | Alice | Microsoft Teams") == L"ms-teams\nAlice", "Teams chat");
|
||||||
|
require(ks::chatKey(L"ms-teams", L"Calendar | Alice | Microsoft Teams").empty(), "Teams calendar excluded");
|
||||||
|
require(ks::chatKey(L"ms-teams", L"Chat | Microsoft Teams").empty(), "Teams chat home excluded");
|
||||||
|
require(ks::chatKey(L"other", L"Chat | Alice | Microsoft Teams").empty(), "app identity required");
|
||||||
|
ks::Settings settings;
|
||||||
|
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]};
|
||||||
|
std::vector<ks::Layout> installed(3);
|
||||||
|
for (size_t i = 0; i < installed.size(); ++i)
|
||||||
|
{
|
||||||
|
installed[i].id = settings.cycle[i];
|
||||||
|
installed[i].handle = (HKL)(uintptr_t)(i + 1);
|
||||||
|
}
|
||||||
|
auto subset = settings.chatCycles[key];
|
||||||
|
require(ks::nextChatLayout(subset, installed, installed[1].handle, false) == installed[0].handle, "cycle within subset");
|
||||||
|
require(ks::nextChatLayout(subset, installed, installed[0].handle, false) == installed[1].handle, "wrap skips excluded language");
|
||||||
|
require(ks::nextChatLayout(subset, installed, installed[2].handle, false) == installed[1].handle, "outside subset enters first language");
|
||||||
|
require(ks::nextChatLayout(settings.cycle, installed, installed[0].handle, true) == installed[2].handle, "reverse wraps");
|
||||||
|
require(ks::nextChatLayout({settings.cycle[1]}, installed, installed[0].handle, false) == installed[1].handle, "single language selects allowed layout");
|
||||||
|
require(!ks::nextChatLayout({L"win:04070407"}, installed, installed[0].handle, false), "missing layout is not activated");
|
||||||
|
require(ks::nextConversionLayout(L"", installed, {1}, 0) == 1, "conversion uses single allowed language");
|
||||||
|
require(ks::chatCycle(settings, key, installed) == settings.chatCycles[key], "profile order");
|
||||||
|
require(ks::chatCycle(settings, L"telegram\nOther", installed) == settings.cycle, "unknown fallback");
|
||||||
|
require(ks::decodeSettings(ks::encodeSettings(settings), installed).chatCycles == settings.chatCycles, "Unicode roundtrip");
|
||||||
|
require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles");
|
||||||
|
auto withoutProfiles = settings;
|
||||||
|
withoutProfiles.chatCycles.clear();
|
||||||
|
require(ks::sharedSyncSettings(settings) == ks::sharedSyncSettings(withoutProfiles), "profiles excluded from sync");
|
||||||
|
auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), settings, installed, false);
|
||||||
|
require(restored.chatCycles == settings.chatCycles, "sync preserves local profiles");
|
||||||
|
settings.chatCycles[key] = {settings.cycle[1]};
|
||||||
|
require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile");
|
||||||
|
settings.chatCycles[key] = {L"win:04070407"};
|
||||||
|
require(ks::chatCycle(settings, key, installed) == settings.cycle, "missing layouts fallback");
|
||||||
|
settings.chatCycles[key] = {};
|
||||||
|
bool rejected = false;
|
||||||
|
try { ks::encodeSettings(settings); } catch (...) { rejected = true; }
|
||||||
|
require(rejected, "empty profile rejected");
|
||||||
|
settings.chatCycles[key] = {settings.cycle[0], settings.cycle[0]};
|
||||||
|
rejected = false;
|
||||||
|
try { ks::encodeSettings(settings); } catch (...) { rejected = true; }
|
||||||
|
require(rejected, "duplicate profile layout rejected");
|
||||||
|
ks::ChatSwitchHook hook;
|
||||||
|
require(hook.update(VK_SPACE, true, MOD_WIN, false) == 0, "unprofiled Win+Space passes");
|
||||||
|
require(hook.update(VK_SPACE, true, MOD_WIN, true) == 1, "Win+Space forward");
|
||||||
|
require(hook.update(VK_SPACE, true, MOD_WIN, true) == -1, "consume repeat");
|
||||||
|
require(hook.update(VK_SPACE, false, 0, false) == -1, "release after profile change");
|
||||||
|
require(hook.update(VK_SPACE, true, MOD_WIN | MOD_SHIFT, true) == 2, "reverse cycle");
|
||||||
|
hook.update(VK_SPACE, false, 0, true);
|
||||||
|
require(hook.update(VK_LSHIFT, true, MOD_ALT, true) == 1, "Alt then Shift");
|
||||||
|
hook.update(VK_LSHIFT, false, 0, true);
|
||||||
|
require(hook.update(VK_LMENU, true, MOD_SHIFT, true) == 1, "Shift then Alt");
|
||||||
|
hook.update(VK_LMENU, false, 0, true);
|
||||||
|
require(hook.update(VK_LSHIFT, true, MOD_ALT | MOD_CONTROL, true) == 0, "do not consume Ctrl+Alt+Shift");
|
||||||
|
require(hook.update('A', true, MOD_ALT, true) == 0, "ordinary keys pass");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
auto root = std::filesystem::temp_directory_path() / (L"KSwitcher-chat-tests-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
contextChecks();
|
||||||
|
{
|
||||||
|
ks::App app(GetModuleHandleW(nullptr), true, root);
|
||||||
|
require(!app.layouts.empty(), "installed layouts available");
|
||||||
|
app.settings.toggle = app.settings.casing = app.settings.translit = app.settings.undo = L"";
|
||||||
|
app.settings.clipboard = app.settings.screenshot = L"";
|
||||||
|
app.settings.direct.assign(app.layouts.size(), L"");
|
||||||
|
app.settings.history = app.settings.autoCorrect = false;
|
||||||
|
const std::wstring key = L"telegram\n! Fixture chat";
|
||||||
|
app.settings.chatCycles[key] = {app.layouts[0].id};
|
||||||
|
ks::showSettings(app);
|
||||||
|
HWND parent = app.settingsWindow;
|
||||||
|
HWND tabs = GetDlgItem(parent, 260);
|
||||||
|
require(tabs && TabCtrl_GetItemCount(tabs) == 5, "five settings categories");
|
||||||
|
for (int page = 0; page < 5; ++page)
|
||||||
|
{
|
||||||
|
TabCtrl_SetCurSel(tabs, page);
|
||||||
|
NMHDR notification{tabs, 260, TCN_SELCHANGE};
|
||||||
|
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
||||||
|
std::vector<RECT> boxes;
|
||||||
|
for (HWND child = GetWindow(parent, GW_CHILD); child; child = GetWindow(child, GW_HWNDNEXT))
|
||||||
|
{
|
||||||
|
if (!(GetWindowLongPtrW(child, GWL_STYLE) & WS_VISIBLE)) continue;
|
||||||
|
RECT rect{}; GetWindowRect(child, &rect);
|
||||||
|
for (const auto &other : boxes)
|
||||||
|
{
|
||||||
|
RECT overlap{};
|
||||||
|
require(!IntersectRect(&overlap, &rect, &other), "visible settings controls never overlap");
|
||||||
|
}
|
||||||
|
boxes.push_back(rect);
|
||||||
|
}
|
||||||
|
RedrawWindow(parent, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
|
||||||
|
RECT rect{}; GetClientRect(parent, &rect);
|
||||||
|
HDC dc = GetDC(parent);
|
||||||
|
auto clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
|
||||||
|
ReleaseDC(parent, dc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / (L"settings-tab-" + std::to_wstring(page) + L".png"),
|
||||||
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
|
}
|
||||||
|
TabCtrl_SetCurSel(tabs, 1);
|
||||||
|
NMHDR notification{tabs, 260, TCN_SELCHANGE};
|
||||||
|
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
||||||
|
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||||
|
HWND dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||||
|
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
||||||
|
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||||
|
require(index >= 0, "saved chat listed");
|
||||||
|
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
|
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "saved single language selected");
|
||||||
|
RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
|
||||||
|
RECT rect{}; GetClientRect(dialog, &rect);
|
||||||
|
HDC dc = GetDC(dialog);
|
||||||
|
auto clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
|
||||||
|
ReleaseDC(dialog, dc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
|
||||||
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
||||||
|
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
||||||
|
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||||
|
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||||
|
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||||
|
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
|
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
|
||||||
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||||
|
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
|
||||||
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
|
require(!app.settingsWindow && app.settings.chatCycles.at(key).size() == app.layouts.size(), "settings saves selected languages");
|
||||||
|
require(ks::loadSettings(root / L"settings.json", app.layouts).chatCycles == app.settings.chatCycles, "profile saved to disk");
|
||||||
|
ks::showSettings(app);
|
||||||
|
parent = app.settingsWindow;
|
||||||
|
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||||
|
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||||
|
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||||
|
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, 713, 0);
|
||||||
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
|
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
|
||||||
|
}
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
std::cout << "Passed " << passed << " chat profile checks\n";
|
||||||
|
CoUninitialize();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { std::cerr << "FAIL: " << e.what() << '\n'; CoUninitialize(); return 1; }
|
||||||
|
}
|
||||||
@@ -50,9 +50,13 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) {
|
|||||||
const auto programFiles=_wgetenv(L"ProgramFiles");
|
const auto programFiles=_wgetenv(L"ProgramFiles");
|
||||||
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
|
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
|
||||||
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe";
|
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe";
|
||||||
std::wstring command=L"\""+farExecutable.wstring()+L"\" -m -p -s \""+folder.wstring()+L"\\profile\" \""+folder.wstring()+L"\"";
|
std::wstring command=L"\""+farExecutable.wstring()+L"\" -m -p -s \""+folder.wstring()+L"\\profile\"";
|
||||||
|
// A GUID-length working directory can horizontally scroll Far's command
|
||||||
|
// line, making absolute cursor-cell assertions invalid. Keep the prompt
|
||||||
|
// short; profile and application data still live in the unique fixture.
|
||||||
|
auto workingDirectory=folder.root_path();
|
||||||
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
|
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
|
||||||
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,folder.c_str(),&startup,&child),"start isolated Far without plugins/macros");
|
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,workingDirectory.c_str(),&startup,&child),"start isolated Far without plugins/macros");
|
||||||
CloseHandle(child.hThread);Sleep(1800);
|
CloseHandle(child.hThread);Sleep(1800);
|
||||||
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation");
|
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation");
|
||||||
input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
|
input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
|
||||||
|
|||||||
+5
-2
@@ -901,7 +901,7 @@ int main(int argc, char **argv) {
|
|||||||
"personal dictionary field fits options window");
|
"personal dictionary field fits options window");
|
||||||
}
|
}
|
||||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
||||||
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
|
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello\r\n Мамамила = Мама мила ");
|
||||||
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
||||||
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||||
@@ -923,7 +923,7 @@ int main(int argc, char **argv) {
|
|||||||
"personal dictionary normalizes case, newlines and duplicate words");
|
"personal dictionary normalizes case, newlines and duplicate words");
|
||||||
wchar_t pairs[128]{};
|
wchar_t pairs[128]{};
|
||||||
GetDlgItemTextW(autoOptions,416,pairs,128);
|
GetDlgItemTextW(autoOptions,416,pairs,128);
|
||||||
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
|
require(std::wstring(pairs)==L"helo=hello\r\nмамамила=мама мила\r\nпрвиіт=привіт\r\n" &&
|
||||||
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
||||||
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
||||||
"typo options and normalized replacement pairs survive reopening");
|
"typo options and normalized replacement pairs survive reopening");
|
||||||
@@ -1021,6 +1021,9 @@ int main(int argc, char **argv) {
|
|||||||
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
|
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
|
||||||
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
||||||
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
||||||
|
// Controls on different settings tabs intentionally share the page area.
|
||||||
|
if (!(GetWindowLongPtrW(GetDlgItem(settings,pair.first),GWL_STYLE)&WS_VISIBLE) ||
|
||||||
|
!(GetWindowLongPtrW(GetDlgItem(settings,pair.second),GWL_STYLE)&WS_VISIBLE)) continue;
|
||||||
RECT first{},second{},overlap{};
|
RECT first{},second{},overlap{};
|
||||||
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
||||||
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
||||||
|
|||||||
@@ -802,14 +802,14 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 22:
|
case 22:
|
||||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||||
app->settings.autoReplace = true;
|
app->settings.autoReplace = true;
|
||||||
app->settings.autoReplacements = {{L"helo", L"hello"}};
|
app->settings.autoReplacements = {{L"helo", L"hello world"}};
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
prepare(L"(Helo)! ", false);
|
prepare(L"(Helo)! ", false);
|
||||||
record(L"(Helo)! ");
|
record(L"(Helo)! ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
break;
|
break;
|
||||||
case 23:
|
case 23:
|
||||||
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
|
require(text() == L"(Hello world)! ", "explicit phrase pair replaces verified text with punctuation and capital");
|
||||||
app->transform(ks::Undo);
|
app->transform(ks::Undo);
|
||||||
break;
|
break;
|
||||||
case 24:
|
case 24:
|
||||||
@@ -835,7 +835,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 27:
|
case 27:
|
||||||
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
||||||
app->settings.autoReplace = true;
|
app->settings.autoReplace = true;
|
||||||
app->settings.autoReplacements = {{L"tehh", L"the"}};
|
app->settings.autoReplacements = {{L"tehh", L"the word"}};
|
||||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
prepare(L"", false);
|
prepare(L"", false);
|
||||||
{
|
{
|
||||||
@@ -852,7 +852,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 28:
|
case 28:
|
||||||
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
|
require(text() == L"the word x", "custom phrase replacement through keyboard hook preserves subsequent typing");
|
||||||
app->settings.autoReplace = false;
|
app->settings.autoReplace = false;
|
||||||
app->settings.autoTypos = false;
|
app->settings.autoTypos = false;
|
||||||
if (typosOnly) { finish(); break; }
|
if (typosOnly) { finish(); break; }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
param([Parameter(Mandatory=$true)][string]$Executable)
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
function Command([string]$Argument) {
|
||||||
|
$commandProcess=Start-Process -FilePath $Executable -ArgumentList $Argument -WindowStyle Hidden -PassThru
|
||||||
|
try {
|
||||||
|
$null=$commandProcess.Handle
|
||||||
|
$commandProcess.WaitForExit()
|
||||||
|
return $commandProcess.ExitCode
|
||||||
|
} finally { $commandProcess.Dispose() }
|
||||||
|
}
|
||||||
|
if ((Command '--status') -ne 3) { throw 'An isolated StayHere test instance is already running' }
|
||||||
|
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||||
|
$null=$process.Handle
|
||||||
|
try {
|
||||||
|
$ready=$false
|
||||||
|
for($i=0;$i -lt 20;$i++) {
|
||||||
|
if((Command '--status') -eq 1){$ready=$true;break}
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
if(!$ready){throw 'StayHere did not start enabled'}
|
||||||
|
if((Command '--pause') -ne 0 -or (Command '--status') -ne 2){throw 'StayHere pause failed'}
|
||||||
|
if((Command '--resume') -ne 0 -or (Command '--status') -ne 1){throw 'StayHere resume failed'}
|
||||||
|
if((Command '--exit') -ne 0 -or !$process.WaitForExit(3000)){throw 'StayHere exit failed'}
|
||||||
|
if((Command '--status') -ne 3){throw 'StayHere instance remained after exit'}
|
||||||
|
Write-Output 'Passed isolated StayHere start/status/pause/resume/exit checks'
|
||||||
|
} finally {
|
||||||
|
if(!$process.HasExited){$null=Command '--exit';if(!$process.WaitForExit(3000)){$process.Kill()}}
|
||||||
|
$process.Dispose()
|
||||||
|
}
|
||||||
+14
-2
@@ -590,6 +590,12 @@ int main(int argc, char **argv)
|
|||||||
options.autoNever = {L"hlelo"};
|
options.autoNever = {L"hlelo"};
|
||||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||||
options.autoNever.clear();
|
options.autoNever.clear();
|
||||||
|
options.autoReplacements[L"мамамила"] = L"мама мила";
|
||||||
|
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
|
||||||
|
"joined words split while preserving capitalization, punctuation and layout");
|
||||||
|
edited.reject(L"мамамила ", options);
|
||||||
|
require(!edited.suggest(L"мамамила ", 2, layouts, options), "undo suppresses phrase replacements");
|
||||||
edited.reject(L"(Hlelo)! ", options);
|
edited.reject(L"(Hlelo)! ", options);
|
||||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
||||||
options.autoReplace = false;
|
options.autoReplace = false;
|
||||||
@@ -711,7 +717,7 @@ int main(int argc, char **argv)
|
|||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
settings.autoReplace = settings.autoTypos = true;
|
settings.autoReplace = settings.autoTypos = true;
|
||||||
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}};
|
||||||
settings.autoMaxRank = 10000;
|
settings.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
@@ -726,7 +732,13 @@ int main(int argc, char **argv)
|
|||||||
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
||||||
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
||||||
"typo options and Unicode pairs survive settings roundtrip");
|
"typo options and Unicode pairs survive settings roundtrip");
|
||||||
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}})
|
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"},
|
||||||
|
{L"helloworld", L" hello world"}, {L"helloworld", L"hello world "},
|
||||||
|
{L"helloworld", L"hello world"}, {L"helloworld", L"hello\tworld"},
|
||||||
|
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
|
||||||
|
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
|
||||||
|
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
|
||||||
|
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
|
||||||
{
|
{
|
||||||
auto invalid = settings;
|
auto invalid = settings;
|
||||||
invalid.autoReplacements = {{pair.first, pair.second}};
|
invalid.autoReplacements = {{pair.first, pair.second}};
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ try {
|
|||||||
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $source 'tools')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $source 'tools')) | Out-Null
|
||||||
[IO.File]::WriteAllText((Join-Path $source 'tools/README.txt'),'portable utilities instructions')
|
[IO.File]::WriteAllText((Join-Path $source 'tools/README.txt'),'portable utilities instructions')
|
||||||
|
[IO.File]::WriteAllText((Join-Path $source 'tools/StayHere.exe'),'bundled StayHere fixture')
|
||||||
function Write-TestManifest($Folder) {
|
function Write-TestManifest($Folder) {
|
||||||
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
|
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
|
||||||
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
|
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
|
||||||
@@ -105,6 +106,7 @@ try {
|
|||||||
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $target 'tools')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $target 'tools')) | Out-Null
|
||||||
[IO.File]::WriteAllText((Join-Path $target 'tools/Personal.exe'),'personal utility fixture')
|
[IO.File]::WriteAllText((Join-Path $target 'tools/Personal.exe'),'personal utility fixture')
|
||||||
|
[IO.File]::WriteAllText((Join-Path $target 'tools/StayHere.exe'),'older StayHere fixture')
|
||||||
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
|
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
|
||||||
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
|
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
|
||||||
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
|
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
|
||||||
@@ -118,10 +120,12 @@ try {
|
|||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'personal utilities preserved'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'personal utilities preserved'
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/README.txt')) -eq 'portable utilities instructions') 'bundled tools documentation installed'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/README.txt')) -eq 'portable utilities instructions') 'bundled tools documentation installed'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/StayHere.exe')) -eq 'bundled StayHere fixture') 'bundled StayHere updated'
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
||||||
Undo-Files $target $backup $changed
|
Undo-Files $target $backup $changed
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'rollback preserves personal utilities'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'rollback preserves personal utilities'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/StayHere.exe')) -eq 'older StayHere fixture') 'rollback restores previous StayHere'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# StayHere
|
||||||
|
|
||||||
|
Маленька портативна утиліта для Windows, без .NET та інсталяції.
|
||||||
|
|
||||||
|
Виберіть **Tools → StayHere** у меню трея (у v0.6.46 цей пункт називається Utilities)
|
||||||
|
або запустіть `tools/StayHere.exe` поруч із KSwitcher.exe.
|
||||||
|
Зелена крапка в треї означає, що підтримка активності ввімкнена.
|
||||||
|
Клацання лівою кнопкою перемикає режим; правою — відкриває меню з перемикачем і виходом.
|
||||||
|
Після кожного запуску режим увімкнений. Автозапуск не додається.
|
||||||
|
|
||||||
|
Раз на 15 секунд перевіряється час бездіяльності. Після 3 хвилин без вводу
|
||||||
|
надсилається натискання й відпускання F15. Поки ви працюєте, клавіші не надсилаються.
|
||||||
|
Курсор миші не рухається. Увімкнений режим також утримує екран і систему від
|
||||||
|
автоматичного сну. На заблокованому екрані та у відключеному сеансі активність
|
||||||
|
не імітується. Ручне блокування Windows не обходиться.
|
||||||
|
|
||||||
|
Механізм перенесено за поведінкою з Typlo. Він підтримує системну активність,
|
||||||
|
але не керує статусом Teams через API: статус залежить також від Teams,
|
||||||
|
календаря, інших пристроїв та політик організації. F15 може мати власне
|
||||||
|
призначення в окремих програмах. На підвищених вікнах Windows може блокувати ввід;
|
||||||
|
тоді підказка іконки показує помилку. Утиліта не запитує права адміністратора.
|
||||||
|
|
||||||
|
## Збірка
|
||||||
|
|
||||||
|
У репозиторії KSwitcher виконайте `./build.ps1 -Stage` з кореня проєкту.
|
||||||
|
Утиліта збирається з `utilities/StayHere/main.cpp` у `build/staged/tools/StayHere.exe`
|
||||||
|
і включається пакувальником у реліз. CMake також має ціль `StayHere`.
|
||||||
|
Для запуску достатньо одного `StayHere.exe`; .NET не потрібний.
|
||||||
|
|
||||||
|
CLI: `--pause`, `--resume`, `--exit` керують запущеним екземпляром.
|
||||||
|
`--status` повертає код 1 для активного режиму, 2 для паузи, 3 якщо утиліта не запущена.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#include <wtsapi32.h>
|
||||||
|
#include <cwchar>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
#ifdef STAYHERE_TEST_INSTANCE
|
||||||
|
constexpr wchar_t ClassName[] = L"StayHere.Test.Tray.1";
|
||||||
|
constexpr wchar_t MutexName[] = L"Local\\StayHere.Test.Tray.1";
|
||||||
|
#else
|
||||||
|
constexpr wchar_t ClassName[] = L"StayHere.Tray.1";
|
||||||
|
constexpr wchar_t MutexName[] = L"Local\\StayHere.Tray.1";
|
||||||
|
#endif
|
||||||
|
constexpr UINT TrayMessage = WM_APP + 1;
|
||||||
|
constexpr UINT ControlMessage = WM_APP + 2;
|
||||||
|
constexpr UINT Toggle = 1, Quit = 2, Pause = 3, Resume = 4, Status = 5;
|
||||||
|
constexpr DWORD IdleThreshold = 180000;
|
||||||
|
bool enabled = true;
|
||||||
|
bool sessionLocked = false;
|
||||||
|
bool powerOk = true;
|
||||||
|
bool inputOk = true;
|
||||||
|
UINT taskbarCreated;
|
||||||
|
HICON activeIcon, pausedIcon;
|
||||||
|
NOTIFYICONDATAW tray{};
|
||||||
|
|
||||||
|
constexpr bool shouldSignal(bool active, bool unlocked, DWORD now, DWORD lastInput) {
|
||||||
|
return active && unlocked && DWORD(now - lastInput) >= IdleThreshold;
|
||||||
|
}
|
||||||
|
static_assert(!shouldSignal(true, true, 179999, 0));
|
||||||
|
static_assert(shouldSignal(true, true, 180000, 0));
|
||||||
|
static_assert(!shouldSignal(false, true, 180000, 0));
|
||||||
|
static_assert(!shouldSignal(true, false, 180000, 0));
|
||||||
|
static_assert(shouldSignal(true, true, 179000, 0xfffffc18)); // tick counter wrap
|
||||||
|
|
||||||
|
bool interactiveDesktop() {
|
||||||
|
if (sessionLocked) return false;
|
||||||
|
HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||||
|
if (!desktop) return false;
|
||||||
|
wchar_t name[128]{};
|
||||||
|
DWORD needed = 0;
|
||||||
|
bool ok = GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &needed)
|
||||||
|
&& _wcsicmp(name, L"Default") == 0;
|
||||||
|
CloseDesktop(desktop);
|
||||||
|
if (!ok) return false;
|
||||||
|
DWORD session;
|
||||||
|
LPWSTR buffer = nullptr;
|
||||||
|
DWORD bytes = 0;
|
||||||
|
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) return false;
|
||||||
|
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, WTSConnectState, &buffer, &bytes)) return false;
|
||||||
|
ok = bytes >= sizeof(WTS_CONNECTSTATE_CLASS)
|
||||||
|
&& *reinterpret_cast<WTS_CONNECTSTATE_CLASS*>(buffer) == WTSActive;
|
||||||
|
WTSFreeMemory(buffer);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
HICON makeIcon(COLORREF color) {
|
||||||
|
HDC screen = GetDC(nullptr);
|
||||||
|
HDC dc = CreateCompatibleDC(screen);
|
||||||
|
HBITMAP bitmap = CreateCompatibleBitmap(screen, 32, 32);
|
||||||
|
HGDIOBJ previous = SelectObject(dc, bitmap);
|
||||||
|
RECT rect{0, 0, 32, 32};
|
||||||
|
FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
|
||||||
|
HBRUSH brush = CreateSolidBrush(color);
|
||||||
|
HGDIOBJ oldBrush = SelectObject(dc, brush);
|
||||||
|
HGDIOBJ oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||||
|
Ellipse(dc, 3, 3, 29, 29);
|
||||||
|
SelectObject(dc, oldBrush);
|
||||||
|
SelectObject(dc, oldPen);
|
||||||
|
DeleteObject(brush);
|
||||||
|
SelectObject(dc, previous);
|
||||||
|
HBITMAP mask = CreateBitmap(32, 32, 1, 1, nullptr);
|
||||||
|
HGDIOBJ maskPrevious = SelectObject(dc, mask);
|
||||||
|
PatBlt(dc, 0, 0, 32, 32, WHITENESS);
|
||||||
|
oldBrush = SelectObject(dc, GetStockObject(BLACK_BRUSH));
|
||||||
|
oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||||
|
Ellipse(dc, 3, 3, 29, 29);
|
||||||
|
SelectObject(dc, oldPen);
|
||||||
|
SelectObject(dc, oldBrush);
|
||||||
|
SelectObject(dc, maskPrevious);
|
||||||
|
ICONINFO info{TRUE, 0, 0, mask, bitmap};
|
||||||
|
HICON icon = CreateIconIndirect(&info);
|
||||||
|
DeleteObject(bitmap);
|
||||||
|
DeleteObject(mask);
|
||||||
|
DeleteDC(dc);
|
||||||
|
ReleaseDC(nullptr, screen);
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTray() {
|
||||||
|
tray.hIcon = enabled ? activeIcon : pausedIcon;
|
||||||
|
const wchar_t* tip = !enabled ? L"StayHere — призупинено"
|
||||||
|
: !powerOk ? L"StayHere — помилка утримання від сну"
|
||||||
|
: !inputOk ? L"StayHere — не вдалося надіслати F15"
|
||||||
|
: L"StayHere — активно (F15 після 3 хв простою)";
|
||||||
|
lstrcpynW(tray.szTip, tip, ARRAYSIZE(tray.szTip));
|
||||||
|
Shell_NotifyIconW(NIM_MODIFY, &tray);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEnabled(bool value) {
|
||||||
|
enabled = value;
|
||||||
|
inputOk = true;
|
||||||
|
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
|
(enabled && interactiveDesktop() ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||||
|
updateTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
void tick() {
|
||||||
|
const bool unlocked = interactiveDesktop();
|
||||||
|
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
|
(enabled && unlocked ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||||
|
LASTINPUTINFO idle{sizeof(idle), 0};
|
||||||
|
if (GetLastInputInfo(&idle) && shouldSignal(enabled, unlocked, GetTickCount(), idle.dwTime)) {
|
||||||
|
// Avoid inserting a key into a chord being held while the user is away.
|
||||||
|
for (int key : {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN,
|
||||||
|
VK_LBUTTON, VK_RBUTTON, VK_MBUTTON}) {
|
||||||
|
if (GetAsyncKeyState(key) & 0x8000) return;
|
||||||
|
}
|
||||||
|
INPUT input[2]{};
|
||||||
|
input[0].type = input[1].type = INPUT_KEYBOARD;
|
||||||
|
input[0].ki.wVk = input[1].ki.wVk = VK_F15;
|
||||||
|
input[1].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||||
|
inputOk = SendInput(2, input, sizeof(INPUT)) == 2;
|
||||||
|
if (!inputOk) { // If only key-down succeeded, make a best effort to release it.
|
||||||
|
SendInput(1, &input[1], sizeof(INPUT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
void showMenu(HWND window) {
|
||||||
|
HMENU menu = CreatePopupMenu();
|
||||||
|
AppendMenuW(menu, MF_STRING | (enabled ? MF_CHECKED : 0), Toggle, L"Підтримувати активність");
|
||||||
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||||
|
AppendMenuW(menu, MF_STRING, Quit, L"Вийти");
|
||||||
|
POINT pos;
|
||||||
|
GetCursorPos(&pos);
|
||||||
|
SetForegroundWindow(window);
|
||||||
|
UINT command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pos.x, pos.y, 0, window, nullptr);
|
||||||
|
DestroyMenu(menu);
|
||||||
|
if (command) SendMessageW(window, ControlMessage, command, 0);
|
||||||
|
PostMessageW(window, WM_NULL, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||||
|
if (message == taskbarCreated) {
|
||||||
|
Shell_NotifyIconW(NIM_ADD, &tray);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
switch (message) {
|
||||||
|
case TrayMessage:
|
||||||
|
if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showMenu(window);
|
||||||
|
else if (lp == WM_LBUTTONUP) setEnabled(!enabled);
|
||||||
|
return 0;
|
||||||
|
case ControlMessage:
|
||||||
|
if (wp == Status) return enabled ? 1 : 2;
|
||||||
|
if (wp == Toggle) setEnabled(!enabled);
|
||||||
|
else if (wp == Pause) setEnabled(false);
|
||||||
|
else if (wp == Resume) setEnabled(true);
|
||||||
|
else if (wp == Quit) DestroyWindow(window);
|
||||||
|
return 0;
|
||||||
|
case WM_TIMER: tick(); return 0;
|
||||||
|
case WM_WTSSESSION_CHANGE:
|
||||||
|
if (wp == WTS_SESSION_LOCK || wp == WTS_REMOTE_DISCONNECT || wp == WTS_CONSOLE_DISCONNECT)
|
||||||
|
sessionLocked = true;
|
||||||
|
else if (wp == WTS_SESSION_UNLOCK || wp == WTS_REMOTE_CONNECT || wp == WTS_CONSOLE_CONNECT)
|
||||||
|
sessionLocked = false;
|
||||||
|
setEnabled(enabled);
|
||||||
|
return 0;
|
||||||
|
case WM_DESTROY:
|
||||||
|
KillTimer(window, 1);
|
||||||
|
WTSUnRegisterSessionNotification(window);
|
||||||
|
SetThreadExecutionState(ES_CONTINUOUS);
|
||||||
|
Shell_NotifyIconW(NIM_DELETE, &tray);
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(window, message, wp, lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) {
|
||||||
|
int argc{};
|
||||||
|
auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||||
|
if(!argv)return 10;
|
||||||
|
const std::wstring argument=argc==2?argv[1]:L"";
|
||||||
|
LocalFree(argv);
|
||||||
|
if(argc>2)return 10;
|
||||||
|
const auto commandLine=argument.c_str();
|
||||||
|
UINT command = 0;
|
||||||
|
if (wcscmp(commandLine, L"--pause") == 0) command = Pause;
|
||||||
|
else if (wcscmp(commandLine, L"--resume") == 0) command = Resume;
|
||||||
|
else if (wcscmp(commandLine, L"--exit") == 0) command = Quit;
|
||||||
|
else if (wcscmp(commandLine, L"--status") == 0) command = Status;
|
||||||
|
else if (*commandLine) return 10;
|
||||||
|
HWND existing = FindWindowW(ClassName, nullptr);
|
||||||
|
if (command) {
|
||||||
|
if (!existing) return 3;
|
||||||
|
DWORD_PTR result = 0;
|
||||||
|
if (!SendMessageTimeoutW(existing, ControlMessage, command, 0, SMTO_ABORTIFHUNG, 2000, &result)) return 4;
|
||||||
|
return command == Status ? static_cast<int>(result) : 0;
|
||||||
|
}
|
||||||
|
HANDLE mutex = CreateMutexW(nullptr, FALSE, MutexName);
|
||||||
|
if (!mutex) return 5;
|
||||||
|
if (GetLastError() == ERROR_ALREADY_EXISTS) { CloseHandle(mutex); return 0; }
|
||||||
|
taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated");
|
||||||
|
activeIcon = makeIcon(RGB(40, 185, 100));
|
||||||
|
pausedIcon = makeIcon(RGB(135, 140, 150));
|
||||||
|
WNDCLASSW wc{};
|
||||||
|
wc.lpfnWndProc = windowProc;
|
||||||
|
wc.hInstance = instance;
|
||||||
|
wc.lpszClassName = ClassName;
|
||||||
|
if (!RegisterClassW(&wc)) return 6;
|
||||||
|
HWND window = CreateWindowExW(0, ClassName, L"StayHere", WS_OVERLAPPED,
|
||||||
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
|
if (!window) return 7;
|
||||||
|
tray.cbSize = sizeof(tray);
|
||||||
|
tray.hWnd = window;
|
||||||
|
tray.uID = 1;
|
||||||
|
tray.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||||
|
tray.uCallbackMessage = TrayMessage;
|
||||||
|
tray.hIcon = activeIcon;
|
||||||
|
lstrcpyW(tray.szTip, L"StayHere");
|
||||||
|
if (!Shell_NotifyIconW(NIM_ADD, &tray)) { DestroyWindow(window); return 8; }
|
||||||
|
WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION);
|
||||||
|
setEnabled(true);
|
||||||
|
if (!SetTimer(window, 1, 15000, nullptr)) { DestroyWindow(window); return 9; }
|
||||||
|
MSG msg{};
|
||||||
|
while (GetMessageW(&msg, nullptr, 0, 0) > 0) {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
DestroyIcon(activeIcon);
|
||||||
|
DestroyIcon(pausedIcon);
|
||||||
|
CloseHandle(mutex);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user