Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2106899d | ||
|
|
3761b790ae | ||
|
|
a0e1de40ef | ||
|
|
5d96abb83e | ||
|
|
011664cc53 | ||
|
|
4d8d8d47b9 | ||
|
|
d0387904a0 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.57 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.63 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
|
||||
+26
-2
@@ -1,4 +1,4 @@
|
||||
# LAN mouse, keyboard and clipboard — v0.6.57 (experimental)
|
||||
# LAN mouse, keyboard and clipboard — v0.6.58 (experimental)
|
||||
|
||||
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
|
||||
code on both computers, and shares mouse and keyboard through a screen edge.
|
||||
@@ -10,7 +10,7 @@ is not implemented by clipboard synchronization.
|
||||
|
||||
## Set up multiple computers
|
||||
|
||||
1. Install the v0.6.57 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
|
||||
1. Install the v0.6.58 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
|
||||
2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs.
|
||||
Only running LAN-enabled KSwitcher instances appear, not generic network devices.
|
||||
3. Match the computer name and instance-ID prefix displayed on the other PC.
|
||||
@@ -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
|
||||
@@ -116,3 +136,7 @@ Three physical computers still need an end-to-end trial for their adapters,
|
||||
firewalls, displays and application privilege levels. The automated TCP tests
|
||||
use local sockets, and input-state tests use simulated desktop environments;
|
||||
they do not claim a completed three-machine hardware test.
|
||||
|
||||
## v0.6.58 connection fixes
|
||||
|
||||
Saved trust keeps TCP listeners available even when discovery announcements are missed. Working authenticated input sessions no longer depend on discovery presence. Pair saved is distinct from input connected; the list refreshes every second. A changed instance ID requires a new pairing. Keep each PC's own data folder during upgrades. Connection state changes and listener Windows errors are written to the diagnostic log.
|
||||
@@ -10,14 +10,18 @@ The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data r
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
|
||||
## KSwitcher computers — v0.6.57 (experimental)
|
||||
## KSwitcher computers — v0.6.58 (experimental)
|
||||
|
||||
TCP input listeners and authenticated connections survive missed discovery announcements.
|
||||
The list distinguishes saved pairing from connected input and refreshes every second.
|
||||
Keep each PC’s own data folder during updates; a different instance ID requires new pairing.
|
||||
|
||||
Arrange multiple paired PCs on a monitor grid. Mouse and keyboard follow shared
|
||||
edges, including A → B → C and back. New text and images go to every online paired
|
||||
PC's history and active clipboard. The history Computers menu filters by multiple
|
||||
sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md).
|
||||
|
||||
**Update every participating PC to v0.6.57:** input protocol v2 is incompatible
|
||||
**Update every participating PC to v0.6.58:** input protocol v2 is incompatible
|
||||
with older one-pair builds. Keep each PC's own data directory. Pair every input
|
||||
source with all destinations, arrange the same physical layout on each PC and
|
||||
enable input sharing. Empty cells and offline neighbors block passage.
|
||||
@@ -25,7 +29,7 @@ enable input sharing. Empty cells and offline neighbors block passage.
|
||||
**Українською.** Розставте кілька спарованих ПК по сітці: миша й клавіатура
|
||||
переходять через спільний край, зокрема A → B → C і назад. Нові текст та зображення
|
||||
передаються всім доступним спарованим ПК в історію й активний буфер. Фільтр
|
||||
Computers в історії має чекбокси. Оновіть усі ПК до v0.6.57, залишивши кожному
|
||||
Computers в історії має чекбокси. Оновіть усі ПК до v0.6.58, залишивши кожному
|
||||
власну папку data. Спаруйте джерело вводу з усіма потрібними ПК й задайте
|
||||
однакове фізичне розташування на кожному. Передача файлів не реалізована.
|
||||
|
||||
@@ -43,6 +47,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
|
||||
@@ -586,8 +596,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
|
||||
@@ -719,9 +729,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
|
||||
@@ -895,3 +907,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.
+12
-17
@@ -1,26 +1,21 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.57 — 2026-11-06
|
||||
## v0.6.63 — 2026-11-10
|
||||
|
||||
## Changes
|
||||
- Settings Save merges independent edits with newer resident settings. Same-field conflicts offer a choice; declining keeps the window and draft open.
|
||||
- Multiple paired PCs on a draggable monitor grid; mouse and keyboard follow neighboring edges, including A → B → C and back. Connections reconnect independently.
|
||||
- New text, formatted text and screenshots synchronize to all online paired PCs: history and active clipboard. Newer local copies are preserved during incoming transfers.
|
||||
- Clipboard history has a persistent Computers checkbox filter and source labels. Lossless PNG transport avoids duplicate image payloads.
|
||||
- LAN modifier chords, DPI-scaled motion, cursor parking/return, automatic conversion ownership and queued wheel reversal fixes. Local shortcut latches reset on ownership changes.
|
||||
- Stable paired status, pairing/input button states, persistent selection and a fixed-size LAN window with scrolling where needed.
|
||||
LAN input now automatically tries direct TCP, reverse TCP after 1.5 seconds, and encrypted UDP over the discovery socket after 5 seconds without an authenticated TCP connection. A working UDP session remains on UDP until disconnected. Update KSwitcher on both PCs through the tray **Update** command to enable all fallback methods; keep each PC's own data directory.
|
||||
|
||||
## Validation
|
||||
Passed: 5 settings-save merge checks, 203 LAN input/three-peer TCP, 289 clipboard transfer, 43 pairing, 828 settings/chat UI, 570 input, 341 native and 126 sync checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Three physical PCs have not been verified together. LAN remains experimental; follow LAN-PREVIEW.md.
|
||||
UDP input uses fresh authenticated sessions, ordered commands, acknowledgements and bounded retries. Lost, reordered or duplicated packets do not duplicate key presses. Loss of authenticated traffic or acknowledgement progress closes the session and restores local control.
|
||||
|
||||
**Update ALL LAN participants to v0.6.57. Input protocol v2 cannot connect to older one-pair builds.** Pair every physical input source with each destination. Configure the same physical arrangement on each PC; layouts are not automatically synchronized. File transfer, offline clipboard backfill, cross-PC dragging and secure desktops remain unsupported.
|
||||
After 15 seconds without a connection, **Connection help** explains what to check and provides copyable administrator PowerShell commands for the actual running KSwitcher executable on that PC. Rules allow inbound LAN traffic only from the local subnet; commands are never executed automatically. A timeout alone is not presented as proof of a firewall block.
|
||||
|
||||
Pairing now shows the target and remaining wait time, retains the code until pairing succeeds, and explicitly explains automatic saving independently of the main Settings **Save** button. Connection failures remain visible during retries.
|
||||
|
||||
Validated with 227 LAN input checks, 43 discovery/pairing checks and 863 UI/settings checks. Fallback tests cover unavailable TCP directions, encrypted UDP, loss, reordering, duplicate/replay protection, tampering and reconnects. The new fallback implementation has not yet been tested between two physical PCs.
|
||||
|
||||
## 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 @@
|
||||
f3ec4f7a62b4479929d94d6d6f7f68edb8b4a47b84edac7258afb64dbe07aef2 KSwitcher-win-x64.zip
|
||||
194b7c071a4a97d60e055342d49e046ad874c0636be5b1f620a0b77a34968100 KSwitcher-win-x64.zip
|
||||
+35
-35
@@ -1,86 +1,86 @@
|
||||
{
|
||||
"version": "0.6.57",
|
||||
"schema": 1,
|
||||
"files": [
|
||||
{
|
||||
"size": 2106368,
|
||||
"sha256": "a7be2f749082bb6417b4cf7e8c8ea221ef4f7ba63dabc3cbbe8694ab1a3d942c",
|
||||
"path": "KSwitcher.exe",
|
||||
"sha256": "3b7315e1545f70739fd1a7c099ddeab28362b323521d93879fa43fdd76133f8e"
|
||||
"size": 2108416
|
||||
},
|
||||
{
|
||||
"size": 2003456,
|
||||
"sha256": "c8a88364c9967ce5fd150519d632b14460e225a0cfae51a8de50f39782d8010c",
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"sha256": "a819ec42030990725326730396680388cbcb870dd00c0a97856cb1615f9db27b"
|
||||
"size": 2005504
|
||||
},
|
||||
{
|
||||
"size": 3151360,
|
||||
"sha256": "68eed5abefed0208ef7bdd431094c7357c53bd3ca03dfd91aaeb7dbb4f291e06",
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"sha256": "8473697245c2c961496135eaaff6de9e9a19e917f2fd7299a16068210223d396"
|
||||
"size": 3153408
|
||||
},
|
||||
{
|
||||
"size": 4401,
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
||||
"path": "LICENSE",
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||
"size": 4401
|
||||
},
|
||||
{
|
||||
"size": 1096,
|
||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
|
||||
"path": "json-LICENSE.txt",
|
||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
|
||||
"size": 1096
|
||||
},
|
||||
{
|
||||
"size": 79896,
|
||||
"sha256": "87f396a6bf988725b14bf5b6eb7c2b60ce33ff51828290ce40e136c66942cc09",
|
||||
"path": "README.md",
|
||||
"sha256": "8b4b1e905a2f0a39d532e61136ec7eca473492a39a538eb073ed829307adefae"
|
||||
"size": 80168
|
||||
},
|
||||
{
|
||||
"size": 7921,
|
||||
"sha256": "72e131bdf46d32a8a17fb352292bc8983c0e5a7ed84812218b11cf6b69c9afc4",
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"sha256": "2467b86bbad7d7dce1e0c65c8c0faf2320e4804e90a85784a058afa357127b6b"
|
||||
"size": 8376
|
||||
},
|
||||
{
|
||||
"size": 732485,
|
||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
|
||||
"path": "dictionaries/en.txt",
|
||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9"
|
||||
"size": 732485
|
||||
},
|
||||
{
|
||||
"size": 20566,
|
||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5"
|
||||
"size": 20566
|
||||
},
|
||||
{
|
||||
"size": 2265,
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
||||
"path": "dictionaries/README.md",
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311"
|
||||
"size": 2265
|
||||
},
|
||||
{
|
||||
"size": 1109134,
|
||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
|
||||
"path": "dictionaries/ru.txt",
|
||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849"
|
||||
"size": 1109134
|
||||
},
|
||||
{
|
||||
"size": 1280,
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
||||
"path": "dictionaries/sources.json",
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408"
|
||||
"size": 1280
|
||||
},
|
||||
{
|
||||
"size": 940799,
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
||||
"path": "dictionaries/uk.txt",
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf"
|
||||
"size": 940799
|
||||
},
|
||||
{
|
||||
"size": 993,
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
||||
"path": "tools/README.txt",
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||
"size": 993
|
||||
},
|
||||
{
|
||||
"size": 134656,
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
|
||||
"path": "tools/StayHere.exe",
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||
"size": 134656
|
||||
},
|
||||
{
|
||||
"size": 2796,
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
||||
"path": "tools/StayHere-README.md",
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
"size": 2796
|
||||
}
|
||||
]
|
||||
],
|
||||
"schema": 1,
|
||||
"version": "0.6.58"
|
||||
}
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,57,0
|
||||
PRODUCTVERSION 0,6,57,0
|
||||
FILEVERSION 0,6,63,0
|
||||
PRODUCTVERSION 0,6,63,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.57"
|
||||
VALUE "FileVersion", "0.6.63"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+23
-8
@@ -986,21 +986,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 +1058,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)
|
||||
@@ -1314,7 +1314,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";
|
||||
@@ -2294,6 +2294,20 @@ 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 == 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 +2466,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())
|
||||
|
||||
+16
-2
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,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 +53,11 @@ 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;
|
||||
};
|
||||
struct Settings
|
||||
{
|
||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||
@@ -81,6 +87,7 @@ 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::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
std::vector<Snippet> snippets;
|
||||
std::vector<std::wstring> snippetTombstones;
|
||||
|
||||
+29
-6
@@ -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;
|
||||
@@ -242,8 +247,8 @@ struct LanService::Impl
|
||||
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||
});
|
||||
clipboardActive=clipboard->active();
|
||||
if(shareInput)for(auto &[peer,entry]:trusted.items())if(peers.contains(peer))
|
||||
endpoints.push_back({peer,entry.at("key"),peers.at(peer).address.sin_addr.s_addr});
|
||||
if(shareInput)for(auto &[peer,entry]:trusted.items())
|
||||
endpoints.push_back({peer,entry.at("key"),peers.contains(peer)?peers.at(peer).address.sin_addr.s_addr:0});
|
||||
if(now>=nextHello)
|
||||
{
|
||||
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
|
||||
@@ -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));
|
||||
|
||||
+73
-20
@@ -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,38 +202,62 @@ 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{}; };
|
||||
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&&e.address==it->second.endpoint.address;});
|
||||
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));
|
||||
}
|
||||
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
|
||||
// Discovery is a hint, not the lifetime of authenticated input sessions.
|
||||
// Keep the last usable address across missed announcements. A new address
|
||||
// is used only once a channel is down, never to tear down working input.
|
||||
for(auto &e:endpoints){if(e.id==local)continue;auto &link=links.at(e.id);
|
||||
if(e.address&&!link.channel->ready()&&e.address!=link.endpoint.address){
|
||||
link.channel->disconnect();link.endpoint.address=e.address;
|
||||
}
|
||||
}
|
||||
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);
|
||||
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){
|
||||
closeSocket(listener);retry=now+1500;error="TCP input port is unavailable.";
|
||||
}else error.clear();
|
||||
auto detail="TCP input port "+std::to_string(port)+" is unavailable (Windows error "+std::to_string(WSAGetLastError())+").";
|
||||
if(error!=detail)log(L"LAN input: "+wide(detail));
|
||||
closeSocket(listener);retry=now+1500;error=std::move(detail);
|
||||
}else {error.clear();log(L"LAN input listener ready on TCP "+std::to_wstring(port));}
|
||||
}
|
||||
if(listener!=INVALID_SOCKET)for(int n=0;n<16&&pending.size()<64;++n){
|
||||
sockaddr_in a{};int size=sizeof(a);SOCKET sock=accept(listener,(sockaddr*)&a,&size);if(sock==INVALID_SOCKET)break;
|
||||
@@ -243,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.endpoint.address==it->address&&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 &){}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +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);
|
||||
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});
|
||||
}
|
||||
@@ -260,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
+50
-3
@@ -58,9 +58,35 @@ 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
|
||||
void validateSettings(const Settings &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 +164,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 +289,19 @@ 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("chatCycles"))
|
||||
{
|
||||
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
|
||||
@@ -336,6 +374,15 @@ 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["chatCycles"] = Json::object();
|
||||
for (const auto &[key, cycle] : s.chatCycles)
|
||||
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
|
||||
|
||||
+15
-7
@@ -39,7 +39,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 +50,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 +66,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 +105,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 +226,7 @@ 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"}) j.erase(key);
|
||||
return bytes(canonicalProfile(j));
|
||||
}
|
||||
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
||||
@@ -306,9 +313,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)
|
||||
{
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
+48
-21
@@ -1045,7 +1045,7 @@ struct SettingsView
|
||||
int selection = -1;
|
||||
bool filling{};
|
||||
std::vector<std::vector<HWND>> pages;
|
||||
bool ready{}, refreshing{};
|
||||
bool ready{}, refreshing{}, visitedLan{};
|
||||
};
|
||||
void updateSettingsState(HWND w, SettingsView *v);
|
||||
void selectSettingsPage(HWND w, SettingsView *v)
|
||||
@@ -1060,12 +1060,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 +1090,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 +1102,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 +1159,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 +1173,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 +1189,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
showRequestedWindow(w);
|
||||
SetFocus(GetDlgItem(w, 710));
|
||||
}
|
||||
#include "ui_app_languages.inl"
|
||||
void collectLayout(HWND w, SettingsView *v)
|
||||
{
|
||||
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
||||
@@ -1625,7 +1646,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 +1710,10 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
switch (msg)
|
||||
{
|
||||
case ConfigSettingsDownloaded:
|
||||
v->draft = v->app->settings;
|
||||
fillSettings(w, v);
|
||||
return 0;
|
||||
case WM_ENABLE:
|
||||
if (wp) updateSettingsState(w, v);
|
||||
break;
|
||||
@@ -1718,7 +1744,8 @@ 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 (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
|
||||
if (id == 230)
|
||||
{
|
||||
@@ -2149,7 +2176,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);
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// 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, AppLanguagesView &v, HWND target)
|
||||
{
|
||||
RECT bounds{};
|
||||
if (!target || !GetWindowRect(target, &bounds) || IsRectEmpty(&bounds))
|
||||
{
|
||||
if (v.pickerOutline) ShowWindow(v.pickerOutline, SW_HIDE);
|
||||
return;
|
||||
}
|
||||
if (!v.pickerOutline)
|
||||
{
|
||||
WNDCLASSW windowClass{};
|
||||
windowClass.lpfnWndProc = DefWindowProcW;
|
||||
windowClass.hInstance = GetModuleHandleW(nullptr);
|
||||
windowClass.hbrBackground = GetSysColorBrush(COLOR_HIGHLIGHT);
|
||||
windowClass.lpszClassName = L"KSwitcher.AppPickerOutline";
|
||||
RegisterClassW(&windowClass);
|
||||
v.pickerOutline = 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 (!v.pickerOutline) return;
|
||||
if (!SetLayeredWindowAttributes(v.pickerOutline, 0, 255, LWA_ALPHA))
|
||||
{
|
||||
DestroyWindow(v.pickerOutline);
|
||||
v.pickerOutline = nullptr;
|
||||
return;
|
||||
}
|
||||
}
|
||||
RECT previous{};
|
||||
if (IsWindowVisible(v.pickerOutline) && GetWindowRect(v.pickerOutline, &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(v.pickerOutline, frame, TRUE))
|
||||
{
|
||||
DeleteObject(frame);
|
||||
return;
|
||||
}
|
||||
SetWindowPos(v.pickerOutline, 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)
|
||||
{
|
||||
HWND dialog = GetParent(button);
|
||||
auto *view = (AppLanguagesView *)GetWindowLongPtrW(dialog, GWLP_USERDATA);
|
||||
if (msg == WM_LBUTTONDOWN)
|
||||
{
|
||||
SetFocus(button);
|
||||
SetCapture(button);
|
||||
if (GetCapture() == button)
|
||||
{
|
||||
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
|
||||
SetDlgItemTextW(dialog, 721, L"Drag to the application's window and release. Esc cancels.");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_CAPTURECHANGED || msg == WM_NCDESTROY)
|
||||
{
|
||||
if (view && view->pickerOutline)
|
||||
{
|
||||
DestroyWindow(view->pickerOutline);
|
||||
view->pickerOutline = 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 && view) updatePickerOutline(dialog, *view, 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);
|
||||
}
|
||||
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) { 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);
|
||||
button(w, L"⊕ Drag to window", 728, 132, 10, 164);
|
||||
SetWindowSubclass(GetDlgItem(w, 728), appWindowPickerProc, 1, 0);
|
||||
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));
|
||||
}
|
||||
+51
-8
@@ -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);
|
||||
@@ -36,7 +56,13 @@ void refreshLan(HWND w,LanView &v)
|
||||
for(const auto &peer:next["peers"])
|
||||
{
|
||||
auto id=peer.at("id").get<std::string>();ids.push_back(id);
|
||||
auto state=peer.at("paired").get<bool>() ? L"Paired" : L"Not paired";
|
||||
std::wstring state=L"Not paired";
|
||||
if(peer.at("paired").get<bool>()){
|
||||
bool connected=next.value("inputReady",nlohmann::json::object()).contains(id);
|
||||
state=connected?L"Pair saved — input connected":
|
||||
!next.value("shareInput",false)?L"Pair saved — input disabled here":
|
||||
!peer.value("online",false)?L"Pair saved — computer offline":L"Pair saved — input not connected";
|
||||
}
|
||||
rows.push_back(wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state);
|
||||
}
|
||||
// Presence/challenge refreshes must not repaint or reset the pairing list.
|
||||
@@ -54,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);
|
||||
@@ -83,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)
|
||||
@@ -102,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)
|
||||
{
|
||||
@@ -109,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)
|
||||
@@ -125,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);
|
||||
@@ -148,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);}
|
||||
|
||||
+9
-4
@@ -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
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.57";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.63";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+133
-11
@@ -7,6 +7,15 @@
|
||||
|
||||
namespace
|
||||
{
|
||||
HWND findTestWindow(LPCWSTR className, LPCWSTR title)
|
||||
{
|
||||
HWND window=nullptr;
|
||||
while((window=FindWindowExW(nullptr,window,className,title))) {
|
||||
DWORD process{}; GetWindowThreadProcessId(window,&process);
|
||||
if(process==GetCurrentProcessId()) return window;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
int passed{};
|
||||
void require(bool ok, const char *message)
|
||||
{
|
||||
@@ -141,31 +150,31 @@ int main()
|
||||
SetDlgItemTextW(parent,240,originalLimit);
|
||||
require(!saveEnabled(),"restoring valid original text clears dirty state");
|
||||
SendMessageW(parent,WM_COMMAND,257,0);
|
||||
HWND ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||
HWND ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||
require(ocr!=nullptr,"OCR preferences open");
|
||||
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
|
||||
SetDlgItemTextW(ocr,503,L"eng");
|
||||
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
|
||||
require(!saveEnabled(),"cancelled child changes leave Settings clean");
|
||||
SendMessageW(parent,WM_COMMAND,257,0);
|
||||
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||
SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng");
|
||||
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
|
||||
SendMessageW(parent,WM_COMMAND,257,0);
|
||||
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||
SetDlgItemTextW(ocr,503,languages);
|
||||
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
|
||||
SendMessageW(parent,WM_COMMAND,255,0);
|
||||
HWND automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
HWND automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
|
||||
require(automatic!=nullptr,"automatic correction preferences open");
|
||||
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
|
||||
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
|
||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
|
||||
SendMessageW(parent,WM_COMMAND,255,0);
|
||||
automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
|
||||
CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED);
|
||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||
require(!saveEnabled(),"reverting auto-correction draft disables Save");
|
||||
@@ -201,7 +210,7 @@ int main()
|
||||
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
||||
require(!saveEnabled(),"changing tabs leaves Settings clean");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
HWND dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||
HWND dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
||||
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
require(index >= 0, "saved chat listed");
|
||||
@@ -216,16 +225,18 @@ int main()
|
||||
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
|
||||
ks::encodePng(ks::decodeImage(clip)));
|
||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
|
||||
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
||||
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
||||
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
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");
|
||||
@@ -236,11 +247,13 @@ int main()
|
||||
parent = app.settingsWindow;
|
||||
require(!saveEnabled(),"reopened saved Settings is clean");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
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);
|
||||
@@ -249,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");
|
||||
@@ -286,7 +390,7 @@ int main()
|
||||
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
||||
ks::showSettings(app);parent=app.settingsWindow;
|
||||
SendMessageW(parent,WM_COMMAND,262,0);
|
||||
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
||||
HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
|
||||
require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
|
||||
@@ -298,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);
|
||||
}
|
||||
{
|
||||
@@ -312,8 +432,10 @@ int main()
|
||||
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
|
||||
ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp);
|
||||
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0);
|
||||
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
||||
HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
|
||||
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
|
||||
wchar_t pairState[512]{};SendDlgItemMessageW(lan,702,LB_GETTEXT,0,(LPARAM)pairState);
|
||||
require(std::wstring(pairState).find(L"Pair saved — input disabled here")!=std::wstring::npos,"saved pairing does not imply an active input connection");
|
||||
require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls");
|
||||
SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0);
|
||||
SendMessageW(lan,WM_TIMER,1,0);
|
||||
@@ -346,7 +468,7 @@ int main()
|
||||
{"positions",{{b,J::array({-1,0})},{c,J::array({1,0})}}}};
|
||||
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
|
||||
ks::App layoutApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(layoutApp);
|
||||
SendMessageW(layoutApp.settingsWindow,WM_COMMAND,262,0);auto lan=FindWindowW(L"KSwitcher.LAN",nullptr);auto grid=GetDlgItem(lan,721);
|
||||
SendMessageW(layoutApp.settingsWindow,WM_COMMAND,262,0);auto lan=findTestWindow(L"KSwitcher.LAN",nullptr);auto grid=GetDlgItem(lan,721);
|
||||
RECT r{};GetClientRect(grid,&r);
|
||||
auto point=[&](int column,int row){return MAKELPARAM((2*column+1)*r.right/10,(2*row+1)*r.bottom/6);};
|
||||
SendMessageW(grid,WM_LBUTTONDOWN,MK_LBUTTON,point(1,1));SendMessageW(grid,WM_MOUSEMOVE,MK_LBUTTON,point(1,0));SendMessageW(grid,WM_LBUTTONUP,0,point(1,0));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "app.h"
|
||||
#include "configuration.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
@@ -752,6 +753,21 @@ 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 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);
|
||||
|
||||
+96
-1
@@ -1,5 +1,6 @@
|
||||
#include <winsock2.h>
|
||||
#include "lan_input.h"
|
||||
#include "lan_datagram.h"
|
||||
#include "lan_mesh.h"
|
||||
#include <iostream>
|
||||
#include <deque>
|
||||
@@ -317,6 +318,11 @@ void hubChecks()
|
||||
auto deadline=GetTickCount64()+5000;
|
||||
while((hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2||hubs[2]->ready().size()!=2)&&GetTickCount64()<deadline)pump();
|
||||
check(hubs[0]->ready().size()==2&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"three TCP hubs authenticate all independent pair channels on one listener per PC");
|
||||
auto generation=hubs[0]->ready();
|
||||
for(auto &list:endpoints)for(auto &e:list)e.address=0;
|
||||
for(int n=0;n<20;++n)pump();
|
||||
check(hubs[0]->ready()==generation&&hubs[1]->ready().size()==2&&hubs[2]->ready().size()==2,"expired discovery does not destroy authenticated channels");
|
||||
for(auto &list:endpoints)for(auto &e:list)e.address=htonl(INADDR_LOOPBACK);
|
||||
InputFrame keyFrame{InputType::Key,7,'A',0,1},cross{InputType::Cross,8,2,30000};
|
||||
hubs[0]->poll(endpoints[0],{{ids[1],keyFrame},{ids[2],cross}},incoming[0],GetTickCount64());
|
||||
for(int n=0;n<100&&(incoming[1].empty()||incoming[2].empty());++n)pump();
|
||||
@@ -327,9 +333,94 @@ void hubChecks()
|
||||
check(hubs[0]->ready().contains(ids[2])&&hubs[0]->ready().at(ids[2])==survivor,"one computer disconnect leaves other authenticated channel intact");
|
||||
for(int n=0;n<300&&(hubs[0]->ready().size()!=2||hubs[1]->ready().size()!=2);++n)pump(1800);
|
||||
check(hubs[0]->ready().size()==2&&hubs[0]->ready().at(ids[1])!=old,"mesh reconnect restores channel with fresh ownership generation");
|
||||
{
|
||||
auto serverPort=freePort();InputHub server(ids[2],serverPort),client(ids[0],freePort());
|
||||
std::vector<InputEndpoint> unknown{{ids[0],secret,0}},known{{ids[2],secret,htonl(INADDR_LOOPBACK),serverPort}};
|
||||
std::vector<RoutedInput> a,b;
|
||||
for(int n=0;n<300&&(server.ready().empty()||client.ready().empty());++n){
|
||||
server.poll(unknown,{},a,GetTickCount64());client.poll(known,{},b,GetTickCount64());Sleep(1);
|
||||
}
|
||||
check(server.ready().size()==1&&client.ready().size()==1,"saved pair listens and authenticates without receiving a discovery announcement");
|
||||
}
|
||||
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);
|
||||
@@ -369,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);
|
||||
@@ -412,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;}
|
||||
}
|
||||
+19
-1
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user