Compare commits
6
Commits
release-v0.6.71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34d89db95b | ||
|
|
c10b141483 | ||
|
|
0c77d5e46d | ||
|
|
70a53834e3 | ||
|
|
73448058d7 | ||
|
|
12fdd7c549 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.71 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.77 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
|
||||
+29
-3
@@ -5,13 +5,39 @@ code on both computers, and shares mouse and keyboard through a screen edge.
|
||||
The module runs inside the resident process. Multiple paired computers can be
|
||||
arranged on a monitor grid. Mouse and keyboard follow neighboring cells, including
|
||||
A → B → C, without relaying input through B. New text and images are also shared
|
||||
with all online paired computers: history and the active clipboard. File transfer
|
||||
is not implemented by clipboard synchronization.
|
||||
with all online paired computers: history and the active clipboard.
|
||||
|
||||
## Files and diagnostic logs
|
||||
|
||||
In **Settings → Computers → Manage computers…**, select an online paired
|
||||
computer and choose **Send file…**. Both computers need a build with file transfer.
|
||||
Files up to 16 MiB travel over encrypted LAN. On the destination, open
|
||||
**Incoming files…** to save or discard each file. The save dialog suggests the
|
||||
original filename. KSwitcher shows a Windows notification when a file is ready
|
||||
to save, and its tray tooltip shows the pending count. Pending files are held
|
||||
in memory until saved, discarded, or KSwitcher closes.
|
||||
|
||||
To retrieve the current or rotated diagnostic log, enable **Allow paired
|
||||
computers to download diagnostic logs** on the computer that owns the logs.
|
||||
This setting defaults to off. On the requesting PC, select that computer and
|
||||
choose **Download log…** or **Previous log…**. Diagnostic logs may contain
|
||||
text copied from applications.
|
||||
|
||||
### MCP
|
||||
|
||||
In **Settings → MCP**, choose **Copy configuration** and paste the block into
|
||||
Codex's `~/.codex/config.toml`. It starts the MCP server over stdio with the
|
||||
absolute path to `KSwitcher.Config.exe` and the `--mcp` argument.
|
||||
`list_log_computers` returns paired computer IDs and availability.
|
||||
`read_diagnostic_log` reads the latest 100 lines by default (1–500 with
|
||||
`lines`). Use `offset` to skip newer lines and page backward. Set `previous` to read the rotated log. Omit `peer` for the
|
||||
local log or pass a paired computer ID for a remote log. Remote reads need
|
||||
the local resident process running and log downloads enabled on the remote PC.
|
||||
|
||||
## Set up multiple computers
|
||||
|
||||
1. Install the v0.6.58 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
|
||||
2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs.
|
||||
2. Open **Settings → Computers → Manage computers…** and enable discovery on both PCs.
|
||||
Only running LAN-enabled KSwitcher instances appear, not generic network devices.
|
||||
3. Match the computer name and instance-ID prefix displayed on the other PC.
|
||||
Select each other, generate one code, enter that same code on both, then click
|
||||
|
||||
@@ -6,6 +6,15 @@ The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data r
|
||||
|
||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
|
||||
|
||||
From v0.6.72, use **Settings → Snippets** to edit snippets in place and
|
||||
**Save snippets** to save them independently. **Clipboard** contains OCR
|
||||
options. **Sync**, **Computers**, and **Diagnostics** have their own tabs.
|
||||
|
||||
**Українською.** З v0.6.72 сніпети редагуються у **Settings → Snippets**
|
||||
і зберігаються окремою кнопкою **Save snippets**. OCR розміщено у
|
||||
**Clipboard**, а для синхронізації, ПК та журналу є окремі вкладки
|
||||
**Sync**, **Computers** і **Diagnostics**.
|
||||
|
||||
[](https://send.monobank.ua/jar/88h2TddKfS)
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
@@ -108,7 +117,7 @@ Double-click the tray icon to open Settings, the bold default menu item.
|
||||
Closing Settings with unsaved changes (including accepted auto-correction or OCR options)
|
||||
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
|
||||
If saving fails, the window stays open with your edits intact.
|
||||
Settings displays **No unsaved changes** with **Save** disabled until an edit is made.
|
||||
Settings displays **No unsaved settings** when the draft matches the saved settings. If **Start with Windows** is enabled, **Save** remains available to recheck the autostart path.
|
||||
The Computers window applies and saves network changes immediately, separately from Settings.
|
||||
Pairing shows the target computer and remaining wait time; enter the same code and click **Pair** on both PCs.
|
||||
The code stays visible for copying or retry until the pair is saved. A saved pair still requires input sharing to be enabled on both PCs.
|
||||
@@ -155,8 +164,12 @@ the elevated task requests UAC approval if the app is not already elevated. No p
|
||||
the task runs in the signed-in user's interactive session, including on battery power.
|
||||
With both options enabled, opening the regular `KSwitcher.exe` after exiting a development
|
||||
build requests administrator approval if needed. Development builds keep their current launch privileges.
|
||||
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
|
||||
future automatic launches. Saving unrelated settings does not recreate startup entries.
|
||||
Disabling the administrator option restores ordinary per-user startup. Disabling startup stops
|
||||
future automatic launches. On launch and after clicking **Save** in Settings, KSwitcher checks whether the
|
||||
enabled autostart entry still points to its current location. If the previous file is missing and
|
||||
KSwitcher has the required privileges, it updates the path automatically. If the previous path may
|
||||
still be in use or the repair requires administrator approval, KSwitcher shows both paths and asks
|
||||
before updating the entry.
|
||||
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
|
||||
Exit [Typlo](https://github.com/bitcodepro/Typlo) when using overlapping shortcuts to avoid competing keyboard hooks.
|
||||
|
||||
@@ -477,13 +490,13 @@ ChatGPT and Codex desktop editors use paced selection for snippet triggers. If o
|
||||
|
||||
### Application-specific snippets — v0.6.51
|
||||
|
||||
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
|
||||
In **Settings → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Use **Save snippets** to apply the changes.
|
||||
|
||||
A matching application-specific snippet takes precedence over a global snippet with the same trigger. The same trigger can be used for different applications; overlapping scopes for the same trigger are rejected. Existing snippets without a scope remain global. Matching is case-insensitive by process name, with or without `.exe`, and is checked before insertion. No OCR or timer is involved. Chrome scope means the entire browser, not one website.
|
||||
|
||||
Scopes are saved with snippets and included in manual import/export and synchronization; merge conflicts consider the application scopes. Use v0.6.51 or newer on every synchronizing PC before sharing scoped snippets: older versions do not understand the restriction and may treat the snippet as global.
|
||||
|
||||
**Українською.** У **Tools → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. **Save** зберігає, **Cancel** відкидає зміни.
|
||||
**Українською.** У **Settings → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. Натисни **Save snippets**, щоб застосувати зміни.
|
||||
|
||||
Сніпет конкретної програми має пріоритет над глобальним із тим самим тригером. Для різних програм однакові тригери дозволені, для перетинних областей — ні. Старі сніпети залишаються глобальними. Зіставлення за назвою процесу не залежить від регістру та суфікса `.exe`; OCR і таймерів немає. Для Chrome це весь браузер, а не окремий сайт. Обмеження входять до експорту/імпорту та синхронізації. Перед синхронізацією онови всі ПК до v0.6.51 або новішої: старі версії не розуміють обмеження та можуть застосувати сніпет глобально.
|
||||
### Existing features
|
||||
@@ -596,9 +609,9 @@ Windows exposes them; selection offsets are available for native EDIT controls a
|
||||
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
|
||||
or layout-refresh events during an operation. Failed selection verification includes the expected
|
||||
selection and actual copied text in the operation details, without implying that a paste occurred.
|
||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||
Problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||
context. Successful operations stay in that bounded memory buffer and are included only when
|
||||
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
||||
a subsequent problem flushes it. Verified pastes that take at least two seconds also write a summary without the copied text. Flushed context is discarded. Ordinary application
|
||||
errors are also logged when logging is enabled. This works in normal Release builds.
|
||||
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
||||
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
||||
@@ -820,13 +833,13 @@ default and require the main **Automatic correction (experimental)** checkbox to
|
||||
|
||||
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
||||
are rejected. The typo must be one word of 1–32 characters with at least one letter. It can contain digits and internal apostrophes. The correction may contain several words
|
||||
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
|
||||
spaces. Corrections also allow apostrophes (`'`, `’`, `ʼ`) between letters, for example
|
||||
`ревю=рев'ю`. Both sides must use the same supported language, without other 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.
|
||||
Update every synchronizing PC to v0.6.60 or newer before sharing apostrophe pairs; older clients reject them.
|
||||
Update every synchronizing PC to v0.6.75 before sharing pairs with digits or apostrophes in the typo.
|
||||
- **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
|
||||
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,82 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.77 — 2026-11-13
|
||||
|
||||
The screenshot editor remembers the local pencil smoothing default across
|
||||
restarts. Adjust the slider with no pencil stroke selected, or use **Apply to
|
||||
all**, to save the value for new strokes. Adjusting a selected stroke changes
|
||||
only that stroke.
|
||||
|
||||
**Українською.** Редактор скріншотів зберігає типове згладжування олівця на
|
||||
цьому ПК та відновлює його після перезапуску. Змініть повзунок без виділеного
|
||||
штриха олівця або натисніть **Apply to all**, щоб запам’ятати значення для
|
||||
нових штрихів. Зміна виділеного штриха впливає лише на нього.
|
||||
|
||||
## v0.6.76 — 2026-11-13
|
||||
|
||||
Incoming LAN files suggest their original filename when saved. KSwitcher shows
|
||||
a Windows tray notification when a file is ready to save, and the tray tooltip
|
||||
shows the pending count.
|
||||
|
||||
**Українською.** Під час збереження вхідного LAN-файлу пропонується його
|
||||
оригінальна назва. KSwitcher показує сповіщення в треї, коли файл готовий
|
||||
до збереження. Підказка іконки показує кількість файлів у черзі.
|
||||
|
||||
## v0.6.75 — 2026-11-13
|
||||
|
||||
Conversion cancels earlier when input changes and skips restoring an untouched
|
||||
clipboard. Diagnostic records identify the active conversion behind `busy`,
|
||||
separate clipboard and paste verification timings, and retain a text-free
|
||||
summary of verified pastes that take at least two seconds. MCP log reads
|
||||
support `offset` to page backward.
|
||||
|
||||
**Українською.** Конвертація раніше скасовується після зміни вводу й пропускає
|
||||
відновлення буфера, якщо його не змінювала. Діагностика показує активну спробу
|
||||
для `busy`, окремий час роботи з буфером і перевірки вставки, а також зберігає
|
||||
підсумок перевірених вставок від двох секунд без скопійованого тексту. У MCP
|
||||
параметр `offset` дає змогу читати старіші рядки.
|
||||
|
||||
Explicit replacements now accept digits and internal apostrophes in the source word, for example нав1язав=навʼязав. Invalid entries identify the offending pair.
|
||||
|
||||
**Українською.** Явні заміни підтримують цифри й внутрішні апострофи у вихідному слові, наприклад нав1язав=навʼязав. Повідомлення про помилку показує проблемну пару.
|
||||
|
||||
## v0.6.74 — 2026-11-13
|
||||
|
||||
- Send files up to 16 MiB to a selected paired PC over encrypted LAN. Save or discard them from **Incoming files**.
|
||||
- Download current and previous diagnostic logs from paired PCs that explicitly allow remote log downloads.
|
||||
- Copy the Codex configuration from **Settings → MCP**. The MCP server lists paired PCs and reads local or remote diagnostic logs.
|
||||
- Search contacts in the **Languages for a chat** table. **Copy selected** in Diagnostics now uses normal clipboard synchronization.
|
||||
|
||||
**Українською.** Передавайте файли до 16 МіБ вибраному спареному ПК зашифрованою локальною мережею. У **Incoming files** можна зберегти або відхилити файл. Поточний і попередній журнали доступні для завантаження після ввімкнення відповідного дозволу на віддаленому ПК. У **Settings → MCP** можна скопіювати конфігурацію Codex для читання локальних і віддалених журналів. У таблиці **Languages for a chat** додано пошук контактів, а **Copy selected** у діагностиці тепер проходить через звичайну синхронізацію буфера.
|
||||
|
||||
## v0.6.73 — 2026-11-13
|
||||
|
||||
After moving KSwitcher, launch it from its current folder. If Windows autostart
|
||||
still points to the old location, KSwitcher updates the path automatically when
|
||||
the previous executable is missing and the process has sufficient rights. If
|
||||
the previous copy may still be in use or the repair needs administrator approval,
|
||||
choose **Update autostart path** and confirm UAC if prompted. To check again,
|
||||
click **Save** in Settings without changing the autostart options.
|
||||
|
||||
**Українською.** Після перенесення KSwitcher запустіть його з нової папки.
|
||||
Якщо автозапуск і далі вказує на старий шлях, KSwitcher виправить його
|
||||
автоматично, коли старий файл відсутній і прав достатньо. Якщо стара копія
|
||||
ще може використовуватися або потрібні права адміністратора, натисніть
|
||||
**Update autostart path** і підтвердьте UAC, якщо з’явиться запит. Для повторної
|
||||
перевірки натисніть **Save** у налаштуваннях без зміни параметрів автозапуску.
|
||||
|
||||
## v0.6.72 — 2026-11-12
|
||||
|
||||
Edit snippets directly in **Settings → Snippets**. **Save snippets** applies
|
||||
snippet edits independently of the settings draft. Find OCR options in
|
||||
**Clipboard** and open backup and synchronization, paired computers, and
|
||||
logging from **Sync**, **Computers**, and **Diagnostics**, respectively.
|
||||
|
||||
**Українською.** Редагуйте сніпети безпосередньо у **Settings → Snippets**.
|
||||
Кнопка **Save snippets** зберігає їх окремо від чернетки налаштувань.
|
||||
Параметри OCR тепер у **Clipboard**. Для синхронізації, спарених ПК та
|
||||
журналу є вкладки **Sync**, **Computers** і **Diagnostics**.
|
||||
|
||||
## v0.6.71 — 2026-11-12
|
||||
|
||||
After navigating snippet suggestions with **↑ / ↓**, press **Enter** to
|
||||
|
||||
@@ -1 +1 @@
|
||||
42390860144e0b3a2b32874500ab9de8de60b76715cc583d306f761a749a83e9 KSwitcher-win-x64.zip
|
||||
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
|
||||
+22
-22
@@ -1,84 +1,84 @@
|
||||
{
|
||||
"version": "0.6.71",
|
||||
"version": "0.6.77",
|
||||
"files": [
|
||||
{
|
||||
"size": 2202624,
|
||||
"path": "KSwitcher.exe",
|
||||
"sha256": "fc85390dcc0a46c8cd10daad77482de4f374f9f61b4b4adee1a9ca022d17e4f8"
|
||||
"size": 2249216,
|
||||
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
|
||||
},
|
||||
{
|
||||
"size": 2129408,
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"sha256": "371c9bf2673049e52f432457a929173f5e5a2a91d1e9dc3e96ef8a2c4699e628"
|
||||
"size": 2251776,
|
||||
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
|
||||
},
|
||||
{
|
||||
"size": 3210240,
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"sha256": "bbcfed4d697c9053ff6ad828289805cd6d5f20b4c3d29027b4d26317b973f725"
|
||||
"size": 3247616,
|
||||
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
|
||||
},
|
||||
{
|
||||
"size": 4401,
|
||||
"path": "LICENSE",
|
||||
"size": 4401,
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||
},
|
||||
{
|
||||
"size": 1075,
|
||||
"path": "json-LICENSE.txt",
|
||||
"size": 1075,
|
||||
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
|
||||
},
|
||||
{
|
||||
"size": 92710,
|
||||
"path": "README.md",
|
||||
"sha256": "3d475635e3190c80dbf499bb06cb0c082c16c8977d735e21e8f658cc8fc2bc51"
|
||||
"size": 93885,
|
||||
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
|
||||
},
|
||||
{
|
||||
"size": 9900,
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e"
|
||||
"size": 11418,
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
|
||||
},
|
||||
{
|
||||
"size": 682867,
|
||||
"path": "dictionaries/en.txt",
|
||||
"size": 682867,
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
|
||||
},
|
||||
{
|
||||
"size": 20138,
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"size": 20138,
|
||||
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
},
|
||||
{
|
||||
"size": 2230,
|
||||
"path": "dictionaries/README.md",
|
||||
"size": 2230,
|
||||
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
|
||||
},
|
||||
{
|
||||
"size": 1060364,
|
||||
"path": "dictionaries/ru.txt",
|
||||
"size": 1060364,
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
|
||||
},
|
||||
{
|
||||
"size": 1279,
|
||||
"path": "dictionaries/sources.json",
|
||||
"size": 1279,
|
||||
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
|
||||
},
|
||||
{
|
||||
"size": 898248,
|
||||
"path": "dictionaries/uk.txt",
|
||||
"size": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
|
||||
},
|
||||
{
|
||||
"size": 993,
|
||||
"path": "tools/README.txt",
|
||||
"size": 993,
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||
},
|
||||
{
|
||||
"size": 134656,
|
||||
"path": "tools/StayHere.exe",
|
||||
"size": 134656,
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||
},
|
||||
{
|
||||
"size": 2796,
|
||||
"path": "tools/StayHere-README.md",
|
||||
"size": 2796,
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
}
|
||||
],
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,71,0
|
||||
PRODUCTVERSION 0,6,71,0
|
||||
FILEVERSION 0,6,77,0
|
||||
PRODUCTVERSION 0,6,77,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.71"
|
||||
VALUE "FileVersion", "0.6.77"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+125
-8
@@ -12,6 +12,7 @@
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <set>
|
||||
#include <shlobj.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
namespace ks
|
||||
@@ -23,6 +24,7 @@ namespace
|
||||
{
|
||||
constexpr UINT SnippetPopupMessage = WM_APP + 81;
|
||||
constexpr UINT SnippetCaretMessage = WM_APP + 82;
|
||||
constexpr UINT CheckAutostartMessage = WM_APP + 83;
|
||||
constexpr UINT_PTR SnippetPopupTimer = 21;
|
||||
constexpr size_t SnippetVisibleRows = 7;
|
||||
constexpr wchar_t SnippetPopupClass[] = L"KSwitcher.SnippetSuggestions";
|
||||
@@ -186,6 +188,7 @@ class Transaction
|
||||
ClipboardSnapshot snapshot;
|
||||
DWORD expected{};
|
||||
bool captured{};
|
||||
bool clipboardModified{};
|
||||
mutable bool invalidLogged{};
|
||||
unsigned copyNumber{};
|
||||
std::wstring adopted;
|
||||
@@ -208,9 +211,14 @@ class Transaction
|
||||
expected = GetClipboardSequenceNumber();
|
||||
try
|
||||
{
|
||||
auto started = GetTickCount64();
|
||||
const bool image = IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
|
||||
IsClipboardFormatAvailable(CF_BITMAP) || IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG"));
|
||||
snapshot.capture(app.window, expected);
|
||||
if (expected != GetClipboardSequenceNumber())
|
||||
throw std::runtime_error("Clipboard changed while taking snapshot");
|
||||
diagnosticEvent(L"Clipboard snapshot captured: elapsedMs=" +
|
||||
std::to_wstring(GetTickCount64() - started) + L" image=" + std::to_wstring(image));
|
||||
break;
|
||||
}
|
||||
catch (...)
|
||||
@@ -234,10 +242,12 @@ class Transaction
|
||||
~Transaction()
|
||||
{
|
||||
if (farSnippet) finishSelection(false);
|
||||
if (captured && GetClipboardSequenceNumber() == expected)
|
||||
if (captured && clipboardModified && GetClipboardSequenceNumber() == expected)
|
||||
try
|
||||
{
|
||||
auto started = GetTickCount64();
|
||||
app.ownSequence = snapshot.restore(app.window, expected);
|
||||
diagnosticEvent(L"Clipboard snapshot restored: elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -294,6 +304,7 @@ class Transaction
|
||||
auto temporary = c;
|
||||
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
|
||||
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
|
||||
clipboardModified = true;
|
||||
app.ownSequence = expected = writeClipboard(app.window, temporary);
|
||||
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
||||
// Never adopt that writer's sequence as our own and paste its content.
|
||||
@@ -809,11 +820,13 @@ void App::addTray()
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = window;
|
||||
icon.uID = 1;
|
||||
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP;
|
||||
icon.uCallbackMessage = TrayMessage;
|
||||
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
||||
auto tip = incomingLanFileCount ? L"KSwitcher — files ready to save: " + std::to_wstring(incomingLanFileCount)
|
||||
: localBuildTitle(L"KSwitcher — keyboard layouts & clipboard");
|
||||
wcsncpy_s(icon.szTip, tip.c_str(), _TRUNCATE);
|
||||
if (Shell_NotifyIconW(NIM_ADD, &icon))
|
||||
{
|
||||
KillTimer(window, TrayRetryTimer);
|
||||
@@ -857,6 +870,56 @@ Bytes App::lanCommand(const Bytes &command)
|
||||
void App::applySettings(const Settings &requested)
|
||||
{
|
||||
applySettings(requested, true);
|
||||
if (settings.autostart && !smoke && !isLocalBuild())
|
||||
PostMessageW(window, CheckAutostartMessage, 0, 0);
|
||||
}
|
||||
|
||||
void App::checkAutostartPath()
|
||||
{
|
||||
if (checkingAutostartPath || !settings.autostart || installingUpdate)
|
||||
return;
|
||||
checkingAutostartPath = true;
|
||||
try
|
||||
{
|
||||
const bool elevated = settings.elevatedAutostart;
|
||||
const auto oldPath = staleAutostartPath(elevated);
|
||||
if (!oldPath.empty())
|
||||
{
|
||||
const auto attributes = GetFileAttributesW(oldPath.c_str());
|
||||
const auto error = attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS;
|
||||
const bool missingFile = error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
|
||||
const bool needsApproval = elevated && !IsUserAnAdmin();
|
||||
bool approved = missingFile && !needsApproval;
|
||||
if (!approved)
|
||||
{
|
||||
const auto text = L"Registered path:\n" + oldPath + L"\n\nCurrent path:\n" + executable() +
|
||||
(needsApproval ? L"\n\nWindows will ask for administrator approval." : L"");
|
||||
const TASKDIALOG_BUTTON update{IDYES, L"Update autostart path"};
|
||||
TASKDIALOGCONFIG dialog{sizeof(dialog)};
|
||||
dialog.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION;
|
||||
dialog.dwCommonButtons = TDCBF_CANCEL_BUTTON;
|
||||
dialog.pszWindowTitle = L"KSwitcher";
|
||||
dialog.pszMainInstruction = L"Windows autostart points to a different copy of KSwitcher";
|
||||
dialog.pszContent = text.c_str();
|
||||
dialog.cButtons = 1;
|
||||
dialog.pButtons = &update;
|
||||
dialog.nDefaultButton = IDCANCEL;
|
||||
int button{};
|
||||
approved = SUCCEEDED(TaskDialogIndirect(&dialog, &button, nullptr, nullptr)) && button == IDYES;
|
||||
}
|
||||
if (approved && settings.autostart && settings.elevatedAutostart == elevated &&
|
||||
!installingUpdate && staleAutostartPath(elevated) == oldPath)
|
||||
{
|
||||
repairAutostartPath(elevated);
|
||||
log(L"Updated Windows autostart path to " + executable());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
report(L"Windows autostart: " + wide(e.what()));
|
||||
}
|
||||
checkingAutostartPath = false;
|
||||
}
|
||||
void App::applySettings(const Settings &requested, bool validateOcrInstallation)
|
||||
{
|
||||
@@ -1974,7 +2037,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
|
||||
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
|
||||
{"elapsedMs", GetTickCount64() - requestedAt}};
|
||||
{"elapsedMs", GetTickCount64() - requestedAt},
|
||||
{"activeAttempt", activeTransformAttempt.load()}};
|
||||
diagnosticOperation(record.dump());
|
||||
}
|
||||
catch (...) {}
|
||||
@@ -1986,6 +2050,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
}
|
||||
activeTransformAttempt = attempt;
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -2007,20 +2072,24 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
struct BusyReset
|
||||
{
|
||||
std::atomic<bool> &b;
|
||||
std::atomic<uint64_t> &active;
|
||||
HWND window;
|
||||
bool deferred{};
|
||||
~BusyReset()
|
||||
{
|
||||
active = 0;
|
||||
if (deferred)
|
||||
PostMessageW(window, ReplayTypingMessage, 0, 0);
|
||||
else
|
||||
b = false;
|
||||
}
|
||||
} reset{busy, window, preDeferred};
|
||||
} reset{busy, activeTransformAttempt, window, preDeferred};
|
||||
const auto workerStarted = GetTickCount64();
|
||||
auto effectiveChat = requestedChat;
|
||||
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
||||
config.cycle = chatCycle(config, languageProfileKey(config, target, effectiveChat), available);
|
||||
auto start = GetTickCount64();
|
||||
ULONGLONG verificationMs{};
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||
@@ -2035,6 +2104,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
|
||||
}
|
||||
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
|
||||
L" queueMs=" + std::to_wstring(workerStarted - requestedAt) +
|
||||
L" profileMs=" + std::to_wstring(start - workerStarted) +
|
||||
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
|
||||
L" trackedExpected=" + diagnosticText(expected));
|
||||
}
|
||||
@@ -2044,6 +2115,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
HWND target;
|
||||
uint64_t version, attempt;
|
||||
ULONGLONG start;
|
||||
const ULONGLONG &verificationMs;
|
||||
bool enabled;
|
||||
std::wstring &reason;
|
||||
std::wstring &text, &result, &expected;
|
||||
@@ -2064,7 +2136,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
||||
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
|
||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"verificationMs", verificationMs},
|
||||
{"foregroundSame", GetForegroundWindow() == target},
|
||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
||||
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
|
||||
{"selectionExpected", bounded(expected)},
|
||||
@@ -2074,7 +2147,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
}
|
||||
catch (...) {} // Logging must never abort a completed input transaction.
|
||||
}
|
||||
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
|
||||
} audit{*this, target, version, attempt, requestedAt, verificationMs, manual || snippet >= 0, reason, text, result, expected,
|
||||
actual, verification, hint, to, command, available};
|
||||
try
|
||||
{
|
||||
@@ -2139,7 +2212,12 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
bool farEditor;
|
||||
~SelectionReset() { if (selected && !farEditor && valid()) chord(VK_RIGHT); }
|
||||
} selectionReset{autoSelected, valid, farSnippet};
|
||||
reason = L"context-changed-before-snapshot";
|
||||
if (!valid()) return;
|
||||
reason = L"clipboard-snapshot";
|
||||
Transaction tx(*this, target, version, farSnippet, snippetEditor);
|
||||
reason = L"context-changed-before-selection";
|
||||
if (!tx.valid()) return;
|
||||
reason = L"selection-unavailable";
|
||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
|
||||
@@ -2259,6 +2337,11 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
if (hint < 0)
|
||||
{
|
||||
if (!tx.valid())
|
||||
{
|
||||
reason = L"context-changed-during-selection";
|
||||
return;
|
||||
}
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||
@@ -2334,7 +2417,10 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
}
|
||||
bool sent = false;
|
||||
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
|
||||
auto probeStarted = GetTickCount64();
|
||||
PasteProbe probe(!farSnippet && ((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
||||
verificationMs = GetTickCount64() - probeStarted;
|
||||
diagnosticEvent(L"Paste probe prepared: elapsedMs=" + std::to_wstring(verificationMs) + L" status=" + probe.status);
|
||||
if (requireReadback && !farSnippet && result != text && probe.status != L"ready")
|
||||
{
|
||||
verification = probe.status;
|
||||
@@ -2372,6 +2458,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
verification = probe.status;
|
||||
if (tx.valid())
|
||||
{
|
||||
auto readStarted = GetTickCount64();
|
||||
auto observed = farSnippet ? tx.readFarSnippet(caretSteps(result)) :
|
||||
probe.read((int)caretSteps(result));
|
||||
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||
@@ -2385,6 +2472,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
else
|
||||
observed.reset();
|
||||
}
|
||||
verificationMs += GetTickCount64() - readStarted;
|
||||
verification = farSnippet ? (observed ? L"read-back-via-far-clipboard" :
|
||||
L"far-readback-unavailable") : probe.status;
|
||||
if (observed && tx.valid())
|
||||
@@ -2933,7 +3021,10 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
case TrayMessage:
|
||||
if (LOWORD(lp) == NIN_BALLOONUSERCLICK)
|
||||
showLogs(*app);
|
||||
{
|
||||
if (std::exchange(app->incomingFileBalloon, false)) app->dispatch(20);
|
||||
else showLogs(*app);
|
||||
}
|
||||
else if (LOWORD(lp) == WM_CONTEXTMENU)
|
||||
app->trayMenu();
|
||||
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
|
||||
@@ -3005,9 +3096,33 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
wcsncpy_s(icon.szInfo, text->c_str(), _TRUNCATE);
|
||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher");
|
||||
icon.dwInfoFlags = NIIF_WARNING;
|
||||
app->incomingFileBalloon = false;
|
||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
||||
return 0;
|
||||
}
|
||||
case LanFilesChangedMessage:
|
||||
{
|
||||
app->incomingLanFileCount = wp;
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = hwnd;
|
||||
icon.uID = 1;
|
||||
icon.uFlags = NIF_TIP | NIF_SHOWTIP;
|
||||
auto tip = wp ? L"KSwitcher — files ready to save: " + std::to_wstring(wp)
|
||||
: localBuildTitle(L"KSwitcher — keyboard layouts & clipboard");
|
||||
wcsncpy_s(icon.szTip, tip.c_str(), _TRUNCATE);
|
||||
if (lp)
|
||||
{
|
||||
icon.uFlags |= NIF_INFO;
|
||||
wcscpy_s(icon.szInfoTitle, L"Incoming file ready to save");
|
||||
wcscpy_s(icon.szInfo, L"Open Settings → Computers → Manage computers → Incoming files.");
|
||||
icon.dwInfoFlags = NIIF_INFO;
|
||||
}
|
||||
if (Shell_NotifyIconW(NIM_MODIFY, &icon) && lp) app->incomingFileBalloon = true;
|
||||
return 0;
|
||||
}
|
||||
case CheckAutostartMessage:
|
||||
app->checkAutostartPath();
|
||||
return 0;
|
||||
case WM_COPYDATA:
|
||||
{
|
||||
auto *data = (COPYDATASTRUCT *)lp;
|
||||
@@ -3238,6 +3353,8 @@ int App::execute()
|
||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
if (!smoke && !isLocalBuild() && settings.autostart)
|
||||
PostMessageW(window, CheckAutostartMessage, 0, 0);
|
||||
try { notificationRegistration = startScreenshotNotificationRegistration(); }
|
||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||
auto screenshotActivation=startScreenshotActivationListener();
|
||||
|
||||
@@ -107,6 +107,8 @@ class App
|
||||
friend struct InputRegressionAccess;
|
||||
void releaseIdleMemory(ULONGLONG now);
|
||||
void applySettings(const Settings &, bool validateOcrInstallation);
|
||||
void checkAutostartPath();
|
||||
bool checkingAutostartPath{};
|
||||
HHOOK keyboardHook{}, mouseHook{};
|
||||
struct MousePress
|
||||
{
|
||||
@@ -160,6 +162,9 @@ class App
|
||||
UINT taskbarCreated{};
|
||||
bool trayRetryPending{};
|
||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||
size_t incomingLanFileCount{};
|
||||
bool incomingFileBalloon{};
|
||||
std::atomic<uint64_t> activeTransformAttempt{};
|
||||
ULONGLONG previousAttemptAt{};
|
||||
HWND previousAttemptWindow{};
|
||||
int previousAttemptCommand{}, repeatAttempts{};
|
||||
|
||||
+5
-3
@@ -245,18 +245,20 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||||
auto small = lower(word);
|
||||
bool inCycle = std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
|
||||
small.find_first_not_of(alphabet) == small.npos &&
|
||||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||
small.find_first_not_of(alphabet) == small.npos && inCycle;
|
||||
auto sameLayout = [&](std::wstring text)
|
||||
{
|
||||
if (word != small) text[0] = upper(text.substr(0, 1))[0];
|
||||
return AutoSuggestion{source, source, prefix + text + suffix};
|
||||
};
|
||||
if (settings.autoReplace && sameLanguage)
|
||||
if (settings.autoReplace && inCycle)
|
||||
{
|
||||
auto replacement = settings.autoReplacements.find(small);
|
||||
if (replacement != settings.autoReplacements.end() &&
|
||||
(word == small || (word.substr(1) == small.substr(1) && word != upper(word))) &&
|
||||
validAutoReplacementSource(small, alphabet) &&
|
||||
validAutoCorrection(replacement->second, alphabet))
|
||||
return sameLayout(replacement->second);
|
||||
}
|
||||
|
||||
+124
-14
@@ -27,6 +27,7 @@ template <class T> struct Com
|
||||
struct Str
|
||||
{
|
||||
BSTR value;
|
||||
Str() : value(nullptr) {}
|
||||
Str(const wchar_t *s) : value(SysAllocString(s))
|
||||
{
|
||||
if (!value)
|
||||
@@ -51,10 +52,108 @@ bool missing(HRESULT hr)
|
||||
{
|
||||
return hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || hr == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND);
|
||||
}
|
||||
constexpr wchar_t RunKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||
|
||||
HRESULT openTaskFolder(Com<ITaskService> &service, Com<ITaskFolder> &folder)
|
||||
{
|
||||
HRESULT hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
|
||||
(void **)service.out());
|
||||
VARIANT empty;
|
||||
VariantInit(&empty);
|
||||
if (SUCCEEDED(hr))
|
||||
hr = service->Connect(empty, empty, empty, empty);
|
||||
if (SUCCEEDED(hr))
|
||||
hr = service->GetFolder(Str(L"\\"), folder.out());
|
||||
return hr;
|
||||
}
|
||||
|
||||
bool sameAccount(const wchar_t *account)
|
||||
{
|
||||
auto sid = currentUserSid();
|
||||
if (sid == account)
|
||||
return true;
|
||||
DWORD sidSize{}, domainSize{};
|
||||
SID_NAME_USE use{};
|
||||
LookupAccountNameW(nullptr, account, nullptr, &sidSize, nullptr, &domainSize, &use);
|
||||
if (GetLastError() != ERROR_INSUFFICIENT_BUFFER)
|
||||
return false;
|
||||
Bytes accountSid(sidSize);
|
||||
std::wstring domain(domainSize, L'\0');
|
||||
if (!LookupAccountNameW(nullptr, account, accountSid.data(), &sidSize, domain.data(), &domainSize, &use))
|
||||
return false;
|
||||
LPWSTR accountSidText{};
|
||||
if (!ConvertSidToStringSidW(accountSid.data(), &accountSidText))
|
||||
return false;
|
||||
bool same = sid == accountSidText;
|
||||
LocalFree(accountSidText);
|
||||
return same;
|
||||
}
|
||||
|
||||
std::wstring registeredTaskPath()
|
||||
{
|
||||
Com<ITaskService> service;
|
||||
Com<ITaskFolder> folder;
|
||||
check(openTaskFolder(service, folder));
|
||||
Com<IRegisteredTask> task;
|
||||
auto hr = folder->GetTask(Str(L"KSwitcher"), task.out());
|
||||
if (missing(hr))
|
||||
return {};
|
||||
check(hr);
|
||||
VARIANT_BOOL enabled{};
|
||||
check(task->get_Enabled(&enabled));
|
||||
if (!enabled)
|
||||
return {};
|
||||
|
||||
Com<ITaskDefinition> definition;
|
||||
Com<IPrincipal> principal;
|
||||
Str user;
|
||||
check(task->get_Definition(definition.out()));
|
||||
check(definition->get_Principal(principal.out()));
|
||||
check(principal->get_UserId(&user.value));
|
||||
if (!user.value || !sameAccount(user.value))
|
||||
return {};
|
||||
|
||||
Com<IActionCollection> actions;
|
||||
LONG count{};
|
||||
check(definition->get_Actions(actions.out()));
|
||||
check(actions->get_Count(&count));
|
||||
if (count != 1)
|
||||
return {};
|
||||
Com<IAction> action;
|
||||
check(actions->get_Item(1, action.out()));
|
||||
TASK_ACTION_TYPE type{};
|
||||
check(action->get_Type(&type));
|
||||
if (type != TASK_ACTION_EXEC)
|
||||
return {};
|
||||
Com<IExecAction> exec;
|
||||
Str path, arguments;
|
||||
check(action->QueryInterface(IID_IExecAction, (void **)exec.out()));
|
||||
check(exec->get_Arguments(&arguments.value));
|
||||
check(exec->get_Path(&path.value));
|
||||
return path.value && (!arguments.value || !*arguments.value) ? path.value : L"";
|
||||
}
|
||||
|
||||
std::wstring registeredRunPath()
|
||||
{
|
||||
DWORD size{};
|
||||
auto status = RegGetValueW(HKEY_CURRENT_USER, RunKey, L"KSwitcher", RRF_RT_REG_SZ, nullptr, nullptr, &size);
|
||||
if (status == ERROR_FILE_NOT_FOUND || status == ERROR_UNSUPPORTED_TYPE)
|
||||
return {};
|
||||
check(HRESULT_FROM_WIN32(status));
|
||||
std::wstring command(size / sizeof(wchar_t), L'\0');
|
||||
check(HRESULT_FROM_WIN32(RegGetValueW(HKEY_CURRENT_USER, RunKey, L"KSwitcher", RRF_RT_REG_SZ, nullptr,
|
||||
command.data(), &size)));
|
||||
command.resize(wcslen(command.c_str()));
|
||||
// KSwitcher writes a quoted executable path with no arguments.
|
||||
if (command.size() >= 2 && command.front() == L'"' && command.find(L'"', 1) == command.size() - 1)
|
||||
return command.substr(1, command.size() - 2);
|
||||
return {};
|
||||
}
|
||||
|
||||
void runEntry(bool enabled)
|
||||
{
|
||||
HKEY key{};
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr,
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, RunKey, 0, nullptr,
|
||||
0, KEY_SET_VALUE, nullptr, &key, nullptr) != ERROR_SUCCESS)
|
||||
throw std::runtime_error("Cannot access autostart settings");
|
||||
auto command = L"\"" + executable() + L"\"";
|
||||
@@ -66,6 +165,27 @@ void runEntry(bool enabled)
|
||||
throw std::runtime_error("Cannot save autostart settings");
|
||||
}
|
||||
} // namespace
|
||||
std::wstring staleAutostartPath(bool elevated)
|
||||
{
|
||||
auto registered = elevated ? registeredTaskPath() : registeredRunPath();
|
||||
auto path = std::filesystem::path(registered);
|
||||
if (!path.is_absolute() || _wcsicmp(path.filename().c_str(), L"KSwitcher.exe") != 0)
|
||||
return {};
|
||||
auto current = executable();
|
||||
std::error_code error;
|
||||
if (_wcsicmp(registered.c_str(), current.c_str()) == 0 || std::filesystem::equivalent(path, current, error))
|
||||
return {};
|
||||
return registered;
|
||||
}
|
||||
|
||||
void repairAutostartPath(bool elevated)
|
||||
{
|
||||
if (elevated)
|
||||
setAutostart(true, true);
|
||||
else
|
||||
runEntry(true);
|
||||
}
|
||||
|
||||
std::wstring currentUserSid()
|
||||
{
|
||||
HANDLE token{};
|
||||
@@ -88,13 +208,10 @@ std::wstring currentUserSid()
|
||||
void configureAutostart(int mode)
|
||||
{
|
||||
Com<ITaskService> service;
|
||||
check(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
|
||||
(void **)service.out()));
|
||||
Com<ITaskFolder> folder;
|
||||
check(openTaskFolder(service, folder));
|
||||
VARIANT empty;
|
||||
VariantInit(&empty);
|
||||
check(service->Connect(empty, empty, empty, empty));
|
||||
Com<ITaskFolder> folder;
|
||||
check(service->GetFolder(Str(L"\\"), folder.out()));
|
||||
Com<IRegisteredTask> existing;
|
||||
HRESULT found = folder->GetTask(Str(L"KSwitcher"), existing.out());
|
||||
if (FAILED(found) && !missing(found))
|
||||
@@ -158,16 +275,9 @@ void setAutostart(bool enabled, bool elevated)
|
||||
if (mode != 2)
|
||||
{
|
||||
Com<ITaskService> service;
|
||||
HRESULT hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
|
||||
(void **)service.out());
|
||||
VARIANT empty;
|
||||
VariantInit(&empty);
|
||||
Com<ITaskFolder> folder;
|
||||
HRESULT hr = openTaskFolder(service, folder);
|
||||
Com<IRegisteredTask> task;
|
||||
if (SUCCEEDED(hr))
|
||||
hr = service->Connect(empty, empty, empty, empty);
|
||||
if (SUCCEEDED(hr))
|
||||
hr = service->GetFolder(Str(L"\\"), folder.out());
|
||||
if (SUCCEEDED(hr))
|
||||
hr = folder->GetTask(Str(L"KSwitcher"), task.out());
|
||||
if (missing(hr))
|
||||
|
||||
+23
-1
@@ -1,6 +1,7 @@
|
||||
#include "configuration.h"
|
||||
#include <commctrl.h>
|
||||
#include <cstring>
|
||||
#include "mcp_logs.h"
|
||||
|
||||
namespace ks
|
||||
{
|
||||
@@ -156,6 +157,26 @@ void App::setHotkeyCapture(HWND field)
|
||||
}
|
||||
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||
|
||||
int runMcpConfigurator(HINSTANCE instance)
|
||||
{
|
||||
App app(instance);
|
||||
WNDCLASSW cls{};
|
||||
cls.lpfnWndProc = controller;
|
||||
cls.hInstance = instance;
|
||||
cls.lpszClassName = L"KSwitcher.Config.MCP";
|
||||
RegisterClassW(&cls);
|
||||
app.window = CreateWindowExW(0, cls.lpszClassName, L"", 0, 0, 0, 0, 0,
|
||||
HWND_MESSAGE, nullptr, instance, nullptr);
|
||||
auto result = runLogMcp([&](const Bytes &payload)
|
||||
{
|
||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
if (!app.window || !service) throw std::runtime_error("Start KSwitcher before reading remote logs.");
|
||||
return request(app, ConfigLan, payload);
|
||||
});
|
||||
if (app.window) DestroyWindow(app.window);
|
||||
return result;
|
||||
}
|
||||
|
||||
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||
{
|
||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||
@@ -236,8 +257,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||
bool mcp = count > 1 && std::wstring(args[1]) == L"--mcp";
|
||||
LocalFree(args);
|
||||
result = ks::runConfigurator(instance, snippets, logs);
|
||||
result = mcp ? ks::runMcpConfigurator(instance) : ks::runConfigurator(instance, snippets, logs);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
|
||||
@@ -35,6 +35,11 @@ bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool validAutoReplacementSource(const std::wstring &text, const std::wstring &alphabet)
|
||||
{
|
||||
return text.find(L' ') == text.npos && text.find_first_of(alphabet) != text.npos &&
|
||||
validAutoCorrection(text, alphabet + L"0123456789");
|
||||
}
|
||||
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
|
||||
const std::vector<std::pair<int, Hotkey>> &bindings)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ struct Layout
|
||||
};
|
||||
bool letter(wchar_t ch);
|
||||
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet);
|
||||
bool validAutoReplacementSource(const std::wstring &text, const std::wstring &alphabet);
|
||||
std::wstring lower(std::wstring text);
|
||||
std::wstring upper(std::wstring text);
|
||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||
|
||||
+147
-4
@@ -55,6 +55,11 @@ struct LanService::Impl
|
||||
Json trusted=Json::object(),positions=Json::object();
|
||||
std::unique_ptr<lan::Pairing> pending;
|
||||
std::unique_ptr<lan::ClipboardExchange> clipboard;
|
||||
std::unique_ptr<lan::ClipboardExchange> transfers;
|
||||
std::deque<lan::ClipboardExchange::Delivery> incomingFiles;
|
||||
struct LogDownload {std::string peer, id; uint64_t deadline; Bytes bytes;};
|
||||
std::optional<LogDownload> logDownload;
|
||||
bool shareLogs{};
|
||||
bool enabled{};
|
||||
std::atomic<bool> stopping{false};
|
||||
std::mutex mutex;
|
||||
@@ -83,6 +88,7 @@ struct LanService::Impl
|
||||
id=j.at("id"); enabled=j.value("enabled",false); trusted=j.at("trusted");
|
||||
positions=j.value("positions",Json::object());
|
||||
shareInput=j.value("shareInput",false);inputPeer=j.value("inputPeer","");inputEdge=j.value("inputEdge",1);
|
||||
shareLogs=j.value("shareLogs",false);
|
||||
if(inputEdge<0||inputEdge>3||(shareInput&&trusted.empty()))throw std::runtime_error("Invalid input sharing configuration.");
|
||||
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
|
||||
for(auto &[key,entry]:trusted.items())
|
||||
@@ -93,12 +99,13 @@ struct LanService::Impl
|
||||
else id=lan::randomHex(16);
|
||||
lan::fillPositions(positions,trusted,inputPeer,inputEdge);
|
||||
clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||
transfers=std::make_unique<lan::ClipboardExchange>(id,"file-transfer");
|
||||
configureRouter();
|
||||
if(enabled) start();
|
||||
}
|
||||
~Impl() { stop(); }
|
||||
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
|
||||
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||
{"shareInput",shareInput},{"shareLogs",shareLogs},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||
bool queue(const std::string &peer,lan::InputFrame frame)
|
||||
{
|
||||
std::lock_guard lock(inputMutex);
|
||||
@@ -153,6 +160,8 @@ struct LanService::Impl
|
||||
inputConnected=false;++inputEpoch;
|
||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();inputReady.clear();}
|
||||
std::lock_guard lock(mutex); pending.reset(); peers.clear();clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||
transfers=std::make_unique<lan::ClipboardExchange>(id,"file-transfer");incomingFiles.clear();logDownload.reset();
|
||||
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,0,FALSE);
|
||||
}
|
||||
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
|
||||
{
|
||||
@@ -161,6 +170,41 @@ struct LanService::Impl
|
||||
}
|
||||
std::string heartbeat(const std::string &from,const std::string &to,const std::string &a,const std::string &b)
|
||||
{ return "KSwitcher LAN v1|presence|"+from+"|"+to+"|"+a+"|"+b; }
|
||||
void receiveTransfers(uint64_t now)
|
||||
{
|
||||
for(auto &item:transfers->deliveries)
|
||||
{
|
||||
if(item.app=="file")
|
||||
{
|
||||
auto end=std::find(item.bytes.begin(),item.bytes.end(),0);
|
||||
if(end!=item.bytes.begin() && end!=item.bytes.end() && end-item.bytes.begin()<=1024 &&
|
||||
item.bytes.end()-end-1<=16*1024*1024)
|
||||
{
|
||||
incomingFiles.push_back(std::move(item));
|
||||
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),TRUE);
|
||||
}
|
||||
}
|
||||
else if(item.app=="log-request" && item.bytes.size()==33)
|
||||
{
|
||||
std::string request(item.bytes.begin()+1,item.bytes.end());
|
||||
if(!lan::identifier(request)||item.bytes[0]>1)continue;
|
||||
Bytes response{2};
|
||||
if(shareLogs)try
|
||||
{
|
||||
auto log=readFile(path.parent_path()/(item.bytes[0]?L"kswitcher.log.1":L"kswitcher.log"),2*1024*1024);
|
||||
response[0]=0;
|
||||
response.insert(response.end(),log.begin(),log.end());
|
||||
}
|
||||
catch(const std::exception &){response={1};}
|
||||
try{transfers->queue({item.peer},std::move(response),"log-reply-"+request,now);}
|
||||
catch(const std::exception &){}
|
||||
}
|
||||
else if(logDownload && now<logDownload->deadline && logDownload->peer==item.peer &&
|
||||
item.app=="log-reply-"+logDownload->id && item.bytes.size()<=2*1024*1024+1 && logDownload->bytes.empty())
|
||||
logDownload->bytes=std::move(item.bytes);
|
||||
}
|
||||
transfers->deliveries.clear();
|
||||
}
|
||||
void receive(SOCKET socket,const sockaddr_in &address,const Json &j,uint64_t now)
|
||||
{
|
||||
if(j.value("app","")!="KSwitcher-LAN" || j.value("version",0)!=1) return;
|
||||
@@ -180,6 +224,7 @@ struct LanService::Impl
|
||||
// announcements cannot extend its PeerLifetime expiry.
|
||||
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
|
||||
clipboard->peerSession(other,j.value("clipboardSession",""));
|
||||
transfers->peerSession(other,j.value("fileSession",""));
|
||||
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
|
||||
if(trusted.contains(other))
|
||||
{
|
||||
@@ -192,6 +237,17 @@ struct LanService::Impl
|
||||
if(!peers.contains(other)||j.value("to","")!=id) return;
|
||||
auto &peer=peers.at(other);
|
||||
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
|
||||
if(type=="file-transfer" && trusted.contains(other))
|
||||
{
|
||||
size_t retainedBytes=0;
|
||||
for(const auto &item:incomingFiles)retainedBytes+=item.bytes.size();
|
||||
try {transfers->receive(other,trusted.at(other).at("key"),j,now,0,
|
||||
[&](const auto &target,const auto &packet){send(socket,peers.at(target).address,packet);},
|
||||
incomingFiles.size(),retainedBytes);}
|
||||
catch(const std::exception &){return;}
|
||||
receiveTransfers(now);
|
||||
return;
|
||||
}
|
||||
if(type=="clipboard" && trusted.contains(other))
|
||||
{
|
||||
try {clipboard->receive(other,trusted.at(other).at("key"),j,now,GetClipboardSequenceNumber(),
|
||||
@@ -246,13 +302,17 @@ struct LanService::Impl
|
||||
clipboard->poll(trusted,now,[&](const auto &target,const auto &packet){
|
||||
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||
});
|
||||
clipboardActive=clipboard->active();
|
||||
transfers->poll(trusted,now,[&](const auto &target,const auto &packet){
|
||||
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||
});
|
||||
clipboardActive=clipboard->active()||transfers->active();
|
||||
if(shareInput)for(auto &[peer,entry]:trusted.items())
|
||||
endpoints.push_back({peer,entry.at("key"),peers.contains(peer)?peers.at(peer).address.sin_addr.s_addr:0});
|
||||
if(now>=nextHello)
|
||||
{
|
||||
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
|
||||
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
|
||||
hello["fileSession"]=transfers->session();
|
||||
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
|
||||
nextHello=now+3000;
|
||||
}
|
||||
@@ -324,6 +384,76 @@ struct LanService::Impl
|
||||
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
auto now=GetTickCount64();
|
||||
if(action=="shareLogs")
|
||||
{
|
||||
bool previous=shareLogs;
|
||||
shareLogs=j.at("enabled");
|
||||
try {save();} catch(...) {shareLogs=previous;throw;}
|
||||
if(!shareLogs)
|
||||
for(const auto &[peer,entry]:trusted.items())transfers->cancel(peer,"log-reply-");
|
||||
}
|
||||
if(action=="sendFile" || action=="requestLog")
|
||||
{
|
||||
auto peer=j.at("peer").get<std::string>();
|
||||
if(!enabled||!trusted.contains(peer)||!peers.contains(peer)||!peers.at(peer).verified||
|
||||
now-peers.at(peer).verified>=lan::PeerLifetime)
|
||||
throw std::runtime_error("Select an online paired computer.");
|
||||
if(!transfers->hasSession(peer))throw std::runtime_error("Update KSwitcher on the selected computer to use file transfers.");
|
||||
if(action=="sendFile")
|
||||
{
|
||||
auto file=std::filesystem::path(wide(j.at("path").get<std::string>()));
|
||||
auto name=utf8(file.filename().wstring());
|
||||
if(name.empty()||name.size()>1024)throw std::runtime_error("Invalid file name.");
|
||||
Bytes payload(name.begin(),name.end());
|
||||
payload.push_back(0);
|
||||
constexpr size_t fileLimit=16*1024*1024;
|
||||
if(std::filesystem::file_size(file)>fileLimit)throw std::runtime_error("File size limit is 16 MiB.");
|
||||
auto bytes=readFile(file,fileLimit);
|
||||
payload.insert(payload.end(),bytes.begin(),bytes.end());
|
||||
transfers->queue({peer},std::move(payload),"file",now);
|
||||
return encoded({{"queued",true}});
|
||||
}
|
||||
if(logDownload && now<logDownload->deadline)
|
||||
throw std::runtime_error("A remote log download is already pending.");
|
||||
auto request=lan::randomHex(16);
|
||||
Bytes payload{static_cast<unsigned char>(j.value("previous",false))};
|
||||
payload.insert(payload.end(),request.begin(),request.end());
|
||||
transfers->queue({peer},std::move(payload),"log-request",now);
|
||||
logDownload=LogDownload{peer,request,now+30000,{}};
|
||||
return encoded({{"request",request}});
|
||||
}
|
||||
if(action=="takeLog")
|
||||
{
|
||||
if(!logDownload||logDownload->id!=j.at("request").get<std::string>()||now>=logDownload->deadline)
|
||||
return encoded({{"state","expired"}});
|
||||
if(logDownload->bytes.empty())return encoded({{"state","waiting"}});
|
||||
auto bytes=std::move(logDownload->bytes);
|
||||
logDownload.reset();
|
||||
if(bytes.front()!=0)return encoded({{"state",bytes.front()==2?"denied":"unavailable"}});
|
||||
std::string hex;
|
||||
hex.reserve((bytes.size()-1)*2);
|
||||
for(auto it=bytes.begin()+1;it!=bytes.end();++it)
|
||||
{
|
||||
hex+="0123456789abcdef"[*it>>4];
|
||||
hex+="0123456789abcdef"[*it&15];
|
||||
}
|
||||
return encoded({{"state","ready"},{"logHex",hex}});
|
||||
}
|
||||
if(action=="saveFile"||action=="discardFile")
|
||||
{
|
||||
auto found=std::find_if(incomingFiles.begin(),incomingFiles.end(),[&](const auto &file){
|
||||
return file.id==j.at("id").get<std::string>() && file.peer==j.at("peer").get<std::string>();
|
||||
});
|
||||
if(found==incomingFiles.end())throw std::runtime_error("This file is no longer available.");
|
||||
if(action=="saveFile")
|
||||
{
|
||||
auto start=std::find(found->bytes.begin(),found->bytes.end(),0)+1;
|
||||
atomicWrite(wide(j.at("path").get<std::string>()),Bytes(start,found->bytes.end()));
|
||||
}
|
||||
incomingFiles.erase(found);
|
||||
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),FALSE);
|
||||
}
|
||||
if(action=="position")
|
||||
{
|
||||
auto peer=j.at("peer").get<std::string>();int x=j.at("x"),y=j.at("y");
|
||||
@@ -368,6 +498,9 @@ struct LanService::Impl
|
||||
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;auto oldPositions=positions;bool oldSharing=shareInput;
|
||||
trusted.erase(peer);positions.erase(peer);if(trusted.empty())shareInput=false;
|
||||
try{save();}catch(...){trusted=std::move(oldTrusted);positions=std::move(oldPositions);shareInput=oldSharing;throw;}
|
||||
std::erase_if(incomingFiles,[&](const auto &file){return file.peer==peer;});
|
||||
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),FALSE);
|
||||
if(logDownload && logDownload->peer==peer)logDownload.reset();
|
||||
if(pending&&pending->peer==peer)pending.reset();
|
||||
configureRouter();
|
||||
status="Pair removed on this computer.";
|
||||
@@ -376,7 +509,16 @@ struct LanService::Impl
|
||||
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
|
||||
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"inputNeedsHelp",enabled&&shareInput&&inputNeedsHelp},{"controlMode",router?int(router->mode()):0}};
|
||||
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
|
||||
auto now=GetTickCount64();
|
||||
result["shareLogs"]=shareLogs;
|
||||
result["files"]=Json::array();
|
||||
result["transferStatus"]=transfers->status;
|
||||
for(const auto &file:incomingFiles)
|
||||
{
|
||||
auto end=std::find(file.bytes.begin(),file.bytes.end(),0);
|
||||
auto filename=utf8(wide(std::string(file.bytes.begin(),end)));
|
||||
result["files"].push_back({{"id",file.id},{"peer",file.peer},{"sender",trusted.at(file.peer).at("name")},
|
||||
{"name",filename},{"size",file.bytes.end()-end-1}});
|
||||
}
|
||||
result["pendingPeer"]=pending&&!pending->confirmed ? pending->peer : "";
|
||||
result["pairSecondsRemaining"]=pending&&!pending->confirmed&&!pending->expired(now)
|
||||
? (lan::PairLifetime-(now-pending->started)+999)/1000 : 0;
|
||||
@@ -384,7 +526,8 @@ struct LanService::Impl
|
||||
{
|
||||
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
|
||||
result["peers"].push_back({{"id",peer},{"name",p.name},{"address",ip},{"paired",trusted.contains(peer)},
|
||||
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true}});
|
||||
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true},
|
||||
{"fileTransfer",transfers->hasSession(peer)}});
|
||||
}
|
||||
for(const auto &[peer,p]:trusted.items()) if(!peers.contains(peer))
|
||||
result["peers"].push_back({{"id",peer},{"name",p.at("name")},{"address",""},{"paired",true},{"verified",false},{"online",false}});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
|
||||
inline constexpr UINT LanFilesChangedMessage=WM_APP+70;
|
||||
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
|
||||
// Discovery, pairing and optional encrypted input within the resident process.
|
||||
class LanService
|
||||
|
||||
+38
-15
@@ -6,8 +6,8 @@
|
||||
|
||||
namespace ks::lan
|
||||
{
|
||||
// Reliable bounded clipboard messages over the existing discovery socket.
|
||||
// Every datagram has a fresh directional AES-GCM key; no plaintext clipboard
|
||||
// Reliable bounded messages over the existing discovery socket.
|
||||
// Every datagram has a fresh directional AES-GCM key; no plaintext transfer
|
||||
// metadata or contents appear outside the encrypted CBOR body.
|
||||
class ClipboardExchange
|
||||
{
|
||||
@@ -15,7 +15,7 @@ class ClipboardExchange
|
||||
struct Item {std::string id,app;Bytes bytes;};
|
||||
struct Sending {std::deque<std::shared_ptr<Item>> queue;size_t ack{};bool begun{};uint64_t last{},progress{};};
|
||||
struct Receiving {std::string id,app;Bytes bytes;size_t size{};uint64_t last{};DWORD sequence{};};
|
||||
std::string local, boot=randomHex(16);
|
||||
std::string local, type, domain, boot=randomHex(16);
|
||||
std::map<std::string,std::string> sessions;
|
||||
std::map<std::string,Sending> sending;
|
||||
std::map<std::string,Receiving> receiving;
|
||||
@@ -31,33 +31,53 @@ class ClipboardExchange
|
||||
Json seal(const std::string &peer,const std::string &key,const Json &body)
|
||||
{
|
||||
auto nonce=randomHex(16);auto number=++nextPacket;
|
||||
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
|
||||
auto packet=envelope("clipboard",local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
|
||||
InputCipher cipher(mac(key,domain+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
|
||||
auto packet=envelope(type,local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
|
||||
packet["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));return packet;
|
||||
}
|
||||
public:
|
||||
struct Delivery {std::string peer,id,app;Bytes bytes;DWORD sequence{};};
|
||||
std::vector<Delivery> deliveries;
|
||||
std::string status="Clipboard sharing ready.";
|
||||
explicit ClipboardExchange(std::string id):local(std::move(id)){}
|
||||
explicit ClipboardExchange(std::string id,std::string messageType="clipboard")
|
||||
:local(std::move(id)),type(std::move(messageType)),domain("KSwitcher "+type+" v1|")
|
||||
{
|
||||
if(type!="clipboard")status.clear();
|
||||
}
|
||||
const std::string &session()const{return boot;}
|
||||
bool active()const{return !sending.empty();}
|
||||
bool hasSession(const std::string &peer)const{return sessions.contains(peer);}
|
||||
void cancel(const std::string &peer,const std::string &prefix)
|
||||
{
|
||||
auto found=sending.find(peer);
|
||||
if(found==sending.end())return;
|
||||
auto &s=found->second;
|
||||
if(s.queue.empty())return;
|
||||
bool reset=s.queue.front()->app.starts_with(prefix);
|
||||
std::erase_if(s.queue,[&](const auto &item){return item->app.starts_with(prefix);});
|
||||
if(reset){s.ack=0;s.begun=false;s.last=0;s.progress=GetTickCount64();}
|
||||
}
|
||||
void peerSession(const std::string &peer,const std::string &value){
|
||||
if(!identifier(value))return;
|
||||
if(sessions.contains(peer)&&sessions.at(peer)!=value){
|
||||
auto found=sending.find(peer);if(found!=sending.end()){found->second.ack=0;found->second.begun=false;found->second.last=0;}
|
||||
if(type!="clipboard"){
|
||||
auto previous=receiving.find(peer);
|
||||
if(previous!=receiving.end()){incomingBytes-=previous->second.size;receiving.erase(previous);}
|
||||
receivedPackets.erase(peer);
|
||||
}
|
||||
}
|
||||
sessions[peer]=value;
|
||||
}
|
||||
void queue(const std::vector<std::string> &peers,Bytes bytes,std::string app,uint64_t now)
|
||||
{
|
||||
if(bytes.empty()||bytes.size()>Limit)throw std::runtime_error("LAN clipboard limit is 32 MiB.");
|
||||
if(bytes.empty()||bytes.size()>Limit)throw std::runtime_error("LAN transfer limit is 32 MiB.");
|
||||
size_t total=bytes.size();std::set<const Item*> counted;
|
||||
for(auto &[peer,s]:sending)for(auto &item:s.queue)if(counted.insert(item.get()).second)total+=item->bytes.size();
|
||||
if(total>2*Limit)throw std::runtime_error("LAN clipboard queue is full.");
|
||||
auto item=std::make_shared<Item>(Item{randomHex(16),app.size()>128?std::string("Application"):app,std::move(bytes)});
|
||||
for(auto &peer:peers){auto &s=sending[peer];if(s.queue.size()>=8)continue;if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
|
||||
if(!peers.empty())status="Sending clipboard to "+std::to_string(peers.size())+" paired computers...";
|
||||
for(auto &peer:peers){auto &s=sending[peer];if(s.queue.size()>=8){if(type!="clipboard")throw std::runtime_error("LAN transfer queue is full.");continue;}if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
|
||||
if(!peers.empty())status=type=="clipboard"?"Sending clipboard to "+std::to_string(peers.size())+" paired computers...":"Sending…";
|
||||
}
|
||||
template<class Send>void poll(const Json &trusted,uint64_t now,Send send)
|
||||
{
|
||||
@@ -67,7 +87,7 @@ class ClipboardExchange
|
||||
{
|
||||
auto &peer=it->first;auto &s=it->second;
|
||||
if(!trusted.contains(peer)||s.queue.empty()){it=sending.erase(it);continue;}
|
||||
if(now-s.progress>30000){status="Clipboard transfer timed out.";it=sending.erase(it);continue;}
|
||||
if(now-s.progress>30000){status="Transfer timed out.";it=sending.erase(it);continue;}
|
||||
if(sessions.contains(peer)&&(!s.last||now-s.last>=100))
|
||||
{
|
||||
auto &item=*s.queue.front();auto key=trusted.at(peer).at("key").template get<std::string>();
|
||||
@@ -80,14 +100,16 @@ class ClipboardExchange
|
||||
++it;
|
||||
}
|
||||
}
|
||||
template<class Send>void receive(const std::string &peer,const std::string &key,const Json &packet,uint64_t now,DWORD sequence,Send send)
|
||||
template<class Send>void receive(const std::string &peer,const std::string &key,const Json &packet,uint64_t now,DWORD sequence,Send send,
|
||||
size_t retained=0,size_t retainedBytes=0)
|
||||
{
|
||||
if(packet.value("session","")!=boot||!sessions.contains(peer)||packet.value("to","")!=local||!identifier(packet.value("nonce","")))return;
|
||||
auto senderBoot=packet.value("fromSession","");auto number=packet.value("number",uint64_t{});
|
||||
if(!identifier(senderBoot)||!number)return;
|
||||
if(type!="clipboard"&&senderBoot!=sessions.at(peer))return;
|
||||
auto &seen=receivedPackets[peer];
|
||||
if((seen.contains(senderBoot)&&number<=seen.at(senderBoot))||(!seen.contains(senderBoot)&&seen.size()>=1024))return;
|
||||
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
|
||||
InputCipher cipher(mac(key,domain+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
|
||||
auto body=Json::from_cbor(cipher.decryptPayload(unhex(packet.at("data").get<std::string>())));
|
||||
seen[senderBoot]=number; // Retransmissions use new numbers; reordered old packets are retried.
|
||||
auto id=body.value("item","");if(!identifier(id))return;
|
||||
@@ -100,7 +122,7 @@ class ClipboardExchange
|
||||
if(item.id!=id||offset>item.bytes.size()||offset<s.ack||offset>s.ack+Window*Chunk||(offset!=item.bytes.size()&&offset%Chunk))return;
|
||||
if(offset==s.ack&&s.begun)return;
|
||||
s.begun=true;s.ack=offset;s.progress=now;s.last=0;
|
||||
if(offset==item.bytes.size()){s.queue.pop_front();s.ack=0;s.begun=false;status="Clipboard delivered.";}
|
||||
if(offset==item.bytes.size()){s.queue.pop_front();s.ack=0;s.begun=false;status=type=="clipboard"?"Clipboard delivered.":"Transfer delivered.";}
|
||||
return;
|
||||
}
|
||||
for(auto &done:completed[peer])if(done.first==id){ack(done.second);return;}
|
||||
@@ -111,7 +133,8 @@ class ClipboardExchange
|
||||
auto found=receiving.find(peer);
|
||||
if(found!=receiving.end()){if(found->second.id==id)ack(found->second.bytes.size());return;}
|
||||
size_t waiting=0;for(auto &d:deliveries)waiting+=d.bytes.size();
|
||||
if(incomingBytes+waiting+size>2*Limit||deliveries.size()>=8)return;
|
||||
if(incomingBytes+waiting+retainedBytes+size>2*Limit||deliveries.size()+retained>=8||
|
||||
(type!="clipboard"&&receiving.size()+retained>=8))return;
|
||||
receiving.emplace(peer,Receiving{id,app,{},size,now,sequence});incomingBytes+=size;ack(0);return;
|
||||
}
|
||||
if(op!="chunk")return;
|
||||
@@ -126,7 +149,7 @@ class ClipboardExchange
|
||||
{
|
||||
deliveries.push_back({peer,r.id,r.app,std::move(r.bytes),r.sequence});
|
||||
auto &done=completed[peer];done.push_back({id,r.size});if(done.size()>64)done.pop_front();
|
||||
incomingBytes-=r.size;receiving.erase(found);status="Clipboard received.";
|
||||
incomingBytes-=r.size;receiving.erase(found);status=type=="clipboard"?"Clipboard received.":"Transfer received.";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "core.h"
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline Bytes decodeLanLog(const std::string &hex)
|
||||
{
|
||||
auto digit = [](char value) -> unsigned
|
||||
{
|
||||
if (value >= '0' && value <= '9') return value - '0';
|
||||
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
|
||||
throw std::runtime_error("Invalid diagnostic log response.");
|
||||
};
|
||||
if (hex.size() % 2) throw std::runtime_error("Invalid diagnostic log response.");
|
||||
Bytes bytes;
|
||||
bytes.reserve(hex.size() / 2);
|
||||
for (size_t i = 0; i < hex.size(); i += 2)
|
||||
bytes.push_back(static_cast<unsigned char>(digit(hex[i]) * 16 + digit(hex[i + 1])));
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -15,13 +15,24 @@ bool shouldElevateOnLaunch()
|
||||
try
|
||||
{
|
||||
auto settings = ks::loadSettings(ks::dataDirectory() / L"settings.json", {});
|
||||
return settings.autostart && settings.elevatedAutostart;
|
||||
if (!settings.autostart || !settings.elevatedAutostart)
|
||||
return false;
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
// The normal startup path handles unreadable settings and reports them.
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
// Let the resident explain the stale path before asking to elevate the repair.
|
||||
return ks::staleAutostartPath(true).empty();
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
// The elevated resident can retry reading the task.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include "../vendor/json.hpp"
|
||||
#include "lan_log.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline int runLogMcp(const std::function<Bytes(const Bytes &)> &lan)
|
||||
{
|
||||
using Json = nlohmann::json;
|
||||
auto command = [&](const Json &request)
|
||||
{
|
||||
auto text = request.dump();
|
||||
return Json::parse(lan(Bytes(text.begin(), text.end())));
|
||||
};
|
||||
const Json tools = Json::parse(R"({"tools":[
|
||||
{"name":"list_log_computers","description":"List paired computers that support remote log downloads.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true}},
|
||||
{"name":"read_diagnostic_log","description":"Read KSwitcher diagnostic log lines. Omit peer for the local computer. Use offset to page backward from the newest line.","inputSchema":{"type":"object","properties":{"peer":{"type":"string"},"previous":{"type":"boolean"},"lines":{"type":"integer","minimum":1,"maximum":500,"default":100},"offset":{"type":"integer","minimum":0,"default":0}}},"annotations":{"readOnlyHint":true}}
|
||||
]})");
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line))
|
||||
{
|
||||
Json id, reply;
|
||||
try
|
||||
{
|
||||
auto message = Json::parse(line);
|
||||
if (!message.contains("id")) continue;
|
||||
id = message.at("id");
|
||||
auto method = message.at("method").get<std::string>();
|
||||
if (method == "initialize")
|
||||
{
|
||||
auto version = message.at("params").value("protocolVersion", "");
|
||||
if (version != "2025-03-26" && version != "2025-06-18" && version != "2025-11-25")
|
||||
version = "2025-11-25";
|
||||
reply["result"] = {{"protocolVersion", version}, {"capabilities", {{"tools", Json::object()}}},
|
||||
{"serverInfo", {{"name", "kswitcher-logs"}, {"version", "1.0.0"}}}};
|
||||
}
|
||||
else if (method == "ping") reply["result"] = Json::object();
|
||||
else if (method == "tools/list") reply["result"] = tools;
|
||||
else if (method == "tools/call")
|
||||
{
|
||||
std::string output;
|
||||
bool failed = false;
|
||||
try
|
||||
{
|
||||
auto params = message.at("params");
|
||||
auto arguments = params.value("arguments", Json::object());
|
||||
auto name = params.at("name").get<std::string>();
|
||||
if (name == "list_log_computers")
|
||||
{
|
||||
auto peers = Json::array();
|
||||
for (const auto &peer : command({{"action", "status"}}).at("peers").get<Json::array_t>())
|
||||
if (peer.value("paired", false))
|
||||
peers.push_back({{"id", peer.at("id")}, {"name", peer.at("name")},
|
||||
{"available", peer.value("verified", false) && peer.value("fileTransfer", false)}});
|
||||
output = peers.dump();
|
||||
}
|
||||
else if (name == "read_diagnostic_log")
|
||||
{
|
||||
auto peer = arguments.value("peer", "");
|
||||
bool previous = arguments.value("previous", false);
|
||||
int lines = arguments.value("lines", 100);
|
||||
if (lines < 1 || lines > 500) throw std::runtime_error("lines must be between 1 and 500.");
|
||||
int offset = arguments.value("offset", 0);
|
||||
if (offset < 0) throw std::runtime_error("offset must be nonnegative.");
|
||||
if (peer.empty()) output = utf8(readDiagnosticLog(previous));
|
||||
else
|
||||
{
|
||||
auto request = command({{"action", "requestLog"}, {"peer", peer}, {"previous", previous}}).at("request");
|
||||
for (;;)
|
||||
{
|
||||
auto result = command({{"action", "takeLog"}, {"request", request}});
|
||||
auto state = result.at("state").get<std::string>();
|
||||
if (state == "ready")
|
||||
{
|
||||
auto bytes = decodeLanLog(result.at("logHex"));
|
||||
output.assign(bytes.begin(), bytes.end());
|
||||
break;
|
||||
}
|
||||
if (state == "denied") throw std::runtime_error("Remote log downloads are disabled on that computer.");
|
||||
if (state == "unavailable") throw std::runtime_error("The remote log is unavailable.");
|
||||
if (state != "waiting") throw std::runtime_error("The remote log download timed out.");
|
||||
Sleep(250);
|
||||
}
|
||||
}
|
||||
auto previousLine = [&](size_t end)
|
||||
{
|
||||
if (!end) return size_t{};
|
||||
if (output[end - 1] == '\n') --end;
|
||||
return end ? output.rfind('\n', end - 1) + 1 : size_t{};
|
||||
};
|
||||
size_t end = output.size();
|
||||
while (offset-- > 0 && end) end = previousLine(end);
|
||||
size_t start = end;
|
||||
while (lines-- > 0 && start) start = previousLine(start);
|
||||
output = output.substr(start, end - start);
|
||||
}
|
||||
else throw std::runtime_error("Unknown tool.");
|
||||
}
|
||||
catch (const std::exception &error) { output = error.what(); failed = true; }
|
||||
reply["result"] = {{"content", Json::array({{{"type", "text"}, {"text", output}}})}, {"isError", failed}};
|
||||
}
|
||||
else reply["error"] = {{"code", -32601}, {"message", "Method not found"}};
|
||||
}
|
||||
catch (const std::exception &) { reply["error"] = {{"code", -32600}, {"message", "Invalid request"}}; }
|
||||
reply["jsonrpc"] = "2.0";
|
||||
reply["id"] = id;
|
||||
std::cout << reply.dump(-1, ' ', false, Json::error_handler_t::replace) << std::endl;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -90,15 +90,22 @@ class PasteProbe
|
||||
return {};
|
||||
}
|
||||
IUIAutomationElement *focused{};
|
||||
BOOL same = FALSE;
|
||||
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
|
||||
if (FAILED(automation->GetFocusedElement(&focused)) || !focused)
|
||||
{
|
||||
automation->CompareElements(element, focused, &same);
|
||||
if (focused) focused->Release();
|
||||
status = L"accessibility-focus-unavailable";
|
||||
return {};
|
||||
}
|
||||
BOOL same = FALSE;
|
||||
if (FAILED(automation->CompareElements(element, focused, &same)))
|
||||
{
|
||||
focused->Release();
|
||||
status = L"accessibility-focus-comparison-failed";
|
||||
return {};
|
||||
}
|
||||
if (!same)
|
||||
{
|
||||
if (focused)
|
||||
focused->Release();
|
||||
focused->Release();
|
||||
status = L"focused-control-changed";
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -816,6 +816,13 @@ void diagnosticOperation(const std::string &json)
|
||||
auto record = nlohmann::json::parse(json);
|
||||
if (!record.value("problem", true))
|
||||
{
|
||||
if (record.value("elapsedMs", 0ULL) >= 2000 && record.value("outcome", "") == "Paste verified")
|
||||
{
|
||||
auto summary = record;
|
||||
for (auto field : {"original", "expected", "actual", "selectionExpected"})
|
||||
summary.erase(field);
|
||||
log(L"Operation " + wide(summary.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
|
||||
}
|
||||
// Keep successful operations only in the bounded, short-lived context.
|
||||
// They are persisted with a subsequent failure, never as standalone rows.
|
||||
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
|
||||
|
||||
@@ -76,6 +76,8 @@ Bytes readFile(const std::filesystem::path &, size_t limit);
|
||||
Bytes protect(const Bytes &);
|
||||
Bytes unprotect(const Bytes &);
|
||||
void setAutostart(bool, bool elevated = false);
|
||||
std::wstring staleAutostartPath(bool elevated);
|
||||
void repairAutostartPath(bool elevated);
|
||||
std::wstring currentUserSid();
|
||||
void configureAutostart(int mode);
|
||||
std::wstring executable();
|
||||
|
||||
+31
-4
@@ -180,6 +180,7 @@ struct Capture
|
||||
LineStyle lineStyle=LineStyle::Solid;
|
||||
int smoothing=25;
|
||||
bool smoothingDrag=false;
|
||||
bool smoothingPreferenceDirty=false;
|
||||
LineEnds arrowEnds=LineEnds::End, freehandEnds=LineEnds::None;
|
||||
COLORREF currentColor=RGB(225,42,50);
|
||||
COLORREF customColors[16]{};
|
||||
@@ -221,8 +222,24 @@ struct Capture
|
||||
MarkPart hoveredPart=MarkPart::None;
|
||||
std::wstring statusText;
|
||||
std::filesystem::path savedPath;
|
||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
|
||||
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor)
|
||||
{
|
||||
if(a.smoothingPreference.empty()) return;
|
||||
try {
|
||||
auto stored=readFile(a.smoothingPreference,1);
|
||||
if(stored.size()==1 && stored.front()<=100) smoothing=stored.front();
|
||||
} catch(const std::exception &) {} // Missing or unreadable preference uses the default.
|
||||
}
|
||||
void saveSmoothingPreference()
|
||||
{
|
||||
if(!smoothingPreferenceDirty || app.smoothingPreference.empty()) return;
|
||||
try {
|
||||
atomicWrite(app.smoothingPreference,Bytes{static_cast<unsigned char>(smoothing)});
|
||||
smoothingPreferenceDirty=false;
|
||||
} catch(const std::exception &) {} // A failed preference write must not interrupt editing.
|
||||
}
|
||||
~Capture() {
|
||||
saveSmoothingPreference();
|
||||
if(pencilCursor) DestroyCursor(pencilCursor);
|
||||
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
|
||||
if (font) DeleteObject(font);
|
||||
@@ -1170,8 +1187,14 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
setScreenshotSmoothing(c->marks[c->selected],amount);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
c->smoothingDrag=LOWORD(wp)==TB_THUMBTRACK;
|
||||
} else if(!pencil) c->smoothing=amount;
|
||||
if(LOWORD(wp)!=TB_THUMBTRACK) c->smoothingDrag=false;
|
||||
} else if(!pencil && c->smoothing!=amount) {
|
||||
c->smoothing=amount;
|
||||
c->smoothingPreferenceDirty=true;
|
||||
}
|
||||
if(LOWORD(wp)!=TB_THUMBTRACK) {
|
||||
c->smoothingDrag=false;
|
||||
c->saveSmoothingPreference();
|
||||
}
|
||||
syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
|
||||
return 0;
|
||||
}
|
||||
@@ -1185,7 +1208,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (c->dragging) return 0;
|
||||
if(LOWORD(wp)==44) {
|
||||
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
|
||||
c->smoothing=amount;
|
||||
if(c->smoothing!=amount) {
|
||||
c->smoothing=amount;
|
||||
c->smoothingPreferenceDirty=true;
|
||||
}
|
||||
c->saveSmoothingPreference();
|
||||
if(std::any_of(c->marks.begin(),c->marks.end(),[&](const Mark &m){return m.freehand && m.smoothing!=amount;})) {
|
||||
c->remember(c->marks);
|
||||
for(auto &mark:c->marks) if(mark.freehand) setScreenshotSmoothing(mark,amount);
|
||||
|
||||
@@ -9,6 +9,7 @@ struct ScreenshotHost {
|
||||
unsigned windows{};
|
||||
bool (*notify)(const Clip &){};
|
||||
bool autoOpenEditor = false;
|
||||
std::filesystem::path smoothingPreference;
|
||||
};
|
||||
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
|
||||
void showScreenshot(ScreenshotHost &);
|
||||
|
||||
@@ -110,6 +110,8 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
||||
InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost host{instance};
|
||||
host.notify=ks::notifyScreenshot;
|
||||
try { host.smoothingPreference=ks::dataDirectory()/L"screenshot-smoothing"; }
|
||||
catch(const std::exception &) {} // The editor can still run without local preferences.
|
||||
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
|
||||
RegisterClassW(&cls);
|
||||
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
|
||||
|
||||
+4
-2
@@ -253,10 +253,12 @@ void validateSettings(const Settings &s)
|
||||
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||
from == lower(from) && to == lower(to) && from != to &&
|
||||
from.find_first_not_of(alphabet) == from.npos &&
|
||||
validAutoReplacementSource(from, alphabet) &&
|
||||
validAutoCorrection(to, alphabet);
|
||||
if (!valid)
|
||||
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 words and apostrophes between letters are allowed.");
|
||||
throw std::runtime_error("Invalid replacement: " + utf8(from.substr(0, 32)) + "=" +
|
||||
utf8(to.substr(0, 32)) + "\n\nUse one lowercase word of 1-32 characters. The typo may include "
|
||||
"digits and internal apostrophes. Use a different correction of 1-32 characters in the same language.");
|
||||
}
|
||||
for (const auto &[language, words] : s.autoWords)
|
||||
{
|
||||
|
||||
+413
-162
@@ -1,6 +1,7 @@
|
||||
#include "lan_layout.h"
|
||||
#include "../resources/resource.h"
|
||||
#include "app.h"
|
||||
#include "lan_log.h"
|
||||
#include "history_preview.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "configuration.h"
|
||||
@@ -270,6 +271,53 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
}
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
}
|
||||
LRESULT CALLBACK listHeaderDraw(HWND list, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (message != WM_NOTIFY) return DefSubclassProc(list, message, wp, lp);
|
||||
const auto *notice = reinterpret_cast<const NMHDR *>(lp);
|
||||
auto header = ListView_GetHeader(list);
|
||||
if (notice->hwndFrom != header || notice->code != NM_CUSTOMDRAW)
|
||||
return DefSubclassProc(list, message, wp, lp);
|
||||
const auto &draw = *reinterpret_cast<const NMCUSTOMDRAW *>(lp);
|
||||
auto background = GetSysColorBrush(COLOR_3DFACE);
|
||||
auto border = GetSysColorBrush(COLOR_3DSHADOW);
|
||||
if (draw.dwDrawStage == CDDS_PREPAINT)
|
||||
{
|
||||
FillRect(draw.hdc, &draw.rc, background);
|
||||
return CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYPOSTPAINT;
|
||||
}
|
||||
if (draw.dwDrawStage == CDDS_ITEMPREPAINT)
|
||||
{
|
||||
RECT cell = draw.rc;
|
||||
FillRect(draw.hdc, &cell, background);
|
||||
RECT divider{cell.right - 1, cell.top, cell.right, cell.bottom};
|
||||
FillRect(draw.hdc, ÷r, border);
|
||||
wchar_t title[64]{};
|
||||
HDITEMW item{HDI_TEXT | HDI_FORMAT};
|
||||
item.pszText = title;
|
||||
item.cchTextMax = std::size(title);
|
||||
Header_GetItem(header, static_cast<int>(draw.dwItemSpec), &item);
|
||||
cell.left += scale(list, 5);
|
||||
cell.right -= scale(list, 5);
|
||||
int dc = SaveDC(draw.hdc);
|
||||
auto font = SendMessageW(header, WM_GETFONT, 0, 0);
|
||||
if (font) SelectObject(draw.hdc, reinterpret_cast<HFONT>(font));
|
||||
SetTextColor(draw.hdc, GetSysColor(COLOR_BTNTEXT));
|
||||
SetBkMode(draw.hdc, TRANSPARENT);
|
||||
UINT alignment = (item.fmt & HDF_RIGHT) ? DT_RIGHT : (item.fmt & HDF_CENTER) ? DT_CENTER : DT_LEFT;
|
||||
DrawTextW(draw.hdc, title, -1, &cell, alignment | DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||
RestoreDC(draw.hdc, dc);
|
||||
return CDRF_SKIPDEFAULT;
|
||||
}
|
||||
if (draw.dwDrawStage == CDDS_POSTPAINT)
|
||||
{
|
||||
RECT line{};
|
||||
GetClientRect(header, &line);
|
||||
line.top = line.bottom - 1;
|
||||
FillRect(draw.hdc, &line, border);
|
||||
}
|
||||
return CDRF_DODEFAULT;
|
||||
}
|
||||
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
|
||||
int width, int height)
|
||||
{
|
||||
@@ -278,6 +326,8 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
|
||||
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||
if (wcscmp(cls, WC_LISTVIEWW) == 0 && (style & LVS_TYPEMASK) == LVS_REPORT && !(style & LVS_NOCOLUMNHEADER))
|
||||
SetWindowSubclass(w, listHeaderDraw, 3, 0);
|
||||
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
|
||||
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
|
||||
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
|
||||
@@ -1087,6 +1137,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
constexpr int SnippetsPage = 4, SaveSnippets = 318;
|
||||
struct SnippetView
|
||||
{
|
||||
App *app{};
|
||||
std::vector<Snippet> snippets, saved;
|
||||
int selection = -1;
|
||||
};
|
||||
struct SettingsView
|
||||
{
|
||||
App *app{};
|
||||
@@ -1099,7 +1156,11 @@ struct SettingsView
|
||||
std::vector<std::wstring> mouseProfiles;
|
||||
unsigned mouseEvents{}, rawMouseEvents{};
|
||||
bool rawMouseRegistered{};
|
||||
SnippetView snippets;
|
||||
};
|
||||
bool snippetCommand(HWND, SnippetView *, WPARAM, bool embedded);
|
||||
bool confirmSnippetClose(HWND, SnippetView *);
|
||||
void refreshSnippetDraft(HWND, SnippetView *);
|
||||
void updateSettingsState(HWND w, SettingsView *v);
|
||||
void selectSettingsPage(HWND w, SettingsView *v)
|
||||
{
|
||||
@@ -1116,6 +1177,8 @@ struct ChatLanguagesView
|
||||
Settings draft;
|
||||
int selection = -1;
|
||||
bool dirty{};
|
||||
bool updatingList{};
|
||||
std::wstring search;
|
||||
};
|
||||
void collectChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
{
|
||||
@@ -1130,9 +1193,17 @@ void collectChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
}
|
||||
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;
|
||||
v->selection = index;
|
||||
int index = v->selection;
|
||||
EnableWindow(GetDlgItem(w, 711), index >= 0);
|
||||
EnableWindow(GetDlgItem(w, IDOK), index >= 0);
|
||||
if (index < 0)
|
||||
{
|
||||
SendDlgItemMessageW(w, 711, LB_SETSEL, FALSE, -1);
|
||||
EnableWindow(GetDlgItem(w, 713), FALSE);
|
||||
value(w, 712, v->keys.empty() ? L"Open a chat, then reopen this dialog to add it." :
|
||||
L"No contacts match your search.");
|
||||
return;
|
||||
}
|
||||
const auto &draft = v->draft;
|
||||
auto saved = draft.chatCycles.find(v->keys[index]);
|
||||
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
||||
@@ -1146,26 +1217,103 @@ void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
L"Chat profile. Select languages, or remove it to use the defaults.");
|
||||
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
|
||||
}
|
||||
void selectChatRow(HWND list, int index)
|
||||
{
|
||||
int row = -1;
|
||||
if (index >= 0)
|
||||
{
|
||||
LVFINDINFOW find{LVFI_PARAM};
|
||||
find.lParam = index;
|
||||
row = ListView_FindItem(list, -1, &find);
|
||||
}
|
||||
ListView_SetItemState(list, -1, 0, LVIS_SELECTED | LVIS_FOCUSED);
|
||||
if (row >= 0)
|
||||
{
|
||||
ListView_SetItemState(list, row, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||
ListView_EnsureVisible(list, row, FALSE);
|
||||
}
|
||||
}
|
||||
void filterChatRows(HWND w, ChatLanguagesView *v)
|
||||
{
|
||||
auto list = GetDlgItem(w, 710);
|
||||
auto search = lower(v->search);
|
||||
int selection = -1;
|
||||
v->updatingList = true;
|
||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||
ListView_DeleteAllItems(list);
|
||||
for (size_t i = 0; i < v->keys.size(); ++i)
|
||||
{
|
||||
const auto &key = v->keys[i];
|
||||
auto contact = key.substr(key.find(L'\n') + 1);
|
||||
if (lower(contact).find(search) == std::wstring::npos) continue;
|
||||
auto app = key.starts_with(L"telegram\n") ? L"Telegram" :
|
||||
key.starts_with(L"telegram-web\n") ? L"Telegram Web" :
|
||||
key.starts_with(L"viber\n") ? L"Viber (inactive)" : L"Teams";
|
||||
LVITEMW item{LVIF_TEXT | LVIF_PARAM};
|
||||
item.iItem = ListView_GetItemCount(list);
|
||||
item.pszText = contact.data();
|
||||
item.lParam = static_cast<LPARAM>(i);
|
||||
int row = ListView_InsertItem(list, &item);
|
||||
if (row < 0) continue;
|
||||
ListView_SetItemText(list, row, 1, const_cast<wchar_t *>(app));
|
||||
if (selection < 0 || static_cast<int>(i) == v->selection) selection = static_cast<int>(i);
|
||||
}
|
||||
selectChatRow(list, selection);
|
||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
v->updatingList = false;
|
||||
v->selection = selection;
|
||||
fillChatLanguages(w, v);
|
||||
}
|
||||
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_NOTIFY && !v->updatingList)
|
||||
{
|
||||
auto *change = reinterpret_cast<const NMLISTVIEW *>(lp);
|
||||
if (change->hdr.idFrom == 710 && change->hdr.code == LVN_ITEMCHANGED &&
|
||||
(change->uNewState & LVIS_SELECTED) && !(change->uOldState & LVIS_SELECTED))
|
||||
{
|
||||
int selected = static_cast<int>(change->lParam);
|
||||
if (selected == v->selection) return 0;
|
||||
try { collectChatLanguages(w, v); }
|
||||
catch (...)
|
||||
{
|
||||
v->updatingList = true;
|
||||
selectChatRow(change->hdr.hwndFrom, v->selection);
|
||||
v->updatingList = false;
|
||||
throw;
|
||||
}
|
||||
v->selection = selected;
|
||||
fillChatLanguages(w, v);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 711 && HIWORD(wp) == LBN_SELCHANGE) { v->dirty = true; return 0; }
|
||||
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
if (id == 714 && HIWORD(wp) == EN_CHANGE && !v->updatingList)
|
||||
{
|
||||
try { collectChatLanguages(w, v); }
|
||||
catch (...) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, v->selection, 0); throw; }
|
||||
fillChatLanguages(w, v);
|
||||
catch (...)
|
||||
{
|
||||
v->updatingList = true;
|
||||
value(w, 714, v->search);
|
||||
v->updatingList = false;
|
||||
throw;
|
||||
}
|
||||
v->search = value(w, 714);
|
||||
filterChatRows(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK || id == 713)
|
||||
{
|
||||
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||
int selected = v->selection;
|
||||
if (id == IDOK && selected < 0) return 0;
|
||||
if (selected < 0 || selected >= (int)v->keys.size())
|
||||
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
|
||||
if (id == 713)
|
||||
@@ -1184,7 +1332,14 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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_DPICHANGED)
|
||||
{
|
||||
resizeForDpi(w, wp, lp);
|
||||
auto list = GetDlgItem(w, 710);
|
||||
ListView_SetColumnWidth(list, 0, scale(w, 442));
|
||||
ListView_SetColumnWidth(list, 1, scale(w, 140));
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
EnableWindow(v->parent, TRUE);
|
||||
@@ -1210,19 +1365,25 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
if (!key.empty()) data.keys.insert(std::move(key));
|
||||
return TRUE;
|
||||
}, (LPARAM)&context);
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&controls);
|
||||
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()}};
|
||||
v->draft = settings->draft;
|
||||
v->updatingList = true;
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||
710, 18, 44, 604, 140);
|
||||
for (const auto &key : v->keys)
|
||||
label(w, L"&Find contact", 18, 19, 106);
|
||||
edit(w, 714, 130, 14, 492);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SINGLESEL |
|
||||
LVS_SHOWSELALWAYS | LVS_NOSORTHEADER, 710, 18, 48, 604, 136);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP);
|
||||
for (const auto &[title, width] : {std::pair{L"Contact", 442}, std::pair{L"App", 140}})
|
||||
{
|
||||
auto split = key.find(L'\n');
|
||||
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"telegram-web\n") ? L"Telegram Web: " : key.starts_with(L"viber\n") ? L"Viber (inactive): " : L"Teams: ") + key.substr(split + 1);
|
||||
SendDlgItemMessageW(w, 710, LB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
LVCOLUMNW column{LVCF_TEXT | LVCF_WIDTH};
|
||||
column.pszText = const_cast<wchar_t *>(title);
|
||||
column.cx = scale(w, width);
|
||||
ListView_InsertColumn(list, Header_GetItemCount(ListView_GetHeader(list)), &column);
|
||||
}
|
||||
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);
|
||||
@@ -1235,12 +1396,10 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
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); }
|
||||
filterChatRows(w, v);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w);
|
||||
SetFocus(GetDlgItem(w, 710));
|
||||
SetFocus(GetDlgItem(w, 714));
|
||||
}
|
||||
#include "ui_app_languages.inl"
|
||||
#include "ui_mouse_buttons.inl"
|
||||
@@ -1700,9 +1859,9 @@ void updateSettingsState(HWND w, SettingsView *v)
|
||||
if (!v || !v->ready || v->filling || v->refreshing) return;
|
||||
v->refreshing = true;
|
||||
bool changed = settingsChanged(w, v);
|
||||
EnableWindow(GetDlgItem(w, IDOK), changed);
|
||||
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." :
|
||||
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved changes.";
|
||||
EnableWindow(GetDlgItem(w, IDOK), changed || (v->app->settings.autostart && !isLocalBuild()));
|
||||
const wchar_t *text = changed ? L"Unsaved settings — click Save to apply." :
|
||||
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved settings.";
|
||||
if (value(w, 261) != text) value(w, 261, text);
|
||||
v->refreshing = false;
|
||||
}
|
||||
@@ -1742,7 +1901,8 @@ void closeSettings(HWND w, SettingsView *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
DestroyWindow(w);
|
||||
if (confirmSnippetClose(w, &v->snippets))
|
||||
DestroyWindow(w);
|
||||
}
|
||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
@@ -1772,12 +1932,22 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
case WM_CTLCOLORSTATIC:
|
||||
if (reinterpret_cast<HWND>(lp) == GetDlgItem(w, 281))
|
||||
{
|
||||
auto dc = reinterpret_cast<HDC>(wp);
|
||||
SetTextColor(dc, GetSysColor(COLOR_WINDOWTEXT));
|
||||
SetBkColor(dc, GetSysColor(COLOR_WINDOW));
|
||||
return reinterpret_cast<LRESULT>(GetSysColorBrush(COLOR_WINDOW));
|
||||
}
|
||||
break;
|
||||
case WM_INPUT:
|
||||
readMouseTestInput(w, *v, reinterpret_cast<HRAWINPUT>(lp));
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
case ConfigSettingsDownloaded:
|
||||
v->draft = v->app->settings;
|
||||
fillSettings(w, v);
|
||||
refreshSnippetDraft(w, &v->snippets);
|
||||
return 0;
|
||||
case WM_ENABLE:
|
||||
if (wp) updateSettingsState(w, v);
|
||||
@@ -1814,6 +1984,14 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
SettingsStateRefresh refresh{w};
|
||||
int id = LOWORD(wp);
|
||||
if (id == 282 && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
writeClipboard(w, textClip(value(w, 281)));
|
||||
value(w, 283, L"Copied to clipboard.");
|
||||
return 0;
|
||||
}
|
||||
if (id >= 301 && id <= SaveSnippets && snippetCommand(w, &v->snippets, wp, true))
|
||||
return 0;
|
||||
if (id == 220 && HIWORD(wp) == LBN_SELCHANGE && !v->filling)
|
||||
{
|
||||
collectLayout(w, v);
|
||||
@@ -1851,7 +2029,8 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == IDOK)
|
||||
{
|
||||
if (!settingsChanged(w, v)) return 0;
|
||||
if (!settingsChanged(w, v) && (!v->app->settings.autostart || isLocalBuild())) return 0;
|
||||
if (!confirmSnippetClose(w, &v->snippets)) return 0;
|
||||
if (v->app->busy)
|
||||
throw std::runtime_error("Wait for the current text operation to finish");
|
||||
v->app->applySettings(collectSettings(w, v));
|
||||
@@ -1886,11 +2065,6 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
showAutoOptions(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 252)
|
||||
{
|
||||
showSnippets(*v->app);
|
||||
return 0;
|
||||
}
|
||||
if (id == 253)
|
||||
{
|
||||
if (v->app->busy)
|
||||
@@ -1927,12 +2101,6 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
struct SnippetView
|
||||
{
|
||||
App *app;
|
||||
std::vector<Snippet> snippets;
|
||||
int selection = -1;
|
||||
};
|
||||
void updateSnippetEditor(HWND w, SnippetView *v)
|
||||
{
|
||||
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
||||
@@ -1985,6 +2153,168 @@ void collectSnippet(HWND w, SnippetView *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
bool sameSnippets(const std::vector<Snippet> &left, const std::vector<Snippet> &right)
|
||||
{
|
||||
return std::equal(left.begin(), left.end(), right.begin(), right.end(), [](const Snippet &a, const Snippet &b) {
|
||||
return a.id == b.id && a.trigger == b.trigger && a.text == b.text && a.apps == b.apps;
|
||||
});
|
||||
}
|
||||
void refreshSnippetDraft(HWND w, SnippetView *v)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
if (!sameSnippets(v->snippets, v->saved) || sameSnippets(v->saved, v->app->settings.snippets))
|
||||
return;
|
||||
v->snippets = v->saved = v->app->settings.snippets;
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 319, L"");
|
||||
}
|
||||
void saveSnippetDraft(HWND w, SnippetView *v)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
if (sameSnippets(v->snippets, v->saved)) return;
|
||||
auto settings = v->app->settings;
|
||||
if (v->app->busy)
|
||||
throw std::runtime_error("Wait for the current operation");
|
||||
if (!sameSnippets(settings.snippets, v->saved) && !sameSnippets(settings.snippets, v->snippets))
|
||||
throw std::runtime_error("Snippets changed elsewhere. Reopen Settings to load the current snippets.");
|
||||
settings.snippets = v->snippets;
|
||||
v->app->applySettings(settings);
|
||||
v->snippets = v->saved = v->app->settings.snippets;
|
||||
if (v->selection >= (int)v->snippets.size()) v->selection = -1;
|
||||
if (auto parent = v->app->settingsWindow)
|
||||
{
|
||||
auto *view = reinterpret_cast<SettingsView *>(GetWindowLongPtrW(parent, GWLP_USERDATA));
|
||||
view->draft.snippets = v->saved;
|
||||
view->draft.snippetTombstones = v->app->settings.snippetTombstones;
|
||||
updateSettingsState(parent, view);
|
||||
}
|
||||
}
|
||||
bool confirmSnippetClose(HWND w, SnippetView *v)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
if (sameSnippets(v->snippets, v->saved)) return true;
|
||||
const int choice = MessageBoxW(w, L"Save your snippet changes before closing?",
|
||||
L"KSwitcher — Unsaved snippets", MB_YESNOCANCEL | MB_ICONWARNING | MB_DEFBUTTON3);
|
||||
if (choice == IDYES) saveSnippetDraft(w, v);
|
||||
return choice == IDYES || choice == IDNO;
|
||||
}
|
||||
bool snippetCommand(HWND w, SnippetView *v, WPARAM wp, bool embedded)
|
||||
{
|
||||
const int id = LOWORD(wp);
|
||||
if (embedded && ((HIWORD(wp) == EN_CHANGE && (id == 302 || id == 303 || id == 315)) ||
|
||||
id == 310 || id == 311 || id == 314))
|
||||
value(w, 319, L"");
|
||||
if (id == 314)
|
||||
{
|
||||
bool restricted = IsDlgButtonChecked(w, 314) == BST_CHECKED;
|
||||
for (int field : {315, 316, 317}) EnableWindow(GetDlgItem(w, field), restricted);
|
||||
return true;
|
||||
}
|
||||
if (id == 317)
|
||||
{
|
||||
auto app = value(w, 316);
|
||||
if (!app.empty())
|
||||
{
|
||||
auto current = value(w, 315);
|
||||
std::wistringstream input(current); std::wstring entry;
|
||||
while (std::getline(input, entry, L',')) if (snippetAppName(entry) == snippetAppName(app)) return true;
|
||||
value(w, 315, current.empty() ? app : current + L", " + app);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
return true;
|
||||
}
|
||||
if (id == 310)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return true;
|
||||
}
|
||||
if (id == 311)
|
||||
{
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (id == 312 || id == 313)
|
||||
{
|
||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return true;
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||
return true;
|
||||
}
|
||||
if (id == (embedded ? SaveSnippets : IDOK))
|
||||
{
|
||||
saveSnippetDraft(w, v);
|
||||
if (embedded)
|
||||
{
|
||||
fillSnippets(w, v);
|
||||
value(w, 319, L"Snippets saved.");
|
||||
}
|
||||
else DestroyWindow(w);
|
||||
return true;
|
||||
}
|
||||
if (!embedded && id == IDCANCEL)
|
||||
{
|
||||
DestroyWindow(w);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void createSnippetEditor(HWND w, SnippetView *view, bool embedded)
|
||||
{
|
||||
const int top = embedded ? 60 : 16;
|
||||
const int textHeight = embedded ? 106 : 240;
|
||||
const int actions = top + 201 + textHeight;
|
||||
label(w, L"Triggers", 24, top, 186);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||
301, 24, top + 24, 186, 170 + textHeight);
|
||||
button(w, L"&Add", 310, 24, actions, 86);
|
||||
button(w, L"&Delete", 311, 120, actions, 90);
|
||||
label(w, L"&Trigger (for example ;addr)", 232, top, 452);
|
||||
edit(w, 302, 232, top + 24, 452);
|
||||
checkbox(w, L"Only in these &applications (unchecked: everywhere)", 314, 232, top + 55, 452);
|
||||
edit(w, 315, 232, top + 83, 452);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, 316, 232, top + 113, 330, 200);
|
||||
button(w, L"Add a&pp", 317, 572, top + 110, 112);
|
||||
label(w, L"Process names, comma-separated (for example chrome.exe)", 232, top + 144, 452);
|
||||
label(w, L"&Replacement text", 232, top + 170, 452);
|
||||
edit(w, 303, 232, top + 194, 452, textHeight, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
button(w, L"Insert cursor &marker", 312, 232, actions, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 432, actions, 190);
|
||||
if (embedded)
|
||||
{
|
||||
control(w, L"STATIC", L"", 0, 319, 232, actions + 35, 286, 24);
|
||||
button(w, L"Sa&ve snippets", SaveSnippets, 530, actions + 30, 154);
|
||||
}
|
||||
else
|
||||
{
|
||||
control(w, L"STATIC", L"Use {{cursor}} / {{enter}} for literal markers.", 0, 0, 232, actions + 35, 452, 22);
|
||||
button(w, L"&Save", IDOK, 474, actions + 65, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 584, actions + 65, 100);
|
||||
}
|
||||
std::set<std::wstring> apps;
|
||||
EnumWindows([](HWND candidate, LPARAM context)->BOOL {
|
||||
if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set<std::wstring>*)context)->insert(lower(name) + L".exe"); }
|
||||
return TRUE;
|
||||
}, (LPARAM)&apps);
|
||||
for (const auto &name : apps) SendDlgItemMessageW(w, 316, CB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
SendDlgItemMessageW(w, 316, CB_SETCURSEL, 0, 0);
|
||||
fillSnippets(w, view);
|
||||
}
|
||||
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED)
|
||||
@@ -1996,102 +2326,24 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (msg == WM_NCCREATE)
|
||||
{
|
||||
auto *app = (App *)((CREATESTRUCTW *)lp)->lpCreateParams;
|
||||
v = new SnippetView{app, app->settings.snippets};
|
||||
v = new SnippetView{app, app->settings.snippets, app->settings.snippets};
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||
return TRUE;
|
||||
}
|
||||
if (!v)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||
try
|
||||
{
|
||||
switch (msg)
|
||||
if (msg == WM_COMMAND && snippetCommand(w, v, wp, false)) return 0;
|
||||
if (msg == WM_CLOSE)
|
||||
{
|
||||
case WM_COMMAND:
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 314)
|
||||
{
|
||||
bool restricted = IsDlgButtonChecked(w, 314) == BST_CHECKED;
|
||||
for (int field : {315, 316, 317}) EnableWindow(GetDlgItem(w, field), restricted);
|
||||
return 0;
|
||||
}
|
||||
if (id == 317)
|
||||
{
|
||||
auto app = value(w, 316);
|
||||
if (!app.empty())
|
||||
{
|
||||
auto current = value(w, 315);
|
||||
std::wistringstream input(current); std::wstring entry;
|
||||
while (std::getline(input, entry, L',')) if (snippetAppName(entry) == snippetAppName(app)) return 0;
|
||||
value(w, 315, current.empty() ? app : current + L", " + app);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 310)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return 0;
|
||||
}
|
||||
if (id == 311)
|
||||
{
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (id == 312 || id == 313)
|
||||
{
|
||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
auto s = v->app->settings;
|
||||
s.snippets = v->snippets;
|
||||
if (v->app->busy)
|
||||
throw std::runtime_error("Wait for the current operation");
|
||||
v->app->applySettings(s);
|
||||
if (v->app->settingsWindow)
|
||||
{
|
||||
auto *parent = (SettingsView *)GetWindowLongPtrW(v->app->settingsWindow, GWLP_USERDATA);
|
||||
parent->draft.snippets = s.snippets;
|
||||
updateSettingsState(v->app->settingsWindow, parent);
|
||||
}
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDCANCEL)
|
||||
{
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
case WM_NCDESTROY:
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
disposeFont(w);
|
||||
v->app->snippetsWindow = nullptr;
|
||||
delete v;
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -2227,7 +2479,9 @@ void showSettings(App &app, bool visible)
|
||||
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
|
||||
InitCommonControlsEx(&tabsInit);
|
||||
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
|
||||
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools", L"Mouse"};
|
||||
TabCtrl_SetPadding(tabs, scale(w, 3), scale(w, 3));
|
||||
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Snippets", L"Mouse",
|
||||
L"Sync", L"Computers", L"Diagnostics", L"MCP"};
|
||||
for (int i = 0; i < (int)std::size(tabNames); ++i)
|
||||
{
|
||||
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
|
||||
@@ -2288,26 +2542,41 @@ void showSettings(App &app, bool visible)
|
||||
button(w, L"Clear &history...", 254, 24, 258, 200);
|
||||
label(w, L"Exclude applications (process names, comma-separated)", 24, 330, 660);
|
||||
edit(w, 243, 24, 362, 660);
|
||||
button(w, L"OCR op&tions...", 257, 24, 394, 200);
|
||||
page();
|
||||
|
||||
label(w, L"Additional tools and preferences", 24, 76, 660);
|
||||
button(w, L"S&nippets...", 252, 24, 116, 210);
|
||||
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
|
||||
button(w, L"OCR op&tions...", 257, 24, 170, 210);
|
||||
label(w, L"Configure text recognition from images.", 254, 174, 430);
|
||||
button(w, L"S&ync...", 258, 24, 224, 210);
|
||||
label(w, L"Synchronize preferences between computers.", 254, 228, 430);
|
||||
button(w, L"KSwitcher &computers...", 262, 24, 278, 210);
|
||||
label(w, L"Share mouse and keyboard with paired computers.", 254, 282, 430);
|
||||
button(w, L"View l&og...", 256, 24, 332, 210);
|
||||
label(w, L"Inspect recent operations and diagnostics.", 254, 336, 430);
|
||||
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
|
||||
v->snippets = {&app, app.settings.snippets, app.settings.snippets};
|
||||
createSnippetEditor(w, &v->snippets, true);
|
||||
page();
|
||||
|
||||
createMousePage(w, *v);
|
||||
page();
|
||||
|
||||
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
|
||||
label(w, L"Back up and synchronize settings through OpenBao.", 24, 76, 660);
|
||||
button(w, L"S&ync settings...", 258, 24, 110, 210);
|
||||
page();
|
||||
|
||||
label(w, L"Share mouse and keyboard with paired computers.", 24, 76, 660);
|
||||
button(w, L"Manage &computers...", 262, 24, 110, 210);
|
||||
page();
|
||||
|
||||
checkbox(w, L"Write lo&g", 208, 24, 76, 210);
|
||||
button(w, L"View l&og...", 256, 24, 110, 210);
|
||||
page();
|
||||
|
||||
label(w, L"Codex configuration", 24, 76, 660);
|
||||
label(w, L"Paste this block into ~/.codex/config.toml to connect Codex to KSwitcher.", 24, 108, 660);
|
||||
edit(w, 281, 24, 140, 660, 108, ES_MULTILINE | ES_READONLY | ES_AUTOHSCROLL | WS_HSCROLL | WS_BORDER);
|
||||
const auto server = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||
value(w, 281, L"[mcp_servers.kswitcher]\r\ncommand = " +
|
||||
wide(nlohmann::json(utf8(server.wstring())).dump()) + L"\r\nargs = [\"--mcp\"]\r\n");
|
||||
label(w, L"Select text and press Ctrl+C, or copy the entire block below.", 24, 258, 660);
|
||||
button(w, L"&Copy configuration", 282, 24, 288, 190);
|
||||
control(w, L"STATIC", L"", 0, 283, 232, 293, 452, 24);
|
||||
label(w, L"For remote logs, enable diagnostic log downloads on the paired computer.", 24, 338, 660);
|
||||
page();
|
||||
|
||||
control(w, L"STATIC", L"No unsaved settings.", 0, 261, 18, 430, 684, 24);
|
||||
button(w, L"Impo&rt...", 250, 18, 466, 125);
|
||||
button(w, L"&Export...", 251, 155, 466, 125);
|
||||
button(w, L"&Save", IDOK, 490, 466, 100);
|
||||
@@ -2325,40 +2594,22 @@ void showSettings(App &app, bool visible)
|
||||
}
|
||||
void showSnippets(App &app)
|
||||
{
|
||||
if (app.settingsWindow)
|
||||
{
|
||||
HWND w = app.settingsWindow;
|
||||
TabCtrl_SetCurSel(GetDlgItem(w, 260), SnippetsPage);
|
||||
selectSettingsPage(w, reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA)));
|
||||
activateWindow(w);
|
||||
return;
|
||||
}
|
||||
if (app.snippetsWindow)
|
||||
{
|
||||
SetForegroundWindow(app.snippetsWindow);
|
||||
return;
|
||||
}
|
||||
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 740, 620);
|
||||
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 720, 565);
|
||||
app.snippetsWindow = w;
|
||||
label(w, L"Triggers", 16, 16);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
||||
16, 44, 200, 480);
|
||||
button(w, L"&Add", 310, 16, 538, 90);
|
||||
button(w, L"&Delete", 311, 116, 538, 100);
|
||||
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
||||
edit(w, 302, 236, 44, 488);
|
||||
control(w, L"BUTTON", L"Only in these &applications (unchecked: everywhere)", WS_TABSTOP | BS_AUTOCHECKBOX, 314, 236, 82, 488, 24);
|
||||
edit(w, 315, 236, 112, 488);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, 316, 236, 148, 364, 200);
|
||||
button(w, L"Add a&pp", 317, 612, 145, 112);
|
||||
std::set<std::wstring> apps;
|
||||
EnumWindows([](HWND candidate, LPARAM context)->BOOL {
|
||||
if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set<std::wstring>*)context)->insert(lower(name) + L".exe"); }
|
||||
return TRUE;
|
||||
}, (LPARAM)&apps);
|
||||
for (const auto &name : apps) SendDlgItemMessageW(w, 316, CB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
SendDlgItemMessageW(w, 316, CB_SETCURSEL, 0, 0);
|
||||
label(w, L"Process names, comma-separated; e.g. chrome.exe, ms-teams.exe", 236, 182, 488);
|
||||
label(w, L"&Replacement text", 236, 212, 480);
|
||||
edit(w, 303, 236, 240, 488, 240, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 492, 488, 36);
|
||||
button(w, L"Insert cursor &marker", 312, 236, 530, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 436, 530, 190);
|
||||
button(w, L"&Save", IDOK, 510, 578, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 624, 578, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
createSnippetEditor(w, reinterpret_cast<SnippetView *>(GetWindowLongPtrW(w, GWLP_USERDATA)), false);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
}
|
||||
|
||||
+98
-3
@@ -9,6 +9,9 @@ struct LanView
|
||||
std::string dragPeer;POINT dragStart{},dragPoint{};bool dragMoved{};
|
||||
std::string gridState;
|
||||
std::vector<std::wstring> rows;
|
||||
std::string logRequest;
|
||||
std::filesystem::path logDestination;
|
||||
std::wstring transferMessage;
|
||||
};
|
||||
nlohmann::json lanRequest(App &app,const nlohmann::json &request)
|
||||
{
|
||||
@@ -20,6 +23,19 @@ std::string selectedLan(HWND w,LanView &v)
|
||||
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
|
||||
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
|
||||
}
|
||||
std::filesystem::path pickLanFile(HWND owner, bool save, const wchar_t *suggestion=L"")
|
||||
{
|
||||
wchar_t path[32768]{};
|
||||
wcsncpy_s(path,suggestion,_TRUNCATE);
|
||||
OPENFILENAMEW dialog{sizeof(dialog)};
|
||||
dialog.hwndOwner=owner;
|
||||
dialog.lpstrFilter=L"All files\0*.*\0";
|
||||
dialog.lpstrFile=path;
|
||||
dialog.nMaxFile=std::size(path);
|
||||
dialog.Flags=OFN_EXPLORER|OFN_NOCHANGEDIR|OFN_PATHMUSTEXIST|(save?OFN_OVERWRITEPROMPT:OFN_FILEMUSTEXIST);
|
||||
if(save?GetSaveFileNameW(&dialog):GetOpenFileNameW(&dialog))return path;
|
||||
return {};
|
||||
}
|
||||
LRESULT CALLBACK lanHelpProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(msg==WM_DPICHANGED){resizeForDpi(w,wp,lp);return 0;}
|
||||
@@ -42,6 +58,25 @@ void showLanHelp(HWND parent,App &app)
|
||||
}
|
||||
void refreshLan(HWND w,LanView &v)
|
||||
{
|
||||
if(!v.logRequest.empty())
|
||||
{
|
||||
auto reply=lanRequest(*v.app,{{"action","takeLog"},{"request",v.logRequest}});
|
||||
auto state=reply.at("state").get<std::string>();
|
||||
if(state!="waiting")
|
||||
{
|
||||
if(state=="ready")
|
||||
{
|
||||
v.logRequest.clear();
|
||||
v.transferMessage.clear();
|
||||
atomicWrite(v.logDestination,decodeLanLog(reply.at("logHex")));
|
||||
v.transferMessage=L"Diagnostic log saved.";
|
||||
}
|
||||
else if(state=="denied")v.transferMessage=L"Remote diagnostic downloads are disabled on that PC.";
|
||||
else if(state=="unavailable")v.transferMessage=L"The selected log is unavailable on that PC.";
|
||||
else v.transferMessage=L"The remote log download timed out.";
|
||||
v.logRequest.clear();
|
||||
}
|
||||
}
|
||||
auto selected=selectedLan(w,v);
|
||||
auto next=lanRequest(*v.app,{{"action","status"}});
|
||||
if(selected.empty())
|
||||
@@ -104,6 +139,18 @@ void refreshLan(HWND w,LanView &v)
|
||||
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
|
||||
EnableWindow(GetDlgItem(w,706),pending);
|
||||
EnableWindow(GetDlgItem(w,707),paired);
|
||||
bool canTransfer=false;
|
||||
for(const auto &peer:v.snapshot["peers"])
|
||||
if(peer.at("id")==selected)canTransfer=paired&&peer.value("verified",false)&&peer.value("fileTransfer",false);
|
||||
EnableWindow(GetDlgItem(w,726),canTransfer);
|
||||
EnableWindow(GetDlgItem(w,727),canTransfer&&v.logRequest.empty());
|
||||
EnableWindow(GetDlgItem(w,728),canTransfer&&v.logRequest.empty());
|
||||
EnableWindow(GetDlgItem(w,730),!v.snapshot["files"].empty());
|
||||
CheckDlgButton(w,729,v.snapshot.value("shareLogs",false)?BST_CHECKED:BST_UNCHECKED);
|
||||
auto fileCount=v.snapshot["files"].size();
|
||||
auto transferStatus=v.transferMessage;
|
||||
if(transferStatus.empty())transferStatus=wide(v.snapshot.value("transferStatus",std::string{}));
|
||||
value(w,731,fileCount?L"Incoming files: "+std::to_wstring(fileCount):transferStatus);
|
||||
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending&&!paired);
|
||||
auto positions=v.snapshot.value("positions",nlohmann::json::object());
|
||||
EnableWindow(GetDlgItem(w,715),enabled&&!positions.empty()&&!v.snapshot.value("shareInput",false));
|
||||
@@ -154,6 +201,49 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
if(id==705) lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});
|
||||
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
|
||||
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
|
||||
if(id==726)
|
||||
{
|
||||
auto file=pickLanFile(w,false);
|
||||
if(!file.empty())
|
||||
{
|
||||
lanRequest(*v->app,{{"action","sendFile"},{"peer",peer},{"path",utf8(file.wstring())}});
|
||||
v->transferMessage.clear();
|
||||
}
|
||||
}
|
||||
if(id==727||id==728)
|
||||
{
|
||||
auto destination=pickLanFile(w,true,id==728?L"kswitcher-previous.log":L"kswitcher-remote.log");
|
||||
if(!destination.empty())
|
||||
{
|
||||
auto reply=lanRequest(*v->app,{{"action","requestLog"},{"peer",peer},{"previous",id==728}});
|
||||
v->logDestination=std::move(destination);
|
||||
v->logRequest=reply.at("request").get<std::string>();
|
||||
v->transferMessage=L"Downloading diagnostic log...";
|
||||
}
|
||||
}
|
||||
if(id==729)lanRequest(*v->app,{{"action","shareLogs"},{"enabled",IsDlgButtonChecked(w,729)==BST_CHECKED}});
|
||||
if(id==730 && !v->snapshot["files"].empty())
|
||||
{
|
||||
const auto file=v->snapshot["files"].front();
|
||||
auto name=wide(file.at("name").get<std::string>());
|
||||
auto question=wide(file.at("sender").get<std::string>())+L" sent "+name+L" ("+
|
||||
std::to_wstring(file.at("size").get<size_t>())+L" bytes).\n\nSave this file? Choose No to discard it.";
|
||||
auto answer=MessageBoxW(w,question.c_str(),L"Incoming file",MB_YESNOCANCEL|MB_ICONQUESTION);
|
||||
if(answer==IDYES || answer==IDNO)
|
||||
{
|
||||
// A peer supplies the display name. Only offer it as a plain Windows filename.
|
||||
const bool validName = !name.empty() && name.size() <= 255 &&
|
||||
name.find_first_of(L"<>:\"/\\|?*") == name.npos &&
|
||||
name.back() != L'.' && name.back() != L' ' &&
|
||||
std::none_of(name.begin(), name.end(), [](wchar_t c) { return c < L' '; });
|
||||
auto destination=answer==IDYES?pickLanFile(w,true,validName?name.c_str():L"received-file"):
|
||||
std::filesystem::path{};
|
||||
if(answer==IDNO||!destination.empty())
|
||||
lanRequest(*v->app,{{"action",answer==IDYES?"saveFile":"discardFile"},{"id",file.at("id")},
|
||||
{"peer",file.at("peer")},{"path",utf8(destination.wstring())}});
|
||||
}
|
||||
v->transferMessage.clear();
|
||||
}
|
||||
if(id==715)
|
||||
{
|
||||
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
|
||||
@@ -167,7 +257,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
void showLan(HWND parent,App &app)
|
||||
{
|
||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,908,0,false);
|
||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers",lanProc,740,908,0,false);
|
||||
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
|
||||
auto v=new LanView{parent,&app};SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)v);
|
||||
label(w,L"Changes here save automatically. No need to click Save in Settings.",18,16,700);
|
||||
@@ -190,8 +280,13 @@ void showLan(HWND parent,App &app)
|
||||
control(w,L"STATIC",L"",0,719,18,674,700,24);
|
||||
button(w,L"Enable &input sharing",715,18,706,330);
|
||||
button(w,L"&Stop input sharing",716,370,706,240);
|
||||
control(w,L"STATIC",L"",0,718,18,744,700,64);
|
||||
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,816,580,40);
|
||||
control(w,L"STATIC",L"",0,718,18,744,700,30);
|
||||
button(w,L"Send file…",726,18,774,144);
|
||||
button(w,L"Incoming files…",730,174,774,160);
|
||||
button(w,L"Download log…",727,346,774,164);
|
||||
button(w,L"Previous log…",728,522,774,196);
|
||||
control(w,L"BUTTON",L"Allow paired computers to download diagnostic logs",BS_AUTOCHECKBOX|WS_TABSTOP,729,18,808,700,24);
|
||||
control(w,L"STATIC",L"",0,731,18,835,700,24);
|
||||
button(w,L"Connection &help",722,18,864,300);
|
||||
button(w,L"&Close",IDCANCEL,618,864,100);
|
||||
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
|
||||
|
||||
+1
-5
@@ -177,11 +177,7 @@ void copyLogRows(HWND w, App &app)
|
||||
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
|
||||
}
|
||||
if (!copied.empty())
|
||||
{
|
||||
auto clip = textClip(copied);
|
||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||
app.ownSequence = writeClipboard(app.window, clip);
|
||||
}
|
||||
writeClipboard(app.window, textClip(copied));
|
||||
}
|
||||
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.71";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.77";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+35
-23
@@ -134,13 +134,13 @@ int main()
|
||||
HWND parent = app.settingsWindow;
|
||||
auto saveEnabled=[&] { return IsWindowEnabled(GetDlgItem(parent,IDOK))!=FALSE; };
|
||||
auto status=[&] { wchar_t text[256]{}; GetDlgItemTextW(parent,261,text,256); return std::wstring(text); };
|
||||
require(!saveEnabled() && status()==L"No unsaved changes.","new Settings shows clean state with Save disabled");
|
||||
require(!saveEnabled() && status()==L"No unsaved settings.","new Settings shows clean state with Save disabled");
|
||||
SendMessageW(parent,WM_COMMAND,IDOK,0);
|
||||
require(IsWindow(parent),"Save command with no changes does not apply or close Settings");
|
||||
bool originalLog=IsDlgButtonChecked(parent,208)==BST_CHECKED;
|
||||
CheckDlgButton(parent,208,originalLog?BST_UNCHECKED:BST_CHECKED);
|
||||
SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0);
|
||||
require(saveEnabled() && status().find(L"Unsaved changes") == 0,"checkbox edit immediately marks Settings dirty");
|
||||
require(saveEnabled() && status().find(L"Unsaved settings") == 0,"checkbox edit immediately marks Settings dirty");
|
||||
CheckDlgButton(parent,208,originalLog?BST_CHECKED:BST_UNCHECKED);
|
||||
SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0);
|
||||
require(!saveEnabled(),"reverting checkbox disables Save again");
|
||||
@@ -179,7 +179,13 @@ int main()
|
||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||
require(!saveEnabled(),"reverting auto-correction draft disables Save");
|
||||
HWND tabs = GetDlgItem(parent, 260);
|
||||
require(tabs && TabCtrl_GetItemCount(tabs) == 6, "six settings categories");
|
||||
require(tabs && TabCtrl_GetItemCount(tabs) == 10, "ten settings categories");
|
||||
wchar_t mcpConfig[4096]{};
|
||||
GetDlgItemTextW(parent, 281, mcpConfig, std::size(mcpConfig));
|
||||
const std::wstring config(mcpConfig);
|
||||
require(config.starts_with(L"[mcp_servers.kswitcher]\r\ncommand = \"") &&
|
||||
config.find(L"KSwitcher.Config.exe\"\r\nargs = [\"--mcp\"]") != std::wstring::npos,
|
||||
"MCP tab shows a Codex stdio configuration");
|
||||
for (int page = 0; page < TabCtrl_GetItemCount(tabs); ++page)
|
||||
{
|
||||
TabCtrl_SetCurSel(tabs, page);
|
||||
@@ -212,10 +218,18 @@ int main()
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
HWND dialog = findTestWindow(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);
|
||||
auto findFixtureChat = [&](HWND chatDialog) {
|
||||
SetDlgItemTextW(chatDialog, 714, L"! fIXTURE CHAT");
|
||||
HWND list = GetDlgItem(chatDialog, 710);
|
||||
require(ListView_GetItemCount(list) == 1, "case-insensitive contact search finds saved chat");
|
||||
wchar_t appName[32]{};
|
||||
ListView_GetItemText(list, 0, 1, appName, std::size(appName));
|
||||
require(std::wstring(appName) == L"Telegram", "chat list shows the source app separately");
|
||||
};
|
||||
SetDlgItemTextW(dialog, 714, L"No matching fixture 8031");
|
||||
require(ListView_GetItemCount(GetDlgItem(dialog, 710)) == 0 &&
|
||||
!IsWindowEnabled(GetDlgItem(dialog, IDOK)), "empty search results disable profile actions");
|
||||
findFixtureChat(dialog);
|
||||
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);
|
||||
@@ -231,9 +245,7 @@ int main()
|
||||
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = findTestWindow(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);
|
||||
findFixtureChat(dialog);
|
||||
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
|
||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
|
||||
@@ -248,13 +260,11 @@ int main()
|
||||
require(!saveEnabled(),"reopened saved Settings is clean");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = findTestWindow(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);
|
||||
findFixtureChat(dialog);
|
||||
SendMessageW(dialog, WM_COMMAND, 713, 0);
|
||||
require(IsWindow(dialog), "chat reset keeps dialog open");
|
||||
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||
require(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
|
||||
require(saveEnabled() && status().find(L"Unsaved settings")==0,"removing child profile immediately marks parent dirty");
|
||||
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||
GetClientRect(parent,&rect); dc=GetDC(parent);
|
||||
clip=ks::screenshotClip(dc,rect.right,rect.bottom,{}); ReleaseDC(parent,dc);
|
||||
@@ -357,7 +367,9 @@ int main()
|
||||
ks::showSettings(app); parent=app.settingsWindow;
|
||||
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
||||
ks::showSnippets(app);
|
||||
HWND snippets = app.snippetsWindow;
|
||||
HWND snippets = parent;
|
||||
require(!app.snippetsWindow && IsWindowVisible(GetDlgItem(snippets, 301)),
|
||||
"opening Snippets from Settings selects its inline editor");
|
||||
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
||||
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
|
||||
@@ -371,20 +383,20 @@ int main()
|
||||
wchar_t appList[128]{};
|
||||
GetDlgItemTextW(snippets,315,appList,128);
|
||||
require(std::wstring(appList)==L"Notepad.exe, MS-Teams.EXE", "Add app appends a second application without duplicates");
|
||||
SendMessageW(snippets,WM_COMMAND,IDOK,0);
|
||||
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
|
||||
require(!saveEnabled() && status()==L"No unsaved changes.","separately saved snippet edit leaves Settings clean");
|
||||
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||
ks::showSnippets(app);snippets=app.snippetsWindow;
|
||||
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
||||
require(IsDlgButtonChecked(snippets,314)==BST_CHECKED, "scope restored when reopening editor");
|
||||
SendMessageW(snippets,WM_COMMAND,318,0);
|
||||
require(IsWindow(snippets) && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "inline snippet editor saves selected applications");
|
||||
require(!saveEnabled() && status()==L"No unsaved settings.","separately saved snippet edit leaves Settings clean");
|
||||
RedrawWindow(snippets,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||
RECT snippetRect{};GetClientRect(snippets,&snippetRect);
|
||||
HDC snippetDc=GetDC(snippets);
|
||||
auto snippetImage=ks::screenshotClip(snippetDc,snippetRect.right,snippetRect.bottom,{});
|
||||
ReleaseDC(snippets,snippetDc);
|
||||
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"snippet-scopes.png",ks::encodePng(ks::decodeImage(snippetImage)));
|
||||
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||
ks::showSnippets(app);snippets=app.snippetsWindow;
|
||||
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
||||
require(IsDlgButtonChecked(snippets,314)==BST_CHECKED, "scope restored when reopening editor");
|
||||
SetDlgItemTextW(snippets,315,L"chrome.exe");
|
||||
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
|
||||
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
||||
|
||||
@@ -800,12 +800,52 @@ int main(int argc, char **argv) {
|
||||
require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area");
|
||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||
// The fixture has a message-only window and no keyboard hook.
|
||||
app.settings.toggle = app.settings.casing = app.settings.translit =
|
||||
app.settings.undo = app.settings.clipboard = app.settings.screenshot = L"";
|
||||
std::fill(app.settings.direct.begin(), app.settings.direct.end(), L"");
|
||||
ks::showSettings(app,false);
|
||||
{
|
||||
auto settings = app.settingsWindow;
|
||||
auto tabs = GetDlgItem(settings, 260);
|
||||
auto capture = GetDlgItem(settings, 737);
|
||||
auto events = GetDlgItem(settings, 733);
|
||||
auto visible = [&](int id) { return (GetWindowLongPtrW(GetDlgItem(settings, id), GWL_STYLE) & WS_VISIBLE) != 0; };
|
||||
auto selectPage = [&](int index) {
|
||||
TabCtrl_SetCurSel(tabs, index);
|
||||
NMHDR changed{tabs, 260, TCN_SELCHANGE};
|
||||
SendMessageW(settings, WM_NOTIFY, 260, reinterpret_cast<LPARAM>(&changed));
|
||||
};
|
||||
RECT lastTab{}, tabClient{};
|
||||
GetClientRect(tabs, &tabClient);
|
||||
require(TabCtrl_GetItemCount(tabs) == 10 && TabCtrl_GetItemRect(tabs, 9, &lastTab) &&
|
||||
lastTab.right <= tabClient.right, "all Settings tabs fit in one row");
|
||||
selectPage(3);
|
||||
require(visible(257) && !visible(301), "OCR options are in Clipboard");
|
||||
selectPage(6);
|
||||
require(visible(258) && !visible(262), "Sync has its own tab");
|
||||
selectPage(7);
|
||||
require(visible(262) && !visible(258), "Computers has its own tab");
|
||||
selectPage(8);
|
||||
require(visible(208) && visible(256), "logging controls are in Diagnostics");
|
||||
selectPage(4);
|
||||
require(visible(301) && visible(303) && visible(318), "Snippets has an inline editor and its own Save button");
|
||||
const bool awake = app.settings.awake;
|
||||
SendDlgItemMessageW(settings, 205, BM_SETCHECK, awake ? BST_UNCHECKED : BST_CHECKED, 0);
|
||||
SendMessageW(settings, WM_COMMAND, 310, 0);
|
||||
SetDlgItemTextW(settings, 302, L";inline-fixture");
|
||||
SetDlgItemTextW(settings, 303, L"inline snippet");
|
||||
selectPage(0);
|
||||
selectPage(4);
|
||||
SendMessageW(settings, WM_COMMAND, 318, 0);
|
||||
require(app.settings.awake == awake && IsWindowEnabled(GetDlgItem(settings, IDOK)) &&
|
||||
std::any_of(app.settings.snippets.begin(), app.settings.snippets.end(), [](const ks::Snippet &snippet) {
|
||||
return snippet.trigger == L";inline-fixture" && snippet.text == L"inline snippet";
|
||||
}), "saving inline snippets preserves the unsaved settings draft across tab changes");
|
||||
SendDlgItemMessageW(settings, 205, BM_SETCHECK, awake ? BST_CHECKED : BST_UNCHECKED, 0);
|
||||
SendMessageW(settings, WM_COMMAND, 311, 0);
|
||||
SendMessageW(settings, WM_COMMAND, 318, 0);
|
||||
selectPage(0);
|
||||
require(!(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE),
|
||||
"mouse detection is hidden on General");
|
||||
TabCtrl_SetCurSel(tabs, 5);
|
||||
|
||||
@@ -48,6 +48,37 @@ int main(){try{
|
||||
check(restarted.deliveries.size()==1&&restarted.deliveries[0].bytes==Bytes({1,2,3}),"clipboard works after peer restart");
|
||||
bool limited=false;try{a.queue({bid},Bytes(32*1024*1024+1),"large",12000);}catch(...){limited=true;}
|
||||
check(limited,"oversized clipboard bounded");
|
||||
{
|
||||
ClipboardExchange sender(aid,"file-transfer"),receiver(bid,"file-transfer"),clipboard(bid);
|
||||
sender.peerSession(bid,receiver.session());receiver.peerSession(aid,sender.session());
|
||||
clipboard.peerSession(aid,sender.session());
|
||||
sender.queue({bid},payload,"file",1);
|
||||
Json first;
|
||||
for(uint64_t now=10;now<1000&&receiver.deliveries.empty();now+=10)
|
||||
{
|
||||
sender.poll(trusted,now,[&](const auto &to,const Json &packet){
|
||||
check(to==bid && packet.at("type")=="file-transfer" && packet.dump().size()<=2048,
|
||||
"file transfer targets a single peer on its own channel");
|
||||
if(first.is_null())first=packet;
|
||||
packets.push_back({to,packet});
|
||||
});
|
||||
while(!packets.empty())
|
||||
{
|
||||
auto packet=std::move(packets.front().second);packets.pop_front();
|
||||
receiver.receive(aid,key,packet,now,0,ack);
|
||||
}
|
||||
while(!acks.empty())
|
||||
{
|
||||
auto packet=std::move(acks.front().second);acks.pop_front();
|
||||
sender.receive(bid,key,packet,now,0,output);
|
||||
}
|
||||
}
|
||||
check(receiver.deliveries.size()==1&&receiver.deliveries.front().bytes==payload,"file payload arrives intact");
|
||||
first["session"]=clipboard.session();
|
||||
bool isolated=false;
|
||||
try{clipboard.receive(aid,key,first,1001,0,ack);}catch(...){isolated=true;}
|
||||
check(isolated&&acks.empty(),"file ciphertext cannot be injected into the clipboard channel");
|
||||
}
|
||||
{
|
||||
WSADATA startup{};check(WSAStartup(MAKEWORD(2,2),&startup)==0,"UDP benchmark starts");
|
||||
SOCKET sa=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP),sb=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
|
||||
|
||||
@@ -14,7 +14,18 @@ int main(int argc, char **argv)
|
||||
std::filesystem::create_directories(root);
|
||||
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
app.smoothingPreference=root/L"screenshot-smoothing";
|
||||
ks::Capture c(app);
|
||||
require(c.smoothing==25,"missing preference uses default smoothing");
|
||||
c.smoothing=61;
|
||||
c.smoothingPreferenceDirty=true;
|
||||
c.saveSmoothingPreference();
|
||||
ks::Capture reopened(app);
|
||||
require(reopened.smoothing==61,"new editor restores saved smoothing");
|
||||
ks::atomicWrite(app.smoothingPreference,ks::Bytes{101});
|
||||
ks::Capture invalidPreference(app);
|
||||
require(invalidPreference.smoothing==25,"invalid preference uses default smoothing");
|
||||
std::filesystem::remove(app.smoothingPreference);
|
||||
c.image=std::make_unique<ks::Surface>(320,240);
|
||||
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
|
||||
c.marks={ks::Mark{{40,40},{180,140},0}};
|
||||
|
||||
@@ -592,6 +592,10 @@ int main(int argc, char **argv)
|
||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||
options.autoNever.clear();
|
||||
options.autoReplacements[L"мамамила"] = L"мама мила";
|
||||
options.autoReplacements[L"нав1язав"] = L"нав’язав";
|
||||
ks::validateSettings(options);
|
||||
correction = edited.suggest(L"Нав1язав ", 2, layouts, options);
|
||||
require(correction && correction->text == L"Нав’язав ", "explicit replacements accept digits in the typo");
|
||||
for (const auto *text : {L"рев'ю", L"рев’ю", L"ревʼю", L"моє рев'ю"})
|
||||
{
|
||||
options.autoReplacements[L"ревю"] = text;
|
||||
|
||||
Reference in new issue
Block a user