Compare commits

..
Author SHA1 Message Date
agrynco f38886d1ea Activate focused dialog buttons with Enter
Refs #4
2026-11-14 00:05:51 +02:00
agrynco e2c6734817 Merge pull request 'Add KSwitcher Files local manager and copy workflow' (#3) from codex/kswitcher-files into main 2026-11-14 00:01:06 +02:00
agrynco 10d5a42f2a Add KSwitcher Files local manager and copy workflow 2026-11-13 23:59:44 +02:00
agrynco 34d89db95b Release KSwitcher v0.6.77 with local pencil smoothing 2026-11-13 14:58:55 +02:00
agrynco c10b141483 Release KSwitcher v0.6.76 with incoming file notifications 2026-11-13 14:24:47 +02:00
agrynco 0c77d5e46d Release KSwitcher v0.6.75 with clearer diagnostics and custom replacements 2026-11-13 13:01:07 +02:00
agrynco 70a53834e3 Release KSwitcher v0.6.74 with LAN file transfer and diagnostics MCP 2026-11-13 12:22:52 +02:00
agrynco 73448058d7 Release KSwitcher v0.6.73 with autostart path repair 2026-11-13 10:28:41 +02:00
agrynco 12fdd7c549 Release KSwitcher v0.6.72 with dedicated settings tabs 2026-11-12 21:17:15 +02:00
agrynco 3cb2eee637 Release KSwitcher v0.6.71 with Enter snippet confirmation 2026-11-12 19:05:38 +02:00
agrynco 41a1426a16 Ignore whole-line caret bounds for snippet suggestions 2026-11-12 17:46:44 +02:00
agrynco 601a7288c4 Release KSwitcher v0.6.70 with text-caret snippet placement 2026-11-12 17:20:47 +02:00
agrynco 7eca94ea59 Release KSwitcher v0.6.69 with snippet autocomplete 2026-11-12 16:51:08 +02:00
agrynco 299e61a5aa Release KSwitcher v0.6.68 with elevated restart 2026-11-12 11:52:23 +02:00
agrynco 2bafdbf863 Release KSwitcher v0.6.67 2026-11-12 10:58:07 +02:00
agrynco 1df2e2e90b Apply mouse bindings to forwarded input 2026-11-12 10:54:47 +02:00
agrynco c0defcbfb2 Release KSwitcher v0.6.66 with tray icon retry 2026-11-12 09:16:38 +02:00
agrynco 8a65e7f7fb Release KSwitcher v0.6.65 2026-11-12 01:18:21 +02:00
agrynco 14f33012a3 feat: release kswitcher 0.6.64
Add mouse button actions and application profiles, with volume controls and per-monitor settings layout fixes.
2026-11-12 00:39:12 +02:00
agrynco 5a2106899d Release v0.6.63: automatic LAN transport fallback and connection help 2026-11-10 19:30:50 +02:00
agrynco 3761b790ae Release v0.6.62: identify applications by window 2026-11-10 00:46:07 +02:00
agrynco a0e1de40ef Pick an application by dragging onto its highlighted window 2026-11-10 00:43:02 +02:00
agrynco 5d96abb83e Release v0.6.61: configure languages per application 2026-11-09 23:24:42 +02:00
agrynco 011664cc53 Release v0.6.60: allow apostrophes in custom corrections 2026-11-09 11:59:49 +02:00
58 changed files with 8432 additions and 471 deletions

No files matched your search

+6
View File
@@ -33,3 +33,9 @@ Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
Updating only the release notes or changing only the Wiki version number does
not satisfy this rule; the current functionality must also be reviewed.
## KSwitcher Files UI
Before changing the file manager UI, read [FILES-UI.md](FILES-UI.md).
Reuse the shared dialog rendering and layout helpers. Update the document
when an agreed UI requirement changes.
+11 -2
View File
@@ -1,10 +1,19 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.59 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.77 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(KSwitcherFiles WIN32 src/files_main.cpp resources/files.rc)
set_target_properties(KSwitcherFiles PROPERTIES OUTPUT_NAME "KSwitcher.Files")
target_compile_definitions(KSwitcherFiles PRIVATE UNICODE _UNICODE NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32)
if(MSVC)
target_compile_options(KSwitcherFiles PRIVATE /utf-8)
else()
target_link_options(KSwitcherFiles PRIVATE -municode -static)
endif()
add_executable(StayHere WIN32 utilities/StayHere/main.cpp)
add_executable(StayHereTest WIN32 utilities/StayHere/main.cpp)
target_compile_definitions(StayHereTest PRIVATE STAYHERE_TEST_INSTANCE)
@@ -173,7 +182,7 @@ add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherFiles RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+37
View File
@@ -0,0 +1,37 @@
# Оформлення KSwitcher Files
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
## Спільні елементи діалогів
- Enter активує кнопку у фокусі. Для кнопок із власним малюванням використовуй спільний обробник `dialogButtonEnter`. У підтвердженні скасування початковий фокус стоїть на No.
- При перенесенні вікна між моніторами з різним DPI оновлюй шрифт, висоту рядків панелей і компонування відкритого прогресу копіювання. Перевір перенесення під час активного копіювання.
- Для власних діалогів використовуй подвійну рамку з одним заголовком у верхній лінії. Звичайний діалог має сіре тло, попередження — темно-червоне тло та білий текст.
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` додає квадратні дужки, центрує текст і підсвічує фокус.
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
- Поля вводу й випадаючі списки використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
## Діалог Copy
- Вирівнюй підпис Existing files по вертикальному центру списку. Ширше поле списку починається ближче до підпису, а його правий край збігається з правим краєм поля імені.
- Зберігай однакові проміжки від верхнього роздільника до поля імені й до списку. Підпис, поля та прапорець мають читатися повністю.
- Відступ після прапорця до нижнього роздільника дорівнює відступу від верхнього роздільника до списку.
- Кнопки Copy та Cancel стоять по центру компактної нижньої секції. Висота кнопок має вміщати весь текст поточного шрифту.
## Вікно прогресу копіювання
- Секції поточного файла й загального прогресу мають однаковий вертикальний відступ від вмісту до верхньої та нижньої ліній. Cancel стоїть по центру спільної нижньої секції.
- Обидві шкали займають усю ширину вмісту. Відсотки показуються по центру шкали, а її висота враховує поточний шрифт.
## Попередження про конфлікт
- Після розрахунку остаточної висоти центр Warning збігається з центром вікна прогресу.
- Remember choice стоїть по вертикальному центру власної секції. Кнопки Overwrite, Skip і Cancel стоять по центру нижньої секції.
- Tab проходить Remember choice, Overwrite, Skip і Cancel. Стрілки вліво та вправо циклічно перемикають три кнопки в порядку їх розташування.
- Стрілки вгору й вниз переходять між сусідніми секціями з інтерактивними елементами. У Warning стрілка вгору з ряду кнопок переводить фокус на Remember choice, а вниз із прапорця — на Overwrite.
## Перевірка змін
Відкрий Copy і Warning в одній збірці та порівняй висоту нижніх секцій, центрування, повноту підписів і видимість фокуса. Перевір Tab, стрілки й Esc. Після узгодження нового правила онови відповідний розділ цього документа.
+49 -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
@@ -76,6 +102,26 @@ the preview does not change firewall rules. Broadcast-blocked networks, separate
VLANs, public IPv4 and IPv6-only networks are not supported by discovery yet.
A second LAN-enabled KSwitcher cannot use the same ports on one computer.
Input first tries TCP in the usual direction, then reverse TCP after 1.5 seconds.
If TCP has not authenticated after 5 seconds, the lower-ID peer negotiates an
encrypted UDP input session on the existing discovery socket (UDP 45883).
UDP uses fresh session challenges, authenticated encryption, ordered event
numbers, acknowledgements and bounded retries; duplicated packets do not repeat
key presses. Two seconds without authenticated traffic or acknowledgement
progress closes the session and returns control locally. A working UDP session
stays on UDP until disconnected; it does not oscillate between transports.
Both PCs must run an updated build to use all fallback methods. Older peers can
still use the original TCP protocol. No extra ports or external relay are used.
After 15 seconds without a connection, the UI points to **Connection help**.
The help window can copy PowerShell commands for the actual resident EXE path,
including a path containing apostrophes. Run each PC's own command in an
administrator PowerShell on that PC. These commands permit inbound TCP 45884
and UDP 45883 only from the local subnet; the program does not execute them.
Moving the EXE may require a new application firewall rule. A timeout alone
does not establish that the firewall is the cause: check that input sharing is
enabled on both PCs and that both are running the intended copy.
The instance ID belongs to a data directory, not a hostname/IP address. Discovered
names and addresses are untrusted. Pairing uses a generated 128-bit code and
mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never
+181 -13
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.
@@ -37,6 +46,68 @@ Saving Settings now merges independent resident/editor changes. If the same fiel
changed on both sides, choose whether to retain your draft without closing the
window. Declining preserves all draft edits. No export/import workaround is needed.
## Mouse buttons for applications
In **Settings → Mouse**, put the pointer over **Test mouse buttons here** and
press the buttons you want to test. The page shows the last detected button and
counts presses separately for ordinary window events and Windows Raw Input.
Use **+** beside **Mouse events** to capture and name a wheel direction.
If Windows reports an existing button or an already added direction, the
dialog selects that event in the list. Extra device controls need to emit a
supported Windows mouse signal before KSwitcher can bind them.
When you move the pointer to another PC using KSwitcher Computers or Mouse Without
Borders, the receiving PC uses its own mouse profiles. Configure the actions in
**Settings → Mouse** on that PC.
Choose **Global** to set up fallback actions. Use **+** beside **Profiles** to create
a named profile and set its applications. Double-click a profile or choose
**Edit…** in its context menu to change its name or applications. The
**Applications** column shows the scope of each profile.
Use the checkbox in each profile row to enable or disable its actions.
Double-click a mouse event, or right-click it and choose
**Edit…**, then pick its action and click **Apply** and **Save**. **Reset to default**
restores the global action for an application or standard behavior for Global.
Drag the crosshair in the profile editor onto an open window to add its
application. The folder icon accepts an executable or a Windows shortcut
to one. You can also type an executable name and click **+**.
An empty application list makes the profile's actions fallbacks. A scoped
profile's actions take precedence when one of its applications is active.
For example, set the side buttons to volume control in **Global** and to browser
navigation in a named profile with Chrome in its application list.
Volume actions show the current system level at the lower right of the
monitor under the pointer. The indicator stays visible during a hold and hides
one second after release.
For **Volume up** and **Volume down**, set **Step (%)** next to **Action**.
New values range from 1 to 50 percentage points.
The default is four percentage points. Hold an assigned button to repeat the action
at increasing speed. If the button also has a double-click action, a single
click waits for the double-click interval before adjusting the volume.
Enabled profiles cannot assign the same mouse event to the same application,
or both assign an unrestricted fallback to the same event.
An application can inherit a global action,
override it, or use standard mouse behavior. Available actions include
browser navigation, system volume, launching an executable, and sending a
key combination. For **Launch application**, enter a name Windows can find
(such as `calc.exe`) or select an executable with **Browse**.
The pointer selects the application profile and the target for browser
navigation. **Send key combination** works when the window under the pointer
is active. To record a combination, click the field and press the keys.
For example, press `Ctrl+W` to bind closing the active browser tab or
`Ctrl+V` to bind pasting in File Explorer. An inactive window receives
the standard mouse action.
Left and right
bindings apply only over the active window, so you can click another window
to switch applications. When both clicks have custom actions, a single click
waits for the Windows double-click interval. If only the double-click has an
action, the first click keeps its standard behavior.
Bindings apply to all mice on this PC. Windows exposes five
standard buttons. Hardware controls such as a wheel-mode or device switch
require a driver or device integration if they do not emit a standard click.
## Run
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
@@ -46,7 +117,13 @@ 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.
Input automatically tries direct TCP, reverse TCP (after 1.5 seconds), and encrypted UDP over discovery (after 5 seconds).
Both PCs need an updated build for all fallback methods. The connection status identifies the selected transport.
If attempts remain unsuccessful for 15 seconds, **Connection help** explains checks and provides copyable administrator PowerShell commands for the running executable on that PC. Run each PC's own command locally; rules allow only the local subnet. Commands are never executed automatically.
**Unsaved changes — click Save to apply** appears for pending edits, including accepted
chat-language, auto-correction and OCR changes. Cancelling a child dialog or restoring
the original values clears its pending changes. Snippets saved in their own window
@@ -85,8 +162,14 @@ In Settings, enable **Start with Windows** and **Start with Windows as administr
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
the task runs in the signed-in user's interactive session, including on battery power.
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
future automatic launches. Saving unrelated settings does not recreate startup entries.
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. 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.
@@ -373,6 +456,32 @@ Recognition uses Chrome accessibility: the address bar and active document must
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
### Snippet autocomplete — v0.6.69
Starting with v0.6.71, **Enter** accepts a suggestion after you navigate
the list with **↑ / ↓**. Without arrow navigation, **Enter** goes to the
editor. З v0.6.71 **Enter** після вибору стрілками вставляє сніпет. Без
переміщення списком **Enter** надходить до редактора.
Starting with v0.6.70, suggestions open at the text caret. If the editor
provides no caret position, you can still type the complete trigger to
expand the snippet.
Type `;` at the start of a trigger to open suggestions near the caret, or
type the first three characters of another trigger. The list filters as you
type and fits the widest matching trigger. Use **↑ / ↓** and **Tab** or click
an entry. **Esc** dismisses the list. Full triggers still expand
automatically. See the [snippet guide](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Snippets).
**Українською.** Введіть `;` на початку тригера або три перші символи іншого
тригера, щоб відкрити підказки біля курсора. Список фільтрується під час набору
й підлаштовує ширину під найдовший збіг. Для вибору натисніть **↑ / ↓** і
**Tab** або клацніть рядок. **Esc** закриває список. Повні тригери й далі
спрацьовують автоматично. Дивіться [посібник зі сніпетів](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Snippets-uk).
З v0.6.70 підказки відкриваються біля текстового курсора. Якщо редактор не
надає його позицію, введіть повний тригер для розгортання сніпета.
### Desktop chat snippet selection — v0.6.55
ChatGPT and Codex desktop editors use paced selection for snippet triggers. If only a verified suffix is selected, KSwitcher extends the selection and verifies the full trigger before replacement. Unrelated text or changed focus cancels the operation. Triggers remain recognized across keyboard layouts. One snippet can target multiple applications: add each with **Add app**, or enter comma-separated process names. These existing application scopes also synchronize.
@@ -381,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
@@ -500,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,
@@ -621,10 +730,13 @@ required for ordinary conflicts. Every server write uses the version just read a
condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
Deleted server versions are not silently recreated.
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
history, screenshots and dictionary files are never uploaded. Full synchronization identifies
The server profile includes all settings from KSwitcher Settings, including mouse events and
bindings, application language profiles, startup, keep-awake, logging, history limits and OCR.
Update the clients that use the same server profile before syncing the expanded format.
Older server backups preserve the local values for fields they never stored. Restored executable
paths refer to the same locations and may need updating on a fresh installation.
Connection details and credentials stay local so you can sign in again after reinstalling.
Clipboard history, screenshots and dictionary files are never uploaded. Full synchronization identifies
missing keyboard layouts by language (when Windows can identify it) and exact ID.
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
@@ -721,11 +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. Both sides must use the same supported language, without punctuation; leading,
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.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
@@ -899,3 +1013,57 @@ History opens in a separate, short-lived instance of **KSwitcher.exe**. **Esc**
Image thumbnails are decoded in **KSwitcher.Screenshot.exe**, returning only bounded preview pixels. Image clipboard backup/restore also uses that helper and encrypted temporary files, with clipboard-sequence checks before restoring. Selection reads avoid loading image formats. Keep all three executables together; no fourth executable is required. Paste allows a brief foreground handoff after history closes while preserving cancellation checks. UI Automation remains in the resident.
A live development-build test measured **4.37 MiB before history and 4.35 MiB after two open/Esc cycles**, compared with an earlier roughly 8–9 MiB plateau. A same-image clipboard-backup benchmark retained **12.78 MiB before versus 0.05 MiB after**. These measure private working set, not a guaranteed total footprint. Include all helpers when measuring active use: combined memory can be higher while history is open. No forced working-set trimming is used. Existing screenshots and shortcuts remain applicable. The v0.6.38 approach of retaining a hidden history window/RichEdit control is superseded by process termination.
### Languages for applications
Open **Settings → Languages → Languages for applications…**. Select an application
on the left and click its allowed languages on the right. Every application has
an independent selection, even when several applications use the same languages.
Existing assignments from the earlier shared-set editor retain their language
choices and become independent profiles when this dialog is applied.
The list includes running applications and saved profiles. Add a closed application
by its executable name, such as `Codex.exe`. Matching ignores case and `.exe` and
covers all windows of that executable, including all tabs of a browser.
To identify an open application, drag **Drag to window** onto its window. An outline
shows which window will be selected. Release to select its executable or press **Esc** to cancel.
Applications without a custom profile use the default languages; merely viewing
an application does not create a profile. **Use defaults** clears only the selected
application's custom choice. Switching applications preserves pending edits.
Choose **Use languages** once, then **Save** in Settings; **Cancel** discards the
dialog's changes. A matching contact profile takes priority over an application
profile, which takes priority over the default cycle.
**Alt+Shift**, **Win+Space**, reverse **Win+Shift+Space** and **Next layout** use the
resolved subset. In an application with custom languages, a layout outside the
subset is returned to the first available allowed language by a periodic check
(250 ms, deferred while KSwitcher is busy). This also handles entering the app
with an excluded language active or selecting one via Windows or a direct shortcut.
Languages remain installed and visible in Windows' language picker. Unavailable
layouts are skipped; if the entire selection is unavailable, the default cycle is
used. Application language profiles are included in server synchronization and
manual settings export/import.
**Українською.** Відкрий **Settings → Languages → Languages for applications…**.
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
незалежний, навіть якщо в кількох програмах він однаковий. Старі прив'язки спільних
наборів зберігають вибрані мови та стають незалежними профілями після застосування
цього вікна. У списку є відкриті програми та збережені профілі; закриту програму
можна додати за назвою файла, наприклад `Codex.exe`. Регістр і `.exe` не мають
значення. Правило охоплює всі вікна програми, зокрема всі вкладки браузера.
Щоб визначити відкриту програму, перетягни приціл **Drag to window** на її вікно.
Рамка показує, яке вікно буде вибрано. Відпусти приціл, щоб вибрати програму,
або натисни **Esc**, щоб скасувати.
Без окремого профілю діють загальні мови; простий перегляд програми не створює
профіль. **Use defaults** повертає лише вибрану програму до загальних мов.
Перехід між програмами зберігає чернетки. **Use languages**, потім **Save**
застосовує всі зміни, **Cancel** скасовує зміни вікна. Пріоритет: профіль контакту
→ профіль програми → загальний цикл.
**Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** використовують
відповідний вибір. Мову поза профілем програми перевірка кожні 250 мс замінює
першою доступною дозволеною мовою (відкладається, коли KSwitcher зайнятий).
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі входять до серверної синхронізації
та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+192 -14
View File
@@ -1,22 +1,200 @@
# Latest Windows release
## v0.6.59 — 2026-11-09
## v0.6.77 — 2026-11-13
## Changes
- Show clear download/upload progress and results, including the server version, applied changes or unchanged local settings. Preserve action errors instead of replacing them with an older status.
- Identify OpenBao HTTP 400 failures as authentication, download or write failures instead of labeling every failure as a rejected write. Failed downloads do not apply remote settings.
- Refresh the already-open Settings window after a successful Download remote, preserving its selected tab. This also works if the Sync dialog was closed while the request was running. Confirmation explains that unsaved Settings edits will be discarded.
- Retain all v0.6.58 LAN, clipboard, screenshot, snippet and chat-profile behavior. No sync schema or compatibility change.
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.
## Validation
Passed: 132 sync, 571 input/UI, 341 native, 829 chat-profile/UI, 205 LAN input, 289 LAN clipboard, 43 LAN pairing and 5 settings-save merge checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Regression tests use an isolated mock OpenBao transport, not a live user account.
**Українською.** Редактор скріншотів зберігає типове згладжування олівця на
цьому ПК та відновлює його після перезапуску. Змініть повзунок без виділеного
штриха олівця або натисніть **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
insert the selected snippet and close the list. The editor does not receive
that keypress. If you have not navigated the list, **Enter** still goes to
the editor. **Tab** and mouse selection also work.
KSwitcher skips suggestions when VS Code's EditContext supplies the bounds
of the whole line in place of the text caret. See the [FAQ](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/FAQ)
for the VS Code workaround.
**Українською.** Після вибору підказки клавішами **↑ / ↓** натисніть
**Enter**, щоб вставити сніпет і закрити список. Редактор не отримує це
натискання. Якщо ви не переміщалися списком, **Enter** і далі надходить
до редактора. Також можна вибрати сніпет через **Tab** або мишею.
KSwitcher приховує підказки, коли EditContext у VS Code повертає координати
всього рядка замість текстового курсора. Обхідний шлях описано у [FAQ українською](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/FAQ-uk).
## v0.6.70 — 2026-11-12
Snippet suggestions are anchored to the text caret. KSwitcher uses the
editor's caret location or text accessibility information, including for
thin carets and wrapped text. If the editor provides no caret position,
the list stays hidden. Full triggers continue to expand automatically.
**Українською.** Підказки сніпетів прив’язані до текстового курсора.
KSwitcher використовує координати курсора або дані доступності редактора,
зокрема для тонкого курсора й перенесених рядків. Якщо редактор не надає
позицію текстового курсора, список не відкривається. Повні тригери й далі
спрацьовують автоматично.
## v0.6.69 — 2026-11-12
Snippet autocomplete opens beside the text caret as soon as you type `;` at
the start of a trigger. Other triggers offer suggestions after three
characters. The list stays open while you type, resizes to fit the matching
triggers, and respects application restrictions. Use **↑ / ↓** and **Tab** or
click a suggestion to insert it. Press **Esc** to dismiss the list. Full
triggers still expand automatically.
**Українською.** Автодоповнення сніпетів відкривається біля текстового курсора,
щойно ви вводите `;` на початку тригера. Для інших тригерів підказки
з’являються після трьох символів. Список залишається відкритим під час набору,
підлаштовує ширину під відповідні тригери й враховує обмеження за програмами.
Виберіть сніпет клавішами **↑ / ↓** і **Tab** або мишею. **Esc** закриває
список. Повні тригери й далі спрацьовують автоматично.
## v0.6.68 — 2026-11-12
With **Start with Windows** and **Start with Windows as administrator** enabled,
opening the regular KSwitcher after exiting a development build requests
administrator approval when needed. UAC prompts on the secure desktop still
require local input.
**Українською.** Якщо ввімкнено **Start with Windows** і **Start with Windows as administrator**,
звичайний KSwitcher після закриття dev-збірки за потреби запитує запуск від
адміністратора. Запити UAC на захищеному робочому столі підтверджуйте
безпосередньо на цьому ПК.
## v0.6.67 — 2026-11-12
Mouse buttons and wheel actions now use the profiles on the PC receiving
input through KSwitcher Computers or Mouse Without Borders. Update the
receiving PC and configure its actions in **Settings → Mouse**.
**Українською.** Кнопки й коліщатко тепер працюють за профілями ПК,
який отримує ввід через KSwitcher Computers або Mouse Without Borders.
Оновіть цей ПК і налаштуйте дії в **Settings → Mouse**.
## v0.6.66 — 2026-11-12
If Windows rejects registration of the notification-area icon at sign-in or
after Explorer restarts, KSwitcher retries about once a second until the
icon is registered. Settings become accessible from the icon without
restarting KSwitcher.
**Українською.** Якщо Windows не приймає значок KSwitcher в області
сповіщень під час входу або після перезапуску Провідника, KSwitcher
повторює спробу приблизно щосекунди. Коли значок з’явиться, з нього
можна відкрити налаштування без перезапуску KSwitcher.
## v0.6.65 — 2026-11-12
Use **Settings → Mouse → +** beside **Mouse events** to capture and name a
vertical or horizontal wheel direction. Capturing a known button or an
already registered direction selects its existing row. A new signal is
accepted only when Windows delivers it through both the test and runtime
input paths. A configured wheel action replaces scrolling for that direction
where the profile applies.
OpenBao now backs up all KSwitcher Settings, including mouse events, bindings,
application profiles, startup, clipboard history limits, and OCR preferences.
Update all clients that share a server profile before syncing. Downloading an
older backup preserves local settings for fields it never included. After
reinstalling Windows, reconnect to OpenBao and choose **Download remote**.
Executable paths and optional OCR software must still be available on the
restored PC. Clipboard contents and Windows credentials stay on the device.
**Українською.** У **Settings → Mouse** натисни **+** біля **Mouse events**,
щоб додати напрямок прокручування. Відомий сигнал виділить наявний рядок.
OpenBao тепер зберігає всі налаштування KSwitcher, зокрема профілі миші,
OCR й автозапуск. Перед синхронізацією онови всі клієнти спільного профілю.
Після перевстановлення Windows підключись до OpenBao й натисни
**Download remote**.
## v0.6.64 — 2026-11-12
The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut.
Actions include keyboard combinations, browser navigation, launching applications, and system volume. Volume actions have a configurable step and an on-screen indicator. When Windows reports separate press and release events, holding the button repeats the volume change at increasing speed.
Use **Test mouse buttons here** to check which events Windows delivers. Mouse profiles are stored on the current PC. The configurator also keeps fixed-size settings dialogs fitted when moving them between displays with different scaling.
## Upgrade
## Upgrade and compatibility
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
**Update every synchronizing PC to v0.6.51 or newer before sharing application-specific snippets. Older versions do not understand application restrictions and may treat scoped snippets as global.** Keep a backup before downgrading. Settings with Telegram Web profiles require v0.6.51 or newer. Chat profiles now synchronize from v0.6.53. Update every synchronizing PC to v0.6.53 or newer before syncing: older clients reject the chat-profile field. Synchronization stores application names, visible chat names and language IDs on your configured OpenBao server. No message contents are included.
For a manual upgrade, exit KSwitcher and its configurator and screenshot editors, then extract the entire **KSwitcher-win-x64.zip** into its installation folder. Verify it with **SHA256SUMS.txt**. Keep personal data, customized dictionaries and tools. Keep the three KSwitcher executables together.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
See the [English Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home) or [Українська Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk).
+1 -1
View File
@@ -1 +1 @@
f0d7d15534db21c17b113a25cc07d59a68fa151ecf995ac360ac476751171a9a KSwitcher-win-x64.zip
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
+34 -34
View File
@@ -1,86 +1,86 @@
{
"version": "0.6.77",
"files": [
{
"sha256": "a7be2f749082bb6417b4cf7e8c8ea221ef4f7ba63dabc3cbbe8694ab1a3d942c",
"path": "KSwitcher.exe",
"size": 2108416
"size": 2249216,
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
},
{
"sha256": "c8a88364c9967ce5fd150519d632b14460e225a0cfae51a8de50f39782d8010c",
"path": "KSwitcher.Config.exe",
"size": 2005504
"size": 2251776,
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
},
{
"sha256": "68eed5abefed0208ef7bdd431094c7357c53bd3ca03dfd91aaeb7dbb4f291e06",
"path": "KSwitcher.Screenshot.exe",
"size": 3153408
"size": 3247616,
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
},
{
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"path": "LICENSE",
"size": 4401
"size": 4401,
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
},
{
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
"path": "json-LICENSE.txt",
"size": 1096
"size": 1075,
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
},
{
"sha256": "87f396a6bf988725b14bf5b6eb7c2b60ce33ff51828290ce40e136c66942cc09",
"path": "README.md",
"size": 80168
"size": 93885,
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
},
{
"sha256": "72e131bdf46d32a8a17fb352292bc8983c0e5a7ed84812218b11cf6b69c9afc4",
"path": "LAN-PREVIEW.md",
"size": 8376
"size": 11418,
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
},
{
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
"path": "dictionaries/en.txt",
"size": 732485
"size": 682867,
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
},
{
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20566
"size": 20138,
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
},
{
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
"path": "dictionaries/README.md",
"size": 2265
"size": 2230,
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
},
{
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
"path": "dictionaries/ru.txt",
"size": 1109134
"size": 1060364,
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
},
{
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
"path": "dictionaries/sources.json",
"size": 1280
"size": 1279,
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
},
{
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
"path": "dictionaries/uk.txt",
"size": 940799
"size": 898248,
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
},
{
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"path": "tools/README.txt",
"size": 993
"size": 993,
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
},
{
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
"path": "tools/StayHere.exe",
"size": 134656
"size": 134656,
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
},
{
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md",
"size": 2796
"size": 2796,
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
}
],
"schema": 1,
"version": "0.6.58"
"schema": 1
}
+6
View File
@@ -46,6 +46,12 @@ try {
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
$filesPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Files.exe'
& windres resources/files.rc -O coff -o build/files-res.o
if ($LASTEXITCODE) { throw 'File manager resource compilation failed' }
& $compiler @common -mwindows -municode -static -s src/files_main.cpp build/files-res.o -luser32 -lgdi32 -lcomctl32 -lshell32 -lcomdlg32 -ladvapi32 -lole32 -o $filesPath
if ($LASTEXITCODE) { throw 'File manager build failed' }
Copy-Item -LiteralPath $filesPath -Destination build/release/KSwitcher.Files.exe -Force
if ($Test) {
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
+2 -1
View File
@@ -6,6 +6,7 @@ New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Files.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
@@ -16,7 +17,7 @@ Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
+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,59,0
PRODUCTVERSION 0,6,59,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.59"
VALUE "FileVersion", "0.6.77"
END
END
BLOCK "VarFileInfo"
+80
View File
@@ -0,0 +1,80 @@
#include <windows.h>
1 ICON "kswitcher-files.ico"
1 RT_MANIFEST "app.manifest"
101 DIALOGEX 0, 0, 256, 141
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Wide panel mode"
FONT 9, "Segoe UI"
BEGIN
GROUPBOX "Panel columns — Name, Size", -1, 8, 6, 240, 59
LTEXT "Size column &width (6-16 characters)", -1, 17, 22, 166, 10
EDITTEXT 201, 196, 18, 41, 14, ES_NUMBER | ES_AUTOHSCROLL
AUTOCHECKBOX "&Align file extensions", 202, 17, 42, 104, 12
AUTOCHECKBOX "Align &folder extensions", 204, 130, 42, 108, 12
GROUPBOX "Status line columns", -1, 8, 72, 240, 39
AUTOCHECKBOX "Show &details", 203, 17, 83, 94, 11
AUTOCHECKBOX "Name", 205, 17, 96, 47, 11
AUTOCHECKBOX "Size", 206, 72, 96, 42, 11
AUTOCHECKBOX "Date", 207, 128, 96, 45, 11
AUTOCHECKBOX "Time", 208, 185, 96, 45, 11
DEFPUSHBUTTON "OK", IDOK, 139, 119, 50, 14
PUSHBUTTON "Cancel", IDCANCEL, 196, 119, 50, 14
END
102 DIALOGEX 0, 0, 350, 114
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copy"
FONT 10, "Consolas"
BEGIN
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
CONTROL "", 211, "Edit", ES_MULTILINE | ES_AUTOHSCROLL | WS_TABSTOP, 14, 29, 322, 18
LTEXT "Existing files:", 212, 14, 61, 122, 14, SS_CENTERIMAGE
COMBOBOX 213, 140, 61, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
CONTROL "Also ask on read-only files", 214, "Button", BS_OWNERDRAW | WS_TABSTOP, 14, 81, 322, 14
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 96, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 96, 86, 18
END
103 DIALOGEX 0, 0, 460, 126
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copying"
FONT 10, "Consolas"
BEGIN
LTEXT "Counting files...", 220, 12, 16, 436, 12, SS_OWNERDRAW
LTEXT "", 221, 12, 31, 436, 12, SS_OWNERDRAW
CONTROL "", 222, "msctls_progress32", PBS_SMOOTH, 12, 46, 436, 14
LTEXT "Files:", 224, 12, 63, 436, 12
LTEXT "Bytes:", 225, 12, 77, 436, 12
CONTROL "", 226, "msctls_progress32", PBS_SMOOTH, 12, 91, 436, 14
LTEXT "", 228, 12, 60, 1, 1
LTEXT "", 229, 12, 104, 1, 1
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 106, 86, 18
END
104 DIALOGEX 0, 0, 360, 106
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Warning"
FONT 10, "Consolas"
BEGIN
LTEXT "", 230, 12, 16, 336, 12
LTEXT "", 231, 12, 33, 336, 12, SS_PATHELLIPSIS
LTEXT "", 233, 12, 55, 1, 1
LTEXT "", 234, 12, 81, 1, 1
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 57, 336, 18
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 89, 110, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 89, 68, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 89, 86, 18
END
105 DIALOGEX 0, 0, 310, 65
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Cancel copy"
FONT 10, "Consolas"
BEGIN
CTEXT "Do you really want to cancel copying?", 235, 12, 17, 286, 16, SS_CENTERIMAGE
LTEXT "", 236, 12, 38, 1, 1
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 83, 42, 68, 18
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 159, 42, 68, 18
END
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+966 -60
View File
File diff suppressed because it is too large. Load diff
+51 -1
View File
@@ -5,6 +5,8 @@
#include "sync.h"
#include "chat_context.h"
#include "lan.h"
#include "mouse_buttons.h"
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -67,7 +69,7 @@ class App
~App();
int execute();
void dispatch(int);
void transform(int, int snippet = -1);
void transform(int, int snippet = -1, size_t snippetLength = 0, HWND snippetEditor = nullptr);
void pasteEntry(std::wstring, bool plain);
void recognizeEntry(std::wstring);
void captureHistory();
@@ -104,7 +106,29 @@ class App
private:
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
void applySettings(const Settings &, bool validateOcrInstallation);
void checkAutostartPath();
bool checkingAutostartPath{};
HHOOK keyboardHook{}, mouseHook{};
struct MousePress
{
HWND target{};
POINT point{};
ULONGLONG started{};
MouseAction action;
bool held{}, waitForSecond{};
};
std::array<MousePress, std::size(MouseButtonNames)> mousePresses{};
std::array<MousePress, std::size(MouseButtonNames)> pendingMouseClicks{};
std::deque<MousePress> mouseActions;
HWND mouseWheelTarget{};
int mouseWheelSignal{-1}, mouseWheelRemainder{};
void queueMouseAction(MousePress);
void flushMouseClicks();
void beginMouseVolume(int button);
void repeatMouseVolume(int button);
bool volumeButtonHeld() const;
void dispatchMouseActions();
std::unique_ptr<LanService> lanService;
HANDLE historyProcess{};
std::mutex jobsMutex;
@@ -136,7 +160,11 @@ class App
bool (*updateNotifier)(const std::wstring &){};
bool stop{};
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{};
@@ -150,7 +178,29 @@ class App
HWND typingTarget{};
bool autoPending{};
int snippetPending = -1;
size_t snippetPendingLength{};
HWND snippetPendingEditor{};
bool snippetTyping{};
HWND snippetPopup{}, snippetTarget{}, snippetFocus{};
uint64_t snippetPopupVersion{};
size_t snippetPrefixLength{}, snippetSelection{};
bool snippetNavigated{};
int snippetRowHeight{};
UINT snippetDpi{96};
RECT snippetPopupAnchor{};
bool snippetCaretPending{};
std::optional<RECT> snippetCaret;
std::vector<size_t> snippetMatches;
std::array<bool, 256> snippetKeys{};
void matchSnippets(HWND, bool expandExact);
bool queueSnippet(HWND, size_t index, size_t length, HWND editor = nullptr);
void showSnippetPopup();
void placeSnippetPopup(RECT);
void hideSnippetPopup();
bool snippetPopupCurrent() const;
bool handleSnippetKey(DWORD, UINT);
void selectSnippet(size_t);
static LRESULT CALLBACK snippetPopupProc(HWND, UINT, WPARAM, LPARAM);
std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
void replayTyping();
+2 -1
View File
@@ -79,7 +79,8 @@ void App::pollSync()
if (encodeSettings(next) != snapshot)
{
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
applySettings(next);
// A restored OCR configuration can precede the optional local installation.
applySettings(next, false);
syncStatus += L" Changes applied on this PC; local backup saved.";
}
else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." :
+6 -4
View File
@@ -245,19 +245,21 @@ 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() &&
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
(word == small || (word.substr(1) == small.substr(1) && word != upper(word))) &&
validAutoReplacementSource(small, alphabet) &&
validAutoCorrection(replacement->second, alphabet))
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
+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))
+32 -2
View File
@@ -92,13 +92,43 @@ inline std::wstring windowChatKey(HWND window)
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
return window ? chatKey(processName(window), chatTitle(window)) : L"";
}
inline std::wstring applicationName(std::wstring process)
{
process = lower(process);
if (process.ends_with(L".exe")) process.resize(process.size() - 4);
return process;
}
inline const AppLanguageSet *applicationLanguageSet(const Settings &settings, const std::wstring &process)
{
auto name = applicationName(process);
if (name.empty()) return nullptr;
for (const auto &[label, set] : settings.appLanguageSets)
for (const auto &app : set.applications)
if (applicationName(app) == name) return &set;
return nullptr;
}
inline std::wstring languageProfileKey(const Settings &settings, HWND window, const std::wstring &chat)
{
if (!chat.empty() && settings.chatCycles.contains(chat)) return chat;
if (settings.appLanguageSets.empty()) return {};
auto app = applicationName(processName(window));
return applicationLanguageSet(settings, app) ? L"app\n" + app : L"";
}
inline std::wstring windowLanguageProfileKey(const Settings &settings, HWND window)
{
return window ? languageProfileKey(settings, window, settings.chatCycles.empty() ? L"" : windowChatKey(window)) : L"";
}
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
const std::vector<Layout> &layouts)
{
auto found = settings.chatCycles.find(key);
if (key.empty() || found == settings.chatCycles.end()) return settings.cycle;
const std::vector<std::wstring> *cycle = found == settings.chatCycles.end() ? nullptr : &found->second;
if (key.starts_with(L"app\n"))
if (auto set = applicationLanguageSet(settings, key.substr(4))) cycle = &set->languages;
if (!cycle) return settings.cycle;
std::vector<std::wstring> result;
for (const auto &id : found->second)
for (const auto &id : *cycle)
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
result.push_back(id);
return result.empty() ? settings.cycle : result;
+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)
{
+19
View File
@@ -21,6 +21,25 @@ int snippetAppPriority(const Snippet &snippet, const std::wstring &process)
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
return 0;
}
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
{
if (text.empty() || text.size() > 32) return false;
auto isLetter = [&](wchar_t c) { return alphabet.find(c) != alphabet.npos; };
for (size_t i = 0; i < text.size(); ++i)
{
if (isLetter(text[i])) continue;
if (text[i] != L' ' && text[i] != L'\'' && text[i] != L'\u2019' && text[i] != L'\u02bc')
return false;
if (i == 0 || i + 1 == text.size() || !isLetter(text[i - 1]) || !isLetter(text[i + 1]))
return false;
}
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)
{
+24
View File
@@ -3,6 +3,7 @@
#include <functional>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <windows.h>
@@ -19,6 +20,8 @@ struct Layout
bool hasLetter(wchar_t ch) const;
};
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);
@@ -52,6 +55,22 @@ struct Hotkey
static std::optional<Hotkey> parse(std::wstring);
std::wstring format() const;
};
struct AppLanguageSet
{
std::vector<std::wstring> languages, applications;
bool operator==(const AppLanguageSet &) const = default;
};
enum class MouseActionType { Standard, Shortcut, Back, Forward, VolumeUp, VolumeDown, Mute, Launch };
inline constexpr int DefaultMouseVolumeStep = 4;
struct MouseAction
{
MouseActionType type = MouseActionType::Standard;
Hotkey shortcut;
std::wstring executable;
std::set<std::wstring> applications{};
int volumeStep = DefaultMouseVolumeStep;
bool operator==(const MouseAction &) const = default;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
@@ -81,6 +100,11 @@ struct Settings
std::vector<std::wstring> cycle, direct, directIds;
// Exact application + visible chat name; an absent entry uses the global cycle.
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
std::map<std::wstring, AppLanguageSet> appLanguageSets;
std::map<std::wstring, std::map<std::wstring, MouseAction>> mouseBindings;
std::map<std::wstring, std::wstring> customMouseEvents;
std::set<std::wstring> disabledMouseProfiles;
std::map<std::wstring, std::set<std::wstring>> mouseProfileApplications;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets;
std::vector<std::wstring> snippetTombstones;
+2370
View File
File diff suppressed because it is too large. Load diff
+174 -8
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;
@@ -72,6 +77,7 @@ struct LanService::Impl
std::map<std::string,uint64_t> inputReady;
std::unique_ptr<lan::InputMesh> router;
std::string inputStatus="Input sharing disabled.";
bool inputNeedsHelp{};
HWND inputWindow{};std::atomic<bool> inputNotification{false};
explicit Impl(const std::filesystem::path &root,HWND window) : path(root/L"lan-pairing.bin"),inputWindow(window)
{
@@ -82,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())
@@ -92,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);
@@ -152,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)
{
@@ -160,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;
@@ -179,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))
{
@@ -191,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(),
@@ -210,7 +267,7 @@ struct LanService::Impl
auto previous=trusted;auto previousPositions=positions;
trusted[other]={{"name",peer.name},{"key",pending->derived}};
try {lan::fillPositions(positions,trusted,inputPeer,inputEdge);save();} catch(...) {trusted=std::move(previous);positions=std::move(previousPositions);throw;}
layoutChanged=true;peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
layoutChanged=true;peer.verified=now; status="Pair saved automatically. Arrange the monitors, then enable input sharing on both PCs.";
}
}
}
@@ -232,6 +289,10 @@ struct LanService::Impl
if(bind(socket,(sockaddr*)&bound,sizeof(bound))) throw std::runtime_error("UDP port 45883 is in use. Only one LAN-enabled KSwitcher may run per computer.");
uint64_t nextHello=0,nextOffer=0;
lan::InputHub hub(id);
hub.datagrams([&](const auto &target,const auto &packet){
std::lock_guard lock(mutex);
if(shareInput&&trusted.contains(target)&&peers.contains(target))send(socket,peers.at(target).address,packet);
});
while(!stopping)
{
auto now=GetTickCount64();bool clipboardActive=false;
@@ -241,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;
}
@@ -279,7 +344,7 @@ struct LanService::Impl
}
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
{std::lock_guard lock(mutex);inputStatus=hub.status();}
{std::lock_guard lock(mutex);inputStatus=hub.status();inputNeedsHelp=hub.needsHelp();}
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,!endpoints.empty()||clipboardActive?5000:100000};
int ready=select(0,&read,nullptr,nullptr,&delay);
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
@@ -289,7 +354,14 @@ struct LanService::Impl
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
if(received<=0) break;
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
try { auto j=Json::parse(buffer,buffer+received); std::lock_guard lock(mutex); receive(socket,sender,j,GetTickCount64()); }
try {
auto j=Json::parse(buffer,buffer+received);
if(j.value("type","")=="input-udp"){
bool allowed=false;auto peer=j.value("id","");
{std::lock_guard lock(mutex);allowed=shareInput&&trusted.contains(peer)&&peers.contains(peer)&&peers.at(peer).address.sin_addr.s_addr==sender.sin_addr.s_addr;}
if(allowed)hub.receiveDatagram(j,GetTickCount64());
}else {std::lock_guard lock(mutex);receive(socket,sender,j,GetTickCount64());}
}
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
}
}
@@ -312,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");
@@ -334,6 +476,14 @@ struct LanService::Impl
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
}
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
if(action=="connectionHelp"){
auto exe=utf8(executable());std::string quoted;
for(char c:exe){quoted+=c;if(c=='\'')quoted+='\'';}
auto script="$ksPath = '"+quoted+"'\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884 -RemoteAddress LocalSubnet -Profile Any\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN UDP' -Direction Inbound -Action Allow -Program $ksPath -Protocol UDP -LocalPort 45883 -RemoteAddress LocalSubnet -Profile Any";
return encoded({{"script",script}});
}
if(action=="cancel") { pending.reset(); status="Pairing cancelled."; }
if(action=="pair")
{
@@ -348,20 +498,36 @@ 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.";
}
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"controlMode",router?int(router->mode()):0}};
{"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;
for(const auto &[peer,p]:peers)
{
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
+60 -18
View File
@@ -1,6 +1,7 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include "lan_channel.h"
#include "lan_datagram.h"
#include <bcrypt.h>
#include <limits>
@@ -78,17 +79,18 @@ std::string inputSessionKey(const std::string &secret,const std::string &from,co
}
struct InputChannel::Impl
{
std::string local,peer,secret,nonce,state="Waiting for the paired computer...";
std::string local,peer,secret,nonce,state="Waiting for the paired computer...",lastError;
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
bool connecting{},authenticated{},helloRead{},managed{};
uint64_t changed{},lastRead{},lastPing{},retry{};
uint64_t changed{},lastRead{},lastPing{},retry{},waitingSince{};
bool reverse{};
Bytes incoming,outgoing;
std::unique_ptr<InputCipher> tx,rx;
Impl(std::string a,std::string b,std::string key,int side,uint16_t p,bool multiple):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p),managed(multiple){}
~Impl(){closeSocket(socket);closeSocket(listener);}
void reset(uint64_t now,const std::string &reason)
{
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;state=reason;
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;waitingSince=0;state=reason;lastError=reason;
}
void begin(uint64_t now)
{
@@ -101,6 +103,7 @@ struct InputChannel::Impl
{
try
{
if(!waitingSince)waitingSince=now;
if(socket==INVALID_SOCKET&&now<retry)return;
if(!managed && listener==INVALID_SOCKET && local>peer)
{
@@ -111,14 +114,15 @@ struct InputChannel::Impl
}
if(socket==INVALID_SOCKET && address && now>=retry)
{
if(local<peer)
if(local<peer||(managed&&now-waitingSince>=1500))
{
reverse=local>peer;
socket=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(socket==INVALID_SOCKET||!nonblocking(socket))throw std::runtime_error("Cannot open input connection.");
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);a.sin_addr.s_addr=address;
int result=connect(socket,(sockaddr*)&a,sizeof(a));
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
connecting=result!=0;begin(now);
if(connecting)state="Connecting to paired PC on TCP 45884...";
if(connecting)state=reverse?"Trying reverse TCP connection...":"Trying direct TCP connection...";
}
else if(!managed)
{
@@ -185,7 +189,7 @@ struct InputChannel::Impl
while(helloRead && incoming.size()>=36)
{
auto f=rx->decrypt(Bytes(incoming.begin(),incoming.begin()+36));incoming.erase(incoming.begin(),incoming.begin()+36);lastRead=now;
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;state="Input connected. Move through the configured screen edge.";}
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;lastError.clear();state="Input connected. Move through the configured screen edge.";}
else if(f.type==InputType::Ready)throw std::runtime_error("Repeated input handshake.");
else if(f.type!=InputType::Ping)received.push_back(f);
}
@@ -198,30 +202,44 @@ InputChannel::~InputChannel()=default;
void InputChannel::poll(uint32_t ip,const std::vector<InputFrame>&out,std::vector<InputFrame>&in,uint64_t now){impl->poll(ip,out,in,now);}
bool InputChannel::adopt(uintptr_t accepted,uint64_t now)
{
if(!impl->managed||impl->socket!=INVALID_SOCKET||!nonblocking(SOCKET(accepted)))return false;
if(!impl->managed||impl->authenticated||!nonblocking(SOCKET(accepted)))return false;
// Reverse connection wins a simultaneous connect race. Never replace an
// authenticated channel or accept two sockets for the same input stream.
if(impl->socket!=INVALID_SOCKET){
if(impl->local>impl->peer||!impl->connecting)return false;
impl->reset(now,"Accepting reverse TCP connection.");
}
impl->reverse=impl->local<impl->peer;
impl->connecting=false;impl->begin(now);impl->socket=SOCKET(accepted);return true;
}
bool InputChannel::ready()const{return impl->authenticated;}
uint64_t InputChannel::generation()const{return impl->changed;}
std::string InputChannel::status()const{return impl->state;}
std::string InputChannel::status()const{
return impl->lastError.empty() ? impl->state : impl->lastError+" Retrying automatically.";
}
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
std::string InputChannel::transport()const{return impl->reverse?"reverse TCP":"direct TCP";}
struct InputHub::Impl
{
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{};std::string lastState; };
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;std::unique_ptr<InputDatagram> udp;uint64_t generation{},epoch{},since{},failedSince{};bool udpReady{};std::string lastState; };
struct Pending {SOCKET socket;uint32_t address;uint64_t since;};
std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET;
std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{};
std::string error;
InputHub::SendDatagram sendDatagram;
uint64_t currentTime{};
Impl(std::string id,uint16_t p):local(std::move(id)),port(p){}
~Impl(){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);}
void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now)
{
currentTime=now;
for(auto it=links.begin();it!=links.end();){
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port;});
if(match==endpoints.end())it=links.erase(it);else ++it;
}
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);link.epoch=++epoch;links.emplace(e.id,std::move(link));
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);
link.udp=std::make_unique<InputDatagram>(local,e.id,e.key);link.since=link.failedSince=now;link.epoch=++epoch;links.emplace(e.id,std::move(link));
}
// Discovery is a hint, not the lifetime of authenticated input sessions.
// Keep the last usable address across missed announcements. A new address
@@ -231,7 +249,7 @@ struct InputHub::Impl
link.channel->disconnect();link.endpoint.address=e.address;
}
}
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
bool listenNeeded=!links.empty();
if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();}
if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
@@ -253,7 +271,7 @@ struct InputHub::Impl
else if(count>=int(length+4)){
done=true;
try{auto hello=Json::parse(bytes+4,bytes+4+length);auto id=hello.value("id","");auto link=links.find(id);
if(id<local&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
if(hello.value("app","")=="KSwitcher-input"&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&!link->second.udp->active()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
}catch(const std::exception &){}
}
}
@@ -261,8 +279,16 @@ struct InputHub::Impl
}
for(auto &[id,link]:links){
std::vector<InputFrame> frames,received;for(auto &f:out)if(f.peer==id&&(!f.epoch||f.epoch==link.epoch))enqueueInput(frames,f.frame);
link.channel->poll(link.endpoint.address,frames,received,now);
auto state=link.channel->status();if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
if(sendDatagram&&!link.channel->ready()&&!link.udp->active()&&local<id&&now-link.since>=5000){link.udp->start(now);link.channel->disconnect();frames.clear();}
if(link.udp->active()){
link.udp->poll(frames,received,now,sendDatagram);
if(!link.udp->active()){link.since=now;link.channel->disconnect();received.clear();}
}else link.channel->poll(link.endpoint.address,frames,received,now);
if(link.udpReady!=link.udp->ready()){link.udpReady=link.udp->ready();link.epoch=++epoch;}
bool ready=link.channel->ready()||link.udp->ready();
if(ready)link.failedSince=link.since=now;
auto state=link.udp->ready()?"Input connected via encrypted UDP.":link.udp->active()?"Trying encrypted UDP over discovery...":link.channel->ready()?"Input connected via "+link.channel->transport()+".":link.channel->status();
if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
if(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;}
for(auto &f:received)in.push_back({id,f,link.epoch});
}
@@ -271,12 +297,28 @@ struct InputHub::Impl
InputHub::InputHub(std::string id,uint16_t port):impl(std::make_unique<Impl>(std::move(id),port)){}
InputHub::~InputHub()=default;
void InputHub::poll(const std::vector<InputEndpoint> &peers,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now){impl->poll(peers,out,in,now);}
std::map<std::string,uint64_t> InputHub::ready()const{std::map<std::string,uint64_t> result;for(auto &[id,l]:impl->links)if(l.channel->ready())result[id]=l.epoch;return result;}
std::map<std::string,uint64_t> InputHub::ready()const{std::map<std::string,uint64_t> result;for(auto &[id,l]:impl->links)if(l.channel->ready()||l.udp->ready())result[id]=l.epoch;return result;}
std::string InputHub::status()const{
auto count=ready().size();if(count)return "Input connected to "+std::to_string(count)+" computer(s). Move through a neighboring screen edge.";
auto count=ready().size();if(count==impl->links.size()&&count){
std::string routes;for(auto &[id,l]:impl->links){if(!routes.empty())routes+=", ";routes+=id.substr(0,8)+": "+(l.udp->ready()?"encrypted UDP":l.channel->transport());}
return "Input connected ("+routes+"). Move through a neighboring screen edge.";
}
if(needsHelp())return "Automatic connection attempts have not succeeded for every PC. Enable input on both PCs. Open Connection help for firewall commands.";
if(!impl->error.empty())return impl->error;
for(auto &[id,l]:impl->links)if(!l.channel->status().empty())return l.channel->status();
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready())return l.lastState;
return "Waiting for paired computers...";
}
void InputHub::disconnect(){for(auto &[id,l]:impl->links)l.channel->disconnect();}
void InputHub::disconnect(){for(auto &[id,l]:impl->links){l.channel->disconnect();l.udp->reset();l.since=impl->currentTime;}}
void InputHub::datagrams(SendDatagram send){impl->sendDatagram=std::move(send);}
void InputHub::receiveDatagram(const Json &j,uint64_t now){
if(!impl->sendDatagram)return;
auto it=impl->links.find(j.value("id",""));if(it==impl->links.end()||it->second.channel->ready())return;
auto &link=it->second;bool active=link.udp->active();
try{link.udp->receive(j,now,impl->sendDatagram);}catch(const std::exception &){return;}
if(!active&&link.udp->active())link.channel->disconnect();
}
bool InputHub::needsHelp()const{
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready()&&impl->currentTime-l.failedSince>=15000)return true;
return false;
}
}
+6
View File
@@ -2,6 +2,7 @@
#include "lan_pairing.h"
#include <memory>
#include <map>
#include <functional>
namespace ks::lan
{
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
@@ -57,6 +58,7 @@ class InputChannel
uint64_t generation() const;
std::string status() const;
void disconnect();
std::string transport()const;
};
struct InputEndpoint {std::string id,key;uint32_t address{};uint16_t port=45884;};
struct RoutedInput {std::string peer;InputFrame frame;uint64_t epoch{};};
@@ -70,5 +72,9 @@ class InputHub
std::map<std::string,uint64_t> ready()const;
std::string status()const;
void disconnect();
using SendDatagram=std::function<void(const std::string &,const Json &)>;
void datagrams(SendDatagram);
void receiveDatagram(const Json &,uint64_t now);
bool needsHelp()const;
};
}
+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.";
}
}
};
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "lan_channel.h"
#include <deque>
namespace ks::lan
{
// Ordered, bounded input on the discovery socket. Retransmission uses fresh
// authenticated packets; event sequence numbers prevent duplicate injection.
// Fresh challenge/response nonces bind every reconnect to a new session.
class InputDatagram
{
std::string local,peer,secret,nonce,remote,txKey,rxKey;
uint64_t started{},lastRead{},lastSend{},sentNumber{},seenNumber{},nextEvent{1},receivedEvent{},acked{},progress{};
bool live{},ackDue{};
std::deque<std::pair<uint64_t,InputFrame>> queue;
std::vector<InputFrame> incoming;
static std::string hex(const Bytes &b){std::string s;for(auto v:b){s+="0123456789abcdef"[v>>4];s+="0123456789abcdef"[v&15];}return s;}
static Bytes unhex(const std::string &s){
if(s.empty()||s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid UDP input packet.");
Bytes b;for(size_t i=0;i<s.size();i+=2)b.push_back(static_cast<unsigned char>(std::stoul(s.substr(i,2),nullptr,16)));return b;
}
std::string proof(const std::string &from,const std::string &to,const std::string &a,const std::string &b)const{
return mac(secret,"KSwitcher UDP input v1|"+from+"|"+to+"|"+a+"|"+b);
}
void keys(){
// Handshake proofs are public. Session keys use a separate domain and
// must never be the MAC transmitted in a welcome packet.
txKey=mac(secret,"KSwitcher UDP input key v1|"+local+"|"+peer+"|"+nonce+"|"+remote);
rxKey=mac(secret,"KSwitcher UDP input key v1|"+peer+"|"+local+"|"+remote+"|"+nonce);
}
public:
using Send=InputHub::SendDatagram;
explicit InputDatagram(std::string a,std::string b,std::string key):local(std::move(a)),peer(std::move(b)),secret(std::move(key)){}
bool active()const{return !nonce.empty();}
bool ready()const{return live;}
void reset(){nonce.clear();remote.clear();txKey.clear();rxKey.clear();queue.clear();incoming.clear();live=ackDue=false;sentNumber=seenNumber=receivedEvent=acked=lastSend=0;nextEvent=1;}
void start(uint64_t now){reset();nonce=randomHex(16);started=lastRead=progress=now;}
void receive(const Json &j,uint64_t now,const Send &send)
{
if(j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||j.value("type","")!="input-udp"||j.value("id","")!=peer||j.value("to","")!=local)return;
auto op=j.value("op","");
if(op=="hello"){
auto other=j.value("nonce","");if(local<peer||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,"")))return;
if(active()&&remote!=other)return;
if(!active()){start(now);remote=other;keys();}
auto response=envelope("input-udp",local);response["to"]=peer;response["op"]="welcome";response["nonce"]=nonce;response["echo"]=remote;response["proof"]=proof(local,peer,nonce,remote);send(peer,response);return;
}
if(op=="welcome"){
auto other=j.value("nonce","");
if(local>peer||!active()||j.value("echo","")!=nonce||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,nonce)))return;
if(!remote.empty()&&remote!=other)return;
remote=other;keys();ackDue=true;return;
}
if(op!="data"||rxKey.empty()||j.value("session","")!=nonce||j.value("fromSession","")!=remote)return;
auto number=j.value("number",uint64_t{});if(!number||number<=seenNumber)return;
InputCipher cipher(mac(rxKey,"packet|"+std::to_string(number)));
auto body=Json::from_cbor(cipher.decryptPayload(unhex(j.at("data").get<std::string>())));
auto ack=body.at("ack").get<uint64_t>(),first=body.at("first").get<uint64_t>();auto frames=body.at("frames");
if(ack>=nextEvent||!frames.is_array()||frames.size()>12||!first)return;
std::vector<InputFrame> decoded;
for(auto &f:frames){
if(!f.is_array()||f.size()!=5)return;
auto type=f[0].get<uint32_t>();if(type<uint32_t(InputType::Enter)||type>uint32_t(InputType::Cross))return;
decoded.push_back({InputType(type),f[1].get<uint32_t>(),f[2].get<int32_t>(),f[3].get<int32_t>(),f[4].get<int32_t>()});
}
seenNumber=number;lastRead=now;live=true;
if(ack>acked){acked=ack;progress=now;while(!queue.empty()&&queue.front().first<=ack)queue.pop_front();}
if(!decoded.empty()){
if(first==receivedEvent+1){
if(incoming.size()+decoded.size()>512){reset();return;}
incoming.insert(incoming.end(),decoded.begin(),decoded.end());receivedEvent+=decoded.size();
}
ackDue=true;
}
}
void poll(const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now,const Send &send)
{
if(!active())return;
if((live&&(now-lastRead>2000||(!queue.empty()&&now-progress>2000)))||(!live&&now-started>3000)){reset();return;}
if(live){
if(queue.empty()&&!out.empty())progress=now;
if(queue.size()+out.size()>512){reset();return;}
for(auto &f:out)queue.emplace_back(nextEvent++,f);
in.insert(in.end(),incoming.begin(),incoming.end());incoming.clear();
}
if(remote.empty()){
if(!lastSend||now-lastSend>=250){auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="hello";j["nonce"]=nonce;j["proof"]=proof(local,peer,nonce,"");send(peer,j);lastSend=now;}return;
}
if(!ackDue&&lastSend&&now-lastSend<(queue.empty()?300:50))return;
Json frames=Json::array();for(auto &[sequence,f]:queue){frames.push_back(Json::array({uint32_t(f.type),f.token,f.a,f.b,f.c}));if(frames.size()==12)break;}
Json body={{"ack",receivedEvent},{"first",queue.empty()?nextEvent:queue.front().first},{"frames",frames}};
auto number=++sentNumber;InputCipher cipher(mac(txKey,"packet|"+std::to_string(number)));
auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="data";j["number"]=number;j["session"]=remote;j["fromSession"]=nonce;j["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));
send(peer,j);lastSend=now;ackDue=false;
}
};
}
+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;
}
}
+86 -9
View File
@@ -2,6 +2,40 @@
#include <shellapi.h>
#include <shlobj.h>
#include "configuration.h"
#include "storage.h"
namespace
{
constexpr std::wstring_view elevatedRestart = L"--elevated-restart=";
bool shouldElevateOnLaunch()
{
if (ks::isLocalBuild() || IsUserAnAdmin())
return false;
try
{
auto settings = ks::loadSettings(ks::dataDirectory() / L"settings.json", {});
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;
}
}
}
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
@@ -43,8 +77,28 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
CoUninitialize();
return result;
}
if (arg.starts_with(elevatedRestart))
{
bool approved = false;
try
{
approved = argc == 2 && IsUserAnAdmin() && arg.substr(elevatedRestart.size()) == ks::currentUserSid();
}
catch (const std::exception &) {}
if (!approved)
{
MessageBoxW(nullptr, L"Administrator approval for the same Windows account is required.",
L"KSwitcher", MB_OK | MB_ICONERROR);
LocalFree(argv);
CoUninitialize();
return 1;
}
arg.clear();
}
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
const DWORD mutexError = GetLastError();
// Opening an existing elevated instance's mutex can be denied to a normal process.
bool existing = mutexError == ERROR_ALREADY_EXISTS || (!mutex && mutexError == ERROR_ACCESS_DENIED);
int cmd = ks::ShowHistory;
if (arg == L"--capture")
cmd = ks::Screenshot;
@@ -73,7 +127,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
}
CloseHandle(mutex);
if (mutex) CloseHandle(mutex);
CoUninitialize();
return 0;
}
@@ -86,13 +140,36 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
int result = 0;
try
{
ks::App app(instance, arg == L"--smoke-test");
app.isolateHistory = !app.smoke;
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
if (argc == 1 && mutex && shouldElevateOnLaunch())
{
auto file = ks::executable();
auto parameters = std::wstring(elevatedRestart) + ks::currentUserSid();
// Let the elevated process acquire the singleton before it starts.
ReleaseMutex(mutex);
CloseHandle(mutex);
mutex = nullptr;
SHELLEXECUTEINFOW request{sizeof(request)};
request.lpVerb = L"runas";
request.lpFile = file.c_str();
request.lpParameters = parameters.c_str();
request.nShow = SW_HIDE;
if (!ShellExecuteExW(&request))
{
if (GetLastError() != ERROR_CANCELLED)
throw std::runtime_error("Windows could not restart KSwitcher as administrator.");
result = 1;
}
}
else
{
ks::App app(instance, arg == L"--smoke-test");
app.isolateHistory = !app.smoke;
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
}
catch (const std::exception &e)
{
+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;
}
}
+171
View File
@@ -0,0 +1,171 @@
#pragma once
#include "core.h"
#include <cstdlib>
namespace ks
{
inline constexpr const wchar_t *MouseButtonNames[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2"};
inline constexpr const wchar_t *MouseEvents[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2",
L"Double left", L"Double right", L"Double middle",
L"Wheel up", L"Wheel down", L"Wheel left", L"Wheel right"};
inline constexpr int BuiltInMouseEventCount = 8;
inline int mouseWheelEvent(UINT message, SHORT delta)
{
if (!delta) return -1;
if (message == WM_MOUSEWHEEL) return BuiltInMouseEventCount + (delta < 0);
if (message == WM_MOUSEHWHEEL) return BuiltInMouseEventCount + 2 + (delta > 0);
return -1;
}
inline int rawMouseSignal(const RAWMOUSE &mouse)
{
constexpr USHORT downFlags[] = {RI_MOUSE_LEFT_BUTTON_DOWN, RI_MOUSE_RIGHT_BUTTON_DOWN,
RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN};
int signal = -1;
for (int button = 0; button < static_cast<int>(std::size(downFlags)); ++button)
{
if (!(mouse.usButtonFlags & downFlags[button])) continue;
if (signal >= 0) return -1;
signal = button;
}
const auto wheel = mouse.usButtonFlags & (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL);
if (!wheel) return signal;
if (signal >= 0 || wheel == (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL)) return -1;
return mouseWheelEvent(wheel == RI_MOUSE_WHEEL ? WM_MOUSEWHEEL : WM_MOUSEHWHEEL,
static_cast<SHORT>(mouse.usButtonData));
}
inline int mouseWheelSteps(int &remainder, SHORT delta)
{
const int total = remainder + std::abs(static_cast<int>(delta));
remainder = total % WHEEL_DELTA;
return total / WHEEL_DELTA;
}
inline bool mouseEventEnabled(const Settings &settings, int event)
{
return event >= 0 && event < static_cast<int>(std::size(MouseEvents)) &&
(event < BuiltInMouseEventCount || settings.customMouseEvents.contains(MouseEvents[event]));
}
inline std::wstring mouseEventName(const Settings &settings, int event)
{
auto name = settings.customMouseEvents.find(MouseEvents[event]);
return name == settings.customMouseEvents.end() ? MouseEvents[event] : name->second;
}
inline constexpr const wchar_t *MouseActions[] = {L"Standard behavior", L"Send key combination", L"Back", L"Forward",
L"Volume up", L"Volume down", L"Mute", L"Launch application"};
inline constexpr wchar_t GlobalMouseProfile[] = L"*";
inline constexpr std::wstring_view NamedMouseProfilePrefix = L"name:";
bool validMouseApplication(const std::wstring &app);
inline bool isNamedMouseProfile(std::wstring_view profile)
{
return profile.starts_with(NamedMouseProfilePrefix);
}
inline std::wstring mouseProfileName(const std::wstring &profile)
{
if (profile == GlobalMouseProfile) return L"Global";
return isNamedMouseProfile(profile) ? profile.substr(NamedMouseProfilePrefix.size()) : profile;
}
inline bool mouseActionApplies(const MouseAction &action, const std::wstring &app)
{
return action.applications.empty() || action.applications.contains(app);
}
inline const std::set<std::wstring> &mouseActionScope(const Settings &settings, const std::wstring &profile,
const MouseAction &action)
{
const auto scope = settings.mouseProfileApplications.find(profile);
return scope == settings.mouseProfileApplications.end() ? action.applications : scope->second;
}
inline bool isMouseVolumeAction(const MouseAction *action)
{
return action && (action->type == MouseActionType::VolumeUp ||
action->type == MouseActionType::VolumeDown || action->type == MouseActionType::Mute);
}
inline bool isMouseVolumeStep(MouseActionType action)
{
return action == MouseActionType::VolumeUp || action == MouseActionType::VolumeDown;
}
inline constexpr UINT MouseVolumeHoldDelay = 350;
inline UINT mouseVolumeRepeatInterval(ULONGLONG heldFor)
{
if (heldFor < 1000) return 140;
if (heldFor < 2000) return 80;
return 40;
}
inline const MouseAction *mouseBinding(const Settings &settings, const std::wstring &app, int event)
{
auto profile = settings.mouseBindings.find(app);
if (profile == settings.mouseBindings.end()) return nullptr;
auto action = profile->second.find(MouseEvents[event]);
return action == profile->second.end() ? nullptr : &action->second;
}
inline const MouseAction *effectiveMouseAction(const Settings &settings, const std::wstring &app, int event,
bool foreground = true, bool *profileScoped = nullptr)
{
if (profileScoped) *profileScoped = false;
if (!mouseEventEnabled(settings, event)) return nullptr;
auto *action = mouseBinding(settings, app, event);
if (!action)
{
const MouseAction *unrestricted = nullptr;
for (auto profile = settings.mouseBindings.lower_bound(std::wstring(NamedMouseProfilePrefix));
profile != settings.mouseBindings.end() && isNamedMouseProfile(profile->first); ++profile)
{
if (settings.disabledMouseProfiles.contains(profile->first)) continue;
const auto binding = profile->second.find(MouseEvents[event]);
if (binding == profile->second.end()) continue;
const auto &candidate = binding->second;
const auto &scope = mouseActionScope(settings, profile->first, candidate);
if (foreground && scope.contains(app))
{
if (profileScoped) *profileScoped = true;
return candidate.type == MouseActionType::Standard ? nullptr : &candidate;
}
if (!unrestricted && scope.empty()) unrestricted = &candidate;
}
const auto *global = mouseBinding(settings, GlobalMouseProfile, event);
if (foreground && global && global->applications.contains(app)) action = global;
else if (unrestricted) action = unrestricted;
else if (global && global->applications.empty()) action = global;
}
return action && action->type != MouseActionType::Standard && mouseActionApplies(*action, app) ? action : nullptr;
}
inline bool doubleMouseClick(POINT first, POINT second, ULONGLONG elapsed)
{
return elapsed <= GetDoubleClickTime() &&
std::abs(static_cast<long long>(first.x) - second.x) * 2 <= GetSystemMetrics(SM_CXDOUBLECLK) &&
std::abs(static_cast<long long>(first.y) - second.y) * 2 <= GetSystemMetrics(SM_CYDOUBLECLK);
}
inline int mouseButton(UINT message, WORD xbutton)
{
switch (message)
{
case WM_LBUTTONDOWN: case WM_LBUTTONUP: return 0;
case WM_RBUTTONDOWN: case WM_RBUTTONUP: return 1;
case WM_MBUTTONDOWN: case WM_MBUTTONUP: return 2;
case WM_XBUTTONDOWN: case WM_XBUTTONUP:
if (xbutton == XBUTTON1) return 3;
if (xbutton == XBUTTON2) return 4;
}
return -1;
}
inline bool mouseButtonDown(UINT message)
{
return message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN ||
message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN;
}
}
+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 {};
}
+130 -1
View File
@@ -4,12 +4,19 @@
#include <fstream>
#include <iomanip>
#include <imm.h>
#include <endpointvolume.h>
#include <mmdeviceapi.h>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <shellapi.h>
#include <shlobj.h>
#include <exdisp.h>
#include <shldisp.h>
#include <tlhelp32.h>
#include <wincrypt.h>
#include <wrl/client.h>
namespace ks
{
@@ -265,6 +272,83 @@ std::wstring processName(HWND window)
CloseHandle(h);
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
}
std::wstring mouseApplicationName(HWND window)
{
auto name = lower(processName(window));
if (name != L"applicationframehost") return name;
std::wstring hosted;
EnumChildWindows(window, [](HWND child, LPARAM output) -> BOOL {
wchar_t kind[64]{};
if (!GetClassNameW(child, kind, std::size(kind)) || wcscmp(kind, L"Windows.UI.Core.CoreWindow") != 0)
return TRUE;
auto &app = *reinterpret_cast<std::wstring *>(output);
app = lower(processName(child));
return app.empty();
}, reinterpret_cast<LPARAM>(&hosted));
return hosted.empty() ? name : hosted;
}
bool launchThroughExplorer(const std::wstring &path)
{
HANDLE token{};
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false;
TOKEN_ELEVATION elevation{};
DWORD size{};
const auto queried = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size);
CloseHandle(token);
if (!queried) return false;
if (!elevation.TokenIsElevated)
{
SHELLEXECUTEINFOW launch{sizeof(launch)};
launch.fMask = SEE_MASK_FLAG_NO_UI;
launch.lpFile = path.c_str();
launch.nShow = SW_SHOWNORMAL;
return ShellExecuteExW(&launch) != FALSE;
}
// An elevated resident delegates execution to the interactive desktop shell.
auto release = [](IUnknown *object) { if (object) object->Release(); };
IShellWindows *windowsRaw{};
auto hr = CoCreateInstance(CLSID_ShellWindows, nullptr, CLSCTX_ALL, IID_IShellWindows,
reinterpret_cast<void **>(&windowsRaw));
std::unique_ptr<IShellWindows, decltype(release)> windows(windowsRaw, release);
if (FAILED(hr) || !windows) return false;
VARIANT empty{}, desktop{};
desktop.vt = VT_I4;
long windowHandle{};
IDispatch *desktopRaw{};
hr = windows->FindWindowSW(&desktop, &empty, SWC_DESKTOP, &windowHandle, SWFO_NEEDDISPATCH, &desktopRaw);
std::unique_ptr<IDispatch, decltype(release)> dispatch(desktopRaw, release);
if (FAILED(hr) || !dispatch) return false;
IServiceProvider *providerRaw{};
hr = dispatch->QueryInterface(IID_IServiceProvider, reinterpret_cast<void **>(&providerRaw));
std::unique_ptr<IServiceProvider, decltype(release)> provider(providerRaw, release);
if (FAILED(hr) || !provider) return false;
IShellBrowser *browserRaw{};
hr = provider->QueryService(SID_STopLevelBrowser, IID_IShellBrowser, reinterpret_cast<void **>(&browserRaw));
std::unique_ptr<IShellBrowser, decltype(release)> browser(browserRaw, release);
if (FAILED(hr) || !browser) return false;
IShellView *viewRaw{};
hr = browser->QueryActiveShellView(&viewRaw);
std::unique_ptr<IShellView, decltype(release)> view(viewRaw, release);
if (FAILED(hr) || !view) return false;
IShellFolderViewDual *folderRaw{};
hr = view->GetItemObject(SVGIO_BACKGROUND, IID_IShellFolderViewDual, reinterpret_cast<void **>(&folderRaw));
std::unique_ptr<IShellFolderViewDual, decltype(release)> folder(folderRaw, release);
if (FAILED(hr) || !folder) return false;
IDispatch *applicationRaw{};
hr = folder->get_Application(&applicationRaw);
std::unique_ptr<IDispatch, decltype(release)> application(applicationRaw, release);
if (FAILED(hr) || !application) return false;
IShellDispatch2 *shellRaw{};
hr = application->QueryInterface(IID_IShellDispatch2, reinterpret_cast<void **>(&shellRaw));
std::unique_ptr<IShellDispatch2, decltype(release)> shell(shellRaw, release);
if (FAILED(hr) || !shell) return false;
BSTR executable = SysAllocString(path.c_str());
if (!executable) return false;
hr = shell->ShellExecute(executable, empty, empty, empty, empty);
SysFreeString(executable);
return SUCCEEDED(hr);
}
Clip readClipboard(HWND window, size_t limit, ClipboardRead mode, std::optional<DWORD> expected)
{
ClipboardLock lock(window);
@@ -411,7 +495,9 @@ bool chord(WORD key, UINT mods)
auto add = [&](WORD k, bool up)
{
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
if ((k >= VK_PRIOR && k <= VK_DOWN) || k == VK_INSERT || k == VK_DELETE ||
k == VK_DIVIDE || k == VK_LWIN || k == VK_SNAPSHOT ||
(k >= VK_VOLUME_MUTE && k <= VK_VOLUME_UP))
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
};
@@ -431,6 +517,42 @@ bool chord(WORD key, UINT mods)
add(k, true);
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
}
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step)
{
using Microsoft::WRL::ComPtr;
ComPtr<IMMDeviceEnumerator> devices;
ComPtr<IMMDevice> output;
ComPtr<IAudioEndpointVolume> volume;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&devices))) ||
FAILED(devices->GetDefaultAudioEndpoint(eRender, eMultimedia, &output)) ||
FAILED(output->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_INPROC_SERVER, nullptr,
reinterpret_cast<void **>(volume.GetAddressOf()))))
return std::nullopt;
float level{};
BOOL muted{};
HRESULT changed;
switch (action)
{
case MouseActionType::VolumeUp:
case MouseActionType::VolumeDown:
{
if (FAILED(volume->GetMasterVolumeLevelScalar(&level))) return std::nullopt;
const float delta = (action == MouseActionType::VolumeUp ? step : -step) / 100.0f;
changed = volume->SetMasterVolumeLevelScalar(std::clamp(level + delta, 0.0f, 1.0f), nullptr);
break;
}
case MouseActionType::Mute:
{
if (FAILED(volume->GetMute(&muted))) return std::nullopt;
changed = volume->SetMute(!muted, nullptr);
break;
}
default: return std::nullopt;
}
if (FAILED(changed) || FAILED(volume->GetMasterVolumeLevelScalar(&level)) || FAILED(volume->GetMute(&muted)))
return std::nullopt;
return SystemVolume{std::clamp(static_cast<int>(level * 100 + 0.5f), 0, 100), muted != FALSE};
}
bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
@@ -694,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,
+16 -2
View File
@@ -8,7 +8,7 @@
namespace ks
{
inline std::wstring localBuildTitle(const std::wstring &title)
inline bool isLocalBuild()
{
static const bool development=[] {
wchar_t path[32768]{};
@@ -16,7 +16,11 @@ inline std::wstring localBuildTitle(const std::wstring &title)
std::error_code error;
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
}();
return development?title+L" [LOCAL DEV]":title;
return development;
}
inline std::wstring localBuildTitle(const std::wstring &title)
{
return isLocalBuild()?title+L" [LOCAL DEV]":title;
}
using Bytes = std::vector<unsigned char>;
struct Format
@@ -43,6 +47,8 @@ bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
std::wstring processName(HWND);
std::wstring mouseApplicationName(HWND);
bool launchThroughExplorer(const std::wstring &);
enum class ClipboardRead { All, Selection, WithoutImages };
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024,
ClipboardRead = ClipboardRead::All, std::optional<DWORD> expected = {});
@@ -50,6 +56,12 @@ DWORD writeClipboard(HWND, const Clip &);
DWORD writeClipboardGuarded(HWND, const Clip &, std::optional<DWORD> expected);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
struct SystemVolume
{
int percent{};
bool muted{};
};
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step);
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
ULONG_PTR inputMarker();
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
@@ -64,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);
+241 -4
View File
@@ -1,4 +1,5 @@
#include "storage.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include "ocr.h"
#include <algorithm>
@@ -58,9 +59,124 @@ bool validId(const std::wstring &id)
{
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
}
void validateLanguageCycle(const std::vector<std::wstring> &cycle)
{
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select between 1 and 64 languages.");
std::set<std::wstring> seen;
for (const auto &id : cycle)
if (id.size() != 12 || !id.starts_with(L"win:") ||
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos || !seen.insert(lower(id)).second)
throw std::runtime_error("Invalid or duplicate language layout.");
}
} // namespace
bool validMouseApplication(const std::wstring &app)
{
return !app.empty() && app.size() <= 260 && app == lower(app) &&
app.find_first_of(L"\\/:*?\"<>|\r\n") == app.npos &&
app != L"kswitcher" && app != L"kswitcher.config";
}
static void validateMouseProfileConflicts(const Settings &s)
{
std::map<std::pair<std::wstring, std::wstring>, std::wstring> claimed;
for (const auto &[profile, bindings] : s.mouseBindings)
{
if (s.disabledMouseProfiles.contains(profile)) continue;
for (const auto &[event, action] : bindings)
{
auto claim = [&](const std::wstring &app)
{
const auto [other, added] = claimed.try_emplace({event, app}, profile);
if (!added && (isNamedMouseProfile(profile) || isNamedMouseProfile(other->second)))
throw std::runtime_error(utf8(L"Mouse action conflict: " + event + L" for " +
(app.empty() ? L"all applications" : app + L".exe") + L" in " +
mouseProfileName(other->second) + L" and " + mouseProfileName(profile) + L"."));
};
if (profile != GlobalMouseProfile && !isNamedMouseProfile(profile))
{
if (mouseActionApplies(action, profile)) claim(profile);
}
else
{
const auto &scope = mouseActionScope(s, profile, action);
if (scope.empty()) claim(L"");
else for (const auto &app : scope) claim(app);
}
}
}
}
void validateSettings(const Settings &s)
{
std::set<std::wstring> eventNames;
for (int event = 0; event < BuiltInMouseEventCount; ++event) eventNames.insert(lower(MouseEvents[event]));
for (const auto &[signal, name] : s.customMouseEvents)
{
if (std::find(std::begin(MouseEvents) + BuiltInMouseEventCount, std::end(MouseEvents), signal) == std::end(MouseEvents) ||
name.empty() || name.size() > 80 || name.find_first_of(L"\r\n\t") != name.npos ||
!eventNames.insert(lower(name)).second)
throw std::runtime_error("Enter a unique event name (up to 80 characters) for a supported signal.");
}
if (s.mouseBindings.size() > 1000) throw std::runtime_error("Too many mouse profiles.");
for (const auto &[app, bindings] : s.mouseBindings)
{
if (isNamedMouseProfile(app))
{
const auto name = mouseProfileName(app);
if (name.empty() || name.size() > 80 || lower(name) == L"global" || name.find_first_of(L"\r\n\t") != name.npos)
throw std::runtime_error("Enter a profile name other than Global (up to 80 characters).");
}
else if (app != GlobalMouseProfile && !validMouseApplication(app))
throw std::runtime_error("Enter the target application's executable name.");
for (const auto &[event, action] : bindings)
{
if (action.applications.size() > 1000 ||
!std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication))
throw std::runtime_error("Enter valid target application executable names.");
const auto signal = std::find(std::begin(MouseEvents), std::end(MouseEvents), event);
if (!mouseEventEnabled(s, static_cast<int>(signal - std::begin(MouseEvents))) ||
static_cast<size_t>(action.type) >= std::size(MouseActions))
throw std::runtime_error("Invalid mouse event or action.");
if (isMouseVolumeStep(action.type) && (action.volumeStep < 1 || action.volumeStep > 100))
throw std::runtime_error("Enter a volume step from 1 to 100.");
if (action.type == MouseActionType::Shortcut &&
(!action.shortcut.key || Hotkey::parse(action.shortcut.format()) != action.shortcut))
throw std::runtime_error("Enter a shortcut such as Ctrl+Shift+Tab.");
if (action.type == MouseActionType::Launch)
{
const std::filesystem::path path(action.executable);
const bool executableName = !path.has_root_path() && !path.has_parent_path() &&
action.executable.find_first_of(L"\\/:*?\"<>|\r\n") == std::wstring::npos;
if ((!executableName && !path.is_absolute()) || lower(path.extension().wstring()) != L".exe")
throw std::runtime_error("Enter an executable name such as calc.exe, or choose a file with Browse.");
}
}
}
for (const auto &profile : s.disabledMouseProfiles)
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile))
throw std::runtime_error("Unknown disabled mouse profile.");
for (const auto &[profile, apps] : s.mouseProfileApplications)
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile) || apps.size() > 1000 ||
!std::all_of(apps.begin(), apps.end(), validMouseApplication))
throw std::runtime_error("Invalid applications for the mouse profile.");
validateMouseProfileConflicts(s);
if (s.appLanguageSets.size() > 1000) throw std::runtime_error("Too many application language sets");
std::set<std::wstring> assigned;
for (const auto &[name, set] : s.appLanguageSets)
{
if (name.empty() || name.size() > 128 || name.find_first_of(L"\r\n") != name.npos)
throw std::runtime_error("Enter a language set name (up to 128 characters).");
if (set.applications.size() > 1000) throw std::runtime_error("Too many applications in a language set");
for (auto app : set.applications)
{
app = lower(app);
if (app.ends_with(L".exe")) app.resize(app.size() - 4);
if (app.empty() || app.size() > 260 || app.find_first_of(L"\\/:*?\"<>|\r\n") != app.npos)
throw std::runtime_error("Use an application executable name, not a path.");
if (!assigned.insert(app).second) throw std::runtime_error("An application can belong to only one language set.");
}
validateLanguageCycle(set.languages);
}
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
for (const auto &[key, cycle] : s.chatCycles)
{
@@ -137,11 +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 &&
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.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 correction words 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)
{
@@ -264,6 +381,83 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.cycle.push_back(w);
}
}
if (j.contains("appLanguageSets"))
{
if (!j.at("appLanguageSets").is_object()) throw std::runtime_error("Invalid application language sets");
for (const auto &[name, item] : j.at("appLanguageSets").items())
{
AppLanguageSet set;
if (!item.at("languages").is_array() || !item.at("applications").is_array())
throw std::runtime_error("Invalid application language set");
for (const auto &id : item.at("languages")) set.languages.push_back(wide(id.get<std::string>()));
for (const auto &app : item.at("applications")) set.applications.push_back(wide(app.get<std::string>()));
s.appLanguageSets[wide(name)] = std::move(set);
}
}
if (j.contains("customMouseEvents"))
{
const auto &events = j.at("customMouseEvents");
if (!events.is_object()) throw std::runtime_error("Invalid custom mouse events.");
for (const auto &[signal, name] : events.items())
s.customMouseEvents.emplace(wide(signal), wide(name.get<std::string>()));
}
if (j.contains("mouseBindings"))
{
const auto &profiles = j.at("mouseBindings");
if (!profiles.is_object()) throw std::runtime_error("Invalid mouse profiles.");
for (const auto &[app, bindings] : profiles.items())
{
if (!bindings.is_object()) throw std::runtime_error("Invalid mouse profile.");
if (isNamedMouseProfile(wide(app))) s.mouseBindings.try_emplace(wide(app));
for (const auto &[event, item] : bindings.items())
{
MouseAction action;
if (item.is_string())
{
auto shortcut = Hotkey::parse(wide(item.get<std::string>()));
if (!shortcut || !shortcut->key) throw std::runtime_error("Invalid mouse shortcut.");
action = {MouseActionType::Shortcut, *shortcut};
}
else
{
auto name = wide(item.at("action").get<std::string>());
if (name == L"Send shortcut") name = MouseActions[static_cast<size_t>(MouseActionType::Shortcut)];
const auto found = std::find(std::begin(MouseActions), std::end(MouseActions), name);
if (found == std::end(MouseActions)) throw std::runtime_error("Invalid mouse action.");
action.type = static_cast<MouseActionType>(found - std::begin(MouseActions));
if (action.type == MouseActionType::Shortcut)
action.shortcut = Hotkey::parse(wide(item.at("shortcut").get<std::string>())).value_or(Hotkey{});
if (action.type == MouseActionType::Launch)
action.executable = wide(item.at("executable").get<std::string>());
if (isMouseVolumeStep(action.type) && item.contains("volumeStep"))
{
const auto &step = item.at("volumeStep");
if (!step.is_number_integer() || step.get<double>() < 1 || step.get<double>() > 100)
throw std::runtime_error("Enter a volume step from 1 to 100.");
action.volumeStep = step.get<int>();
}
if (item.contains("applications"))
for (const auto &name : item.at("applications").get<std::vector<std::string>>())
action.applications.insert(wide(name));
}
s.mouseBindings[wide(app)][wide(event)] = std::move(action);
}
}
}
if (j.contains("mouseProfileApplications"))
{
const auto &scopes = j.at("mouseProfileApplications");
if (!scopes.is_object()) throw std::runtime_error("Invalid mouse profile applications.");
for (const auto &[profile, names] : scopes.items())
{
if (names.is_null()) continue;
auto &scope = s.mouseProfileApplications[wide(profile)];
for (const auto &name : names.get<std::vector<std::string>>()) scope.insert(wide(name));
}
}
if (j.contains("disabledMouseProfiles") && !j.at("disabledMouseProfiles").is_null())
for (const auto &profile : j.at("disabledMouseProfiles").get<std::vector<std::string>>())
s.disabledMouseProfiles.insert(wide(profile));
if (j.contains("chatCycles"))
{
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
@@ -336,6 +530,49 @@ Bytes encodeSettings(const Settings &s)
{"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
j["layouts"] = Json::array();
j["appLanguageSets"] = Json::object();
for (const auto &[name, set] : s.appLanguageSets)
{
auto &item = j["appLanguageSets"][utf8(name)];
item["languages"] = Json::array();
item["applications"] = Json::array();
for (const auto &id : set.languages) item["languages"].push_back(utf8(id));
for (const auto &app : set.applications) item["applications"].push_back(utf8(app));
}
j["customMouseEvents"] = Json::object();
for (const auto &[signal, name] : s.customMouseEvents) j["customMouseEvents"][utf8(signal)] = utf8(name);
j["mouseBindings"] = Json::object();
for (const auto &[app, bindings] : s.mouseBindings)
{
if (isNamedMouseProfile(app)) j["mouseBindings"][utf8(app)] = Json::object();
for (const auto &[event, action] : bindings)
{
auto &item = j["mouseBindings"][utf8(app)][utf8(event)];
if (action.type == MouseActionType::Shortcut && action.applications.empty())
item = utf8(action.shortcut.format());
else
{
item = {{"action", utf8(MouseActions[static_cast<size_t>(action.type)])}};
if (action.type == MouseActionType::Shortcut)
{
item["action"] = "Send shortcut";
item["shortcut"] = utf8(action.shortcut.format());
}
if (action.type == MouseActionType::Launch) item["executable"] = utf8(action.executable);
if (isMouseVolumeStep(action.type) && action.volumeStep != DefaultMouseVolumeStep)
item["volumeStep"] = action.volumeStep;
for (const auto &app : action.applications) item["applications"].push_back(utf8(app));
}
}
}
j["mouseProfileApplications"] = Json::object();
for (const auto &[profile, apps] : s.mouseProfileApplications)
{
auto &scope = j["mouseProfileApplications"][utf8(profile)] = Json::array();
for (const auto &app : apps) scope.push_back(utf8(app));
}
j["disabledMouseProfiles"] = Json::array();
for (const auto &profile : s.disabledMouseProfiles) j["disabledMouseProfiles"].push_back(utf8(profile));
j["chatCycles"] = Json::object();
for (const auto &[key, cycle] : s.chatCycles)
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
+20 -5
View File
@@ -1,4 +1,5 @@
#include "sync.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include <winhttp.h>
#include <wincred.h>
@@ -215,7 +216,20 @@ std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTranspor
{
return std::make_unique<OpenBao>(c, std::move(transport));
}
namespace { Json canonicalProfile(Json); }
namespace {
Json canonicalProfile(Json);
constexpr const char *PreviouslyLocalSettings[] = {
"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents"
};
void preserveMissingSettings(Json &profile, const Json &fallback)
{
for (auto key : PreviouslyLocalSettings)
if (!profile.contains(key) && fallback.contains(key)) profile[key] = fallback.at(key);
}
}
Bytes sharedSyncSettings(const Settings &s)
{
auto j = parse(encodeSettings(s));
@@ -225,13 +239,12 @@ Bytes sharedSyncSettings(const Settings &s)
std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) {
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
});
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
return bytes(canonicalProfile(j));
}
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
{
auto j = parse(shared), original = parse(encodeSettings(local));
preserveMissingSettings(j, original);
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
@@ -243,7 +256,7 @@ Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const s
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
throw std::runtime_error("Unsupported synchronized settings format.");
for (auto it = j.begin(); it != j.end(); ++it)
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported fields.");
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
std::set<std::string> installed;
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
@@ -305,7 +318,9 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
else
{
auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal);
if (!profile.contains("chatCycles")) profile["chatCycles"] = parse(local).value("chatCycles", Json::object());
const auto current = parse(local);
if (!profile.contains("chatCycles")) profile["chatCycles"] = current.value("chatCycles", Json::object());
preserveMissingSettings(profile, current);
auto selected = bytes(normalizeProfile(profile));
merged = {selected, selected, selected, {}};
}
+32 -2
View File
@@ -58,6 +58,28 @@ Json flatProfile(Json j)
for (auto it = chats.begin(); it != chats.end();)
if (it.value().is_null()) it = chats.erase(it);
else ++it;
if (j.contains("mouseBindings"))
{
auto &profiles = j.at("mouseBindings");
for (auto profile = profiles.begin(); profile != profiles.end();)
{
auto &buttons = profile.value();
for (auto button = buttons.begin(); button != buttons.end();)
if (button.value().is_null()) button = buttons.erase(button);
else ++button;
if (buttons.is_null() || (buttons.empty() && !isNamedMouseProfile(wide(profile.key()))))
profile = profiles.erase(profile);
else ++profile;
}
}
if (j.contains("customMouseEvents"))
{
auto &events = j.at("customMouseEvents");
for (auto event = events.begin(); event != events.end();)
if (event.value().is_null()) event = events.erase(event);
else ++event;
if (events.is_null()) j.erase("customMouseEvents");
}
Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items())
{
@@ -99,7 +121,7 @@ std::wstring fieldLabel(const std::string &key)
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
{"chatCycles", L"Chat languages"},
{"chatCycles", L"Chat languages"}, {"mouseBindings", L"Mouse buttons"},
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
@@ -133,7 +155,11 @@ MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::stri
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
if (!forcedChild && l == b) return {r, r, r};
if (!forcedChild && r == b) return {l, l, l};
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
// A mouse action and its application scope must come from the same edit.
constexpr std::string_view mouseProfiles = "/mouseBindings/";
const bool mouseAction = path.starts_with(mouseProfiles) && path.find('/', mouseProfiles.size()) != std::string::npos;
if (!mouseAction && l.is_object() && r.is_object() && (b.is_object() || b.is_null()) &&
!tombstone(l) && !tombstone(r) && !tombstone(b))
{
MergeNode out{Json::object(), Json::object(), Json::object()};
std::set<std::string> keys;
@@ -197,6 +223,10 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r
// client's chat profiles until both copies understand this field.
if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object());
if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles");
// Older server backups omit the newly synchronized fields. An explicit
// empty value from a current client still means the user cleared it.
preserveMissingSettings(remoteProfile, localProfile);
preserveMissingSettings(localProfile, remoteProfile);
auto l = treeProfile(localProfile), r = treeProfile(remoteProfile);
// With no common history, union independent entries; differing scalar values
// remain on their respective devices until the user resolves the conflict.
+564 -199
View File
File diff suppressed because it is too large. Load diff
+318
View File
@@ -0,0 +1,318 @@
// Included inside ui.cpp's anonymous namespace, alongside the settings editors.
std::wstring trimApplicationText(const std::wstring &text)
{
auto first = text.find_first_not_of(L" \t\r\n");
return first == text.npos ? L"" : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
}
struct AppLanguagesView
{
HWND parent{};
SettingsView *settings{};
std::map<std::wstring, std::vector<std::wstring>> profiles;
std::vector<std::wstring> applications;
int selection = -1;
bool dirty{};
HWND pickerOutline{};
};
constexpr UINT AppWindowPicked = WM_APP + 78;
void showAppLanguageStatus(HWND w, const AppLanguagesView &v)
{
if (v.selection < 0)
value(w, 721, L"Select an application, drag the crosshair, or enter an executable name.");
else if (v.dirty || v.profiles.contains(v.applications[v.selection]))
value(w, 721, L"Custom languages for this application. Other applications are unchanged.");
else
value(w, 721, L"Using default languages. Click languages to customize this application.");
}
void fillAppLanguages(HWND w, AppLanguagesView *v)
{
bool selected = v->selection >= 0 && v->selection < (int)v->applications.size();
EnableWindow(GetDlgItem(w, 722), selected);
SendDlgItemMessageW(w, 722, LB_SETSEL, FALSE, -1);
if (!selected)
{
EnableWindow(GetDlgItem(w, 725), FALSE);
showAppLanguageStatus(w, *v);
return;
}
auto found = v->profiles.find(v->applications[v->selection]);
const auto &cycle = found == v->profiles.end() ? v->settings->draft.cycle : found->second;
for (size_t i = 0; i < v->settings->order.size(); ++i)
{
const auto &id = v->settings->app->layouts[v->settings->order[i]].id;
SendDlgItemMessageW(w, 722, LB_SETSEL,
std::find(cycle.begin(), cycle.end(), id) != cycle.end(), i);
}
showAppLanguageStatus(w, *v);
EnableWindow(GetDlgItem(w, 725), found != v->profiles.end());
}
void collectAppLanguages(HWND w, AppLanguagesView *v)
{
if (v->selection < 0 || !v->dirty) return;
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 722, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
v->profiles[v->applications[v->selection]] = std::move(cycle);
v->dirty = false;
}
void selectAppLanguageProfile(HWND w, AppLanguagesView *v, const std::wstring &app)
{
collectAppLanguages(w, v);
auto found = std::find(v->applications.begin(), v->applications.end(), app);
int index = (int)(found - v->applications.begin());
if (found == v->applications.end())
{
auto row = SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
if (row < 0) throw std::runtime_error("Could not add the application to the list.");
v->applications.push_back(app);
}
v->selection = index;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0);
fillAppLanguages(w, v);
}
HWND appPickerTarget(HWND button, LPARAM position)
{
POINT point{(short)LOWORD(position), (short)HIWORD(position)};
ClientToScreen(button, &point);
auto target = GetAncestor(WindowFromPoint(point), GA_ROOT);
auto dialog = GetParent(button);
return target && target != dialog && target != GetWindow(dialog, GW_OWNER) ? target : nullptr;
}
void updatePickerOutline(HWND dialog, HWND &outline, HWND target)
{
RECT bounds{};
if (!target || !GetWindowRect(target, &bounds) || IsRectEmpty(&bounds))
{
if (outline) ShowWindow(outline, SW_HIDE);
return;
}
if (!outline)
{
WNDCLASSW windowClass{};
windowClass.lpfnWndProc = DefWindowProcW;
windowClass.hInstance = GetModuleHandleW(nullptr);
windowClass.hbrBackground = GetSysColorBrush(COLOR_HIGHLIGHT);
windowClass.lpszClassName = L"KSwitcher.AppPickerOutline";
RegisterClassW(&windowClass);
outline = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
windowClass.lpszClassName, nullptr, WS_POPUP, 0, 0, 0, 0, dialog, nullptr, windowClass.hInstance, nullptr);
if (!outline) return;
if (!SetLayeredWindowAttributes(outline, 0, 255, LWA_ALPHA))
{
DestroyWindow(outline);
outline = nullptr;
return;
}
}
RECT previous{};
if (IsWindowVisible(outline) && GetWindowRect(outline, &previous) && EqualRect(&bounds, &previous)) return;
int width = bounds.right - bounds.left, height = bounds.bottom - bounds.top;
int stroke = scale(target, 4);
HRGN frame = CreateRectRgn(0, 0, width, height);
if (!frame) return;
if (width > 2 * stroke && height > 2 * stroke)
{
HRGN opening = CreateRectRgn(stroke, stroke, width - stroke, height - stroke);
if (opening)
{
CombineRgn(frame, frame, opening, RGN_DIFF);
DeleteObject(opening);
}
}
if (!SetWindowRgn(outline, frame, TRUE))
{
DeleteObject(frame);
return;
}
SetWindowPos(outline, HWND_TOPMOST, bounds.left, bounds.top, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR data)
{
HWND dialog = GetParent(button);
auto &outline = *reinterpret_cast<HWND *>(data);
if (msg == WM_LBUTTONDOWN)
{
SetFocus(button);
SetCapture(button);
if (GetCapture() == button)
{
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
SendMessageW(dialog, AppWindowPicked, 0, -1);
}
return 0;
}
if (msg == WM_CAPTURECHANGED || msg == WM_NCDESTROY)
{
if (outline)
{
DestroyWindow(outline);
outline = nullptr;
}
if (msg == WM_CAPTURECHANGED) SendMessageW(dialog, AppWindowPicked, 0, 0);
}
if (GetCapture() == button)
{
if (msg == WM_GETDLGCODE && wp == VK_ESCAPE) return DLGC_WANTMESSAGE;
if (msg == WM_CANCELMODE || msg == WM_RBUTTONDOWN || (msg == WM_KEYDOWN && wp == VK_ESCAPE))
{
ReleaseCapture();
return 0;
}
if (msg == WM_MOUSEMOVE || msg == WM_SETCURSOR)
{
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
if (msg == WM_MOUSEMOVE) updatePickerOutline(dialog, outline, appPickerTarget(button, lp));
return TRUE;
}
if (msg == WM_LBUTTONUP)
{
HWND target = appPickerTarget(button, lp);
ReleaseCapture();
SendMessageW(dialog, AppWindowPicked, (WPARAM)target, 1);
return 0;
}
}
if (msg == WM_NCDESTROY) RemoveWindowSubclass(button, appWindowPickerProc, id);
return DefSubclassProc(button, msg, wp, lp);
}
void createAppWindowPicker(HWND w, int id, int x, int y, int width, HWND &outline)
{
control(w, L"BUTTON", L" ⊕ Drag to window", WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,
id, x, y, width, 28);
SetWindowSubclass(GetDlgItem(w, id), appWindowPickerProc, 1, reinterpret_cast<DWORD_PTR>(&outline));
}
LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *v = (AppLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
if (!v) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == AppWindowPicked)
{
if (lp == -1)
{
value(w, 721, L"Drag to the application's window and release. Esc cancels.");
return 0;
}
if (!lp) { showAppLanguageStatus(w, *v); return 0; }
HWND target = (HWND)wp;
auto app = target && target != w && target != v->parent ? applicationName(processName(target)) : L"";
if (app.empty()) value(w, 721, L"No application found. Release the crosshair over another window.");
else selectAppLanguageProfile(w, v, app);
return 0;
}
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 722 && HIWORD(wp) == LBN_SELCHANGE)
{
v->dirty = true;
EnableWindow(GetDlgItem(w, 725), TRUE);
showAppLanguageStatus(w, *v);
return 0;
}
if (id == 720 && HIWORD(wp) == LBN_SELCHANGE)
{
int next = (int)SendDlgItemMessageW(w, 720, LB_GETCURSEL, 0, 0);
try { collectAppLanguages(w, v); }
catch (...) { SendDlgItemMessageW(w, 720, LB_SETCURSEL, v->selection, 0); throw; }
v->selection = next;
fillAppLanguages(w, v);
return 0;
}
if (id == 725 && v->selection >= 0)
{
v->profiles.erase(v->applications[v->selection]);
v->dirty = false;
fillAppLanguages(w, v);
return 0;
}
if (id == 727)
{
auto app = applicationName(trimApplicationText(value(w, 726)));
Settings check;
check.appLanguageSets[L"check"] = {{L"win:04090409"}, {app}};
validateSettings(check);
selectAppLanguageProfile(w, v, app);
value(w, 726, L"");
SetFocus(GetDlgItem(w, 722));
return 0;
}
if (id == IDOK)
{
collectAppLanguages(w, v);
auto next = v->settings->draft;
next.appLanguageSets.clear();
// Keep the existing on-disk format, but save independent application profiles.
for (const auto &[app, cycle] : v->profiles) next.appLanguageSets[app] = {cycle, {app}};
validateSettings(next);
v->settings->draft.appLanguageSets = std::move(next.appLanguageSets);
DestroyWindow(w);
return 0;
}
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
if (msg == WM_NCDESTROY)
{
EnableWindow(v->parent, TRUE);
SetForegroundWindow(v->parent);
disposeFont(w);
delete v;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
}
catch (const std::exception &e) { error(w, e); }
return DefWindowProcW(w, msg, wp, lp);
}
void showAppLanguages(HWND parent, SettingsView *settings)
{
std::set<std::wstring> apps;
for (const auto &[name, set] : settings->draft.appLanguageSets)
for (const auto &app : set.applications) apps.insert(applicationName(app));
EnumWindows([](HWND candidate, LPARAM context) -> BOOL {
if (IsWindowVisible(candidate) && !chatTitle(candidate).empty())
{
auto name = applicationName(processName(candidate));
if (!name.empty()) ((std::set<std::wstring> *)context)->insert(name);
}
return TRUE;
}, (LPARAM)&apps);
HWND w = makeWindow(*settings->app, L"KSwitcher.AppLanguages", L"Languages for applications", appLanguagesProc, 720, 500);
auto *v = new AppLanguagesView{parent, settings};
for (const auto &[name, set] : settings->draft.appLanguageSets)
for (const auto &app : set.applications) v->profiles[applicationName(app)] = set.languages;
v->applications.assign(apps.begin(), apps.end());
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Application", 18, 16, 108);
createAppWindowPicker(w, 728, 132, 10, 164, v->pickerOutline);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
720, 18, 44, 278, 280);
label(w, L"Allowed languages (click to include)", 316, 16, 386);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
722, 316, 44, 386, 280);
for (int i : settings->order)
SendDlgItemMessageW(w, 722, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
for (const auto &app : v->applications) SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
control(w, L"STATIC", L"", 0, 721, 18, 338, 684, 22);
label(w, L"Add an executable name (e.g. Codex.exe)", 18, 372, 684);
edit(w, 726, 18, 400, 526);
button(w, L"&Add application", 727, 558, 398, 144);
label(w, L"Switch applications freely. Use languages, then Save settings. Chat profiles take priority.", 18, 436, 684);
button(w, L"Use &defaults", 725, 18, 466, 170);
button(w, L"&Use languages", IDOK, 414, 466, 144);
button(w, L"&Cancel", IDCANCEL, 572, 466, 130);
if (!v->applications.empty())
{
v->selection = 0;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, 0, 0);
}
fillAppLanguages(w, v);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetFocus(GetDlgItem(w, v->selection < 0 ? 726 : 720));
}
+139 -7
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,8 +23,60 @@ 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;}
if(msg==WM_COMMAND&&LOWORD(wp)==724){SendDlgItemMessageW(w,723,EM_SETSEL,0,-1);SendDlgItemMessageW(w,723,WM_COPY,0,0);value(w,724,L"Copied");return 0;}
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)){DestroyWindow(w);return 0;}
if(msg==WM_DESTROY){auto parent=GetWindow(w,GW_OWNER);EnableWindow(parent,TRUE);SetForegroundWindow(parent);return 0;}
return DefWindowProcW(w,msg,wp,lp);
}
void showLanHelp(HWND parent,App &app)
{
auto script=wide(lanRequest(app,{{"action","connectionHelp"}}).at("script").get<std::string>());
auto w=makeWindow(app,L"KSwitcher.LAN.Help",L"KSwitcher — connection help",lanHelpProc,740,430,0,false);
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
control(w,L"STATIC",L"First enable input sharing on both PCs. KSwitcher tries direct TCP, reverse TCP, then encrypted UDP automatically. Both PCs need an updated version for all three methods.",0,725,18,16,700,60);
control(w,L"STATIC",L"Still disconnected? A firewall may be blocking the app. On EACH PC, open this help and run its own command in PowerShell as administrator. This allows only local-network connections to that PC's current KSwitcher executable. No restart or new pairing is needed.",0,726,18,88,700,80);
auto field=edit(w,723,18,180,700,180,ES_MULTILINE|ES_READONLY|ES_AUTOVSCROLL|WS_VSCROLL);
SetWindowTextW(field,script.c_str());
button(w,L"&Copy command",724,18,380,180);button(w,L"&Close",IDCANCEL,618,380,100);
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
}
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())
@@ -60,16 +115,42 @@ void refreshLan(HWND w,LanView &v)
for(size_t i=0;i<v.ids.size();++i)if(v.ids[i]==selected)
SendDlgItemMessageW(w,702,LB_SETCURSEL,i,0);
selected=selectedLan(w,v);
// Keep the code available while the user enters it on the other PC, and
// after cancellation/timeout for retry. Clear it only after trust is saved.
if(v.snapshot.value("pending",false))
for(const auto &peer:next["peers"])
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{})&&peer.value("paired",false))value(w,703,L"");
v.snapshot=std::move(next);
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
for(const auto &peer:v.snapshot["peers"]) if(peer.at("id")==selected) {online=peer.at("online");paired=peer.at("paired");}
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get<std::string>()) : v.error);
auto pairingStatus=wide(v.snapshot.at("status").get<std::string>());
if(pending)
{
std::wstring peerName;
for(const auto &peer:v.snapshot["peers"])
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{}))peerName=wide(peer.at("name").get<std::string>());
pairingStatus=L"Waiting for "+peerName+L" ("+std::to_wstring(v.snapshot.value("pairSecondsRemaining",0))+L" s).\nEnter the same code and click Pair there too. Pair is not saved yet.";
}
value(w,708,v.error.empty() ? pairingStatus : v.error);
value(w,705,pending ? L"Waiting..." : L"&Pair");
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
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));
@@ -89,6 +170,7 @@ void refreshLan(HWND w,LanView &v)
if(mode==2)inputStatus=L"Mouse and keyboard control the OTHER PC. Ctrl+Alt+Esc returns here.";
else if(mode==3)inputStatus=L"This PC is controlled remotely. Local physical input takes control back.";
value(w,718,inputStatus);
value(w,722,v.snapshot.value("inputNeedsHelp",false)?L"Connection failed — &help":L"Connection &help");
}
#include "ui_lan_layout.inl"
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
@@ -108,6 +190,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
v->error.clear();
if(id==722){showLanHelp(w,*v->app);return 0;}
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
if(id==704)
{
@@ -115,9 +198,52 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
std::string grouped; for(size_t i=0;i<code.size();++i) {if(i&&i%4==0) grouped+='-';grouped+=code[i];}
value(w,703,wide(grouped)); SetFocus(GetDlgItem(w,703)); SendDlgItemMessageW(w,703,EM_SETSEL,0,-1);
}
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
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}});
@@ -131,10 +257,10 @@ 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,836,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"Find running KSwitcher instances on your local network and pair them.",18,16,700);
label(w,L"Changes here save automatically. No need to click Save in Settings.",18,16,700);
control(w,L"STATIC",L"",0,709,18,45,700,24);
button(w,L"&Enable discovery",701,18,78,210);
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
@@ -154,9 +280,15 @@ 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,28);
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,776,580,40);
button(w,L"&Close",IDCANCEL,618,792,100);
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);
SetTimer(w,1,1000,nullptr);
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
+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)
{
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.59";
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;
+147 -24
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,8 +179,14 @@ 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) == 5, "five settings categories");
for (int page = 0; page < 5; ++page)
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);
NMHDR notification{tabs, 260, TCN_SELCHANGE};
@@ -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);
@@ -225,16 +239,16 @@ int main()
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
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);
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
@@ -246,11 +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(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
require(IsWindow(dialog), "chat reset keeps dialog open");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
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);
@@ -258,11 +272,104 @@ int main()
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
// Existing shared sets become independent application drafts on edit.
app.settings.appLanguageSets[L"Legacy shared"] = {{app.layouts[0].id}, {L"Codex.exe", L"notepad"}};
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
require(dialog && !IsWindowEnabled(parent), "application language editor opens");
auto selectApp = [&](const wchar_t *name) {
int index = (int)SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)name);
require(index >= 0, "application listed");
SendDlgItemMessageW(dialog, 720, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(720, LBN_SELCHANGE), 0);
};
selectApp(L"codex");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "legacy languages displayed");
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
HWND picker = GetDlgItem(dialog, 728);
RECT dialogBounds{};
GetWindowRect(dialog, &dialogBounds);
HWND target = CreateWindowExW(WS_EX_TOPMOST | WS_EX_NOACTIVATE, L"EDIT", L"Picker fixture",
WS_POPUP | WS_VISIBLE, dialogBounds.left + 32, dialogBounds.top + 100, 120, 48,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
require(picker && target, "window picker and target available");
POINT drop{dialogBounds.left + 33, dialogBounds.top + 101};
require(GetAncestor(WindowFromPoint(drop), GA_ROOT) == target, "fixture window is under the drop point");
ScreenToClient(picker, &drop);
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
require(GetCapture() == picker, "window picker captures drag");
SendMessageW(picker, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(drop.x, drop.y));
HWND outline = findTestWindow(L"KSwitcher.AppPickerOutline", nullptr);
RECT outlineBounds{}, targetBounds{};
require(IsWindowVisible(outline) && GetWindowRect(outline, &outlineBounds) &&
GetWindowRect(target, &targetBounds) && EqualRect(&outlineBounds, &targetBounds), "drag highlights the target window");
SendMessageW(picker, WM_KEYDOWN, VK_ESCAPE, 0);
require(!IsWindow(outline) && GetCapture() != picker, "Escape clears the outline and releases capture");
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
SendMessageW(picker, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(drop.x, drop.y));
outline = findTestWindow(L"KSwitcher.AppPickerOutline", nullptr);
SendMessageW(picker, WM_LBUTTONUP, 0, MAKELPARAM(drop.x, drop.y));
require(!IsWindow(outline), "dropping clears the outline");
auto pickedApp = ks::applicationName(ks::processName(target));
DestroyWindow(target);
auto pickedRow = SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)pickedApp.c_str());
require(GetCapture() != picker && pickedRow >= 0 &&
SendDlgItemMessageW(dialog, 720, LB_GETCURSEL, 0, 0) == pickedRow, "dropping on a window selects its executable");
selectApp(L"notepad");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "editing one app leaves formerly shared app unchanged");
selectApp(L"codex");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == (LRESULT)app.layouts.size(), "application navigation preserves drafts");
RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
GetClientRect(dialog, &rect);
dc = GetDC(dialog);
clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
ReleaseDC(dialog, dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"application-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(app.settings.appLanguageSets.size() == 1 && app.settings.appLanguageSets.contains(L"Legacy shared"), "cancel preserves legacy settings unchanged");
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
selectApp(L"codex");
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
selectApp(L"notepad");
SetDlgItemTextW(dialog, 726, L"Fixture-App.EXE");
SendMessageW(dialog, WM_COMMAND, 727, 0);
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "new application inherits defaults");
selectApp(L"codex");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(app.settings.appLanguageSets.size() == 2, "viewing an app does not create a profile");
require(app.settings.appLanguageSets.at(L"codex").languages.size() == app.layouts.size(), "edited application languages saved");
require(app.settings.appLanguageSets.at(L"notepad").languages == std::vector<std::wstring>{app.layouts[0].id}, "other application retains original languages");
require(ks::loadSettings(root / L"settings.json", app.layouts).appLanguageSets == app.settings.appLanguageSets, "independent application profiles saved to disk");
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
selectApp(L"codex");
SendMessageW(dialog, WM_COMMAND, 725, 0);
selectApp(L"notepad");
selectApp(L"codex");
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "default reset survives navigation");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!ks::applicationLanguageSet(app.settings, L"codex"), "use defaults removes only selected application profile");
require(ks::applicationLanguageSet(app.settings, L"notepad") != nullptr, "default reset leaves other application profile intact");
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
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");
@@ -276,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");
@@ -307,12 +414,28 @@ int main()
require(GetDlgItem(lan,711)==nullptr,"obsolete side radios are removed");
require(SendDlgItemMessageW(lan,702,LB_GETCOUNT,0,0)==0,"disabled discovery lists no generic network devices");
require(!std::filesystem::exists(root/L"lan-pairing.bin"),"opening LAN settings does not enable or save discovery");
SendMessageW(lan,WM_COMMAND,722,0);
auto help=findTestWindow(L"KSwitcher.LAN.Help",nullptr);
require(help&&!IsWindowEnabled(lan),"connection help opens without changing network settings");
wchar_t command[4096]{};GetDlgItemTextW(help,723,command,4096);
require(std::wstring(command).find(ks::executable())!=std::wstring::npos&&
std::wstring(command).find(L"-LocalPort 45884")!=std::wstring::npos&&
std::wstring(command).find(L"-LocalPort 45883")!=std::wstring::npos&&
std::wstring(command).find(L"-RemoteAddress LocalSubnet")!=std::wstring::npos,
"help supplies local-subnet firewall commands for the actual resident executable and both transports");
RedrawWindow(help,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT helpRect{};GetClientRect(help,&helpRect);HDC helpDc=GetDC(help);
auto helpImage=ks::screenshotClip(helpDc,helpRect.right,helpRect.bottom,{});ReleaseDC(help,helpDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-connection-help.png",ks::encodePng(ks::decodeImage(helpImage)));
SendMessageW(help,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(lan),"closing connection help returns to LAN setup");
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
require(status().find(L"Network changes save automatically")!=std::wstring::npos,"Settings explains why network actions do not enable Save");
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
}
{
+295
View File
@@ -125,6 +125,47 @@ struct InputRegressionAccess {
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
static bool wheelQueuesAction(App &app)
{
MSLLHOOKSTRUCT mouse{};
if (!GetCursorPos(&mouse.pt) || !WindowFromPoint(mouse.pt)) return false;
auto previous = app.settings;
app.settings.customMouseEvents[L"Wheel down"] = L"Wheel test";
app.settings.mouseBindings[GlobalMouseProfile][L"Wheel down"] = {MouseActionType::Mute};
mouse.mouseData = MAKEWPARAM(0, static_cast<WORD>(-30));
const auto wheel = [&] { return App::mouseProc(HC_ACTION, WM_MOUSEWHEEL, reinterpret_cast<LPARAM>(&mouse)); };
mouse.flags = LLMHF_INJECTED;
wheel();
const bool ignoredInjection = app.mouseActions.empty();
mouse.flags = 0;
const bool partial = wheel() == 1 && app.mouseActions.empty();
mouse.mouseData = MAKEWPARAM(0, static_cast<WORD>(-90));
const bool queued = wheel() == 1 && app.mouseActions.size() == 1 &&
app.mouseActions.front().action.type == MouseActionType::Mute;
app.mouseActions.clear();
app.settings = std::move(previous);
return ignoredInjection && partial && queued;
}
static bool volumeHoldStopsOnRelease(App &app)
{
constexpr int button = 3;
auto &press = app.mousePresses[button];
press = {app.window, {}, GetTickCount64(), {MouseActionType::VolumeUp}, true, false};
app.beginMouseVolume(button);
const bool onDown = app.mouseActions.size() == 1 && app.volumeButtonHeld();
app.repeatMouseVolume(button);
const bool initialDelay = app.mouseActions.size() == 1;
press.started -= 2100;
app.repeatMouseVolume(button);
const bool repeated = app.mouseActions.size() == 2;
MSLLHOOKSTRUCT mouse{};
mouse.mouseData = MAKEWPARAM(0, XBUTTON1);
const bool consumed = App::mouseProc(HC_ACTION, WM_XBUTTONUP, reinterpret_cast<LPARAM>(&mouse)) == 1;
app.repeatMouseVolume(button);
const bool stopped = !app.volumeButtonHeld() && app.mouseActions.size() == 2;
app.mouseActions.clear();
return onDown && initialDelay && repeated && consumed && stopped;
}
};
}
int main(int argc, char **argv) {
@@ -181,6 +222,14 @@ int main(int argc, char **argv) {
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
require(app.window!=nullptr,"message-only test window");
require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app),
"volume hold repeats after its delay and stops on release without an extra step");
require(ks::InputRegressionAccess::wheelQueuesAction(app),
"a registered physical wheel signal queues one action per detent");
require(ks::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) &&
ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) &&
!ks::isMouseVolumeStep(ks::MouseActionType::Mute),
"volume repeat accelerates and excludes mute");
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
@@ -751,7 +800,253 @@ 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);
NMHDR changed{tabs, 260, TCN_SELCHANGE};
SendMessageW(settings, WM_NOTIFY, 260, reinterpret_cast<LPARAM>(&changed));
require(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE,
"mouse detection is visible on Mouse");
RECT suggestedSettings{};
GetWindowRect(settings, &suggestedSettings);
suggestedSettings.right -= 12;
suggestedSettings.bottom -= 12;
const UINT settingsDpi = GetDpiForWindow(settings);
SendMessageW(settings, WM_DPICHANGED, MAKEWPARAM(settingsDpi, settingsDpi),
reinterpret_cast<LPARAM>(&suggestedSettings));
require(!(GetWindowLongPtrW(settings, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
!(GetWindowLongPtrW(events, GWL_STYLE) & WS_HSCROLL),
"a DPI resize keeps both settings and the action table free of unnecessary scrollbars");
RECT profilesBounds{}, eventsBounds{};
require(GetWindowRect(GetDlgItem(settings, 731), &profilesBounds) &&
GetWindowRect(events, &eventsBounds) &&
profilesBounds.top == eventsBounds.top && profilesBounds.bottom == eventsBounds.bottom &&
profilesBounds.right - profilesBounds.left == eventsBounds.right - eventsBounds.left,
"mouse profile and action tables have equal dimensions and aligned edges");
SendMessageW(events, WM_KEYDOWN, VK_RETURN, 0);
auto editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
"Global actions do not include inheritance");
SendMessageW(editor, WM_COMMAND, IDCANCEL, 0);
require(!GetDlgItem(settings, 741) && !GetDlgItem(settings, 732) && !GetDlgItem(settings, 745),
"applications are chosen in the action editor only");
for (auto message : {WM_XBUTTONDOWN, WM_XBUTTONUP, WM_XBUTTONDBLCLK, WM_XBUTTONUP})
SendMessageW(capture, message, MAKEWPARAM(0, XBUTTON2), 0);
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 4,
"mouse button capture selects the second side button");
wchar_t detected[100]{};
GetDlgItemTextW(settings, 738, detected, 100);
require(std::wstring(detected) == L"Window event: XButton2 | Presses: 2",
"detection shows the last button and counts repeated presses");
SendMessageW(capture, WM_MBUTTONDBLCLK, 0, 0);
SendMessageW(capture, WM_MBUTTONUP, 0, 0);
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 7,
"a double middle click selects its own event");
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)), "button detection is not a settings edit");
require(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2,
"mouse events have eight rows and two columns");
SendMessageW(settings, WM_COMMAND, 762, 0);
const auto addEvent = FindWindowW(L"KSwitcher.AddMouseEvent", nullptr);
require(addEvent && GetDlgItem(addEvent, 764) && GetDlgItem(addEvent, 763) &&
!IsWindowEnabled(GetDlgItem(addEvent, IDOK)), "Add mouse event waits for a physical signal");
SendMessageW(addEvent, WM_COMMAND, IDOK, 0);
require(IsWindow(addEvent) && ListView_GetItemCount(events) == 8,
"the event editor cannot save an uncaptured signal");
SendMessageW(addEvent, WM_COMMAND, IDCANCEL, 0);
NMITEMACTIVATE click{};
click.hdr = {events, 733, NM_DBLCLK};
click.iItem = 7;
SendMessageW(settings, WM_NOTIFY, 733, reinterpret_cast<LPARAM>(&click));
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && !IsWindowEnabled(settings) && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
"double-click opens the Global action editor");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 1, 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
SendDlgItemMessageW(editor, 747, ks::CaptureHotkeyMessage, VK_TAB, MOD_CONTROL);
require(!GetDlgItem(editor, 748), "the action editor has no application list");
SendMessageW(editor, WM_COMMAND, IDOK, 0);
const auto profiles = GetDlgItem(settings, 731);
require(ListView_GetItemCount(profiles) == 1 &&
IsWindowEnabled(GetDlgItem(settings, IDOK)), "adding a mouse binding enables Save");
wchar_t action[80]{};
ListView_GetItemText(events, 7, 1, action, 80);
require(std::wstring(action) == L"Ctrl+Tab", "the Action column reflects the selected event's binding");
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor != nullptr, "F2 opens the selected mouse action");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)),
"removing the draft mouse binding clears pending changes");
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor != nullptr, "opens the action editor to configure volume");
RECT fullCombo{}, compactHeading{};
GetWindowRect(GetDlgItem(editor, 734), &fullCombo);
GetWindowRect(GetDlgItem(editor, IDOK), &compactHeading);
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
require(IsWindowVisible(GetDlgItem(editor, 759)) &&
GetDlgItemInt(editor, 759, nullptr, FALSE) == ks::DefaultMouseVolumeStep,
"volume actions show the default step");
RECT volumeCombo{}, volumeField{}, volumeSpin{}, volumeHeading{};
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
GetWindowRect(GetDlgItem(editor, IDOK), &volumeHeading);
int minimum{}, maximum{};
SendDlgItemMessageW(editor, 760, UDM_GETRANGE32,
reinterpret_cast<WPARAM>(&minimum), reinterpret_cast<LPARAM>(&maximum));
require(IsWindowVisible(GetDlgItem(editor, 760)) && minimum == 1 && maximum == 50 &&
volumeCombo.top == volumeField.top && volumeCombo.right < volumeField.left &&
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom &&
std::abs(volumeCombo.bottom - volumeField.bottom) <= 1 &&
volumeCombo.right < fullCombo.right && volumeHeading.top == compactHeading.top,
"volume step spinner is beside the shortened action dropdown");
RECT suggested{};
GetWindowRect(editor, &suggested);
suggested.right -= 12;
suggested.bottom -= 12;
const UINT editorDpi = GetDpiForWindow(editor);
SendMessageW(editor, WM_DPICHANGED, MAKEWPARAM(editorDpi, editorDpi),
reinterpret_cast<LPARAM>(&suggested));
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
require(!(GetWindowLongPtrW(editor, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
volumeCombo.top == volumeField.top && volumeCombo.bottom == volumeField.bottom &&
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom,
"a DPI resize keeps the action row aligned and restores the full client area");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::Back), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
RECT backCombo{}, backHeading{};
GetWindowRect(GetDlgItem(editor, 734), &backCombo);
GetWindowRect(GetDlgItem(editor, IDOK), &backHeading);
require(EqualRect(&fullCombo, &backCombo) && EqualRect(&compactHeading, &backHeading) &&
!IsWindowVisible(GetDlgItem(editor, 760)), "back restores full width without an unused row");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
SetDlgItemInt(editor, 759, 7, FALSE);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && GetDlgItemInt(editor, 759, nullptr, FALSE) == 7,
"reopening a volume action restores the configured step");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
SendMessageW(settings, WM_COMMAND, 754, 0);
auto profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog && GetDlgItem(profileDialog, 755), "add profile opens the Name dialog");
SetDlgItemTextW(profileDialog, 755, L"Browsing");
for (const auto *name : {L"chrome.exe", L"msedge.exe"})
{
SetDlgItemTextW(profileDialog, 749, name);
SendMessageW(profileDialog, WM_COMMAND, 750, 0);
}
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
wchar_t applications[80]{};
ListView_GetItemText(profiles, 1, 2, applications, 80);
require(std::wstring(applications) == L"chrome.exe, msedge.exe",
"applications are visible in the profile table");
require(ListView_GetNextItem(profiles, -1, LVNI_SELECTED) == 1 &&
IsWindowEnabled(GetDlgItem(settings, IDOK)), "new profile is selected and enables Save");
RECT profileRow{};
require(ListView_GetItemRect(profiles, 1, &profileRow, LVIR_BOUNDS),
"profile row bounds are available");
const int nameWidth = ListView_GetColumnWidth(profiles, 1);
const int rowCenter = (profileRow.top + profileRow.bottom) / 2;
int checkboxX = -1;
for (int x = nameWidth; x < profileRow.right; ++x)
{
LVHITTESTINFO hit{};
hit.pt = {x, rowCenter};
if (ListView_HitTest(profiles, &hit) == 1 && (hit.flags & LVHT_ONITEMSTATEICON))
{
checkboxX = x;
break;
}
}
require(checkboxX >= nameWidth && checkboxX < nameWidth + ListView_GetColumnWidth(profiles, 0) &&
ListView_GetCheckState(profiles, 1),
"the enabled checkbox is hit-testable in the second column");
SendMessageW(profiles, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(checkboxX, rowCenter));
SendMessageW(profiles, WM_LBUTTONUP, 0, MAKELPARAM(checkboxX, rowCenter));
require(!ListView_GetCheckState(profiles, 1), "clicking the row checkbox disables its profile");
ListView_SetItemState(profiles, 0, LVIS_SELECTED, LVIS_SELECTED);
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(nameWidth / 2, rowCenter));
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog != nullptr, "double-clicking a profile name opens the clicked row");
SendMessageW(profileDialog, WM_COMMAND, IDCANCEL, 0);
const int applicationsX = nameWidth + ListView_GetColumnWidth(profiles, 0) + 4;
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(applicationsX, rowCenter));
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog && SendDlgItemMessageW(profileDialog, 748, LB_GETCOUNT, 0, 0) == 2,
"double-click opens the profile editor with its applications");
SetDlgItemTextW(profileDialog, 755, L"Work browsing");
SendDlgItemMessageW(profileDialog, 748, LB_SETCURSEL, 0, 0);
SendMessageW(profileDialog, WM_COMMAND, 751, 0);
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
wchar_t renamed[80]{};
ListView_GetItemText(profiles, 1, 1, renamed, 80);
ListView_GetItemText(profiles, 1, 2, applications, 80);
require(std::wstring(renamed) == L"Work browsing" &&
std::wstring(applications) == L"msedge.exe" &&
!ListView_GetCheckState(profiles, 1),
"renaming keeps the profile selected and disabled");
const int appsWidth = ListView_GetColumnWidth(profiles, 2);
ListView_SetColumnWidth(profiles, 2, appsWidth + 100);
SendMessageW(settings, WM_SIZE, 0, 0);
require(ListView_GetColumnWidth(profiles, 2) == appsWidth + 100 &&
(GetWindowLongPtrW(profiles, GWL_STYLE) & WS_HSCROLL),
"the Applications column retains its wider size and can scroll");
SendMessageW(settings, ks::ConfigSettingsDownloaded, 0, 0);
}
{
auto window = app.settingsWindow;
auto previous = app.settings;
+121
View File
@@ -10,6 +10,41 @@ namespace ks
struct InputRegressionAccess
{
static bool hasLast(App &app) { return app.last.has_value(); }
static bool suggestionsVisible(const App &app)
{
return app.snippetPopup && IsWindowVisible(app.snippetPopup);
}
static bool suggestionsNearCaret(const App &app)
{
GUITHREADINFO info{sizeof(info)};
RECT popup{};
if (!GetGUIThreadInfo(GetCurrentThreadId(), &info) || !info.hwndCaret ||
!GetWindowRect(app.snippetPopup, &popup))
return false;
POINT caret{info.rcCaret.left, info.rcCaret.bottom};
return ClientToScreen(info.hwndCaret, &caret) &&
caret.x >= popup.left && caret.x <= popup.right &&
std::min(std::abs(caret.y - popup.top), std::abs(caret.y - popup.bottom)) <= 32;
}
static LONG suggestionsWidth(const App &app)
{
RECT popup{};
GetWindowRect(app.snippetPopup, &popup);
return popup.right - popup.left;
}
static bool findingSuggestionCaret(const App &app) { return app.snippetCaretPending; }
static void watchSuggestions(App &app, int &hideCount)
{
SetWindowSubclass(app.snippetPopup, [](HWND window, UINT message, WPARAM wp, LPARAM lp,
UINT_PTR, DWORD_PTR context) -> LRESULT {
if (message == WM_WINDOWPOSCHANGING &&
(reinterpret_cast<const WINDOWPOS *>(lp)->flags & SWP_HIDEWINDOW))
++*reinterpret_cast<int *>(context);
return DefSubclassProc(window, message, wp, lp);
}, 1, reinterpret_cast<DWORD_PTR>(&hideCount));
std::lock_guard lock(app.stateMutex);
app.run.at = GetTickCount64() - 2500;
}
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
@@ -23,6 +58,8 @@ ks::App *app{};
HWND host{}, field{};
ks::Clip saved;
bool snippetRecoveryOnly = false;
LONG unfilteredSnippetWidth{};
int snippetPopupHides{};
bool captured = false, failed = false;
bool conversionOnly = false;
bool typosOnly = false;
@@ -185,6 +222,19 @@ void record(const std::wstring &keys)
for (wchar_t c : keys)
app->run.record(c, host, app->layouts[en].handle, GetTickCount64());
}
void typeKeys(std::initializer_list<WORD> keys)
{
std::vector<INPUT> inputs;
for (WORD key : keys)
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
{
auto input = ks::keyboardInput(key, flags);
input.ki.dwExtraInfo = 123; // Traverse the real keyboard hook.
inputs.push_back(input);
}
require(SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) == inputs.size(),
"snippet autocomplete keyboard input submitted");
}
void finish()
{
KillTimer(host, 1);
@@ -589,6 +639,75 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
finish();
break;
case 66:
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
app->settings.snippets = {{L";qz-first", L"first"}, {L";qz-second", L"second"},
{L";other-much-longer-trigger", L"unrelated"},
{L";qz-unavailable", L"unavailable", L"", {L"kswitcher-no-such-process"}}};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"prefix ", false);
typeKeys({VK_OEM_1});
break;
case 67:
require(ks::InputRegressionAccess::suggestionsVisible(*app) && GetFocus() == field,
"semicolon alone shows suggestions without moving focus");
require(ks::InputRegressionAccess::suggestionsNearCaret(*app),
"suggestions open beside the editor caret");
unfilteredSnippetWidth = ks::InputRegressionAccess::suggestionsWidth(*app);
ks::InputRegressionAccess::watchSuggestions(*app, snippetPopupHides);
typeKeys({'Q', 'Z'});
break;
case 68:
require(ks::InputRegressionAccess::suggestionsVisible(*app) && snippetPopupHides == 0,
"typing after a pause filters suggestions without hiding the popup");
require(ks::InputRegressionAccess::suggestionsNearCaret(*app),
"filtering keeps suggestions beside the updated editor caret");
require(ks::InputRegressionAccess::suggestionsWidth(*app) < unfilteredSnippetWidth,
"suggestions narrow when the longest trigger is filtered out");
typeKeys({VK_ESCAPE});
break;
case 69:
require(!ks::InputRegressionAccess::suggestionsVisible(*app) && text() == L"prefix ;qz",
"Escape dismisses suggestions and preserves typed text");
typeKeys({VK_BACK, 'Z'});
break;
case 70:
require(ks::InputRegressionAccess::suggestionsVisible(*app), "editing the prefix reopens suggestions");
enterPresses = 0;
typeKeys({VK_UP, VK_RETURN});
break;
case 71:
require(text() == L"prefix second", "Up wraps to the last eligible snippet and Enter replaces only the prefix");
require(enterPresses == 0 && !ks::InputRegressionAccess::suggestionsVisible(*app),
"confirming with Enter closes suggestions without sending Enter to the editor");
require(ks::readClipboard(app->window).text() == sentinel, "autocomplete restores the clipboard");
prepare(L"prefix ", false);
typeKeys({VK_OEM_1, 'Q', 'Z'});
break;
case 72:
require(ks::InputRegressionAccess::suggestionsVisible(*app), "suggestions reopen for a new prefix");
typeKeys({VK_RETURN});
break;
case 73:
require(enterPresses == 1 && text() == L"prefix ;qz\r\n" &&
!ks::InputRegressionAccess::suggestionsVisible(*app),
"Enter without arrow navigation reaches the editor");
app->resetInput();
SetFocus(host);
typeKeys({VK_OEM_1});
break;
case 74:
if (ks::InputRegressionAccess::findingSuggestionCaret(*app))
{
stage = 74;
break;
}
require(!ks::InputRegressionAccess::suggestionsVisible(*app),
"suggestions stay hidden when the focused window has no text caret");
finish();
break;
case 64:
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
@@ -1061,6 +1180,8 @@ int main(int argc, char **argv)
snippetRecoveryOnly = argc > 1 && std::string(argv[1]) == "--snippet-recovery-only";
if (snippetRecoveryOnly || (argc > 1 && std::string(argv[1]) == "--snippets-only"))
stage = 64;
if (argc > 1 && std::string(argv[1]) == "--snippet-autocomplete")
stage = 66;
SetTimer(host, 1, 100, nullptr);
auto forwarding = SetWindowsHookExW(WH_KEYBOARD_LL,forwardingHook,GetModuleHandleW(nullptr),0);
require(forwarding!=nullptr,"downstream forwarding fixture installed before application hook");
+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);
+82 -1
View File
@@ -1,5 +1,6 @@
#include <winsock2.h>
#include "lan_input.h"
#include "lan_datagram.h"
#include "lan_mesh.h"
#include <iostream>
#include <deque>
@@ -344,6 +345,82 @@ void hubChecks()
endpoints[0].erase(endpoints[0].begin());hubs[0]->poll(endpoints[0],{},incoming[0],GetTickCount64()+1800);
check(!hubs[0]->ready().contains(ids[1])&&hubs[0]->ready().contains(ids[2]),"forgetting one pair revokes only its channel");
}
void fallbackChecks()
{
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
struct Close{~Close(){WSACleanup();}} close;
std::string a(32,'a'),b(32,'b'),key(64,'f');
for(bool udp:{false,true}){
auto pa=freePort(),pb=freePort(),blocked=freePort();InputHub left(a,pa),right(b,pb);
std::vector<InputEndpoint> ea{{b,key,htonl(INADDR_LOOPBACK),blocked}},eb{{a,key,htonl(INADDR_LOOPBACK),udp?blocked:pa}};
std::vector<RoutedInput> ia,ib;std::vector<Json> wire;
if(udp){auto send=[&](const auto &,const auto &j){wire.push_back(j);};left.datagrams(send);right.datagrams(send);}
auto now=GetTickCount64();
auto pump=[&]{
now+=10;left.poll(ea,{},ia,now);right.poll(eb,{},ib,now);
auto packets=std::move(wire);wire.clear();for(auto &j:packets){if(j.at("to")==a)left.receiveDatagram(j,now);else right.receiveDatagram(j,now);}Sleep(1);
};
for(int n=0;n<1000&&(left.ready().empty()||right.ready().empty());++n)pump();
check(left.ready().size()==1&&right.ready().size()==1,udp?"encrypted UDP connects when both TCP directions fail":"reverse TCP connects when direct TCP destination is unreachable");
check(left.status().find(udp?"encrypted UDP":"reverse TCP")!=std::string::npos,"status identifies the selected transport");
InputFrame frame{InputType::Key,11,'B',0,1};left.poll(ea,{{b,frame}},ia,now);
for(int n=0;n<100&&ib.empty();++n)pump();
check(ib.size()==1&&ib[0].frame==frame,"fallback carries input once to the destination");
auto old=left.ready().at(b);left.disconnect();ia.clear();ib.clear();
for(int n=0;n<1500&&(left.ready().empty()||right.ready().empty()||left.ready().at(b)==old);++n)pump();
check(left.ready().size()==1&&right.ready().size()==1&&left.ready().at(b)!=old,"fallback reconnect changes input epoch");
left.poll({}, {},ia,now);check(left.ready().empty(),"forget removes fallback channel");
}
{
InputHub hub(a,freePort());hub.datagrams([](const auto &,const auto &){});
std::vector<InputEndpoint> peers{{b,key,htonl(INADDR_LOOPBACK),freePort()}};std::vector<RoutedInput> in;
auto start=GetTickCount64();for(int i=0;i<=160;i++)hub.poll(peers,{},in,start+i*100);
check(hub.ready().empty()&&hub.needsHelp(),"all transports unavailable expose connection help after bounded attempts");
}
}
void datagramChecks()
{
std::string a(32,'a'),b(32,'b'),key(64,'f');InputDatagram left(a,b,key),right(b,a,key);
uint64_t now=1000;std::vector<Json> wire,history;std::vector<InputFrame> ia,ib;
auto send=[&](const auto &,const auto &j){wire.push_back(j);history.push_back(j);};
int packet=0;bool losses=false;
auto pump=[&]{
now+=10;left.poll({},ia,now,send);right.poll({},ib,now,send);
auto packets=std::move(wire);wire.clear();std::reverse(packets.begin(),packets.end());
for(auto &j:packets){if(losses&&++packet%4==0)continue;auto &target=j.at("to")==a?left:right;target.receive(j,now,send);target.receive(j,now,send);}
};
left.start(now);for(int n=0;n<100&&(!left.ready()||!right.ready());++n)pump();
check(left.ready()&&right.ready(),"UDP mutually authenticates saved pair keys");
wire.clear();InputFrame probe{InputType::Move,17,10,20,0};left.poll({probe},ia,now+60,send);now+=60;
auto valid=wire.back(),damaged=valid;auto ciphertext=damaged.at("data").get<std::string>();ciphertext[0]=ciphertext[0]=='0'?'1':'0';damaged["data"]=ciphertext;
rejects([&]{right.receive(damaged,now,send);},"modified UDP ciphertext is rejected");
auto publicWelcome=std::find_if(history.begin(),history.end(),[](const auto &j){return j.value("op","")=="welcome";});
auto publicProof=publicWelcome->at("proof").get<std::string>();
auto serverPacket=std::find_if(history.begin(),history.end(),[&](const auto &j){return j.value("op","")=="data"&&j.at("id")==b;});
auto encoded=serverPacket->at("data").get<std::string>();Bytes payload;
for(size_t i=0;i<encoded.size();i+=2)payload.push_back(static_cast<unsigned char>(std::stoul(encoded.substr(i,2),nullptr,16)));
InputCipher exposed(mac(publicProof,"packet|"+std::to_string(serverPacket->at("number").get<uint64_t>())));
rejects([&]{exposed.decryptPayload(payload);},"public handshake proof cannot decrypt UDP session data");
right.receive(valid,now,send);right.poll({},ib,now,send);
check(ib==std::vector<InputFrame>{probe},"rejected tampering does not advance the UDP sequence");
ib.clear();wire.clear();
std::vector<InputFrame> expected;for(int n=0;n<40;n++)expected.push_back({InputType::Key,17,n,0,n%2});
losses=true;left.poll(expected,ia,now,send);for(int n=0;n<180&&ib.size()<expected.size();++n)pump();
check(ib==expected,"lost reordered and duplicate UDP packets preserve exact event order without double key injection");
for(int n=0;n<50;n++)pump();
check(ib==expected,"replayed acknowledgements do not repeat delivered keys");
auto captured=history;left.reset();right.reset();ia.clear();ib.clear();
for(auto &j:captured)if(j.at("to")==a)left.receive(j,now,send);else right.receive(j,now,send);
check(!left.ready()&&!right.ready()&&ia.empty()&&ib.empty(),"old UDP session cannot authorize input after reset");
left.reset();right.reset();wire.clear();losses=false;left.start(now);
for(int n=0;n<100&&(!left.ready()||!right.ready());++n)pump();
check(left.ready()&&right.ready(),"fresh UDP session reconnects after reset");
left.poll({},ia,now+2100,send);right.poll({},ib,now+2100,send);
check(!left.ready()&&!right.ready(),"UDP heartbeat loss revokes input within two seconds");
InputDatagram wrong(b,a,std::string(64,'e'));left.reset();left.start(now);wire.clear();left.poll({},ia,now,send);
for(auto &j:wire)wrong.receive(j,now,[](const auto &,const auto &){});
check(!wrong.active()&&!wrong.ready(),"wrong pair key cannot start UDP handshake");
}
void channelChecks()
{
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
@@ -383,6 +460,10 @@ void channelChecks()
client.poll(htonl(INADDR_LOOPBACK),{},received,start+11000);
check(!client.ready()&&client.status().find("authentication timed out")!=std::string::npos,
"initial authentication timeout distinguished from lost active connection");
client.poll(htonl(INADDR_LOOPBACK),{},received,start+13000);
check(client.status().find("authentication timed out")!=std::string::npos&&
client.status().find("Retrying automatically")!=std::string::npos,
"retry keeps the last connection failure visible");
}
{
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
@@ -426,6 +507,6 @@ void channelChecks()
}
int main()
{
try{channelChecks();hubChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
try{channelChecks();hubChecks();fallbackChecks();datagramChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
}
+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}};
+117 -1
View File
@@ -1,4 +1,5 @@
#include "settings_merge.h"
#include "mouse_buttons.h"
#include <iostream>
using namespace ks;
int main(){try{
@@ -17,5 +18,120 @@ int main(){try{
draft=base;current=base;draft.chatCycles[L"telegram\nfirst"]={L"win:04090409"};current.chatCycles[L"telegram\nsecond"]={L"win:04220422"};
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
if(merged.chatCycles.size()!=2)throw std::runtime_error("independent nested changes lost");
std::cout<<"Passed 5 settings-save merge checks\n";
draft = base;
current = base;
draft.mouseBindings[L"chrome"][L"XButton1"] = {MouseActionType::Shortcut, {MOD_CONTROL, VK_TAB}};
current.mouseBindings[L"chrome"][L"XButton2"] = {MouseActionType::Shortcut, {MOD_CONTROL | MOD_SHIFT, VK_TAB}};
merged = decodeSettings(mergeConfigurationSettings(encodeSettings(base), encodeSettings(draft),
encodeSettings(current)), {});
if (merged.mouseBindings.at(L"chrome").size() != 2)
throw std::runtime_error("independent mouse bindings lost");
auto shared = sharedSyncSettings(merged);
if (shared == sharedSyncSettings(base) ||
restoreSyncSettings(shared, Settings{}, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must restore from a synchronized backup");
auto withButtons = merged;
draft = current = withButtons;
draft.mouseBindings.at(L"chrome").erase(L"XButton1");
current.mouseBindings.at(L"chrome")[L"XButton2"] = {MouseActionType::Shortcut, {MOD_CONTROL, VK_F4}};
merged = decodeSettings(mergeConfigurationSettings(encodeSettings(withButtons), encodeSettings(draft),
encodeSettings(current)), {});
const auto &buttons = merged.mouseBindings.at(L"chrome");
if (buttons.size() != 1 || buttons.at(L"XButton2") != MouseAction{MouseActionType::Shortcut, {MOD_CONTROL, VK_F4}})
throw std::runtime_error("mouse button removal lost during merge");
merged.mouseBindings[GlobalMouseProfile][L"Double middle"] = {MouseActionType::Mute};
merged.mouseBindings[L"chrome"][L"Double middle"] = {MouseActionType::Standard};
merged.mouseBindings[L"edge"][L"XButton1"] = {MouseActionType::Launch, {}, L"C:\\Tools\\app.exe"};
merged.mouseBindings[L"edge"][L"XButton2"] = {MouseActionType::Launch, {}, L"calc.exe"};
merged.mouseBindings[GlobalMouseProfile][L"Left"] = {MouseActionType::Shortcut, {MOD_CONTROL, 'W'}, {}, {L"chrome", L"msedge"}};
merged.mouseBindings[GlobalMouseProfile][L"Middle"] = {MouseActionType::Back, {}, {}, {L"chrome"}};
auto restored = decodeSettings(encodeSettings(merged), {});
if (restored.mouseBindings != merged.mouseBindings ||
effectiveMouseAction(restored, L"chrome", 7) ||
!effectiveMouseAction(restored, L"edge", 7) ||
effectiveMouseAction(restored, L"edge", 7)->type != MouseActionType::Mute)
throw std::runtime_error("mouse action override or inheritance failed");
if (!effectiveMouseAction(restored, L"chrome", 0) || !effectiveMouseAction(restored, L"msedge", 0) ||
effectiveMouseAction(restored, L"explorer", 0) || !effectiveMouseAction(restored, L"chrome", 2) ||
effectiveMouseAction(restored, L"msedge", 2) || !effectiveMouseAction(restored, L"explorer", 7))
throw std::runtime_error("application restrictions or unrestricted fallback failed");
draft = current = restored;
draft.mouseBindings[GlobalMouseProfile][L"Middle"].applications.clear();
current.mouseBindings[GlobalMouseProfile][L"Middle"].type = MouseActionType::Mute;
const auto competing = mergeSyncProfile(encodeSettings(restored), encodeSettings(draft), encodeSettings(current));
if (competing.conflicts.size() != 1)
throw std::runtime_error("competing edits to a mouse action and its scope must conflict");
Settings profiles;
profiles.mouseBindings[L"name:Browsing"][L"XButton1"] = {MouseActionType::Forward};
profiles.mouseBindings[L"name:Browsing"][L"XButton2"] = {MouseActionType::Back};
profiles.mouseProfileApplications[L"name:Browsing"] = {L"chrome", L"msedge"};
profiles.mouseBindings[L"name:Empty"];
profiles.mouseBindings[GlobalMouseProfile][L"XButton1"] = {MouseActionType::VolumeUp};
profiles.mouseBindings[GlobalMouseProfile][L"XButton2"] = {MouseActionType::VolumeDown};
profiles.mouseBindings[GlobalMouseProfile][L"XButton2"].volumeStep = 7;
auto resolve = [&](const wchar_t *app, int event, bool foreground = true)
{
const auto *action = effectiveMouseAction(profiles, app, event, foreground);
return action ? action->type : MouseActionType::Standard;
};
profiles = decodeSettings(mergeConfigurationSettings(encodeSettings(base), encodeSettings(profiles), encodeSettings(base)), {});
if (profiles.mouseBindings.at(GlobalMouseProfile).at(L"XButton1").volumeStep != DefaultMouseVolumeStep ||
profiles.mouseBindings.at(GlobalMouseProfile).at(L"XButton2").volumeStep != 7)
throw std::runtime_error("default and custom volume steps must round-trip independently");
auto invalidVolume = profiles;
invalidVolume.mouseBindings[GlobalMouseProfile][L"XButton1"].volumeStep = 0;
rejected = false;
try { validateSettings(invalidVolume); }
catch (const std::runtime_error &) { rejected = true; }
if (!rejected) throw std::runtime_error("invalid volume steps must be rejected");
if (!profiles.mouseBindings.contains(L"name:Empty") ||
resolve(L"chrome", 3) != MouseActionType::Forward || resolve(L"msedge", 4) != MouseActionType::Back ||
resolve(L"chrome", 3, false) != MouseActionType::VolumeUp ||
resolve(L"explorer", 4) != MouseActionType::VolumeDown)
throw std::runtime_error("named profiles must persist and override fallback only for the matching foreground app");
profiles.mouseBindings[L"name:Alternative"][L"XButton1"] = {MouseActionType::Back};
profiles.mouseProfileApplications[L"name:Alternative"] = {L"chrome"};
profiles.disabledMouseProfiles.insert(L"name:Browsing");
profiles = decodeSettings(encodeSettings(profiles), {});
if (resolve(L"chrome", 3) != MouseActionType::Back)
throw std::runtime_error("disabled profiles must not resolve actions");
profiles.disabledMouseProfiles.clear();
rejected = false;
try { validateSettings(profiles); }
catch (const std::runtime_error &e) { rejected = std::string(e.what()).starts_with("Mouse action conflict:"); }
if (!rejected) throw std::runtime_error("overlapping active profiles must be rejected");
Settings wheel;
const int down = mouseWheelEvent(WM_MOUSEWHEEL, -WHEEL_DELTA);
RAWMOUSE raw{};
raw.usButtonFlags = RI_MOUSE_WHEEL;
raw.usButtonData = static_cast<USHORT>(-WHEEL_DELTA);
if (rawMouseSignal(raw) != down || mouseEventEnabled(wheel, down))
throw std::runtime_error("wheel capture must match the runtime signal and be opt-in");
raw.usButtonFlags = RI_MOUSE_HWHEEL;
if (rawMouseSignal(raw) != mouseWheelEvent(WM_MOUSEHWHEEL, -WHEEL_DELTA))
throw std::runtime_error("horizontal wheel capture must match the runtime signal");
int remainder{};
if (mouseWheelSteps(remainder, -30) != 0 || mouseWheelSteps(remainder, -90) != 1 ||
mouseWheelSteps(remainder, 240) != 2 || remainder != 0)
throw std::runtime_error("high-resolution wheel deltas must accumulate by detent");
wheel.customMouseEvents.emplace(MouseEvents[down], L"Lower volume");
wheel.mouseBindings[GlobalMouseProfile][MouseEvents[down]] = {MouseActionType::VolumeDown};
wheel.mouseBindings[L"name:Browser"][L"XButton1"] = {MouseActionType::Back};
wheel.mouseProfileApplications[L"name:Browser"] = {L"chrome"};
wheel.disabledMouseProfiles.insert(L"name:Browser");
wheel.appLanguageSets[L"Browser"] = {{L"win:00000409"}, {L"chrome"}};
const auto restoredWheel = decodeSettings(encodeSettings(wheel), {});
const auto syncedWheel = restoreSyncSettings(sharedSyncSettings(wheel), Settings{}, {});
if (mouseEventName(restoredWheel, down) != L"Lower volume" ||
!effectiveMouseAction(restoredWheel, L"explorer", down) ||
sharedSyncSettings(syncedWheel) != sharedSyncSettings(wheel))
throw std::runtime_error("custom wheel event and action must restore from a synchronized backup");
if (!restoreSyncSettings(sharedSyncSettings(Settings{}), wheel, {}).customMouseEvents.empty())
throw std::runtime_error("an explicit empty backup must clear custom mouse events");
wheel.customMouseEvents.clear();
rejected = false;
try { validateSettings(wheel); }
catch (const std::runtime_error &) { rejected = true; }
if (!rejected) throw std::runtime_error("unregistered mouse signals cannot have bindings");
std::cout<<"Settings-save merge checks passed\n";
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
+19 -5
View File
@@ -155,11 +155,25 @@ int main()
rightScope["snippets"].back()["apps"]=Json::array({"NOTEPAD.exe"});
rejects([&]{mergeSyncSettings(base,bytes(leftScope),bytes(rightScope));}, "sync detects overlapping application scopes");
}
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
auto restored = restoreSyncSettings(base, local, {});
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
auto hostile = j; hostile["startWithSystem"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
require(j.contains("startWithSystem") && j.contains("tesseractPath") && j.contains("historyEnabled"),
"server backup includes startup, OCR and history settings");
auto restored = restoreSyncSettings(base, Settings{}, {});
require(sharedSyncSettings(restored) == base, "restore all settings onto a fresh installation");
auto legacySettings = j;
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents"})
legacySettings.erase(key);
FakeProvider legacyProvider;
legacyProvider.remote = {3, bytes(legacySettings)};
auto recovered = synchronize(legacyProvider, {}, base, SyncAction::Download);
require(sharedSyncSettings(restoreSyncSettings(recovered.settings, local, {})) == base &&
legacyProvider.writes == 0, "older server backup preserves fields it never stored");
auto upgraded = synchronize(legacyProvider, bytes(legacySettings), base, SyncAction::Automatic);
require(legacyProvider.writes == 1 && parse(upgraded.settings) == j,
"automatic sync upgrades an older server backup");
auto hostile = j; hostile["unknownSetting"] = false;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject unsupported settings");
hostile = j; hostile["schemaVersion"] = 2;
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
hostile = j; hostile["layouts"] = Json::array({"00000422"});
+18 -1
View File
@@ -592,6 +592,20 @@ 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;
ks::validateSettings(options);
correction = edited.suggest(L"(Ревю)! ", 2, layouts, options);
auto expected = std::wstring(text);
expected[0] = ks::upper(expected.substr(0, 1))[0];
require(correction && correction->text == L"(" + expected + L")! " && correction->target == 2,
"apostrophe corrections preserve punctuation, capitalization and layout");
}
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
"joined words split while preserving capitalization, punctuation and layout");
@@ -718,7 +732,8 @@ int main(int argc, char **argv)
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoReplace = settings.autoTypos = true;
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}};
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"},
{L"ревю", L"рев'ю"}, {L"пять", L"п’ять"}, {L"імя", L"імʼя"}};
settings.autoMaxRank = 10000;
settings.autoMinLength = 5;
settings.autoPrefixChars = L"(#";
@@ -739,6 +754,8 @@ int main(int argc, char **argv)
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
{L"review", L"'review"}, {L"review", L"review’"},
{L"review", L"re''view"}, {L"review", L"reʼ view"},
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
{
auto invalid = settings;