Compare commits

...
Author SHA1 Message Date
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
agrynco 4d8d8d47b9 Release v0.6.59: clarify sync results and refresh downloaded settings 2026-11-09 11:22:27 +02:00
40 changed files with 3315 additions and 175 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.58 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.64 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+20
View File
@@ -76,6 +76,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
+118 -3
View File
@@ -37,6 +37,59 @@ 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.
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.
@@ -47,6 +100,12 @@ Closing Settings with unsaved changes (including accepted auto-correction or OCR
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.
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
@@ -590,8 +649,8 @@ Nonconflicting changes synchronize immediately, and unresolved conflicts survive
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
- **Download remote** replaces this PC's shared settings with the server profile.
- If the server has no profile yet, choose **Upload local** to create it.
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
- Unsaved edits in Settings are not synchronized. After a successful **Download remote**, the open Settings window refreshes automatically and keeps the selected tab. The download confirmation warns that unsaved edits will be discarded.
- The status distinguishes downloading, downloaded settings, no local changes and failure. HTTP 400 errors identify authentication, download or upload; field errors remain visible until the next action.
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
@@ -723,9 +782,11 @@ default and require the main **Automatic correction (experimental)** checkbox to
`првиіт=привіт` 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
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.60 or newer before sharing apostrophe pairs; older clients reject them.
- **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 +960,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. Profiles stay local to this PC, survive cloud sync and are included in 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.
+8 -14
View File
@@ -1,23 +1,17 @@
# Latest Windows release
## v0.6.58 — 2026-11-06
## v0.6.64 — 2026-11-12
## Changes
- Keep trusted TCP input listeners and connections alive across missing discovery announcements. Retain the last known address for reconnection; authenticate incoming trusted peers even before discovery sees their address.
- Show saved pairing separately from actual input connectivity: connected, disabled here, offline or not connected. Background identity verification does not flash the list.
- Log TCP listener failures with the Windows error and peer connection state changes for troubleshooting.
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.
## Validation
Passed: 205 LAN input checks (including missed discovery and authentication before discovery), 289 clipboard transfer, 43 pairing, 829 settings/chat UI, 570 input, 341 native, 126 sync 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. LAN remains experimental; three physical PCs have not been verified together.
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.
Update both PCs to receive these fixes. Preserve each PC's own data directory and pairing identity. Input protocol v2 remains compatible with v0.6.57. A changed instance ID requires pairing again. The computer list refreshes automatically once per second while open.
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 @@
16d8138c63442931bf947707b28820d9b89943169ac9f25db3cb50d23c0ff319 KSwitcher-win-x64.zip
8fdd701d2ed9223056f7e624e888ef459c919f605c624d5f35c3dbd3652a430b KSwitcher-win-x64.zip
+34 -34
View File
@@ -1,86 +1,86 @@
{
"version": "0.6.64",
"files": [
{
"sha256": "a7be2f749082bb6417b4cf7e8c8ea221ef4f7ba63dabc3cbbe8694ab1a3d942c",
"path": "KSwitcher.exe",
"size": 2108416
"size": 2185728,
"sha256": "81ccb50fd3ac8a4768f0be9a4b70ef21c2a1f11e73b57871cb5123f29bfbd503"
},
{
"sha256": "c8a88364c9967ce5fd150519d632b14460e225a0cfae51a8de50f39782d8010c",
"path": "KSwitcher.Config.exe",
"size": 2005504
"size": 2120192,
"sha256": "83d76af6ff44f56532d2537e57dfb7e3859d72f34180d3c7c02497e71763378e"
},
{
"sha256": "68eed5abefed0208ef7bdd431094c7357c53bd3ca03dfd91aaeb7dbb4f291e06",
"path": "KSwitcher.Screenshot.exe",
"size": 3153408
"size": 3207168,
"sha256": "6e93963d4252a87bd6ea9d53ccd2e602c9c2709ca3fe835dc90a633b77b00193"
},
{
"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": 89826,
"sha256": "9148049aa80595e0de30ecc9af1478572f85a8076da981ea80dafd5bb9c4815b"
},
{
"sha256": "72e131bdf46d32a8a17fb352292bc8983c0e5a7ed84812218b11cf6b69c9afc4",
"path": "LAN-PREVIEW.md",
"size": 8376
"size": 9900,
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e"
},
{
"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
}
+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,58,0
PRODUCTVERSION 0,6,58,0
FILEVERSION 0,6,64,0
PRODUCTVERSION 0,6,64,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.58"
VALUE "FileVersion", "0.6.64"
END
END
BLOCK "VarFileInfo"
+348 -17
View File
@@ -847,6 +847,8 @@ void App::resetLanInput()
// shortcut/chat latches across an ownership handoff.
hotkeyHook = {};
chatSwitchHook = {};
pendingMouseClicks = {};
mouseActions.clear();
resetInput();
}
void App::resetInput()
@@ -986,21 +988,21 @@ void App::refreshChatContext()
HWND target = GetForegroundWindow();
chatWindow = target;
activeChatTitle = chatTitle(target);
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
activeChat = windowLanguageProfileKey(settings, target);
}
void App::switchChatLayout(bool reverse)
{
HWND target = chatWindow;
auto key = activeChat;
if (!target || target != GetForegroundWindow() || key.empty() || windowChatKey(target) != key ||
!settings.chatCycles.contains(key) || busy.exchange(true)) return;
if (!target || target != GetForegroundWindow() || key.empty() ||
windowLanguageProfileKey(settings, target) != key || busy.exchange(true)) return;
auto cycle = chatCycle(settings, key, layouts);
auto available = layouts;
resetInput();
enqueue([this, target, key, cycle, available, reverse]
enqueue([this, target, key, cycle, available, reverse, profileSettings = settings]
{
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
auto valid = [&] { return GetForegroundWindow() == target && windowLanguageProfileKey(profileSettings, target) == key; };
if (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
if (!valid()) return;
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
@@ -1058,11 +1060,11 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
}
}
bool chatEnabled = false;
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && !settings.chatCycles.empty() &&
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && (!settings.chatCycles.empty() || !settings.appLanguageSets.empty()) &&
!isHotkey(key.vkCode, mods))
{
refreshChatContext();
chatEnabled = !activeChat.empty() && settings.chatCycles.contains(activeChat);
chatEnabled = !activeChat.empty();
}
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
if (chatAction)
@@ -1169,23 +1171,324 @@ LRESULT App::keyboardAfterHooks(int code, WPARAM wp, LPARAM lp, LRESULT routed)
}
return routed;
}
constexpr UINT MouseActionMessage = WM_APP + 79;
constexpr UINT MouseVolumeReleasedMessage = WM_APP + 80;
constexpr UINT_PTR MouseClickTimer = 9;
constexpr UINT_PTR FirstMouseVolumeTimer = 10;
namespace
{
constexpr wchar_t VolumeIndicatorClass[] = L"KSwitcher.VolumeIndicator";
constexpr UINT_PTR VolumeIndicatorTimer = 1;
constexpr WORD VolumeIndicatorMuted = 1, VolumeIndicatorHeld = 2;
void paintVolumeIndicator(HWND w)
{
PAINTSTRUCT paint{};
const auto dc = BeginPaint(w, &paint);
RECT bounds{};
GetClientRect(w, &bounds);
FillRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOW));
FrameRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOWFRAME));
const int dpi = static_cast<int>(GetDpiForWindow(w));
const int padding = MulDiv(8, dpi, 96);
const int captionHeight = MulDiv(26, dpi, 96);
const auto state = GetWindowLongPtrW(w, GWLP_USERDATA);
const int percent = LOWORD(state);
const bool muted = (HIWORD(state) & VolumeIndicatorMuted) != 0;
RECT track{padding, padding, bounds.right - padding, bounds.bottom - captionHeight};
FillRect(dc, &track, GetSysColorBrush(COLOR_3DFACE));
RECT fill = track;
fill.top = fill.bottom - MulDiv(fill.bottom - fill.top, percent, 100);
FillRect(dc, &fill, GetSysColorBrush(muted ? COLOR_GRAYTEXT : COLOR_HIGHLIGHT));
RECT caption{0, track.bottom, bounds.right, bounds.bottom};
const auto text = muted ? std::wstring(L"Mute") : std::to_wstring(percent) + L"%";
const auto font = CreateFontW(-MulDiv(12, dpi, 96), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, 0, 0, 0, 0, L"Segoe UI");
const auto previous = font ? SelectObject(dc, font) : nullptr;
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, GetSysColor(COLOR_WINDOWTEXT));
DrawTextW(dc, text.c_str(), -1, &caption, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
if (previous) SelectObject(dc, previous);
if (font) DeleteObject(font);
EndPaint(w, &paint);
}
LRESULT CALLBACK volumeIndicatorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_PAINT: paintVolumeIndicator(w); return 0;
case WM_NCHITTEST: return HTTRANSPARENT;
case WM_MOUSEACTIVATE: return MA_NOACTIVATE;
case WM_TIMER:
if (wp == VolumeIndicatorTimer)
{
KillTimer(w, VolumeIndicatorTimer);
if (!(HIWORD(GetWindowLongPtrW(w, GWLP_USERDATA)) & VolumeIndicatorHeld))
ShowWindow(w, SW_HIDE);
return 0;
}
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
void setVolumeIndicatorHold(HWND indicator, bool held)
{
const auto state = GetWindowLongPtrW(indicator, GWLP_USERDATA);
const WORD flags = (HIWORD(state) & VolumeIndicatorMuted) | (held ? VolumeIndicatorHeld : 0);
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(LOWORD(state), flags));
if (held) KillTimer(indicator, VolumeIndicatorTimer);
else SetTimer(indicator, VolumeIndicatorTimer, 1000, nullptr);
}
void showVolumeIndicator(HWND owner, HINSTANCE instance, POINT point, SystemVolume volume, bool held)
{
MONITORINFO monitor{sizeof(monitor)};
const auto targetMonitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
if (!GetMonitorInfoW(targetMonitor, &monitor)) return;
const auto &area = monitor.rcWork;
auto indicator = reinterpret_cast<HWND>(GetPropW(owner, VolumeIndicatorClass));
if (!indicator)
{
WNDCLASSW type{};
type.lpfnWndProc = volumeIndicatorProc;
type.hInstance = instance;
type.lpszClassName = VolumeIndicatorClass;
RegisterClassW(&type);
indicator = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_TOPMOST |
WS_EX_LAYERED | WS_EX_TRANSPARENT, VolumeIndicatorClass, L"Volume",
WS_POPUP, area.left, area.top, 1, 1, owner, nullptr, instance, nullptr);
if (!indicator) return;
if (!SetPropW(owner, VolumeIndicatorClass, indicator) ||
!SetLayeredWindowAttributes(indicator, 0, 255, LWA_ALPHA))
{
RemovePropW(owner, VolumeIndicatorClass);
DestroyWindow(indicator);
return;
}
}
// Place the window on the destination monitor before querying its DPI.
if (MonitorFromWindow(indicator, MONITOR_DEFAULTTONEAREST) != targetMonitor)
SetWindowPos(indicator, nullptr, area.left, area.top, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW);
const int dpi = static_cast<int>(GetDpiForWindow(indicator));
const int width = MulDiv(48, dpi, 96);
const int height = MulDiv(132, dpi, 96);
const int margin = MulDiv(16, dpi, 96);
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(volume.percent, volume.muted ? VolumeIndicatorMuted : 0));
setVolumeIndicatorHold(indicator, held);
SetWindowPos(indicator, HWND_TOPMOST, area.right - width - margin, area.bottom - height - margin,
width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
InvalidateRect(indicator, nullptr, FALSE);
}
}
void App::queueMouseAction(MousePress press)
{
if (press.action.type == MouseActionType::Standard) return;
mouseActions.push_back(std::move(press));
PostMessageW(window, MouseActionMessage, 0, 0);
}
void App::flushMouseClicks()
{
ULONGLONG next = 0;
const auto now = GetTickCount64();
for (auto &press : pendingMouseClicks)
{
if (!press.target) continue;
const auto deadline = press.started + GetDoubleClickTime();
if (now >= deadline)
{
queueMouseAction(std::exchange(press, {}));
}
else if (!next || deadline < next) next = deadline;
}
if (next) SetTimer(window, MouseClickTimer, static_cast<UINT>(next - now), nullptr);
else KillTimer(window, MouseClickTimer);
}
void App::beginMouseVolume(int button)
{
const auto &press = mousePresses[button];
if (!press.waitForSecond) queueMouseAction(press);
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
SetTimer(window, FirstMouseVolumeTimer + button, delay, nullptr);
}
void App::repeatMouseVolume(int button)
{
const auto timer = FirstMouseVolumeTimer + button;
auto &press = mousePresses[button];
if (!press.held || !isMouseVolumeStep(press.action.type) ||
busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased() ||
!IsWindow(press.target) || (!press.action.applications.empty() && press.target != GetForegroundWindow()))
{
KillTimer(window, timer);
return;
}
const auto elapsed = GetTickCount64() - press.started;
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
if (elapsed < delay) return;
press.waitForSecond = false;
queueMouseAction(press);
SetTimer(window, timer, mouseVolumeRepeatInterval(elapsed), nullptr);
}
bool App::volumeButtonHeld() const
{
return std::any_of(mousePresses.begin(), mousePresses.end(), [](const MousePress &press)
{
return press.held && !press.waitForSecond && isMouseVolumeStep(press.action.type);
});
}
void App::dispatchMouseActions()
{
std::deque<MousePress> actions;
actions.swap(mouseActions);
if (busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased())
{
if (!actions.empty()) log(L"Mouse action skipped while input was unavailable.");
return;
}
for (const auto &press : actions)
{
if (!IsWindow(press.target)) continue;
const auto &action = press.action;
switch (action.type)
{
case MouseActionType::Standard: break;
case MouseActionType::Shortcut:
if (press.target != GetForegroundWindow())
log(L"Mouse shortcut skipped because the active window changed.");
else if (!chord(static_cast<WORD>(action.shortcut.key), action.shortcut.modifiers))
log(L"Windows did not accept all input for the mouse shortcut.");
else
log(L"Windows accepted input for the mouse shortcut.");
break;
case MouseActionType::Back:
case MouseActionType::Forward:
PostMessageW(press.target, WM_APPCOMMAND, reinterpret_cast<WPARAM>(press.target),
MAKELPARAM(0, FAPPCOMMAND_MOUSE | (action.type == MouseActionType::Back ?
APPCOMMAND_BROWSER_BACKWARD : APPCOMMAND_BROWSER_FORWARD)));
break;
case MouseActionType::VolumeUp:
case MouseActionType::VolumeDown:
case MouseActionType::Mute:
if (const auto volume = changeSystemVolume(action.type, action.volumeStep))
showVolumeIndicator(window, instance, press.point, *volume, volumeButtonHeld());
else log(L"Mouse action could not change or read system volume.");
break;
case MouseActionType::Launch:
if (!launchThroughExplorer(action.executable))
log(L"Mouse action could not launch " + action.executable);
else
log(L"Mouse action launched " + std::filesystem::path(action.executable).filename().wstring());
break;
}
}
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
if (current->lanService)
auto &app = *current;
const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp);
const int button = mouse.flags & LLMHF_INJECTED ? -1 : mouseButton(UINT(wp), HIWORD(mouse.mouseData));
const bool down = mouseButtonDown(UINT(wp));
if (button >= 0 && app.mousePresses[button].held)
{
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
!current->busy && !current->suspended && !current->hotkeyCapture);
if (current->lanService->takeInputChange()) current->resetLanInput();
auto &active = app.mousePresses[button];
const bool consumed = active.action.type != MouseActionType::Standard;
if (!down)
{
auto press = std::exchange(active, {});
if (isMouseVolumeStep(press.action.type))
{
KillTimer(app.window, FirstMouseVolumeTimer + button);
PostMessageW(app.window, MouseVolumeReleasedMessage, 0, 0);
}
if (press.waitForSecond)
{
app.pendingMouseClicks[button] = std::move(press);
app.flushMouseClicks();
}
else if (!isMouseVolumeStep(press.action.type)) app.queueMouseAction(std::move(press));
}
if (consumed) return 1;
}
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
if (app.lanService)
{
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
current->resetInput();
const bool mouseActionHeld = std::any_of(app.mousePresses.begin(), app.mousePresses.end(),
[](const MousePress &press) { return press.held; });
bool consumed = app.lanService->mouse(UINT(wp), mouse,
!app.busy && !app.suspended && !app.hotkeyCapture && !mouseActionHeld);
if (app.lanService->takeInputChange()) app.resetLanInput();
if (consumed) return 1;
}
if (button >= 0 && down && !app.settings.mouseBindings.empty() && !app.busy && !app.suspended &&
!app.installingUpdate && !app.hotkeyCapture && modifiersReleased())
{
const auto hit = WindowFromPoint(mouse.pt);
const auto target = GetAncestor(hit, GA_ROOT);
const auto name = target ? mouseApplicationName(target) : L"";
wchar_t windowClass[64]{};
if (target && name == L"explorer") GetClassNameW(target, windowClass, std::size(windowClass));
const bool ownWindow = name == L"kswitcher" || name == L"kswitcher.config";
const bool shellSurface = target == GetShellWindow() || wcscmp(windowClass, L"Progman") == 0 ||
wcscmp(windowClass, L"WorkerW") == 0 || wcscmp(windowClass, L"Shell_TrayWnd") == 0 ||
wcscmp(windowClass, L"Shell_SecondaryTrayWnd") == 0;
const auto *global = mouseBinding(app.settings, GlobalMouseProfile, button);
const bool globalLaunch = button >= 2 && global && global->type == MouseActionType::Launch &&
mouseActionApplies(*global, name);
const bool restricted = ownWindow || shellSurface || name.empty();
const bool testButton = name == L"kswitcher.config" && hit == GetDlgItem(target, 737);
const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", button, false) : nullptr;
const auto *restrictedAction = isMouseVolumeAction(fallback) ? fallback : globalLaunch ? global : nullptr;
const bool eligible = target && !testButton && (!restricted || (button >= 2 && restrictedAction)) &&
(button >= 2 || target == GetForegroundWindow());
if (eligible)
{
const bool foreground = target == GetForegroundWindow();
bool singleScoped{}, doubleScoped{};
auto *single = restricted ? restrictedAction :
effectiveMouseAction(app.settings, name, button, foreground, &singleScoped);
auto *doubleClick = !restricted && button < 3 ?
effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr;
if (!foreground)
{
if (single && single->type == MouseActionType::Shortcut) single = nullptr;
if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr;
}
const auto now = GetTickCount64();
auto &pending = app.pendingMouseClicks[button];
const bool second = doubleClick && pending.target == target &&
doubleMouseClick(pending.point, mouse.pt, now - pending.started);
if (second) pending = {};
else if (pending.target) app.queueMouseAction(std::exchange(pending, {}));
const auto *action = second ? doubleClick : single;
if (action || doubleClick)
app.mousePresses[button] = {target, mouse.pt, now, action ? *action : MouseAction{},
true, doubleClick && !second};
if (action)
{
if (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name);
if (isMouseVolumeStep(action->type))
app.beginMouseVolume(button);
app.resetInput();
return 1;
}
}
}
// Remote clicks invalidate a typing range just like local clicks.
if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL)
{
if (app.busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
app.resetInput();
}
}
return CallNextHookEx(nullptr, code, wp, lp);
@@ -1314,7 +1617,7 @@ void App::transform(int command, int snippet)
} reset{busy, window, preDeferred};
auto effectiveChat = requestedChat;
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
config.cycle = chatCycle(config, effectiveChat, available);
config.cycle = chatCycle(config, languageProfileKey(config, target, effectiveChat), available);
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected, actual, verification = L"not-attempted";
@@ -2205,6 +2508,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
case MouseActionMessage:
app->dispatchMouseActions();
return 0;
case MouseVolumeReleasedMessage:
if (const auto indicator = reinterpret_cast<HWND>(GetPropW(hwnd, VolumeIndicatorClass)))
setVolumeIndicatorHold(indicator, app->volumeButtonHeld());
return 0;
case WM_POWERBROADCAST:
if (wp == PBT_APMSUSPEND && app->lanService)
{
@@ -2294,6 +2604,26 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
return 0;
case WM_TIMER:
if (wp == MouseClickTimer) { app->flushMouseClicks(); return 0; }
if (wp >= FirstMouseVolumeTimer && wp < FirstMouseVolumeTimer + std::size(MouseButtonNames))
{
app->repeatMouseVolume(static_cast<int>(wp - FirstMouseVolumeTimer));
return 0;
}
if (wp == 8)
{
if (app->settings.appLanguageSets.empty() || app->busy || app->installingUpdate) return 0;
app->refreshChatContext();
if (!app->activeChat.starts_with(L"app\n")) return 0;
auto cycle = chatCycle(app->settings, app->activeChat, app->layouts);
auto current = windowLayout(app->chatWindow);
bool allowed = std::any_of(app->layouts.begin(), app->layouts.end(), [&](const Layout &layout) {
return layout.handle == current && std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end();
});
if (!allowed) app->switchChatLayout(false);
return 0;
}
if (wp == 7)
{
if (app->lanService)
@@ -2452,6 +2782,7 @@ int App::execute()
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
SetTimer(window, 1, 60000, nullptr);
if (!smoke) SetTimer(window, 6, 1000, nullptr);
if (!smoke) SetTimer(window, 8, 250, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
if (!registrationWarning.empty())
+19
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>
@@ -105,6 +107,23 @@ class App
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
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;
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;
+16 -2
View File
@@ -13,7 +13,7 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
validateSyncConfig(c);
if (action == 4 && (c != conflictConnection || choices.empty()))
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
if (busy) return L"Wait for the current text operation to finish.";
if (busy) return syncStatus = L"Sync did not start. Wait for the current text operation to finish, then try again.";
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
if (syncWorker.joinable()) syncWorker.join();
syncConnection = c;
@@ -22,7 +22,8 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
auto availableLayouts = layouts;
syncRunning = true;
syncDone = false;
syncStatus = L"Connecting to OpenBao...";
syncStatus = action == 2 ? L"Downloading remote settings from OpenBao..." :
action == 3 ? L"Uploading local settings to OpenBao..." : L"Connecting to OpenBao...";
nextSync = GetTickCount64() + SyncIntervalMs;
try
{
@@ -42,6 +43,9 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
}
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
catch (const std::exception &e) { result.status = wide(e.what()); }
if (!result.success)
result.status = std::wstring(action == 2 ? L"Download failed. Local settings were not changed. " :
action == 3 ? L"Upload failed. " : L"Synchronization failed. ") + result.status;
syncResult = std::move(result);
syncDone.store(true);
});
@@ -76,8 +80,18 @@ void App::pollSync()
{
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
applySettings(next);
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." :
L" No local settings were changed; conflicts still need your choice.";
saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base);
if (result.downloaded)
{
if (settingsWindow) SendMessageW(settingsWindow, ConfigSettingsDownloaded, 0, 0);
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
config && configurationPeer(config, L"KSwitcher.Config.exe"))
PostMessageW(config, ConfigSettingsDownloaded, 0, 0);
}
pendingSyncConflicts = std::move(result.conflicts);
conflictConnection = syncConnection;
SYSTEMTIME time{}; GetLocalTime(&time);
+1 -1
View File
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
validAutoCorrection(replacement->second, alphabet))
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
+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;
+13
View File
@@ -52,6 +52,19 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
}
return FALSE;
}
if (message == ConfigSettingsDownloaded)
{
try
{
app->settings = decodeSettings(request(*app, ConfigRead), app->layouts);
if (app->settingsWindow) SendMessageW(app->settingsWindow, ConfigSettingsDownloaded, 0, 0);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"Unable to refresh downloaded settings", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_APP + 20)
{
try
+1
View File
@@ -2,6 +2,7 @@
#include "app.h"
namespace ks
{
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
+14
View File
@@ -21,6 +21,20 @@ 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;
}
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
+22
View File
@@ -3,6 +3,7 @@
#include <functional>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <windows.h>
@@ -19,6 +20,7 @@ struct Layout
bool hasLetter(wchar_t ch) const;
};
bool letter(wchar_t ch);
bool validAutoCorrection(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 +54,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 +99,10 @@ 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::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;
+27 -4
View File
@@ -72,6 +72,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)
{
@@ -210,7 +211,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 +233,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;
@@ -279,7 +284,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 +294,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.
}
}
@@ -334,6 +346,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")
{
@@ -354,9 +374,12 @@ struct LanService::Impl
}
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["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));
+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;
};
}
+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;
}
};
}
+123
View File
@@ -0,0 +1,123 @@
#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"};
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;
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;
}
}
+123 -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));
+8
View File
@@ -43,6 +43,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 +52,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 &);
+219 -3
View File
@@ -1,4 +1,5 @@
#include "storage.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include "ocr.h"
#include <algorithm>
@@ -58,9 +59,114 @@ 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)
{
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.");
if (std::find(std::begin(MouseEvents), std::end(MouseEvents), event) == std::end(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)
{
@@ -138,10 +244,9 @@ void validateSettings(const Settings &s)
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;
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("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between words and apostrophes between letters are allowed.");
}
for (const auto &[language, words] : s.autoWords)
{
@@ -264,6 +369,76 @@ 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("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 +511,47 @@ 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["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));
+17 -7
View File
@@ -1,4 +1,5 @@
#include "sync.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include <winhttp.h>
#include <wincred.h>
@@ -39,7 +40,7 @@ class OpenBao final : public SyncProvider
SyncConfig config;
SyncTransport transport;
std::wstring token;
Json response(unsigned status, const Bytes &result, bool missing)
Json response(unsigned status, const Bytes &result, bool missing, const std::wstring &path, const wchar_t *method)
{
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
if (status == 404 && missing)
@@ -50,7 +51,14 @@ class OpenBao final : public SyncProvider
return nullptr;
}
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
if (status == 400) throw std::runtime_error("OpenBao rejected the write or its version changed. Sync again; check KV v2 configuration.");
if (status == 400)
{
if (path.starts_with(L"/v1/auth/"))
throw std::runtime_error("OpenBao rejected the authentication request (HTTP 400). Check username, password and login mount.");
if (std::wstring_view(method) == L"GET")
throw std::runtime_error("OpenBao rejected the download request (HTTP 400). Check KV v2 mount and profile path.");
throw std::runtime_error("OpenBao rejected the write (HTTP 400) or its version changed. Sync again; check KV v2 configuration.");
}
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
return result.empty() ? Json::object() : parse(result);
}
@@ -59,7 +67,7 @@ class OpenBao final : public SyncProvider
if (transport)
{
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
return response(reply.status, reply.body, missing);
return response(reply.status, reply.body, missing, path, method);
}
URL_COMPONENTS url{sizeof(url)};
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
@@ -98,7 +106,7 @@ class OpenBao final : public SyncProvider
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
result.insert(result.end(), chunk, chunk + received);
}
return response(status, result, missing);
return response(status, result, missing, path, method);
}
public:
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
@@ -219,7 +227,8 @@ Bytes sharedSyncSettings(const Settings &s)
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);
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications"}) j.erase(key);
return bytes(canonicalProfile(j));
}
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
@@ -306,9 +315,10 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
auto revision = remote.revision;
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
return {true, merged.local, revision,
merged.conflicts.empty() ? L"Synchronized. Server version " + std::to_wstring(revision) + L"." :
merged.conflicts.empty() ? std::wstring(action == SyncAction::Download ? L"Downloaded remote settings. Server version " :
action == SyncAction::Upload ? L"Uploaded local settings. Server version " : L"Synchronized. Server version ") + std::to_wstring(revision) + L"." :
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
merged.base, merged.conflicts};
merged.base, merged.conflicts, action == SyncAction::Download};
}
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
{
+1
View File
@@ -63,6 +63,7 @@ struct SyncResult
std::wstring status;
Bytes base;
std::vector<SyncConflict> conflicts;
bool downloaded{};
};
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
+20 -2
View File
@@ -58,6 +58,20 @@ 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;
}
}
Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items())
{
@@ -99,7 +113,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 +147,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;
+162 -44
View File
@@ -15,7 +15,9 @@
#include <richedit.h>
#include <set>
#include <shellapi.h>
#include <shlobj.h>
#include <sstream>
#include <wrl/client.h>
namespace ks
{
@@ -40,7 +42,12 @@ struct WindowLayout
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
bool autoOptions{};
bool fixedClient{}, mouseSettings{};
HWND rowCombo{};
};
#ifndef KS_RESIDENT_APP
void scaleMouseTableColumns(HWND w, int previousDpi, int nextDpi);
#endif
WindowLayout *windowLayoutState(HWND w)
{
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
@@ -57,24 +64,44 @@ void fitToMonitor(HWND w, RECT rect)
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
void fitFixedClient(HWND w, WindowLayout &layout, RECT frame)
{
RECT current{}, client{};
GetWindowRect(w, &current);
GetClientRect(w, &client);
const auto style = GetWindowLongPtrW(w, GWL_STYLE);
const UINT dpi = GetDpiForWindow(w);
const int vertical = (style & WS_VSCROLL) ? GetSystemMetricsForDpi(SM_CXVSCROLL, dpi) : 0;
const int horizontal = (style & WS_HSCROLL) ? GetSystemMetricsForDpi(SM_CYHSCROLL, dpi) : 0;
frame.right = frame.left + scale(w, layout.width) + current.right - current.left - client.right - vertical;
frame.bottom = frame.top + scale(w, layout.height) + current.bottom - current.top - client.bottom - horizontal;
fitToMonitor(w, frame);
}
void arrangeWindow(HWND w)
{
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling || layout->arranging) return;
layout->arranging = true;
// Showing either scrollbar can make the other necessary. Let the client size settle.
for (int pass = 0; pass < 3; ++pass)
{
RECT client{};
GetClientRect(w, &client);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
RECT client{}; GetClientRect(w, &client);
RECT client{};
GetClientRect(w, &client);
const auto style = GetWindowLongPtrW(w, GWL_STYLE);
const UINT dpi = GetDpiForWindow(w);
const int verticalBar = GetSystemMetricsForDpi(SM_CXVSCROLL, dpi);
const int horizontalBar = GetSystemMetricsForDpi(SM_CYHSCROLL, dpi);
// Start from the full client area so two obsolete bars cannot keep each other visible.
const int fullWidth = client.right + ((style & WS_VSCROLL) ? verticalBar : 0);
const int fullHeight = client.bottom + ((style & WS_HSCROLL) ? horizontalBar : 0);
const int width = scale(w, layout->width);
const int height = scale(w, layout->height);
const bool needsHorizontal = width > fullWidth || (height > fullHeight && width > fullWidth - verticalBar);
const bool needsVertical = height > fullHeight - (needsHorizontal ? horizontalBar : 0);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS, 0, width - 1,
(UINT)std::max(1, fullWidth - (needsVertical ? verticalBar : 0)), layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS, 0, height - 1,
(UINT)std::max(1, fullHeight - (needsHorizontal ? horizontalBar : 0)), layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
GetClientRect(w, &client);
bool expandable = layout->history || layout->stretch || layout->autoOptions;
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
@@ -143,6 +170,15 @@ void arrangeWindow(HWND w)
SWP_NOACTIVATE | SWP_NOZORDER);
}
}
if (layout->rowCombo)
{
RECT bounds{};
GetWindowRect(layout->rowCombo, &bounds);
const int difference = scale(w, 25) - (bounds.bottom - bounds.top);
const auto itemHeight = SendMessageW(layout->rowCombo, CB_GETITEMHEIGHT, static_cast<WPARAM>(-1), 0);
if (difference && itemHeight != CB_ERR && itemHeight + difference > 0)
SendMessageW(layout->rowCombo, CB_SETITEMHEIGHT, static_cast<WPARAM>(-1), itemHeight + difference);
}
layout->arranging = false;
}
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
@@ -191,7 +227,8 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
{
RECT rect{};
GetWindowRect(w, &rect);
fitToMonitor(w, rect);
if (layout->fixedClient) fitFixedClient(w, *layout, rect);
else fitToMonitor(w, rect);
arrangeWindow(w);
}
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
@@ -340,7 +377,7 @@ void error(HWND w, const std::exception &e)
MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING);
}
HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height,
DWORD extra = 0, bool resizable = true)
DWORD extra = 0, bool resizable = true, HWND owner = nullptr)
{
WNDCLASSW wc{};
wc.hInstance = app.instance;
@@ -354,22 +391,25 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
const DWORD exStyle = owner ? WS_EX_DLGMODALFRAME : WS_EX_APPWINDOW;
AdjustWindowRectExForDpi(&rect, style, FALSE, exStyle, dpi);
POINT cursor{};
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
HWND w =
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
CreateWindowExW(exStyle, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, owner, nullptr, app.instance, &app);
if (!w)
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
// The system DPI may still describe the old primary display. The HWND tells
// us the actual destination DPI, and must size the frame AND its controls.
dpi = GetDpiForWindow(w);
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
RECT frame{}, client{};
GetWindowRect(w, &frame);
GetClientRect(w, &client);
int outerWidth = scale(w, width) + (frame.right - frame.left) - client.right;
int outerHeight = scale(w, height) + (frame.bottom - frame.top) - client.bottom;
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
@@ -441,7 +481,17 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
if (layout && layout->history)
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
fitToMonitor(w, *(RECT *)lp);
if (layout && layout->fixedClient)
{
RECT frame{};
GetWindowRect(w, &frame);
fitFixedClient(w, *layout, frame);
}
arrangeWindow(w);
#ifndef KS_RESIDENT_APP
if (layout && layout->mouseSettings && previous != next)
scaleMouseTableColumns(w, previous, next);
#endif
}
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
{
@@ -1045,7 +1095,10 @@ struct SettingsView
int selection = -1;
bool filling{};
std::vector<std::vector<HWND>> pages;
bool ready{}, refreshing{};
bool ready{}, refreshing{}, visitedLan{};
std::vector<std::wstring> mouseProfiles;
unsigned mouseEvents{}, rawMouseEvents{};
bool rawMouseRegistered{};
};
void updateSettingsState(HWND w, SettingsView *v);
void selectSettingsPage(HWND w, SettingsView *v)
@@ -1060,12 +1113,27 @@ struct ChatLanguagesView
HWND parent{};
SettingsView *settings{};
std::vector<std::wstring> keys;
Settings draft;
int selection = -1;
bool dirty{};
};
void collectChatLanguages(HWND w, ChatLanguagesView *v)
{
if (!v->dirty || v->selection < 0) return;
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
v->draft.chatCycles[v->keys[v->selection]] = std::move(cycle);
v->dirty = false;
}
void fillChatLanguages(HWND w, ChatLanguagesView *v)
{
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
if (index < 0 || index >= (int)v->keys.size()) return;
const auto &draft = v->settings->draft;
v->selection = index;
const auto &draft = v->draft;
auto saved = draft.chatCycles.find(v->keys[index]);
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
for (size_t i = 0; i < v->settings->order.size(); ++i)
@@ -1075,7 +1143,7 @@ void fillChatLanguages(HWND w, ChatLanguagesView *v)
std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end(), i);
}
value(w, 712, saved == draft.chatCycles.end() ? L"No profile yet. Select the languages for this chat." :
L"Saved profile. Select languages, or remove it to use the defaults.");
L"Chat profile. Select languages, or remove it to use the defaults.");
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
}
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
@@ -1087,25 +1155,29 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE) { fillChatLanguages(w, v); return 0; }
if (id == 711 && HIWORD(wp) == LBN_SELCHANGE) { v->dirty = true; return 0; }
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE)
{
try { collectChatLanguages(w, v); }
catch (...) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, v->selection, 0); throw; }
fillChatLanguages(w, v);
return 0;
}
if (id == IDOK || id == 713)
{
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
if (selected < 0 || selected >= (int)v->keys.size())
throw std::runtime_error("Open Telegram, Telegram Web K in Chrome, or Teams, then reopen this dialog. Viber OCR is disabled.");
auto next = v->settings->draft;
if (id == 713) next.chatCycles.erase(v->keys[selected]);
else
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
if (id == 713)
{
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
next.chatCycles[v->keys[selected]] = std::move(cycle);
v->draft.chatCycles.erase(v->keys[selected]);
v->dirty = false;
fillChatLanguages(w, v);
return 0;
}
validateSettings(next);
v->settings->draft.chatCycles = std::move(next.chatCycles);
collectChatLanguages(w, v);
validateSettings(v->draft);
v->settings->draft.chatCycles = std::move(v->draft.chatCycles);
DestroyWindow(w);
return 0;
}
@@ -1140,6 +1212,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
}, (LPARAM)&context);
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
v->draft = settings->draft;
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
@@ -1153,12 +1226,12 @@ void showChatLanguages(HWND parent, SettingsView *settings)
}
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
label(w, L"Click languages to include (saved in the order shown)", 18, 228, 604);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
711, 18, 256, 604, 132);
for (int i : settings->order)
SendDlgItemMessageW(w, 711, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
label(w, L"Applies to Alt+Shift, Win+Space and KSwitcher Next layout.", 18, 402, 604);
label(w, L"Matched by chat name. Identical names share a profile. Then Save settings.", 18, 430, 604);
label(w, L"Switch chats freely. Use languages, then Save settings.", 18, 430, 604);
button(w, L"&Remove profile", 713, 18, 476, 170);
button(w, L"&Use languages", IDOK, 300, 476, 170);
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
@@ -1169,6 +1242,8 @@ void showChatLanguages(HWND parent, SettingsView *settings)
showRequestedWindow(w);
SetFocus(GetDlgItem(w, 710));
}
#include "ui_app_languages.inl"
#include "ui_mouse_buttons.inl"
void collectLayout(HWND w, SettingsView *v)
{
if (v->selection < 0 || v->selection >= (int)v->order.size())
@@ -1247,6 +1322,7 @@ void fillSettings(HWND w, SettingsView *v)
v->order.push_back(i);
v->selection = 0;
fillLayouts(w, v);
fillMouseProfiles(w, *v);
v->filling = false;
updateSettingsState(w, v);
}
@@ -1625,7 +1701,8 @@ void updateSettingsState(HWND w, SettingsView *v)
v->refreshing = true;
bool changed = settingsChanged(w, v);
EnableWindow(GetDlgItem(w, IDOK), changed);
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." : L"No unsaved changes.";
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." :
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved changes.";
if (value(w, 261) != text) value(w, 261, text);
v->refreshing = false;
}
@@ -1688,6 +1765,20 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_DRAWITEM:
if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile)
{
drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp));
return TRUE;
}
break;
case WM_INPUT:
readMouseTestInput(w, *v, reinterpret_cast<HRAWINPUT>(lp));
return DefWindowProcW(w, msg, wp, lp);
case ConfigSettingsDownloaded:
v->draft = v->app->settings;
fillSettings(w, v);
return 0;
case WM_ENABLE:
if (wp) updateSettingsState(w, v);
break;
@@ -1695,6 +1786,24 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (LOWORD(wp) != WA_INACTIVE) updateSettingsState(w, v);
break;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom == 731)
{
if (((NMHDR *)lp)->code == LVN_ITEMCHANGING)
return blockMouseProfileStateChange(w, *v, *reinterpret_cast<const NMLISTVIEW *>(lp));
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
{
const auto &change = *reinterpret_cast<const NMLISTVIEW *>(lp);
if (!(change.uOldState & LVIS_SELECTED) && (change.uNewState & LVIS_SELECTED))
fillMouseEvents(w, *v);
}
return 0;
}
if (((NMHDR *)lp)->idFrom == 733 && ((NMHDR *)lp)->code == NM_DBLCLK)
{
const auto &click = *reinterpret_cast<const NMITEMACTIVATE *>(lp);
showMouseActionEditor(w, *v, click.iItem);
return 0;
}
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
{
selectSettingsPage(w, v);
@@ -1718,7 +1827,9 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
if (id == 262) { showLan(w, *v->app); return 0; }
if (id == 262) { v->visitedLan=true; showLan(w, *v->app); return 0; }
if (id == 263) { collectLayout(w, v); showAppLanguages(w, v); return 0; }
if (handleMouseBindings(w, *v, wp)) return 0;
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
if (id == 230)
{
@@ -1803,6 +1914,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
closeSettings(w, v);
return 0;
case WM_NCDESTROY:
if (v->rawMouseRegistered) registerMouseTestInput(nullptr);
disposeFont(w);
v->app->settingsWindow = nullptr;
delete v;
@@ -2107,13 +2219,16 @@ void showSettings(App &app, bool visible)
return;
}
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 720, 510, 0, false);
auto &layout = *windowLayoutState(w);
layout.fixedClient = true;
layout.mouseSettings = true;
app.settingsWindow = w;
auto *v = (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
InitCommonControlsEx(&tabsInit);
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools"};
for (int i = 0; i < 5; ++i)
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools", L"Mouse"};
for (int i = 0; i < (int)std::size(tabNames); ++i)
{
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
TabCtrl_InsertItem(tabs, i, &item);
@@ -2149,7 +2264,7 @@ void showSettings(App &app, bool visible)
label(w, L"Backspace / Delete clears it", 454, 270, 230);
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
label(w, L"Telegram / Telegram Web (Chrome) / Teams", 272, 382, 412);
button(w, L"Languages for &applications...", 263, 272, 378, 300);
page();
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
@@ -2189,6 +2304,9 @@ void showSettings(App &app, bool visible)
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
page();
createMousePage(w, *v);
page();
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
button(w, L"Impo&rt...", 250, 18, 466, 125);
button(w, L"&Export...", 251, 155, 466, 125);
+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));
}
+44 -7
View File
@@ -20,6 +20,26 @@ 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)] : "";
}
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)
{
auto selected=selectedLan(w,v);
@@ -60,12 +80,26 @@ 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);
@@ -89,6 +123,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 +143,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,7 +151,7 @@ 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==715)
@@ -131,10 +167,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 — mouse and keyboard",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 +190,10 @@ 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,64);
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,816,580,40);
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);}
+877
View File
@@ -0,0 +1,877 @@
std::wstring selectedMouseProfile(HWND w, const SettingsView &view)
{
const auto row = ListView_GetNextItem(GetDlgItem(w, 731), -1, LVNI_SELECTED);
return row < 0 || static_cast<size_t>(row) >= view.mouseProfiles.size() ? GlobalMouseProfile : view.mouseProfiles[row];
}
constexpr int RenameMouseProfile = 757, DeleteMouseProfile = 758;
std::wstring mouseActionLabel(const MouseAction &action)
{
if (action.type == MouseActionType::Shortcut) return action.shortcut.format();
if (action.type == MouseActionType::Launch)
return L"Launch " + std::filesystem::path(action.executable).filename().wstring();
return MouseActions[static_cast<size_t>(action.type)];
}
void selectMouseEvent(HWND w, int event)
{
HWND list = GetDlgItem(w, 733);
ListView_SetItemState(list, event, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
ListView_EnsureVisible(list, event, FALSE);
}
void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
{
const auto profile = selectedMouseProfile(w, view);
EnableWindow(GetDlgItem(w, RenameMouseProfile), isNamedMouseProfile(profile));
EnableWindow(GetDlgItem(w, DeleteMouseProfile), isNamedMouseProfile(profile));
HWND list = GetDlgItem(w, 733);
for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event)
{
auto *action = mouseBinding(view.draft, profile, event);
std::wstring text;
if (profile != GlobalMouseProfile && !action)
{
text += L"Global: ";
action = mouseBinding(view.draft, GlobalMouseProfile, event);
}
text += action ? mouseActionLabel(*action) : MouseActions[0];
ListView_SetItemText(list, event, 1, text.data());
}
selectMouseEvent(w, selection);
}
std::set<std::wstring> profileApplications(const Settings &settings, const std::wstring &profile)
{
const auto scope = settings.mouseProfileApplications.find(profile);
if (scope != settings.mouseProfileApplications.end()) return scope->second;
if (profile != GlobalMouseProfile && !isNamedMouseProfile(profile)) return {profile};
std::set<std::wstring> names;
const auto bindings = settings.mouseBindings.find(profile);
if (bindings != settings.mouseBindings.end())
for (const auto &[event, action] : bindings->second)
names.insert(action.applications.begin(), action.applications.end());
return names;
}
void selectMouseProfile(HWND w, int row)
{
auto list = GetDlgItem(w, 731);
ListView_SetItemState(list, row, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
ListView_EnsureVisible(list, row, FALSE);
}
void appendMouseProfile(HWND w, SettingsView &view, const std::wstring &profile)
{
auto list = GetDlgItem(w, 731);
std::wstring text;
for (const auto &app : profile == GlobalMouseProfile ? std::set<std::wstring>{} : profileApplications(view.draft, profile))
{
if (!text.empty()) text += L", ";
text += app + L".exe";
}
if (text.empty()) text = profile == GlobalMouseProfile ? L"Fallback" : L"All applications";
LVITEMW item{LVIF_TEXT};
item.iItem = static_cast<int>(view.mouseProfiles.size());
wchar_t activeLabel[] = L"";
item.pszText = activeLabel;
const int row = ListView_InsertItem(list, &item);
if (row < 0) throw std::runtime_error("Could not add the profile to the list.");
auto label = mouseProfileName(profile);
ListView_SetItemText(list, row, 1, label.data());
ListView_SetItemText(list, row, 2, text.data());
ListView_SetCheckState(list, row, !view.draft.disabledMouseProfiles.contains(profile));
view.mouseProfiles.push_back(profile);
}
bool blockMouseProfileStateChange(HWND w, SettingsView &view, const NMLISTVIEW &change)
{
const UINT previous = change.uOldState & LVIS_STATEIMAGEMASK;
const UINT next = change.uNewState & LVIS_STATEIMAGEMASK;
if (!(change.uChanged & LVIF_STATE) || !previous || !next || previous == next ||
change.iItem < 0 || static_cast<size_t>(change.iItem) >= view.mouseProfiles.size()) return false;
const auto &profile = view.mouseProfiles[change.iItem];
if (!isNamedMouseProfile(profile)) return true;
if (next == INDEXTOSTATEIMAGEMASK(2))
{
Settings candidate;
candidate.mouseBindings = view.draft.mouseBindings;
candidate.disabledMouseProfiles = view.draft.disabledMouseProfiles;
candidate.mouseProfileApplications = view.draft.mouseProfileApplications;
candidate.disabledMouseProfiles.erase(profile);
try { validateSettings(candidate); }
catch (const std::exception &e) { error(w, e); return true; }
view.draft.disabledMouseProfiles.erase(profile);
}
else view.draft.disabledMouseProfiles.insert(profile);
updateSettingsState(w, &view);
return false;
}
void fillMouseProfiles(HWND w, SettingsView &view)
{
const auto previous = selectedMouseProfile(w, view);
ListView_DeleteAllItems(GetDlgItem(w, 731));
view.mouseProfiles.clear();
appendMouseProfile(w, view, GlobalMouseProfile);
for (const auto &[app, bindings] : view.draft.mouseBindings)
if (app != GlobalMouseProfile) appendMouseProfile(w, view, app);
auto selected = std::find(view.mouseProfiles.begin(), view.mouseProfiles.end(), previous);
selectMouseProfile(w, selected == view.mouseProfiles.end() ? 0 : static_cast<int>(selected - view.mouseProfiles.begin()));
fillMouseEvents(w, view);
}
LRESULT CALLBACK mouseButtonCaptureProc(HWND field, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR)
{
const bool doubleClick = msg == WM_LBUTTONDBLCLK || msg == WM_RBUTTONDBLCLK || msg == WM_MBUTTONDBLCLK;
switch (msg)
{
case WM_LBUTTONDBLCLK: msg = WM_LBUTTONDOWN; break;
case WM_RBUTTONDBLCLK: msg = WM_RBUTTONDOWN; break;
case WM_MBUTTONDBLCLK: msg = WM_MBUTTONDOWN; break;
case WM_XBUTTONDBLCLK: msg = WM_XBUTTONDOWN; break;
}
const int button = mouseButton(msg, HIWORD(wp));
if (button >= 0)
{
if (mouseButtonDown(msg))
{
SetFocus(field);
SetCapture(field);
HWND settings = GetParent(field);
auto *view = reinterpret_cast<SettingsView *>(GetWindowLongPtrW(settings, GWLP_USERDATA));
if (view)
selectMouseEvent(settings, doubleClick && button < 3 ? button + 5 : button);
if (view)
value(settings, 738, std::wstring(L"Window event: ") + MouseButtonNames[button] +
L" | Presses: " + std::to_wstring(++view->mouseEvents));
}
else if (GetCapture() == field)
ReleaseCapture();
return msg == WM_XBUTTONDOWN || msg == WM_XBUTTONUP ? TRUE : 0;
}
if (msg == WM_NCDESTROY) RemoveWindowSubclass(field, mouseButtonCaptureProc, id);
return DefSubclassProc(field, msg, wp, lp);
}
bool registerMouseTestInput(HWND w)
{
RAWINPUTDEVICE mouse{0x01, 0x02, w ? DWORD{0} : RIDEV_REMOVE, w};
return RegisterRawInputDevices(&mouse, 1, sizeof(mouse)) != FALSE;
}
void readMouseTestInput(HWND w, SettingsView &view, HRAWINPUT handle)
{
HWND capture = GetDlgItem(w, 737);
if (!IsWindowVisible(capture)) return;
RAWINPUT input{};
UINT size = sizeof(input);
if (GetRawInputData(handle, RID_INPUT, &input, &size, sizeof(RAWINPUTHEADER)) == UINT(-1) ||
input.header.dwType != RIM_TYPEMOUSE || !input.data.mouse.usButtonFlags)
return;
POINT point{};
if (!GetCursorPos(&point) || WindowFromPoint(point) != capture) return;
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};
static_assert(std::size(downFlags) == std::size(MouseButtonNames));
for (size_t i = 0; i < std::size(downFlags); ++i)
{
if (!(input.data.mouse.usButtonFlags & downFlags[i])) continue;
value(w, 739, std::wstring(L"Raw Input: ") + MouseButtonNames[i] +
L" | Presses: " + std::to_wstring(++view.rawMouseEvents));
}
}
constexpr int MouseVolumeStepField = 759;
constexpr int MouseVolumeStepSpin = 760, MouseVolumeStepLabel = 761;
constexpr int MouseActionEditorHeight = 194, CompactMouseActionEditorHeight = 138, MaxEditedMouseVolumeStep = 50;
void layoutMouseActionFields(HWND w, bool volume, bool extraRow)
{
auto &layout = *windowLayoutState(w);
const auto combo = GetDlgItem(w, 734);
const auto buttons = GetDlgItem(w, IDOK);
const int height = extraRow ? MouseActionEditorHeight : CompactMouseActionEditorHeight;
const int offset = height - layout.height;
bool footer = false;
for (auto &[child, rect] : layout.controls)
{
if (child == combo) rect.right = volume ? 368 : 462;
if (child == buttons) footer = true;
if (footer) OffsetRect(&rect, 0, offset);
}
if (offset)
{
RECT frame{};
GetWindowRect(w, &frame);
layout.height = height;
fitFixedClient(w, layout, frame);
}
arrangeWindow(w);
}
void updateMouseActionFields(HWND w)
{
const auto index = SendDlgItemMessageW(w, 734, CB_GETCURSEL, 0, 0);
const auto type = SendDlgItemMessageW(w, 734, CB_GETITEMDATA, index, 0);
const bool shortcut = type == static_cast<int>(MouseActionType::Shortcut);
const bool launch = type == static_cast<int>(MouseActionType::Launch);
const bool volume = isMouseVolumeStep(static_cast<MouseActionType>(type));
ShowWindow(GetDlgItem(w, 747), shortcut ? SW_SHOWNA : SW_HIDE);
ShowWindow(GetDlgItem(w, 742), launch ? SW_SHOWNA : SW_HIDE);
ShowWindow(GetDlgItem(w, 743), launch ? SW_SHOWNA : SW_HIDE);
ShowWindow(GetDlgItem(w, MouseVolumeStepField), volume ? SW_SHOWNA : SW_HIDE);
ShowWindow(GetDlgItem(w, MouseVolumeStepSpin), volume ? SW_SHOWNA : SW_HIDE);
ShowWindow(GetDlgItem(w, MouseVolumeStepLabel), volume ? SW_SHOWNA : SW_HIDE);
ShowWindow(GetDlgItem(w, 744), shortcut || launch ? SW_SHOWNA : SW_HIDE);
value(w, 744, shortcut ? L"Click the field and press the key combination" :
launch ? L"Application (e.g. calc.exe)" : L"");
layoutMouseActionFields(w, volume, shortcut || launch);
}
struct MouseActionEditor
{
HWND parent{};
SettingsView *view{};
std::wstring profile;
int event{};
};
struct MouseProfileEditor
{
std::wstring previous;
std::set<std::wstring> applications;
HWND pickerOutline{};
};
constexpr int ActionApplications = 748, ActionApplicationName = 749, AddActionApp = 750, RemoveActionApp = 751;
constexpr int BrowseActionApp = 752;
constexpr int PickActionApp = 753;
constexpr int AddMouseProfile = 754, MouseProfileName = 755;
void drawActionApplicationIcon(HWND editor, const DRAWITEMSTRUCT &item)
{
RECT bounds = item.rcItem;
const bool pressed = (item.itemState & ODS_SELECTED) != 0;
const bool disabled = (item.itemState & ODS_DISABLED) != 0;
DrawFrameControl(item.hDC, &bounds, DFC_BUTTON,
DFCS_BUTTONPUSH | (pressed ? DFCS_PUSHED : 0) | (disabled ? DFCS_INACTIVE : 0));
const int offset = pressed ? scale(editor, 1) : 0;
const int x = (bounds.left + bounds.right) / 2 + offset;
const int y = (bounds.top + bounds.bottom) / 2 + offset;
const int half = scale(editor, 6);
const int weight = std::max(1, scale(editor, 2));
HBRUSH ink = GetSysColorBrush(disabled ? COLOR_GRAYTEXT : COLOR_BTNTEXT);
if (item.CtlID == BrowseActionApp)
{
RECT folder{x - half - 1, y - half + weight, x + half + 1, y + half};
RECT tab{folder.left, folder.top - weight, x, folder.top};
FrameRect(item.hDC, &folder, ink);
FillRect(item.hDC, &tab, ink);
}
else if (item.CtlID == PickActionApp)
{
RECT target{x - half, y - half, x + half + 1, y + half + 1};
RECT horizontal{x - half - weight, y, x + half + weight + 1, y + 1};
RECT vertical{x, y - half - weight, x + 1, y + half + weight + 1};
const auto previousBrush = SelectObject(item.hDC, GetStockObject(HOLLOW_BRUSH));
SetDCPenColor(item.hDC, GetSysColor(disabled ? COLOR_GRAYTEXT : COLOR_BTNTEXT));
const auto previousPen = SelectObject(item.hDC, GetStockObject(DC_PEN));
Ellipse(item.hDC, target.left, target.top, target.right, target.bottom);
SelectObject(item.hDC, previousPen);
SelectObject(item.hDC, previousBrush);
FillRect(item.hDC, &horizontal, ink);
FillRect(item.hDC, &vertical, ink);
}
else if (item.CtlID == RenameMouseProfile)
{
POINT pencil[]{{x - half, y + half}, {x - half + weight, y + 1},
{x + half - weight, y - half}, {x + half, y - half + weight},
{x - 1, y + half - weight}};
const auto brush = SelectObject(item.hDC, ink);
const auto pen = SelectObject(item.hDC, GetStockObject(NULL_PEN));
Polygon(item.hDC, pencil, static_cast<int>(std::size(pencil)));
SelectObject(item.hDC, pen);
SelectObject(item.hDC, brush);
}
else if (item.CtlID == DeleteMouseProfile)
{
RECT bin{x - half + 2, y - half + weight, x + half - 1, y + half};
RECT lid{x - half, y - half, x + half + 1, y - half + 1};
FrameRect(item.hDC, &bin, ink);
FillRect(item.hDC, &lid, ink);
}
else
{
RECT horizontal{x - half, y - weight / 2, x + half, y - weight / 2 + weight};
FillRect(item.hDC, &horizontal, ink);
}
if (item.CtlID == AddActionApp || item.CtlID == AddMouseProfile)
{
RECT vertical{x - weight / 2, y - half, x - weight / 2 + weight, y + half};
FillRect(item.hDC, &vertical, ink);
}
if ((item.itemState & ODS_FOCUS) && !(item.itemState & ODS_NOFOCUSRECT))
{
InflateRect(&bounds, -scale(editor, 4), -scale(editor, 4));
DrawFocusRect(item.hDC, &bounds);
}
}
void addActionApplicationTooltip(HWND tips, HWND editor, int id, const wchar_t *text)
{
TOOLINFOW tool{sizeof(tool), TTF_IDISHWND | TTF_SUBCLASS, editor,
reinterpret_cast<UINT_PTR>(GetDlgItem(editor, id))};
tool.lpszText = const_cast<wchar_t *>(text);
SendMessageW(tips, TTM_ADDTOOLW, 0, reinterpret_cast<LPARAM>(&tool));
}
void fillActionApplications(HWND w, const MouseProfileEditor &editor)
{
SendDlgItemMessageW(w, ActionApplications, LB_RESETCONTENT, 0, 0);
for (const auto &name : editor.applications)
{
const auto label = name + L".exe";
SendDlgItemMessageW(w, ActionApplications, LB_ADDSTRING, 0, reinterpret_cast<LPARAM>(label.c_str()));
}
}
void addActionApplication(HWND w, MouseProfileEditor &editor)
{
const auto name = applicationName(trimApplicationText(value(w, ActionApplicationName)));
if (!validMouseApplication(name)) throw std::runtime_error("Enter a valid application executable name.");
if (editor.applications.size() >= 1000 && !editor.applications.contains(name))
throw std::runtime_error("Too many applications in this profile.");
editor.applications.insert(name);
fillActionApplications(w, editor);
value(w, ActionApplicationName, L"");
}
std::wstring actionApplicationExecutable(std::filesystem::path path)
{
if (lower(path.extension().wstring()) == L".lnk")
{
Microsoft::WRL::ComPtr<IShellLinkW> link;
Microsoft::WRL::ComPtr<IPersistFile> file;
wchar_t target[32768]{};
if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&link))) ||
FAILED(link.As(&file)) || FAILED(file->Load(path.c_str(), STGM_READ)) ||
link->GetPath(target, static_cast<int>(std::size(target)), nullptr, 0) != S_OK)
throw std::runtime_error("Could not read the shortcut target.");
path = target;
}
if (lower(path.extension().wstring()) != L".exe")
throw std::runtime_error("Choose an executable or a shortcut that points to one.");
return path.filename().wstring();
}
void browseActionApplication(HWND w, MouseProfileEditor &editor)
{
wchar_t path[32768]{};
OPENFILENAMEW picker{sizeof(picker)};
picker.hwndOwner = w;
picker.lpstrFilter = L"Applications and shortcuts (*.exe;*.lnk)\0*.exe;*.lnk\0";
picker.lpstrFile = path;
picker.nMaxFile = static_cast<DWORD>(std::size(path));
picker.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | OFN_NODEREFERENCELINKS;
if (!GetOpenFileNameW(&picker)) return;
value(w, ActionApplicationName, actionApplicationExecutable(path));
addActionApplication(w, editor);
}
void saveMouseAction(HWND w, MouseActionEditor &editor)
{
const auto choice = SendDlgItemMessageW(w, 734, CB_GETCURSEL, 0, 0);
if (choice == CB_ERR) throw std::runtime_error("Choose an action.");
const auto type = SendDlgItemMessageW(w, 734, CB_GETITEMDATA, choice, 0);
const auto &profile = editor.profile;
const auto *event = MouseEvents[editor.event];
Settings check;
check.mouseBindings = editor.view->draft.mouseBindings;
check.disabledMouseProfiles = editor.view->draft.disabledMouseProfiles;
check.mouseProfileApplications = editor.view->draft.mouseProfileApplications;
if (type < 0 || (type == static_cast<int>(MouseActionType::Standard) && profile == GlobalMouseProfile))
{
if (auto found = check.mouseBindings.find(profile); found != check.mouseBindings.end())
{
found->second.erase(event);
if (found->second.empty() && !isNamedMouseProfile(profile)) check.mouseBindings.erase(found);
}
}
else
{
MouseAction action{static_cast<MouseActionType>(type)};
if (action.type == MouseActionType::Shortcut)
{
auto shortcut = Hotkey::parse(value(w, 747));
if (!shortcut || !shortcut->key) throw std::runtime_error("Click the field and press a key combination.");
action.shortcut = *shortcut;
}
if (action.type == MouseActionType::Launch) action.executable = trimApplicationText(value(w, 742));
if (isMouseVolumeStep(action.type))
{
const auto text = value(w, MouseVolumeStepField);
if (text.empty() || text.size() > 3 || text.find_first_not_of(L"0123456789") != text.npos)
throw std::runtime_error("Enter a volume step from 1 to 50.");
action.volumeStep = std::stoi(text);
const auto *previous = mouseBinding(editor.view->draft, profile, editor.event);
const bool unchanged = previous && previous->type == action.type && previous->volumeStep == action.volumeStep;
if (action.volumeStep < 1 || (action.volumeStep > MaxEditedMouseVolumeStep && !unchanged))
throw std::runtime_error("Enter a volume step from 1 to 50.");
}
if (const auto *previous = mouseBinding(editor.view->draft, profile, editor.event))
action.applications = previous->applications;
check.mouseBindings[profile][event] = std::move(action);
}
validateSettings(check);
editor.view->draft.mouseBindings = std::move(check.mouseBindings);
fillMouseEvents(editor.parent, *editor.view, editor.event);
}
LRESULT CALLBACK mouseActionEditorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *editor = reinterpret_cast<MouseActionEditor *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!editor) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == WM_COMMAND)
{
switch (LOWORD(wp))
{
case 734:
if (HIWORD(wp) == CBN_SELCHANGE)
updateMouseActionFields(w);
return 0;
case 743:
{
wchar_t path[32768]{};
OPENFILENAMEW picker{sizeof(picker)};
picker.hwndOwner = w;
picker.lpstrFilter = L"Applications (*.exe)\0*.exe\0";
picker.lpstrFile = path;
picker.nMaxFile = static_cast<DWORD>(std::size(path));
picker.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
if (GetOpenFileNameW(&picker)) value(w, 742, path);
return 0;
}
case IDOK:
saveMouseAction(w, *editor);
DestroyWindow(w);
return 0;
case IDCANCEL:
DestroyWindow(w);
return 0;
}
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_NCDESTROY)
{
auto parent = editor->parent;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
disposeFont(w);
delete editor;
if (IsWindow(parent))
{
EnableWindow(parent, TRUE);
SetForegroundWindow(parent);
SetFocus(GetDlgItem(parent, 733));
}
}
}
catch (const std::exception &e) { error(w, e); }
return DefWindowProcW(w, msg, wp, lp);
}
void showMouseActionEditor(HWND parent, SettingsView &view, int event)
{
if (event < 0 || event >= static_cast<int>(std::size(MouseEvents)) || !IsWindowEnabled(parent)) return;
const auto profile = selectedMouseProfile(parent, view);
const bool application = profile != GlobalMouseProfile;
const auto *action = mouseBinding(view.draft, profile, event);
const auto title = std::wstring(L"Edit mouse action");
HWND w = makeWindow(*view.app, L"KSwitcher.MouseAction", title.c_str(), mouseActionEditorProc,
480, MouseActionEditorHeight, 0, false, parent);
auto *editor = new MouseActionEditor{parent, &view, profile, event};
SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(editor));
label(w, std::wstring(MouseEvents[event]).append(L" · ").append(mouseProfileName(profile)).c_str(),
18, 14, 444);
label(w, L"&Action", 18, 43, 330);
windowLayoutState(w)->fixedClient = true;
windowLayoutState(w)->rowCombo =
control(w, L"COMBOBOX", L"", WS_TABSTOP | WS_VSCROLL | CBS_DROPDOWNLIST, 734, 18, 65, 444, 215);
if (application)
{
SendDlgItemMessageW(w, 734, CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(L"Inherit from Global"));
SendDlgItemMessageW(w, 734, CB_SETITEMDATA, 0, -1);
}
for (size_t type = 0; type < std::size(MouseActions); ++type)
{
const auto row = SendDlgItemMessageW(w, 734, CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(MouseActions[type]));
if (row >= 0) SendDlgItemMessageW(w, 734, CB_SETITEMDATA, row, type);
}
control(w, L"STATIC", L"", 0, 744, 18, 101, 444, 21);
hotkeyEdit(*view.app, w, 747, 18, 125, 444);
edit(w, 742, 18, 125, 334);
button(w, L"&Browse…", 743, 362, 124, 100);
control(w, L"STATIC", L"Step (%)", 0, MouseVolumeStepLabel, 380, 43, 82, 22);
auto stepField = edit(w, MouseVolumeStepField, 380, 65, 64, 25, ES_NUMBER);
SendMessageW(stepField, EM_SETLIMITTEXT, 3, 0);
INITCOMMONCONTROLSEX spinControls{sizeof(spinControls), ICC_UPDOWN_CLASS};
InitCommonControlsEx(&spinControls);
HWND spin = control(w, UPDOWN_CLASSW, L"", UDS_SETBUDDYINT | UDS_ARROWKEYS | UDS_NOTHOUSANDS,
MouseVolumeStepSpin, 444, 65, 18, 25);
SendMessageW(spin, UDM_SETBUDDY, reinterpret_cast<WPARAM>(stepField), 0);
SendMessageW(spin, UDM_SETRANGE32, 1, MaxEditedMouseVolumeStep);
SetDlgItemInt(w, MouseVolumeStepField,
action && isMouseVolumeStep(action->type) ? action->volumeStep : DefaultMouseVolumeStep, FALSE);
button(w, L"&Apply", IDOK, 248, 154, 100);
button(w, L"Cancel", IDCANCEL, 362, 154, 100);
SendDlgItemMessageW(w, 734, CB_SETCURSEL, action ? static_cast<int>(action->type) + application : 0, 0);
if (action && action->type == MouseActionType::Shortcut) value(w, 747, action->shortcut.format());
if (action && action->type == MouseActionType::Launch) value(w, 742, action->executable);
updateMouseActionFields(w);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetFocus(GetDlgItem(w, 734));
}
constexpr int EditMouseEvent = 735, ResetMouseEvent = 746;
constexpr auto MouseEventHint = L"Double-click to edit. Right-click for more actions.";
int mouseProfileRowAt(HWND list, POINT point)
{
LVHITTESTINFO hit{};
hit.pt = point;
const int item = ListView_SubItemHitTest(list, &hit);
if (item >= 0) return item;
RECT client{};
if (!GetClientRect(list, &client) || !PtInRect(&client, point)) return -1;
const int top = ListView_GetTopIndex(list);
const int end = std::min(ListView_GetItemCount(list), top + ListView_GetCountPerPage(list) + 1);
for (int row = top; row < end; ++row)
{
RECT bounds{};
if (ListView_GetItemRect(list, row, &bounds, LVIR_BOUNDS) && point.y >= bounds.top && point.y < bounds.bottom)
return row;
}
return -1;
}
void showMouseListMenu(HWND list, POINT point, bool profiles)
{
HWND w = GetParent(list);
int event;
if (point.x == -1 && point.y == -1)
{
event = ListView_GetNextItem(list, -1, LVNI_SELECTED);
RECT row{};
if (event < 0 || !ListView_GetItemRect(list, event, &row, LVIR_BOUNDS)) return;
point = {row.left, row.bottom};
ClientToScreen(list, &point);
}
else
{
LVHITTESTINFO hit{};
hit.pt = point;
ScreenToClient(list, &hit.pt);
event = ListView_SubItemHitTest(list, &hit);
if (event < 0) return;
}
auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
HMENU menu = CreatePopupMenu();
if (!menu) return;
if (profiles)
{
selectMouseProfile(w, event);
const UINT flags = MF_STRING | (isNamedMouseProfile(selectedMouseProfile(w, view)) ? MF_ENABLED : MF_GRAYED);
AppendMenuW(menu, flags, RenameMouseProfile, L"&Edit…");
AppendMenuW(menu, flags, DeleteMouseProfile, L"&Delete…");
}
else
{
selectMouseEvent(w, event);
const bool canReset = mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr;
AppendMenuW(menu, MF_STRING, EditMouseEvent, L"&Edit…");
AppendMenuW(menu, MF_STRING | (canReset ? MF_ENABLED : MF_GRAYED), ResetMouseEvent, L"&Reset to default");
}
const auto command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON,
point.x, point.y, 0, w, nullptr);
DestroyMenu(menu);
if (command) SendMessageW(w, WM_COMMAND, command, 0);
}
LRESULT CALLBACK mouseEventListProc(HWND list, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR)
{
const bool profiles = GetDlgCtrlID(list) == 731;
if (profiles && msg == WM_LBUTTONDBLCLK)
{
const int row = mouseProfileRowAt(list, {GET_X_LPARAM(lp), GET_Y_LPARAM(lp)});
if (row >= 0)
{
const auto owner = GetParent(list);
selectMouseProfile(owner, row);
SendMessageW(owner, WM_COMMAND, RenameMouseProfile, 0);
}
return 0;
}
if (msg == WM_GETDLGCODE && wp == VK_RETURN) return DLGC_WANTMESSAGE;
if (msg == WM_KEYDOWN && (wp == VK_RETURN || wp == VK_F2))
{
SendMessageW(GetParent(list), WM_COMMAND, profiles ? RenameMouseProfile : EditMouseEvent, 0);
return 0;
}
if (msg == WM_CONTEXTMENU)
{
showMouseListMenu(list, {GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}, profiles);
return 0;
}
if (msg == WM_NCDESTROY) RemoveWindowSubclass(list, mouseEventListProc, id);
return DefSubclassProc(list, msg, wp, lp);
}
LRESULT CALLBACK mouseProfileNameProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
const HWND owner = GetWindow(w, GW_OWNER);
auto *editor = reinterpret_cast<MouseProfileEditor *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!editor) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == AppWindowPicked)
{
if (lp == 1)
{
const auto name = mouseApplicationName(reinterpret_cast<HWND>(wp));
if (name.empty()) throw std::runtime_error("Drop the crosshair onto an application window.");
value(w, ActionApplicationName, name);
addActionApplication(w, *editor);
}
return 0;
}
if (msg == WM_DRAWITEM && (wp == AddActionApp || wp == RemoveActionApp || wp == BrowseActionApp || wp == PickActionApp))
{
drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp));
return TRUE;
}
if (msg == WM_COMMAND)
{
if (LOWORD(wp) == BrowseActionApp) { browseActionApplication(w, *editor); return 0; }
if (LOWORD(wp) == AddActionApp) { addActionApplication(w, *editor); return 0; }
if (LOWORD(wp) == RemoveActionApp)
{
const auto row = SendDlgItemMessageW(w, ActionApplications, LB_GETCURSEL, 0, 0);
if (row >= 0 && static_cast<size_t>(row) < editor->applications.size())
{
editor->applications.erase(std::next(editor->applications.begin(), row));
fillActionApplications(w, *editor);
}
return 0;
}
}
}
catch (const std::exception &e) { error(w, e); return 0; }
if (msg == WM_COMMAND && LOWORD(wp) == IDOK)
{
try
{
auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(owner, GWLP_USERDATA));
const auto &previous = editor->previous;
const auto name = trimApplicationText(value(w, MouseProfileName));
const auto duplicate = std::any_of(view.mouseProfiles.begin(), view.mouseProfiles.end(), [&](const auto &profile)
{
return profile != previous && lower(mouseProfileName(profile)) == lower(name);
});
if (duplicate) throw std::runtime_error("A profile with that name already exists.");
const auto key = std::wstring(NamedMouseProfilePrefix) + name;
Settings check;
check.mouseBindings = view.draft.mouseBindings;
check.disabledMouseProfiles = view.draft.disabledMouseProfiles;
check.mouseProfileApplications = view.draft.mouseProfileApplications;
if (!previous.empty() && key != previous)
{
auto profile = check.mouseBindings.extract(previous);
if (profile.empty()) throw std::runtime_error("The profile no longer exists.");
profile.key() = key;
check.mouseBindings.insert(std::move(profile));
if (check.disabledMouseProfiles.erase(previous)) check.disabledMouseProfiles.insert(key);
}
else check.mouseBindings.try_emplace(key);
check.mouseProfileApplications.erase(previous);
check.mouseProfileApplications[key] = editor->applications;
for (auto &[event, action] : check.mouseBindings.at(key)) action.applications.clear();
validateSettings(check);
view.draft.mouseBindings = std::move(check.mouseBindings);
view.draft.disabledMouseProfiles = std::move(check.disabledMouseProfiles);
view.draft.mouseProfileApplications = std::move(check.mouseProfileApplications);
fillMouseProfiles(owner, view);
const auto selected = std::find(view.mouseProfiles.begin(), view.mouseProfiles.end(), key);
selectMouseProfile(owner, static_cast<int>(selected - view.mouseProfiles.begin()));
fillMouseEvents(owner, view);
updateSettingsState(owner, &view);
DestroyWindow(w);
}
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (msg == WM_CLOSE || (msg == WM_COMMAND && LOWORD(wp) == IDCANCEL))
{
DestroyWindow(w);
return 0;
}
if (msg == WM_NCDESTROY)
{
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
delete editor;
disposeFont(w);
EnableWindow(owner, TRUE);
SetForegroundWindow(owner);
}
return DefWindowProcW(w, msg, wp, lp);
}
void showMouseProfileName(HWND owner, SettingsView &view, bool renaming)
{
HWND w = makeWindow(*view.app, L"KSwitcher.MouseProfileName", renaming ? L"Edit profile" : L"Add profile", mouseProfileNameProc,
480, 296, 0, false, owner);
windowLayoutState(w)->fixedClient = true;
const auto previous = renaming ? selectedMouseProfile(owner, view) : std::wstring{};
auto *editor = new MouseProfileEditor{previous, renaming ? profileApplications(view.draft, previous) : std::set<std::wstring>{}};
SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(editor));
label(w, L"&Name", 18, 12, 444);
edit(w, MouseProfileName, 18, 36, 444);
if (renaming) value(w, MouseProfileName, mouseProfileName(previous));
label(w, L"Applications (empty list means all applications)", 18, 75, 444);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_BORDER | WS_VSCROLL | LBS_NOINTEGRALHEIGHT,
ActionApplications, 18, 100, 404, 96);
control(w, L"BUTTON", L"&Remove application", WS_TABSTOP | BS_OWNERDRAW, RemoveActionApp, 430, 100, 32, 28);
HWND appName = edit(w, ActionApplicationName, 18, 207, 324);
SendMessageW(appName, EM_SETCUEBANNER, FALSE, reinterpret_cast<LPARAM>(L"Application executable (e.g. chrome.exe)"));
HWND picker = control(w, L"BUTTON", L"Drag to application window", WS_TABSTOP | BS_OWNERDRAW,
PickActionApp, 350, 205, 32, 28);
SetWindowSubclass(picker, appWindowPickerProc, 1, reinterpret_cast<DWORD_PTR>(&editor->pickerOutline));
control(w, L"BUTTON", L"&Browse for application", WS_TABSTOP | BS_OWNERDRAW, BrowseActionApp, 390, 205, 32, 28);
control(w, L"BUTTON", L"Add a&pplication", WS_TABSTOP | BS_OWNERDRAW, AddActionApp, 430, 205, 32, 28);
HWND tips = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, w, nullptr,
view.app->instance, nullptr);
addActionApplicationTooltip(tips, w, RemoveActionApp, L"Remove application");
addActionApplicationTooltip(tips, w, AddActionApp, L"Add application");
addActionApplicationTooltip(tips, w, BrowseActionApp, L"Browse for an executable or shortcut");
addActionApplicationTooltip(tips, w, PickActionApp, L"Drag to an application window");
fillActionApplications(w, *editor);
control(w, L"BUTTON", renaming ? L"&Apply" : L"&Add", WS_TABSTOP | BS_DEFPUSHBUTTON, IDOK, 248, 251, 100, 28);
button(w, L"Cancel", IDCANCEL, 362, 251, 100);
EnableWindow(owner, FALSE);
showRequestedWindow(w);
SetFocus(GetDlgItem(w, MouseProfileName));
}
bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp)
{
const int id = LOWORD(wp);
if (id == AddMouseProfile) { showMouseProfileName(w, view, false); return true; }
if (id == RenameMouseProfile || id == DeleteMouseProfile)
{
const auto profile = selectedMouseProfile(w, view);
if (!isNamedMouseProfile(profile)) return true;
if (id == RenameMouseProfile) showMouseProfileName(w, view, true);
else if (MessageBoxW(w, (L"Delete " + mouseProfileName(profile) + L" and all its actions?").c_str(),
L"Delete profile", MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) == IDYES)
{
view.draft.disabledMouseProfiles.erase(profile);
view.draft.mouseBindings.erase(profile);
view.draft.mouseProfileApplications.erase(profile);
fillMouseProfiles(w, view);
}
return true;
}
if (id != EditMouseEvent && id != ResetMouseEvent) return false;
const int event = ListView_GetNextItem(GetDlgItem(w, 733), -1, LVNI_SELECTED);
if (event < 0) return true;
if (id == EditMouseEvent) showMouseActionEditor(w, view, event);
else if (auto profile = view.draft.mouseBindings.find(selectedMouseProfile(w, view));
profile != view.draft.mouseBindings.end())
{
profile->second.erase(MouseEvents[event]);
if (profile->second.empty() && !isNamedMouseProfile(profile->first)) view.draft.mouseBindings.erase(profile);
fillMouseEvents(w, view, event);
}
return true;
}
void scaleMouseTableColumns(HWND w, int previousDpi, int nextDpi)
{
for (int id : {731, 733})
{
const auto list = GetDlgItem(w, id);
const int count = Header_GetItemCount(ListView_GetHeader(list));
for (int column = 0; column < count; ++column)
ListView_SetColumnWidth(list, column, MulDiv(ListView_GetColumnWidth(list, column), nextDpi, previousDpi));
}
}
void createMousePage(HWND w, SettingsView &view)
{
constexpr int profilesLeft = 18, actionsLeft = 369, tableWidth = 333;
constexpr int profilesRight = profilesLeft + tableWidth;
label(w, L"Profiles", 18, 64, 120);
label(w, L"Mouse events", actionsLeft, 64, tableWidth);
control(w, L"BUTTON", L"Delete profile", WS_TABSTOP | BS_OWNERDRAW, DeleteMouseProfile, profilesRight - 80, 61, 24, 24);
control(w, L"BUTTON", L"Edit profile", WS_TABSTOP | BS_OWNERDRAW, RenameMouseProfile, profilesRight - 52, 61, 24, 24);
control(w, L"BUTTON", L"Add profile", WS_TABSTOP | BS_OWNERDRAW, AddMouseProfile, profilesRight - 24, 61, 24, 24);
HWND tips = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, w, nullptr,
view.app->instance, nullptr);
addActionApplicationTooltip(tips, w, AddMouseProfile, L"Add profile");
addActionApplicationTooltip(tips, w, RenameMouseProfile, L"Edit profile");
addActionApplicationTooltip(tips, w, DeleteMouseProfile, L"Delete profile");
constexpr int listTop = 90, listHeight = 224;
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
constexpr DWORD listStyle = WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS | LVS_NOSORTHEADER;
HWND profiles = control(w, WC_LISTVIEWW, L"", listStyle, 731, profilesLeft, listTop, tableWidth, listHeight);
SetWindowSubclass(profiles, mouseEventListProc, 3, 0);
ListView_SetExtendedListViewStyle(profiles, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP | LVS_EX_CHECKBOXES);
wchar_t nameTitle[] = L"Name", activeTitle[] = L"Active", applicationsTitle[] = L"Applications";
LVCOLUMNW profileColumn{LVCF_TEXT | LVCF_WIDTH};
profileColumn.pszText = activeTitle;
profileColumn.cx = scale(w, 56);
ListView_InsertColumn(profiles, 0, &profileColumn);
profileColumn.pszText = nameTitle;
profileColumn.cx = scale(w, 100);
ListView_InsertColumn(profiles, 1, &profileColumn);
profileColumn.pszText = applicationsTitle;
profileColumn.cx = scale(w, tableWidth - 160);
ListView_InsertColumn(profiles, 2, &profileColumn);
int order[]{1, 0, 2};
ListView_SetColumnOrderArray(profiles, 3, order);
HWND list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT |
LVS_SINGLESEL | LVS_SHOWSELALWAYS | LVS_NOSORTHEADER, 733, actionsLeft, listTop, tableWidth, listHeight);
SetWindowSubclass(list, mouseEventListProc, 3, 0);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP);
wchar_t actionTitle[] = L"Action";
LVCOLUMNW column{LVCF_TEXT | LVCF_WIDTH};
column.pszText = nameTitle;
column.cx = scale(w, 116);
ListView_InsertColumn(list, 0, &column);
column.pszText = actionTitle;
column.cx = scale(w, tableWidth - 120);
ListView_InsertColumn(list, 1, &column);
for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event)
{
LVITEMW item{LVIF_TEXT};
item.iItem = event;
item.pszText = const_cast<wchar_t *>(MouseEvents[event]);
ListView_InsertItem(list, &item);
}
control(w, L"STATIC", MouseEventHint, 0, 740, actionsLeft, 324, tableWidth, 22);
control(w, L"BUTTON", L"Test mouse buttons here", WS_TABSTOP | BS_PUSHBUTTON,
737, profilesLeft, listTop + listHeight + 10, tableWidth, 56);
SetWindowSubclass(GetDlgItem(w, 737), mouseButtonCaptureProc, 1, 0);
control(w, L"STATIC", L"Window event: waiting for a press", 0, 738, actionsLeft, 370, tableWidth, 20);
view.rawMouseRegistered = registerMouseTestInput(w);
control(w, L"STATIC", view.rawMouseRegistered ? L"Raw Input: waiting for a press" : L"Raw Input: unavailable",
0, 739, actionsLeft, 394, tableWidth, 20);
}
+9 -4
View File
@@ -1,5 +1,5 @@
#include "ui_sync_conflicts.inl"
struct SyncView { HWND parent; App *app; };
struct SyncView { HWND parent; App *app; bool actionError{}; };
void updateSyncControls(HWND w, App &app)
{
bool enabled = checked(w, 601);
@@ -17,7 +17,8 @@ void updateSyncControls(HWND w, App &app)
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
else
status = app.syncCommand();
if (value(w, 608) != status) value(w, 608, status);
auto *view = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if ((!view || !view->actionError) && value(w, 608) != status) value(w, 608, status);
}
SyncConfig syncFields(HWND w)
{
@@ -47,6 +48,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
int id = LOWORD(wp);
if (id == 601 && HIWORD(wp) == BN_CLICKED)
{
v->actionError = false;
updateSyncControls(w, *v->app);
return 0;
}
@@ -56,6 +58,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
if (id == 609)
{
v->actionError = false;
auto c = loadSyncConfig(v->app->data);
c.enabled = false;
saveSyncConfig(v->app->data, c);
@@ -69,6 +72,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
if (id == IDOK || id == 610 || id == 611)
{
v->actionError = false;
if (!checked(w, 601))
{
if (id != IDOK) return 0;
@@ -80,7 +84,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0;
}
if (id != IDOK && MessageBoxW(w, id == 610 ?
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. The open Settings window will refresh automatically; its unsaved edits will be discarded." :
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
auto c = syncFields(w);
@@ -100,6 +104,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
catch (const std::exception &e)
{
v->actionError = true; // Keep this action's error visible across status polling.
// Never display parser exceptions containing connection file contents.
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
if (dynamic_cast<const std::runtime_error *>(&e))
@@ -130,7 +135,7 @@ void showSyncOptions(HWND parent, App &app)
label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
label(w, L"Sync uses saved settings. Download remote refreshes the open Settings window.", 18, 330, 622);
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 608, app.syncCommand());
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.58";
inline constexpr wchar_t AppVersion[] = L"0.6.64";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+113 -2
View File
@@ -179,8 +179,8 @@ 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) == 6, "six settings categories");
for (int page = 0; page < TabCtrl_GetItemCount(tabs); ++page)
{
TabCtrl_SetCurSel(tabs, page);
NMHDR notification{tabs, 260, TCN_SELCHANGE};
@@ -225,6 +225,7 @@ 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");
@@ -235,6 +236,7 @@ int main()
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, 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");
@@ -250,6 +252,8 @@ int main()
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, 713, 0);
require(IsWindow(dialog), "chat reset keeps dialog open");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
GetClientRect(parent,&rect); dc=GetDC(parent);
@@ -258,6 +262,97 @@ 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");
@@ -307,12 +402,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);
}
{
+240
View File
@@ -1,4 +1,5 @@
#include "app.h"
#include "configuration.h"
#include "ui_scroll.h"
#include "ui_geometry.h"
#include "screenshot.h"
@@ -124,6 +125,26 @@ struct InputRegressionAccess {
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
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) {
@@ -180,6 +201,12 @@ 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::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());
@@ -752,6 +779,219 @@ int main(int argc, char **argv) {
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
{
auto settings = app.settingsWindow;
auto tabs = GetDlgItem(settings, 260);
auto capture = GetDlgItem(settings, 737);
auto events = GetDlgItem(settings, 733);
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");
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;
TabCtrl_SetCurSel(GetDlgItem(window,260),3);
app.settings.awake = !previous.awake;
app.settings.maxItems = previous.maxItems + 1;
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
wchar_t count[32]{};
GetDlgItemTextW(window,240,count,32);
require(app.settingsWindow == window &&
SendDlgItemMessageW(window,205,BM_GETCHECK,0,0) == (app.settings.awake ? BST_CHECKED : BST_UNCHECKED) &&
std::stoi(count) == app.settings.maxItems && TabCtrl_GetCurSel(GetDlgItem(window,260)) == 3,
"download refreshes existing settings controls while preserving window and active tab");
app.settings = previous;
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
TabCtrl_SetCurSel(GetDlgItem(window,260),0);
auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose);
require(timer != 0, "settings close prompt responder");
SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0);
+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;}
}
+84 -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,87 @@ 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, merged, {}, false).mouseBindings != merged.mouseBindings)
throw std::runtime_error("mouse profiles must stay local during sync");
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");
std::cout<<"Settings-save merge checks passed\n";
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
+19 -1
View File
@@ -298,6 +298,9 @@ int main()
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
result = synchronize(provider, base, bytes(left), SyncAction::Download);
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
require(result.downloaded, "explicit download requests a settings window refresh");
require(result.status.find(L"Downloaded remote settings. Server version 1.") != std::wstring::npos,
"explicit download reports its result and server version");
provider.race = true;
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
@@ -395,10 +398,25 @@ int main()
rejects([&] { bao->read(); }, "permission denied is not absent");
readStatus = 302;
rejects([&] { bao->read(); }, "redirect response rejected");
readStatus = 400;
try { bao->read(); require(false, "download HTTP 400 must fail"); }
catch (const std::runtime_error &e) {
require(std::string(e.what()).find("download request (HTTP 400)") != std::string::npos,
"download failure is not mislabeled as a write failure");
}
writeStatus = 400;
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
}
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
require(logins == 1 && revokes == 1 && reads == 6 && writes == 2, "temporary login lifecycle");
try {
makeSyncProvider(config, [](const std::wstring &, const std::wstring &, const std::wstring &, const Bytes &) {
return SyncHttpResponse{400, {}};
});
require(false, "login HTTP 400 must fail");
} catch (const std::runtime_error &e) {
require(std::string(e.what()).find("authentication request (HTTP 400)") != std::string::npos,
"login failure identifies authentication instead of a write");
}
forgetSyncPassword(config); forgetSyncPassword(config);
++checks;
std::filesystem::remove_all(root);
+14 -1
View File
@@ -592,6 +592,16 @@ 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"мама мила";
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 +728,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 +750,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;