Compare commits

...
6 Commits
37 changed files with 1469 additions and 290 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
+22 -9
View File
@@ -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**.
[![Support KSwitcher development — buy the developer a coffee](resources/support/donate-en.png)](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.
+77
View File
@@ -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
View File
@@ -1 +1 @@
42390860144e0b3a2b32874500ab9de8de60b76715cc583d306f761a749a83e9 KSwitcher-win-x64.zip
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
+22 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
{
+5
View File
@@ -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)
{
+1
View File
@@ -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
View File
@@ -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}});
+1
View File
@@ -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
View File
@@ -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.";
}
}
};
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {};
}
+7
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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 &);
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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, &divider, 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+40
View File
@@ -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);
+31
View File
@@ -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);
+11
View File
@@ -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}};
+4
View File
@@ -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;