Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f33012a3 | ||
|
|
5a2106899d | ||
|
|
3761b790ae | ||
|
|
a0e1de40ef | ||
|
|
5d96abb83e | ||
|
|
011664cc53 | ||
|
|
4d8d8d47b9 | ||
|
|
d0387904a0 | ||
|
|
5de010ee44 |
No files matched your search
+19
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.56 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.64 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
@@ -178,3 +178,21 @@ install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
|||||||
|
|
||||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||||
install(DIRECTORY dictionaries DESTINATION .)
|
install(DIRECTORY dictionaries DESTINATION .)
|
||||||
|
|
||||||
|
add_executable(KSwitcherLanClipboardTests tests/lan_clipboard.cpp)
|
||||||
|
target_link_libraries(KSwitcherLanClipboardTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherLanClipboardTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherLanClipboardTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME lan-clipboard-regressions COMMAND KSwitcherLanClipboardTests)
|
||||||
|
|
||||||
|
add_executable(KSwitcherSettingsMergeTests tests/settings_merge.cpp)
|
||||||
|
target_link_libraries(KSwitcherSettingsMergeTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherSettingsMergeTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherSettingsMergeTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME settings-save-merge COMMAND KSwitcherSettingsMergeTests)
|
||||||
+57
-21
@@ -1,41 +1,46 @@
|
|||||||
# LAN mouse and keyboard — v0.6.56 (experimental)
|
# LAN mouse, keyboard and clipboard — v0.6.58 (experimental)
|
||||||
|
|
||||||
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
|
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
|
||||||
code on both computers, and shares mouse and keyboard through a screen edge.
|
code on both computers, and shares mouse and keyboard through a screen edge.
|
||||||
The module runs inside the resident process. This experimental feature supports **one active
|
The module runs inside the resident process. Multiple paired computers can be
|
||||||
pair of computers**, with the neighbour on the left, right, above or below.
|
arranged on a monitor grid. Mouse and keyboard follow neighboring cells, including
|
||||||
Several trusted pairs may be saved, but only the selected neighbour is active.
|
A → B → C, without relaying input through B. New text and images are also shared
|
||||||
Clipboard and file transfer are not implemented yet.
|
with all online paired computers: history and the active clipboard. File transfer
|
||||||
|
is not implemented by clipboard synchronization.
|
||||||
|
|
||||||
## Set up two computers
|
## Set up multiple computers
|
||||||
|
|
||||||
1. Update both PCs to v0.6.56 or newer using Update in the tray menu. 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.
|
2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs.
|
||||||
Only running LAN-enabled KSwitcher instances appear, not generic network devices.
|
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.
|
3. Match the computer name and instance-ID prefix displayed on the other PC.
|
||||||
Select each other, generate one code, enter that same code on both, then click
|
Select each other, generate one code, enter that same code on both, then click
|
||||||
Pair on both within two minutes. Spaces, hyphens and uppercase hex are accepted.
|
Pair on both within two minutes. Spaces, hyphens and uppercase hex are accepted.
|
||||||
4. Select the paired neighbour and its position relative to this computer.
|
4. Pair each physical input source with every destination it must control. For
|
||||||
Example: B stands to the right of A, so choose **Right on A** and **Left on B**.
|
unrestricted control from any PC, pair every pair of PCs. Drag monitors into
|
||||||
For a vertical arrangement choose **Above** on the lower PC and **Below** on
|
neighboring grid cells and arrange the same physical layout relative to each
|
||||||
the upper PC. Click **Apply position / enable input** on both.
|
local PC. For A → B → C, A sees B at (1,0), C at (2,0); B sees A at (-1,0),
|
||||||
|
C at (1,0). Enable input sharing on all PCs. The button controls all pairs,
|
||||||
|
independently of the selected list item. Positions save immediately.
|
||||||
5. Stop MWB or another mouse/keyboard router on both computers before trying
|
5. Stop MWB or another mouse/keyboard router on both computers before trying
|
||||||
KSwitcher input sharing, to avoid two tools forwarding the same events.
|
KSwitcher input sharing, to avoid two tools forwarding the same events.
|
||||||
6. Wait for the authenticated input connection. Move the cursor through the
|
6. Wait for the authenticated input connection. Move the cursor through the
|
||||||
configured outer screen edge with no keys or buttons held. After the other
|
configured outer screen edge with no keys or buttons held. After the other
|
||||||
computer acknowledges the handoff, both mouse and keyboard control that PC.
|
computer acknowledges the handoff, both mouse and keyboard control that PC.
|
||||||
Crossing its return edge returns control to the source computer.
|
Crossing another shared edge switches directly to that neighbor. Crossing back
|
||||||
|
into the source computer restores its cursor at the corresponding edge. Empty
|
||||||
|
cells, diagonal-only neighbors and offline PCs do not transfer control.
|
||||||
|
|
||||||
The source cursor is parked and hidden beneath a tiny no-activation window. A
|
The source cursor is parked and hidden beneath a tiny no-activation window. A
|
||||||
small banner identifies remote control; no full-screen layered bitmap is retained.
|
small banner identifies remote control; no full-screen layered bitmap is retained.
|
||||||
The destination uses its own keyboard layout and runs its own hotkeys/snippets.
|
The destination uses its own keyboard layout and runs its own hotkeys/snippets.
|
||||||
The source suppresses forwarded events before its local hotkeys, snippets and
|
The source suppresses forwarded events before its local hotkeys, snippets and
|
||||||
other low-level hook consumers. Received input is marked so it is not forwarded
|
other low-level hook consumers. Received input is marked so it is not forwarded
|
||||||
again. Source recent-input tracking resets when ownership changes.
|
again. Source recent-input tracking and shortcut latches reset when ownership changes.
|
||||||
|
|
||||||
**Ctrl+Alt+Esc** is emergency return, not the normal switching mechanism. Local
|
**Ctrl+Alt+Esc** is emergency return, not the normal switching mechanism. Local
|
||||||
physical input on a remotely controlled PC takes control back as well. Stop input
|
physical input on a remotely controlled PC takes control back as well. Stop input
|
||||||
sharing, disabling discovery, forgetting the active pair, suspension, secure
|
sharing, disabling discovery, forgetting the currently controlled pair, suspension, secure
|
||||||
desktop changes and connection loss cancel remote ownership. TCP heartbeat loss
|
desktop changes and connection loss cancel remote ownership. TCP heartbeat loss
|
||||||
is detected after two seconds; a handoff request expires after one second.
|
is detected after two seconds; a handoff request expires after one second.
|
||||||
Held remote keys/buttons are released. Releases blocked by a secure desktop are
|
Held remote keys/buttons are released. Releases blocked by a secure desktop are
|
||||||
@@ -44,9 +49,12 @@ sent remotely are consumed after return, avoiding stray local modifier events.
|
|||||||
|
|
||||||
## Layout and limitations
|
## Layout and limitations
|
||||||
|
|
||||||
- Positions are relative to this PC and must be opposite on the two computers.
|
- Positions are relative to this PC. All peers have independent authenticated
|
||||||
Inconsistent positions are rejected before input is authorized. Changes apply
|
connections; a failed peer does not disconnect the others. The physical source
|
||||||
immediately and reconnect the input channel.
|
uses its own grid to choose every destination. Moving a monitor cancels current
|
||||||
|
control safely and applies the updated layout without dropping all connections.
|
||||||
|
Layouts are not synchronized automatically. One source controls a destination
|
||||||
|
at a time. Each grid tile represents an entire PC, not an individual monitor.
|
||||||
- Geometry uses the virtual desktop's outer bounding rectangle, including negative
|
- Geometry uses the virtual desktop's outer bounding rectangle, including negative
|
||||||
monitor coordinates. Internal monitor boundaries do not transfer control.
|
monitor coordinates. Internal monitor boundaries do not transfer control.
|
||||||
Different resolutions map the entry position proportionally. Irregular layouts
|
Different resolutions map the entry position proportionally. Irregular layouts
|
||||||
@@ -68,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.
|
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.
|
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
|
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
|
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
|
mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never
|
||||||
@@ -75,7 +103,8 @@ transmitted or logged. The pair key is separate from the setup code.
|
|||||||
|
|
||||||
The input channel authenticates both peers with the saved pair key and fresh
|
The input channel authenticates both peers with the saved pair key and fresh
|
||||||
session challenges. Directional AES-256-GCM session keys bind the identities,
|
session challenges. Directional AES-256-GCM session keys bind the identities,
|
||||||
nonces and screen positions. Fixed-size authenticated frames use strict sequence
|
nonces and the input protocol domain. Screen positions determine routing after
|
||||||
|
authentication; they are no longer part of the pair channel key. Fixed-size authenticated frames use strict sequence
|
||||||
numbers, so tampering, reflection and replay are rejected. Input is not dispatched
|
numbers, so tampering, reflection and replay are rejected. Input is not dispatched
|
||||||
until authentication succeeds. This PSK channel does not provide forward secrecy.
|
until authentication succeeds. This PSK channel does not provide forward secrecy.
|
||||||
Queues and frame sizes are bounded; overflow disconnects rather than dropping a
|
Queues and frame sizes are bounded; overflow disconnects rather than dropping a
|
||||||
@@ -98,9 +127,16 @@ Tests cover pairing, encrypted frames, replay/tamper rejection, actual loopback
|
|||||||
TCP authentication and bidirectional delivery, all four edges, ownership races,
|
TCP authentication and bidirectional delivery, all four edges, ownership races,
|
||||||
source suppression, scan codes, clicks/wheel, emergency return, held-key release,
|
source suppression, scan codes, clicks/wheel, emergency return, held-key release,
|
||||||
blocked injection, secure desktop recovery, exact one-pixel movement and timeout.
|
blocked injection, secure desktop recovery, exact one-pixel movement and timeout.
|
||||||
UI tests check all four positions and preserve an edited choice during refresh.
|
Tests additionally exercise three actual TCP hubs, independent reconnect,
|
||||||
|
A → B → C and back, vertical turns, unavailable neighbors, stale ownership,
|
||||||
|
modifier delivery to only the active destination, and local shortcut recovery.
|
||||||
|
UI tests drag three monitors and check persistence.
|
||||||
|
|
||||||
Two physical computers still need an end-to-end trial for their adapters,
|
Three physical computers still need an end-to-end trial for their adapters,
|
||||||
firewalls, displays and application privilege levels. The automated TCP tests
|
firewalls, displays and application privilege levels. The automated TCP tests
|
||||||
use local sockets, and input-state tests use simulated desktop environments;
|
use local sockets, and input-state tests use simulated desktop environments;
|
||||||
they do not claim a completed two-machine hardware test.
|
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,21 +10,85 @@ 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.
|
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||||
|
|
||||||
## KSwitcher computers — v0.6.56 (experimental)
|
## KSwitcher computers — v0.6.58 (experimental)
|
||||||
|
|
||||||
KSwitcher includes opt-in LAN discovery, same-code pairing and
|
TCP input listeners and authenticated connections survive missed discovery announcements.
|
||||||
mouse/keyboard sharing for one active pair. Place the neighbour left, right,
|
The list distinguishes saved pairing from connected input and refreshes every second.
|
||||||
above or below; moving through that outer screen edge transfers both mouse and
|
Keep each PC’s own data folder during updates; a different instance ID requires new pairing.
|
||||||
keyboard. Ctrl+Alt+Esc returns control in an emergency. See
|
|
||||||
[LAN-PREVIEW.md](LAN-PREVIEW.md) for setup, encryption and validation limits.
|
|
||||||
Clipboard and file sharing are still pending.
|
|
||||||
|
|
||||||
**Українською.** У v0.6.56 є пошук KSwitcher у мережі, спарювання однаковим
|
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
|
||||||
перемикаються разом через відповідний край екрана. Ctrl+Alt+Esc аварійно повертає
|
sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md).
|
||||||
керування. Передача буфера та файлів ще не реалізована; перевірка на двох
|
|
||||||
фізичних ПК ще потрібна.
|
**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.
|
||||||
|
|
||||||
|
**Українською.** Розставте кілька спарованих ПК по сітці: миша й клавіатура
|
||||||
|
переходять через спільний край, зокрема A → B → C і назад. Нові текст та зображення
|
||||||
|
передаються всім доступним спарованим ПК в історію й активний буфер. Фільтр
|
||||||
|
Computers в історії має чекбокси. Оновіть усі ПК до v0.6.58, залишивши кожному
|
||||||
|
власну папку data. Спаруйте джерело вводу з усіма потрібними ПК й задайте
|
||||||
|
однакове фізичне розташування на кожному. Передача файлів не реалізована.
|
||||||
|
|
||||||
|
Saving Settings now merges independent resident/editor changes. If the same field
|
||||||
|
changed on both sides, choose whether to retain your draft without closing the
|
||||||
|
window. Declining preserves all draft edits. No export/import workaround is needed.
|
||||||
|
|
||||||
|
## Mouse buttons for applications
|
||||||
|
|
||||||
|
In **Settings → Mouse**, put the pointer over **Test mouse buttons here** and
|
||||||
|
press the buttons you want to test. The page shows the last detected button and
|
||||||
|
counts presses separately for ordinary window events and Windows Raw Input.
|
||||||
|
|
||||||
|
Choose **Global** to set up fallback actions. Use **+** beside **Profiles** to create
|
||||||
|
a named profile and set its applications. Double-click a profile or choose
|
||||||
|
**Edit…** in its context menu to change its name or applications. The
|
||||||
|
**Applications** column shows the scope of each profile.
|
||||||
|
Use the checkbox in each profile row to enable or disable its actions.
|
||||||
|
Double-click a mouse event, or right-click it and choose
|
||||||
|
**Edit…**, then pick its action and click **Apply** and **Save**. **Reset to default**
|
||||||
|
restores the global action for an application or standard behavior for Global.
|
||||||
|
Drag the crosshair in the profile editor onto an open window to add its
|
||||||
|
application. The folder icon accepts an executable or a Windows shortcut
|
||||||
|
to one. You can also type an executable name and click **+**.
|
||||||
|
An empty application list makes the profile's actions fallbacks. A scoped
|
||||||
|
profile's actions take precedence when one of its applications is active.
|
||||||
|
For example, set the side buttons to volume control in **Global** and to browser
|
||||||
|
navigation in a named profile with Chrome in its application list.
|
||||||
|
Volume actions show the current system level at the lower right of the
|
||||||
|
monitor under the pointer. The indicator stays visible during a hold and hides
|
||||||
|
one second after release.
|
||||||
|
For **Volume up** and **Volume down**, set **Step (%)** next to **Action**.
|
||||||
|
New values range from 1 to 50 percentage points.
|
||||||
|
The default is four percentage points. Hold an assigned button to repeat the action
|
||||||
|
at increasing speed. If the button also has a double-click action, a single
|
||||||
|
click waits for the double-click interval before adjusting the volume.
|
||||||
|
Enabled profiles cannot assign the same mouse event to the same application,
|
||||||
|
or both assign an unrestricted fallback to the same event.
|
||||||
|
An application can inherit a global action,
|
||||||
|
override it, or use standard mouse behavior. Available actions include
|
||||||
|
browser navigation, system volume, launching an executable, and sending a
|
||||||
|
key combination. For **Launch application**, enter a name Windows can find
|
||||||
|
(such as `calc.exe`) or select an executable with **Browse**.
|
||||||
|
|
||||||
|
The pointer selects the application profile and the target for browser
|
||||||
|
navigation. **Send key combination** works when the window under the pointer
|
||||||
|
is active. To record a combination, click the field and press the keys.
|
||||||
|
For example, press `Ctrl+W` to bind closing the active browser tab or
|
||||||
|
`Ctrl+V` to bind pasting in File Explorer. An inactive window receives
|
||||||
|
the standard mouse action.
|
||||||
|
Left and right
|
||||||
|
bindings apply only over the active window, so you can click another window
|
||||||
|
to switch applications. When both clicks have custom actions, a single click
|
||||||
|
waits for the Windows double-click interval. If only the double-click has an
|
||||||
|
action, the first click keeps its standard behavior.
|
||||||
|
|
||||||
|
Bindings apply to all mice on this PC. Windows exposes five
|
||||||
|
standard buttons. Hardware controls such as a wheel-mode or device switch
|
||||||
|
require a driver or device integration if they do not emit a standard click.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -36,6 +100,12 @@ Closing Settings with unsaved changes (including accepted auto-correction or OCR
|
|||||||
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
|
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.
|
If saving fails, the window stays open with your edits intact.
|
||||||
Settings displays **No unsaved changes** with **Save** disabled until an edit is made.
|
Settings displays **No unsaved 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
|
**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
|
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
|
the original values clears its pending changes. Snippets saved in their own window
|
||||||
@@ -579,8 +649,8 @@ Nonconflicting changes synchronize immediately, and unresolved conflicts survive
|
|||||||
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
- **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.
|
- **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.
|
- 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;
|
- 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 existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
- 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
|
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
|
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
|
||||||
@@ -712,9 +782,11 @@ default and require the main **Automatic correction (experimental)** checkbox to
|
|||||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||||
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
||||||
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
|
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
|
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.
|
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
|
- **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
|
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
|
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||||
@@ -888,3 +960,57 @@ History opens in a separate, short-lived instance of **KSwitcher.exe**. **Esc**
|
|||||||
Image thumbnails are decoded in **KSwitcher.Screenshot.exe**, returning only bounded preview pixels. Image clipboard backup/restore also uses that helper and encrypted temporary files, with clipboard-sequence checks before restoring. Selection reads avoid loading image formats. Keep all three executables together; no fourth executable is required. Paste allows a brief foreground handoff after history closes while preserving cancellation checks. UI Automation remains in the resident.
|
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.
|
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.
+9
-15
@@ -1,23 +1,17 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.56 — 2026-11-06
|
## v0.6.64 — 2026-11-12
|
||||||
|
|
||||||
## Changes
|
The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut.
|
||||||
- Experimental, opt-in LAN mouse and keyboard sharing inside the existing resident process; no extra service.
|
|
||||||
- Discover running KSwitcher instances and pair them using the same generated code on both PCs. Trust is DPAPI-protected locally, outside settings sync/export.
|
|
||||||
- Arrange the active neighbour left, right, above or below. Select opposite sides on both PCs; mouse and keyboard transfer together through that screen edge.
|
|
||||||
- Authenticated AES-256-GCM input channel; Ctrl+Alt+Esc emergency return, disconnect recovery and held-key release.
|
|
||||||
- One active pair. Clipboard/files and remote secure desktops are not supported. Stop MWB before using input sharing. Disabled by default.
|
|
||||||
|
|
||||||
## Validation
|
Actions include keyboard combinations, browser navigation, launching applications, and system volume. Volume actions have a configurable step and an on-screen indicator. When Windows reports separate press and release events, holding the button repeats the volume change at increasing speed.
|
||||||
124 input/channel, 41 pairing/configuration/network and 813 settings/chat-profile checks passed before release preparation. Full release regression passed: 124 LAN input, 34 LAN pairing (41 with network lifecycle), 813 chat/settings, 568 input, 332 native, 126 sync, dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Physical two-PC validation is pending: network tests use loopback and input-state tests simulate desktops. LAN sharing remains experimental; follow LAN-PREVIEW.md on both PCs.
|
|
||||||
|
Use **Test mouse buttons here** to check which events Windows delivers. Mouse profiles are stored on the current PC. The configurator also keeps fixed-size settings dialogs fitted when moving them between displays with different scaling.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
## Upgrade and compatibility
|
|
||||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
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.
|
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).
|
||||||
|
|
||||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
|
||||||
@@ -1 +1 @@
|
|||||||
7ddf8545bcd1694e760503e712b6b34f396311446596bca348b3cc27ee838abe KSwitcher-win-x64.zip
|
8fdd701d2ed9223056f7e624e888ef459c919f605c624d5f35c3dbd3652a430b KSwitcher-win-x64.zip
|
||||||
+47
-47
@@ -1,86 +1,86 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"version": "0.6.64",
|
||||||
"version": "0.6.56",
|
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"sha256": "02f709062f32047f2befc4de67efe5e033c48a8155bb0230bf012496fe20db9b",
|
"path": "KSwitcher.exe",
|
||||||
"size": 1974784,
|
"size": 2185728,
|
||||||
"path": "KSwitcher.exe"
|
"sha256": "81ccb50fd3ac8a4768f0be9a4b70ef21c2a1f11e73b57871cb5123f29bfbd503"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "9b3ec79ae158ee51e61249f01e46a20917db9c25c6a8f58c52d927f8a6c3ee9c",
|
"path": "KSwitcher.Config.exe",
|
||||||
"size": 1887232,
|
"size": 2120192,
|
||||||
"path": "KSwitcher.Config.exe"
|
"sha256": "83d76af6ff44f56532d2537e57dfb7e3859d72f34180d3c7c02497e71763378e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "21a5422be5e97c7f758d46481fe2c9481d2c09b231cc7fe90b6e7a0793f32b9d",
|
"path": "KSwitcher.Screenshot.exe",
|
||||||
"size": 3046912,
|
"size": 3207168,
|
||||||
"path": "KSwitcher.Screenshot.exe"
|
"sha256": "6e93963d4252a87bd6ea9d53ccd2e602c9c2709ca3fe835dc90a633b77b00193"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
"path": "LICENSE",
|
||||||
"size": 4401,
|
"size": 4401,
|
||||||
"path": "LICENSE"
|
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831",
|
"path": "json-LICENSE.txt",
|
||||||
"size": 1096,
|
"size": 1075,
|
||||||
"path": "json-LICENSE.txt"
|
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "6b7b8a912c10c04c8bdddce90572a6a8addcb6c76d478fe8455c2685fc3384d8",
|
"path": "README.md",
|
||||||
"size": 79329,
|
"size": 89826,
|
||||||
"path": "README.md"
|
"sha256": "9148049aa80595e0de30ecc9af1478572f85a8076da981ea80dafd5bb9c4815b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "a8ec24f5a6cc9145324a52b73525418d1f8bd0eb3b4cf75fc93b57dcc7918603",
|
"path": "LAN-PREVIEW.md",
|
||||||
"size": 6773,
|
"size": 9900,
|
||||||
"path": "LAN-PREVIEW.md"
|
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "d0c022b27b9fbc2ff7b6b8bec723cc1adc4eef517202f9ff77eed5d8bea3acb9",
|
"path": "dictionaries/en.txt",
|
||||||
"size": 732485,
|
"size": 682867,
|
||||||
"path": "dictionaries/en.txt"
|
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "0bdfb0201d1803634f268cdff0993b8349d2c70e956a06dbb667c8dd65254ec5",
|
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||||
"size": 20566,
|
"size": 20138,
|
||||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
|
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
"path": "dictionaries/README.md",
|
||||||
"size": 2265,
|
"size": 2230,
|
||||||
"path": "dictionaries/README.md"
|
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "80e6b6a27815973535f7facb636c597eb845dfee45cfabfc4e9b3341b9332849",
|
"path": "dictionaries/ru.txt",
|
||||||
"size": 1109134,
|
"size": 1060364,
|
||||||
"path": "dictionaries/ru.txt"
|
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
"path": "dictionaries/sources.json",
|
||||||
"size": 1280,
|
"size": 1279,
|
||||||
"path": "dictionaries/sources.json"
|
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
"path": "dictionaries/uk.txt",
|
||||||
"size": 940799,
|
"size": 898248,
|
||||||
"path": "dictionaries/uk.txt"
|
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
"path": "tools/README.txt",
|
||||||
"size": 993,
|
"size": 993,
|
||||||
"path": "tools/README.txt"
|
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
|
"path": "tools/StayHere.exe",
|
||||||
"size": 134656,
|
"size": 134656,
|
||||||
"path": "tools/StayHere.exe"
|
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
"path": "tools/StayHere-README.md",
|
||||||
"size": 2796,
|
"size": 2796,
|
||||||
"path": "tools/StayHere-README.md"
|
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"schema": 1
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,14 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
|
||||||
|
& .\build\KSwitcherSettingsMergeTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Settings merge tests failed' }
|
||||||
|
& $compiler @common -Werror tests/lan_clipboard.cpp build/lan_channel.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanClipboardTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'LAN clipboard test build failed' }
|
||||||
|
& .\build\KSwitcherLanClipboardTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'LAN clipboard tests failed' }
|
||||||
& $compiler @common -Werror tests/lan_input.cpp build/lan_channel.o build/lan_input.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanInputTests.exe
|
& $compiler @common -Werror tests/lan_input.cpp build/lan_channel.o build/lan_input.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'LAN input test build failed' }
|
if ($LASTEXITCODE) { throw 'LAN input test build failed' }
|
||||||
& .\build\KSwitcherLanInputTests.exe
|
& .\build\KSwitcherLanInputTests.exe
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,56,0
|
FILEVERSION 0,6,64,0
|
||||||
PRODUCTVERSION 0,6,56,0
|
PRODUCTVERSION 0,6,64,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.56"
|
VALUE "FileVersion", "0.6.64"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+431
-26
@@ -3,6 +3,7 @@
|
|||||||
#include "../vendor/json.hpp"
|
#include "../vendor/json.hpp"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
|
#include "lan_clipboard_image.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include "diagnostics.h"
|
#include "diagnostics.h"
|
||||||
#include "paste_probe.h"
|
#include "paste_probe.h"
|
||||||
@@ -724,11 +725,11 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
|
|
||||||
Bytes App::lanCommand(const Bytes &command)
|
Bytes App::lanCommand(const Bytes &command)
|
||||||
{
|
{
|
||||||
if (!lanService) lanService = std::make_unique<LanService>(data);
|
if (!lanService) lanService = std::make_unique<LanService>(data,window);
|
||||||
auto reply = lanService->command(command);
|
auto reply = lanService->command(command);
|
||||||
if (lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
if (lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
||||||
else KillTimer(window, 7);
|
else KillTimer(window, 7);
|
||||||
if (lanService->takeInputChange()) resetInput();
|
if (lanService->takeInputChange()) resetLanInput();
|
||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
void App::applySettings(const Settings &requested)
|
void App::applySettings(const Settings &requested)
|
||||||
@@ -840,6 +841,16 @@ bool App::isHotkey(DWORD key, UINT mods)
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
void App::resetLanInput()
|
||||||
|
{
|
||||||
|
// Releases may have been consumed on another desktop. Do not retain local
|
||||||
|
// shortcut/chat latches across an ownership handoff.
|
||||||
|
hotkeyHook = {};
|
||||||
|
chatSwitchHook = {};
|
||||||
|
pendingMouseClicks = {};
|
||||||
|
mouseActions.clear();
|
||||||
|
resetInput();
|
||||||
|
}
|
||||||
void App::resetInput()
|
void App::resetInput()
|
||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
@@ -977,21 +988,21 @@ void App::refreshChatContext()
|
|||||||
HWND target = GetForegroundWindow();
|
HWND target = GetForegroundWindow();
|
||||||
chatWindow = target;
|
chatWindow = target;
|
||||||
activeChatTitle = chatTitle(target);
|
activeChatTitle = chatTitle(target);
|
||||||
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
|
activeChat = windowLanguageProfileKey(settings, target);
|
||||||
}
|
}
|
||||||
void App::switchChatLayout(bool reverse)
|
void App::switchChatLayout(bool reverse)
|
||||||
{
|
{
|
||||||
HWND target = chatWindow;
|
HWND target = chatWindow;
|
||||||
auto key = activeChat;
|
auto key = activeChat;
|
||||||
if (!target || target != GetForegroundWindow() || key.empty() || windowChatKey(target) != key ||
|
if (!target || target != GetForegroundWindow() || key.empty() ||
|
||||||
!settings.chatCycles.contains(key) || busy.exchange(true)) return;
|
windowLanguageProfileKey(settings, target) != key || busy.exchange(true)) return;
|
||||||
auto cycle = chatCycle(settings, key, layouts);
|
auto cycle = chatCycle(settings, key, layouts);
|
||||||
auto available = layouts;
|
auto available = layouts;
|
||||||
resetInput();
|
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};
|
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 (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
|
||||||
if (!valid()) return;
|
if (!valid()) return;
|
||||||
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
||||||
@@ -1049,11 +1060,11 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool chatEnabled = false;
|
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))
|
!isHotkey(key.vkCode, mods))
|
||||||
{
|
{
|
||||||
refreshChatContext();
|
refreshChatContext();
|
||||||
chatEnabled = !activeChat.empty() && settings.chatCycles.contains(activeChat);
|
chatEnabled = !activeChat.empty();
|
||||||
}
|
}
|
||||||
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
|
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
|
||||||
if (chatAction)
|
if (chatAction)
|
||||||
@@ -1132,7 +1143,7 @@ LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
|||||||
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
|
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
|
||||||
{
|
{
|
||||||
bool consumed = current->lanService->keyboard(*(KBDLLHOOKSTRUCT *)lp, wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN);
|
bool consumed = current->lanService->keyboard(*(KBDLLHOOKSTRUCT *)lp, wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN);
|
||||||
if (current->lanService->takeInputChange()) current->resetInput();
|
if (current->lanService->takeInputChange()) current->resetLanInput();
|
||||||
if (consumed) return 1; // Forwarded keys must not reach local hotkeys/snippets or other routers.
|
if (consumed) return 1; // Forwarded keys must not reach local hotkeys/snippets or other routers.
|
||||||
}
|
}
|
||||||
// MWB suppresses source-machine events that it forwards elsewhere. Let
|
// MWB suppresses source-machine events that it forwards elsewhere. Let
|
||||||
@@ -1160,23 +1171,324 @@ LRESULT App::keyboardAfterHooks(int code, WPARAM wp, LPARAM lp, LRESULT routed)
|
|||||||
}
|
}
|
||||||
return routed;
|
return routed;
|
||||||
}
|
}
|
||||||
|
constexpr UINT MouseActionMessage = WM_APP + 79;
|
||||||
|
constexpr UINT MouseVolumeReleasedMessage = WM_APP + 80;
|
||||||
|
constexpr UINT_PTR MouseClickTimer = 9;
|
||||||
|
constexpr UINT_PTR FirstMouseVolumeTimer = 10;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr wchar_t VolumeIndicatorClass[] = L"KSwitcher.VolumeIndicator";
|
||||||
|
constexpr UINT_PTR VolumeIndicatorTimer = 1;
|
||||||
|
constexpr WORD VolumeIndicatorMuted = 1, VolumeIndicatorHeld = 2;
|
||||||
|
|
||||||
|
void paintVolumeIndicator(HWND w)
|
||||||
|
{
|
||||||
|
PAINTSTRUCT paint{};
|
||||||
|
const auto dc = BeginPaint(w, &paint);
|
||||||
|
RECT bounds{};
|
||||||
|
GetClientRect(w, &bounds);
|
||||||
|
FillRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOW));
|
||||||
|
FrameRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOWFRAME));
|
||||||
|
const int dpi = static_cast<int>(GetDpiForWindow(w));
|
||||||
|
const int padding = MulDiv(8, dpi, 96);
|
||||||
|
const int captionHeight = MulDiv(26, dpi, 96);
|
||||||
|
const auto state = GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||||
|
const int percent = LOWORD(state);
|
||||||
|
const bool muted = (HIWORD(state) & VolumeIndicatorMuted) != 0;
|
||||||
|
RECT track{padding, padding, bounds.right - padding, bounds.bottom - captionHeight};
|
||||||
|
FillRect(dc, &track, GetSysColorBrush(COLOR_3DFACE));
|
||||||
|
RECT fill = track;
|
||||||
|
fill.top = fill.bottom - MulDiv(fill.bottom - fill.top, percent, 100);
|
||||||
|
FillRect(dc, &fill, GetSysColorBrush(muted ? COLOR_GRAYTEXT : COLOR_HIGHLIGHT));
|
||||||
|
RECT caption{0, track.bottom, bounds.right, bounds.bottom};
|
||||||
|
const auto text = muted ? std::wstring(L"Mute") : std::to_wstring(percent) + L"%";
|
||||||
|
const auto font = CreateFontW(-MulDiv(12, dpi, 96), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
|
||||||
|
DEFAULT_CHARSET, 0, 0, 0, 0, L"Segoe UI");
|
||||||
|
const auto previous = font ? SelectObject(dc, font) : nullptr;
|
||||||
|
SetBkMode(dc, TRANSPARENT);
|
||||||
|
SetTextColor(dc, GetSysColor(COLOR_WINDOWTEXT));
|
||||||
|
DrawTextW(dc, text.c_str(), -1, &caption, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
|
||||||
|
if (previous) SelectObject(dc, previous);
|
||||||
|
if (font) DeleteObject(font);
|
||||||
|
EndPaint(w, &paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK volumeIndicatorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case WM_PAINT: paintVolumeIndicator(w); return 0;
|
||||||
|
case WM_NCHITTEST: return HTTRANSPARENT;
|
||||||
|
case WM_MOUSEACTIVATE: return MA_NOACTIVATE;
|
||||||
|
case WM_TIMER:
|
||||||
|
if (wp == VolumeIndicatorTimer)
|
||||||
|
{
|
||||||
|
KillTimer(w, VolumeIndicatorTimer);
|
||||||
|
if (!(HIWORD(GetWindowLongPtrW(w, GWLP_USERDATA)) & VolumeIndicatorHeld))
|
||||||
|
ShowWindow(w, SW_HIDE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setVolumeIndicatorHold(HWND indicator, bool held)
|
||||||
|
{
|
||||||
|
const auto state = GetWindowLongPtrW(indicator, GWLP_USERDATA);
|
||||||
|
const WORD flags = (HIWORD(state) & VolumeIndicatorMuted) | (held ? VolumeIndicatorHeld : 0);
|
||||||
|
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(LOWORD(state), flags));
|
||||||
|
if (held) KillTimer(indicator, VolumeIndicatorTimer);
|
||||||
|
else SetTimer(indicator, VolumeIndicatorTimer, 1000, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showVolumeIndicator(HWND owner, HINSTANCE instance, POINT point, SystemVolume volume, bool held)
|
||||||
|
{
|
||||||
|
MONITORINFO monitor{sizeof(monitor)};
|
||||||
|
const auto targetMonitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
|
||||||
|
if (!GetMonitorInfoW(targetMonitor, &monitor)) return;
|
||||||
|
const auto &area = monitor.rcWork;
|
||||||
|
auto indicator = reinterpret_cast<HWND>(GetPropW(owner, VolumeIndicatorClass));
|
||||||
|
if (!indicator)
|
||||||
|
{
|
||||||
|
WNDCLASSW type{};
|
||||||
|
type.lpfnWndProc = volumeIndicatorProc;
|
||||||
|
type.hInstance = instance;
|
||||||
|
type.lpszClassName = VolumeIndicatorClass;
|
||||||
|
RegisterClassW(&type);
|
||||||
|
indicator = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_TOPMOST |
|
||||||
|
WS_EX_LAYERED | WS_EX_TRANSPARENT, VolumeIndicatorClass, L"Volume",
|
||||||
|
WS_POPUP, area.left, area.top, 1, 1, owner, nullptr, instance, nullptr);
|
||||||
|
if (!indicator) return;
|
||||||
|
if (!SetPropW(owner, VolumeIndicatorClass, indicator) ||
|
||||||
|
!SetLayeredWindowAttributes(indicator, 0, 255, LWA_ALPHA))
|
||||||
|
{
|
||||||
|
RemovePropW(owner, VolumeIndicatorClass);
|
||||||
|
DestroyWindow(indicator);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Place the window on the destination monitor before querying its DPI.
|
||||||
|
if (MonitorFromWindow(indicator, MONITOR_DEFAULTTONEAREST) != targetMonitor)
|
||||||
|
SetWindowPos(indicator, nullptr, area.left, area.top, 0, 0,
|
||||||
|
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW);
|
||||||
|
const int dpi = static_cast<int>(GetDpiForWindow(indicator));
|
||||||
|
const int width = MulDiv(48, dpi, 96);
|
||||||
|
const int height = MulDiv(132, dpi, 96);
|
||||||
|
const int margin = MulDiv(16, dpi, 96);
|
||||||
|
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(volume.percent, volume.muted ? VolumeIndicatorMuted : 0));
|
||||||
|
setVolumeIndicatorHold(indicator, held);
|
||||||
|
SetWindowPos(indicator, HWND_TOPMOST, area.right - width - margin, area.bottom - height - margin,
|
||||||
|
width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||||
|
InvalidateRect(indicator, nullptr, FALSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::queueMouseAction(MousePress press)
|
||||||
|
{
|
||||||
|
if (press.action.type == MouseActionType::Standard) return;
|
||||||
|
mouseActions.push_back(std::move(press));
|
||||||
|
PostMessageW(window, MouseActionMessage, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::flushMouseClicks()
|
||||||
|
{
|
||||||
|
ULONGLONG next = 0;
|
||||||
|
const auto now = GetTickCount64();
|
||||||
|
for (auto &press : pendingMouseClicks)
|
||||||
|
{
|
||||||
|
if (!press.target) continue;
|
||||||
|
const auto deadline = press.started + GetDoubleClickTime();
|
||||||
|
if (now >= deadline)
|
||||||
|
{
|
||||||
|
queueMouseAction(std::exchange(press, {}));
|
||||||
|
}
|
||||||
|
else if (!next || deadline < next) next = deadline;
|
||||||
|
}
|
||||||
|
if (next) SetTimer(window, MouseClickTimer, static_cast<UINT>(next - now), nullptr);
|
||||||
|
else KillTimer(window, MouseClickTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::beginMouseVolume(int button)
|
||||||
|
{
|
||||||
|
const auto &press = mousePresses[button];
|
||||||
|
if (!press.waitForSecond) queueMouseAction(press);
|
||||||
|
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
|
||||||
|
SetTimer(window, FirstMouseVolumeTimer + button, delay, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::repeatMouseVolume(int button)
|
||||||
|
{
|
||||||
|
const auto timer = FirstMouseVolumeTimer + button;
|
||||||
|
auto &press = mousePresses[button];
|
||||||
|
if (!press.held || !isMouseVolumeStep(press.action.type) ||
|
||||||
|
busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased() ||
|
||||||
|
!IsWindow(press.target) || (!press.action.applications.empty() && press.target != GetForegroundWindow()))
|
||||||
|
{
|
||||||
|
KillTimer(window, timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto elapsed = GetTickCount64() - press.started;
|
||||||
|
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
|
||||||
|
if (elapsed < delay) return;
|
||||||
|
press.waitForSecond = false;
|
||||||
|
queueMouseAction(press);
|
||||||
|
SetTimer(window, timer, mouseVolumeRepeatInterval(elapsed), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::volumeButtonHeld() const
|
||||||
|
{
|
||||||
|
return std::any_of(mousePresses.begin(), mousePresses.end(), [](const MousePress &press)
|
||||||
|
{
|
||||||
|
return press.held && !press.waitForSecond && isMouseVolumeStep(press.action.type);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::dispatchMouseActions()
|
||||||
|
{
|
||||||
|
std::deque<MousePress> actions;
|
||||||
|
actions.swap(mouseActions);
|
||||||
|
if (busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased())
|
||||||
|
{
|
||||||
|
if (!actions.empty()) log(L"Mouse action skipped while input was unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const auto &press : actions)
|
||||||
|
{
|
||||||
|
if (!IsWindow(press.target)) continue;
|
||||||
|
const auto &action = press.action;
|
||||||
|
switch (action.type)
|
||||||
|
{
|
||||||
|
case MouseActionType::Standard: break;
|
||||||
|
case MouseActionType::Shortcut:
|
||||||
|
if (press.target != GetForegroundWindow())
|
||||||
|
log(L"Mouse shortcut skipped because the active window changed.");
|
||||||
|
else if (!chord(static_cast<WORD>(action.shortcut.key), action.shortcut.modifiers))
|
||||||
|
log(L"Windows did not accept all input for the mouse shortcut.");
|
||||||
|
else
|
||||||
|
log(L"Windows accepted input for the mouse shortcut.");
|
||||||
|
break;
|
||||||
|
case MouseActionType::Back:
|
||||||
|
case MouseActionType::Forward:
|
||||||
|
PostMessageW(press.target, WM_APPCOMMAND, reinterpret_cast<WPARAM>(press.target),
|
||||||
|
MAKELPARAM(0, FAPPCOMMAND_MOUSE | (action.type == MouseActionType::Back ?
|
||||||
|
APPCOMMAND_BROWSER_BACKWARD : APPCOMMAND_BROWSER_FORWARD)));
|
||||||
|
break;
|
||||||
|
case MouseActionType::VolumeUp:
|
||||||
|
case MouseActionType::VolumeDown:
|
||||||
|
case MouseActionType::Mute:
|
||||||
|
if (const auto volume = changeSystemVolume(action.type, action.volumeStep))
|
||||||
|
showVolumeIndicator(window, instance, press.point, *volume, volumeButtonHeld());
|
||||||
|
else log(L"Mouse action could not change or read system volume.");
|
||||||
|
break;
|
||||||
|
case MouseActionType::Launch:
|
||||||
|
if (!launchThroughExplorer(action.executable))
|
||||||
|
log(L"Mouse action could not launch " + action.executable);
|
||||||
|
else
|
||||||
|
log(L"Mouse action launched " + std::filesystem::path(action.executable).filename().wstring());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (code == HC_ACTION && current)
|
if (code == HC_ACTION && current)
|
||||||
{
|
{
|
||||||
if (current->lanService)
|
auto &app = *current;
|
||||||
|
const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp);
|
||||||
|
const int button = mouse.flags & LLMHF_INJECTED ? -1 : mouseButton(UINT(wp), HIWORD(mouse.mouseData));
|
||||||
|
const bool down = mouseButtonDown(UINT(wp));
|
||||||
|
if (button >= 0 && app.mousePresses[button].held)
|
||||||
{
|
{
|
||||||
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
|
auto &active = app.mousePresses[button];
|
||||||
!current->busy && !current->suspended && !current->hotkeyCapture);
|
const bool consumed = active.action.type != MouseActionType::Standard;
|
||||||
if (current->lanService->takeInputChange()) current->resetInput();
|
if (!down)
|
||||||
|
{
|
||||||
|
auto press = std::exchange(active, {});
|
||||||
|
if (isMouseVolumeStep(press.action.type))
|
||||||
|
{
|
||||||
|
KillTimer(app.window, FirstMouseVolumeTimer + button);
|
||||||
|
PostMessageW(app.window, MouseVolumeReleasedMessage, 0, 0);
|
||||||
|
}
|
||||||
|
if (press.waitForSecond)
|
||||||
|
{
|
||||||
|
app.pendingMouseClicks[button] = std::move(press);
|
||||||
|
app.flushMouseClicks();
|
||||||
|
}
|
||||||
|
else if (!isMouseVolumeStep(press.action.type)) app.queueMouseAction(std::move(press));
|
||||||
|
}
|
||||||
if (consumed) return 1;
|
if (consumed) return 1;
|
||||||
}
|
}
|
||||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
if (app.lanService)
|
||||||
// range just like local clicks, including while a transaction is pending.
|
|
||||||
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
|
||||||
{
|
{
|
||||||
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
|
const bool mouseActionHeld = std::any_of(app.mousePresses.begin(), app.mousePresses.end(),
|
||||||
current->resetInput();
|
[](const MousePress &press) { return press.held; });
|
||||||
|
bool consumed = app.lanService->mouse(UINT(wp), mouse,
|
||||||
|
!app.busy && !app.suspended && !app.hotkeyCapture && !mouseActionHeld);
|
||||||
|
if (app.lanService->takeInputChange()) app.resetLanInput();
|
||||||
|
if (consumed) return 1;
|
||||||
|
}
|
||||||
|
if (button >= 0 && down && !app.settings.mouseBindings.empty() && !app.busy && !app.suspended &&
|
||||||
|
!app.installingUpdate && !app.hotkeyCapture && modifiersReleased())
|
||||||
|
{
|
||||||
|
const auto hit = WindowFromPoint(mouse.pt);
|
||||||
|
const auto target = GetAncestor(hit, GA_ROOT);
|
||||||
|
const auto name = target ? mouseApplicationName(target) : L"";
|
||||||
|
wchar_t windowClass[64]{};
|
||||||
|
if (target && name == L"explorer") GetClassNameW(target, windowClass, std::size(windowClass));
|
||||||
|
const bool ownWindow = name == L"kswitcher" || name == L"kswitcher.config";
|
||||||
|
const bool shellSurface = target == GetShellWindow() || wcscmp(windowClass, L"Progman") == 0 ||
|
||||||
|
wcscmp(windowClass, L"WorkerW") == 0 || wcscmp(windowClass, L"Shell_TrayWnd") == 0 ||
|
||||||
|
wcscmp(windowClass, L"Shell_SecondaryTrayWnd") == 0;
|
||||||
|
const auto *global = mouseBinding(app.settings, GlobalMouseProfile, button);
|
||||||
|
const bool globalLaunch = button >= 2 && global && global->type == MouseActionType::Launch &&
|
||||||
|
mouseActionApplies(*global, name);
|
||||||
|
const bool restricted = ownWindow || shellSurface || name.empty();
|
||||||
|
const bool testButton = name == L"kswitcher.config" && hit == GetDlgItem(target, 737);
|
||||||
|
const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", button, false) : nullptr;
|
||||||
|
const auto *restrictedAction = isMouseVolumeAction(fallback) ? fallback : globalLaunch ? global : nullptr;
|
||||||
|
const bool eligible = target && !testButton && (!restricted || (button >= 2 && restrictedAction)) &&
|
||||||
|
(button >= 2 || target == GetForegroundWindow());
|
||||||
|
if (eligible)
|
||||||
|
{
|
||||||
|
const bool foreground = target == GetForegroundWindow();
|
||||||
|
bool singleScoped{}, doubleScoped{};
|
||||||
|
auto *single = restricted ? restrictedAction :
|
||||||
|
effectiveMouseAction(app.settings, name, button, foreground, &singleScoped);
|
||||||
|
auto *doubleClick = !restricted && button < 3 ?
|
||||||
|
effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr;
|
||||||
|
if (!foreground)
|
||||||
|
{
|
||||||
|
if (single && single->type == MouseActionType::Shortcut) single = nullptr;
|
||||||
|
if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr;
|
||||||
|
}
|
||||||
|
const auto now = GetTickCount64();
|
||||||
|
auto &pending = app.pendingMouseClicks[button];
|
||||||
|
const bool second = doubleClick && pending.target == target &&
|
||||||
|
doubleMouseClick(pending.point, mouse.pt, now - pending.started);
|
||||||
|
if (second) pending = {};
|
||||||
|
else if (pending.target) app.queueMouseAction(std::exchange(pending, {}));
|
||||||
|
const auto *action = second ? doubleClick : single;
|
||||||
|
if (action || doubleClick)
|
||||||
|
app.mousePresses[button] = {target, mouse.pt, now, action ? *action : MouseAction{},
|
||||||
|
true, doubleClick && !second};
|
||||||
|
if (action)
|
||||||
|
{
|
||||||
|
if (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name);
|
||||||
|
if (isMouseVolumeStep(action->type))
|
||||||
|
app.beginMouseVolume(button);
|
||||||
|
app.resetInput();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remote clicks invalidate a typing range just like local clicks.
|
||||||
|
if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL)
|
||||||
|
{
|
||||||
|
if (app.busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
|
||||||
|
app.resetInput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return CallNextHookEx(nullptr, code, wp, lp);
|
return CallNextHookEx(nullptr, code, wp, lp);
|
||||||
@@ -1305,7 +1617,7 @@ void App::transform(int command, int snippet)
|
|||||||
} reset{busy, window, preDeferred};
|
} reset{busy, window, preDeferred};
|
||||||
auto effectiveChat = requestedChat;
|
auto effectiveChat = requestedChat;
|
||||||
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
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();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||||
@@ -1805,15 +2117,56 @@ void App::pasteEntry(std::wstring id, bool plain)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
namespace {
|
||||||
|
Clip sharedClipboard(Clip clip)
|
||||||
|
{
|
||||||
|
// File handles/paths and application-private formats are local to their PC.
|
||||||
|
std::erase_if(clip.formats,[](const Format &f){return !(
|
||||||
|
(f.name.empty()&&(f.id==CF_UNICODETEXT||f.id==CF_TEXT||f.id==CF_LOCALE||f.id==CF_DIB||f.id==CF_DIBV5))||
|
||||||
|
f.name==L"PNG"||f.name==L"HTML Format"||f.name==L"Rich Text Format");});
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void App::publishClipboard(Clip clip,const std::wstring &app)
|
||||||
|
{
|
||||||
|
clip=sharedClipboard(std::move(clip));if(clip.formats.empty())return;
|
||||||
|
auto originalSize=clip.size();
|
||||||
|
clip=lan::compactClipboardImage(std::move(clip));
|
||||||
|
auto packet=new LanClipboard{{},{},utf8(app),encodeClip(clip)};
|
||||||
|
log(L"LAN clipboard queued: original="+std::to_wstring(originalSize)+L" bytes, wire="+std::to_wstring(packet->data.size())+L" bytes");
|
||||||
|
if(!PostMessageW(window,LanClipboardPublishMessage,0,reinterpret_cast<LPARAM>(packet)))delete packet;
|
||||||
|
}
|
||||||
|
void App::receiveClipboard()
|
||||||
|
{
|
||||||
|
if(!lanService)return;
|
||||||
|
auto packets=lanService->takeClipboard();auto config=settings;
|
||||||
|
for(auto &packet:packets)enqueue([this,packet=std::move(packet),config]{
|
||||||
|
try {
|
||||||
|
auto clip=sharedClipboard(decodeClip(packet.data));if(clip.formats.empty())return;
|
||||||
|
auto sequence=GetClipboardSequenceNumber();
|
||||||
|
if(!suspended&&(sequence==packet.sequence||sequence==lanSequence.load())){
|
||||||
|
const std::wstring marker=L"KSwitcher.LAN.Clipboard";
|
||||||
|
auto active=lan::clipboardImageForPaste(clip);
|
||||||
|
active.formats.push_back({RegisterClipboardFormatW(marker.c_str()),marker,Bytes{1}});
|
||||||
|
ownSequence=lanSequence=writeClipboardGuarded(window,active,sequence);
|
||||||
|
log(L"LAN clipboard activated: "+std::to_wstring(packet.data.size())+L" bytes");
|
||||||
|
}
|
||||||
|
if(config.history&&history){history->add(clip,wide(packet.app),config,packet.peer,wide(packet.name));
|
||||||
|
scheduleHistoryOcr(config);PostMessageW(window,HistoryMessage,0,0);}
|
||||||
|
}catch(const std::exception &e){log(L"LAN clipboard receive: "+wide(e.what()));}
|
||||||
|
});
|
||||||
|
}
|
||||||
void App::captureHistory()
|
void App::captureHistory()
|
||||||
{
|
{
|
||||||
DWORD sequence = GetClipboardSequenceNumber();
|
DWORD sequence = GetClipboardSequenceNumber();
|
||||||
if (suspended || sequence == ownSequence || !settings.history || !history ||
|
if (suspended || sequence == ownSequence || !history ||
|
||||||
GetTickCount64() < pauseUntil)
|
GetTickCount64() < pauseUntil)
|
||||||
return;
|
return;
|
||||||
|
const bool share=lanService&&lanService->clipboardEnabled();
|
||||||
|
if(!settings.history&&!share)return;
|
||||||
Settings config = settings;
|
Settings config = settings;
|
||||||
enqueue(
|
enqueue(
|
||||||
[this, sequence, config]
|
[this, sequence, config, share]
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1821,19 +2174,28 @@ void App::captureHistory()
|
|||||||
return;
|
return;
|
||||||
UINT exclude = RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"),
|
UINT exclude = RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"),
|
||||||
ignore = RegisterClipboardFormatW(L"Clipboard Viewer Ignore");
|
ignore = RegisterClipboardFormatW(L"Clipboard Viewer Ignore");
|
||||||
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore))
|
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore) ||
|
||||||
|
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"KSwitcher.LAN.Clipboard")))
|
||||||
return;
|
return;
|
||||||
auto owner = GetClipboardOwner();
|
auto owner = GetClipboardOwner();
|
||||||
auto app = processName(owner ? owner : GetForegroundWindow());
|
auto app = processName(owner ? owner : GetForegroundWindow());
|
||||||
for (auto &name : config.excluded)
|
for (auto &name : config.excluded)
|
||||||
if (lower(name) == lower(app))
|
if (lower(name) == lower(app))
|
||||||
return;
|
return;
|
||||||
|
if(!config.history){
|
||||||
|
auto clip=readClipboard(window,(size_t)config.maxImageMiB*1024*1024,ClipboardRead::All,sequence);
|
||||||
|
for(const auto &f:clip.formats)if(f.name==L"CanIncludeInClipboardHistory"&&f.data.size()>=4&&!f.data[0]&&!f.data[1]&&!f.data[2]&&!f.data[3])return;
|
||||||
|
if(sequence==GetClipboardSequenceNumber())publishClipboard(std::move(clip),app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
|
if (IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
|
||||||
IsClipboardFormatAvailable(CF_BITMAP) ||
|
IsClipboardFormatAvailable(CF_BITMAP) ||
|
||||||
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG")))
|
IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG")))
|
||||||
{
|
{
|
||||||
if (history->captureImage(sequence, app, config))
|
if (history->captureImage(sequence, app, config))
|
||||||
{
|
{
|
||||||
|
auto entries=history->entries();
|
||||||
|
if(share&&!entries.empty())publishClipboard(history->load(entries.front().id),app);
|
||||||
scheduleHistoryOcr(config);
|
scheduleHistoryOcr(config);
|
||||||
PostMessageW(window, HistoryMessage, 0, 0);
|
PostMessageW(window, HistoryMessage, 0, 0);
|
||||||
}
|
}
|
||||||
@@ -1848,6 +2210,7 @@ void App::captureHistory()
|
|||||||
!f.data[1] && !f.data[2] && !f.data[3])
|
!f.data[1] && !f.data[2] && !f.data[3])
|
||||||
return;
|
return;
|
||||||
history->add(clip, app, config);
|
history->add(clip, app, config);
|
||||||
|
if(share)publishClipboard(clip,app);
|
||||||
scheduleHistoryOcr(config);
|
scheduleHistoryOcr(config);
|
||||||
PostMessageW(window, HistoryMessage, 0, 0);
|
PostMessageW(window, HistoryMessage, 0, 0);
|
||||||
}
|
}
|
||||||
@@ -2145,6 +2508,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_HOTKEY:
|
case WM_HOTKEY:
|
||||||
app->dispatch((int)wp);
|
app->dispatch((int)wp);
|
||||||
return 0;
|
return 0;
|
||||||
|
case MouseActionMessage:
|
||||||
|
app->dispatchMouseActions();
|
||||||
|
return 0;
|
||||||
|
case MouseVolumeReleasedMessage:
|
||||||
|
if (const auto indicator = reinterpret_cast<HWND>(GetPropW(hwnd, VolumeIndicatorClass)))
|
||||||
|
setVolumeIndicatorHold(indicator, app->volumeButtonHeld());
|
||||||
|
return 0;
|
||||||
case WM_POWERBROADCAST:
|
case WM_POWERBROADCAST:
|
||||||
if (wp == PBT_APMSUSPEND && app->lanService)
|
if (wp == PBT_APMSUSPEND && app->lanService)
|
||||||
{
|
{
|
||||||
@@ -2222,13 +2592,46 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
case LanClipboardPublishMessage:
|
||||||
|
{
|
||||||
|
std::unique_ptr<LanClipboard> packet(reinterpret_cast<LanClipboard*>(lp));
|
||||||
|
try{if(app->lanService)app->lanService->publishClipboard(std::move(packet->data),packet->app);}
|
||||||
|
catch(const std::exception &e){log(L"LAN clipboard send: "+wide(e.what()));}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case LanClipboardReadyMessage: app->receiveClipboard();return 0;
|
||||||
|
case LanInputReadyMessage:
|
||||||
|
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
|
||||||
|
return 0;
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
|
if (wp == MouseClickTimer) { app->flushMouseClicks(); return 0; }
|
||||||
|
if (wp >= FirstMouseVolumeTimer && wp < FirstMouseVolumeTimer + std::size(MouseButtonNames))
|
||||||
|
{
|
||||||
|
app->repeatMouseVolume(static_cast<int>(wp - FirstMouseVolumeTimer));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (wp == 8)
|
||||||
|
{
|
||||||
|
if (app->settings.appLanguageSets.empty() || app->busy || app->installingUpdate) return 0;
|
||||||
|
app->refreshChatContext();
|
||||||
|
if (!app->activeChat.starts_with(L"app\n")) return 0;
|
||||||
|
auto cycle = chatCycle(app->settings, app->activeChat, app->layouts);
|
||||||
|
auto current = windowLayout(app->chatWindow);
|
||||||
|
bool allowed = std::any_of(app->layouts.begin(), app->layouts.end(), [&](const Layout &layout) {
|
||||||
|
return layout.handle == current && std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end();
|
||||||
|
});
|
||||||
|
if (!allowed) app->switchChatLayout(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (wp == 7)
|
if (wp == 7)
|
||||||
{
|
{
|
||||||
if (app->lanService)
|
if (app->lanService)
|
||||||
{
|
{
|
||||||
if (app->suspended) app->lanService->cancelInput();
|
// suspended also guards clipboard/text transactions (correction,
|
||||||
if (app->lanService->tickInput(!app->busy && !app->hotkeyCapture)) app->resetInput();
|
// conversion, snippets). Those must not disconnect remote input.
|
||||||
|
// Actual system suspension is handled by WM_POWERBROADCAST.
|
||||||
|
if (app->lanService->tickInput(!app->busy && !app->suspended && !app->hotkeyCapture)) app->resetLanInput();
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -2346,6 +2749,7 @@ int App::execute()
|
|||||||
historyTop = wide(state.value("top", std::string{}));
|
historyTop = wide(state.value("top", std::string{}));
|
||||||
historyQuery = wide(state.value("query", std::string{}));
|
historyQuery = wide(state.value("query", std::string{}));
|
||||||
historyPinnedOnly = state.value("pinnedOnly", false);
|
historyPinnedOnly = state.value("pinnedOnly", false);
|
||||||
|
historyComputers = state.value("computers",std::vector<std::string>{});
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
showHistory(*this);
|
showHistory(*this);
|
||||||
remoteHistory("ready", std::to_wstring(reinterpret_cast<uintptr_t>(historyWindow)));
|
remoteHistory("ready", std::to_wstring(reinterpret_cast<uintptr_t>(historyWindow)));
|
||||||
@@ -2373,11 +2777,12 @@ int App::execute()
|
|||||||
addTray();
|
addTray();
|
||||||
if (!smoke) startBrowserChatMonitor();
|
if (!smoke) startBrowserChatMonitor();
|
||||||
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
|
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
|
||||||
try { lanService = std::make_unique<LanService>(data); }
|
try { lanService = std::make_unique<LanService>(data,window); }
|
||||||
catch (const std::exception &e) { report(L"LAN module: " + wide(e.what())); }
|
catch (const std::exception &e) { report(L"LAN module: " + wide(e.what())); }
|
||||||
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||||
|
if (!smoke) SetTimer(window, 8, 250, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
if (!registrationWarning.empty())
|
if (!registrationWarning.empty())
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include "sync.h"
|
#include "sync.h"
|
||||||
#include "chat_context.h"
|
#include "chat_context.h"
|
||||||
#include "lan.h"
|
#include "lan.h"
|
||||||
|
#include "mouse_buttons.h"
|
||||||
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -40,6 +42,7 @@ class App
|
|||||||
unsigned historyEntryWindows{};
|
unsigned historyEntryWindows{};
|
||||||
std::wstring historySelection, historyTop, historyQuery;
|
std::wstring historySelection, historyTop, historyQuery;
|
||||||
bool historyPinnedOnly{};
|
bool historyPinnedOnly{};
|
||||||
|
std::vector<std::string> historyComputers;
|
||||||
HINSTANCE instance{};
|
HINSTANCE instance{};
|
||||||
std::filesystem::path data;
|
std::filesystem::path data;
|
||||||
std::vector<Layout> layouts;
|
std::vector<Layout> layouts;
|
||||||
@@ -48,7 +51,7 @@ class App
|
|||||||
AutoCorrector autoCorrector;
|
AutoCorrector autoCorrector;
|
||||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||||
std::atomic<bool> busy{false}, suspended{false};
|
std::atomic<bool> busy{false}, suspended{false};
|
||||||
std::atomic<DWORD> ownSequence{0};
|
std::atomic<DWORD> ownSequence{0}, lanSequence{0};
|
||||||
std::atomic<uint64_t> inputVersion{0};
|
std::atomic<uint64_t> inputVersion{0};
|
||||||
std::atomic<ULONGLONG> pauseUntil{0};
|
std::atomic<ULONGLONG> pauseUntil{0};
|
||||||
HWND pasteTarget{};
|
HWND pasteTarget{};
|
||||||
@@ -70,6 +73,8 @@ class App
|
|||||||
void pasteEntry(std::wstring, bool plain);
|
void pasteEntry(std::wstring, bool plain);
|
||||||
void recognizeEntry(std::wstring);
|
void recognizeEntry(std::wstring);
|
||||||
void captureHistory();
|
void captureHistory();
|
||||||
|
void publishClipboard(Clip, const std::wstring &);
|
||||||
|
void receiveClipboard();
|
||||||
void enqueue(std::function<void()>);
|
void enqueue(std::function<void()>);
|
||||||
void enqueueHistorySearch(std::function<void()>);
|
void enqueueHistorySearch(std::function<void()>);
|
||||||
void report(const std::wstring &);
|
void report(const std::wstring &);
|
||||||
@@ -90,6 +95,7 @@ class App
|
|||||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||||
void resetInput();
|
void resetInput();
|
||||||
|
void resetLanInput();
|
||||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||||
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
||||||
Bytes syncConflictData();
|
Bytes syncConflictData();
|
||||||
@@ -101,6 +107,23 @@ class App
|
|||||||
friend struct InputRegressionAccess;
|
friend struct InputRegressionAccess;
|
||||||
void releaseIdleMemory(ULONGLONG now);
|
void releaseIdleMemory(ULONGLONG now);
|
||||||
HHOOK keyboardHook{}, mouseHook{};
|
HHOOK keyboardHook{}, mouseHook{};
|
||||||
|
struct MousePress
|
||||||
|
{
|
||||||
|
HWND target{};
|
||||||
|
POINT point{};
|
||||||
|
ULONGLONG started{};
|
||||||
|
MouseAction action;
|
||||||
|
bool held{}, waitForSecond{};
|
||||||
|
};
|
||||||
|
std::array<MousePress, std::size(MouseButtonNames)> mousePresses{};
|
||||||
|
std::array<MousePress, std::size(MouseButtonNames)> pendingMouseClicks{};
|
||||||
|
std::deque<MousePress> mouseActions;
|
||||||
|
void queueMouseAction(MousePress);
|
||||||
|
void flushMouseClicks();
|
||||||
|
void beginMouseVolume(int button);
|
||||||
|
void repeatMouseVolume(int button);
|
||||||
|
bool volumeButtonHeld() const;
|
||||||
|
void dispatchMouseActions();
|
||||||
std::unique_ptr<LanService> lanService;
|
std::unique_ptr<LanService> lanService;
|
||||||
HANDLE historyProcess{};
|
HANDLE historyProcess{};
|
||||||
std::mutex jobsMutex;
|
std::mutex jobsMutex;
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ void App::saveHistoryView(const std::wstring &selection, const std::wstring &top
|
|||||||
{
|
{
|
||||||
if (!historyService) return;
|
if (!historyService) return;
|
||||||
try {
|
try {
|
||||||
auto text = HistoryJson{{"selection", utf8(selection)}, {"top", utf8(top)}, {"query", utf8(query)}, {"pinnedOnly", pinnedOnly}}.dump();
|
auto text = HistoryJson{{"selection", utf8(selection)}, {"top", utf8(top)}, {"query", utf8(query)}, {"pinnedOnly", pinnedOnly}, {"computers", historyComputers}}.dump();
|
||||||
atomicWrite(data / L"history-view.bin", protect(Bytes(text.begin(), text.end())));
|
atomicWrite(data / L"history-view.bin", protect(Bytes(text.begin(), text.end())));
|
||||||
} catch (...) {} // Closing the UI must still release its process if state cannot be saved.
|
} catch (...) {} // Closing the UI must still release its process if state cannot be saved.
|
||||||
}
|
}
|
||||||
+16
-2
@@ -13,7 +13,7 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
|
|||||||
validateSyncConfig(c);
|
validateSyncConfig(c);
|
||||||
if (action == 4 && (c != conflictConnection || choices.empty()))
|
if (action == 4 && (c != conflictConnection || choices.empty()))
|
||||||
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
|
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.
|
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
|
||||||
if (syncWorker.joinable()) syncWorker.join();
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
syncConnection = c;
|
syncConnection = c;
|
||||||
@@ -22,7 +22,8 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
|
|||||||
auto availableLayouts = layouts;
|
auto availableLayouts = layouts;
|
||||||
syncRunning = true;
|
syncRunning = true;
|
||||||
syncDone = false;
|
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;
|
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||||
try
|
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 nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
||||||
catch (const std::exception &e) { result.status = wide(e.what()); }
|
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);
|
syncResult = std::move(result);
|
||||||
syncDone.store(true);
|
syncDone.store(true);
|
||||||
});
|
});
|
||||||
@@ -76,8 +80,18 @@ void App::pollSync()
|
|||||||
{
|
{
|
||||||
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
|
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
|
||||||
applySettings(next);
|
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);
|
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);
|
pendingSyncConflicts = std::move(result.conflicts);
|
||||||
conflictConnection = syncConnection;
|
conflictConnection = syncConnection;
|
||||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
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);
|
auto replacement = settings.autoReplacements.find(small);
|
||||||
if (replacement != settings.autoReplacements.end() &&
|
if (replacement != settings.autoReplacements.end() &&
|
||||||
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
|
validAutoCorrection(replacement->second, alphabet))
|
||||||
return sameLayout(replacement->second);
|
return sameLayout(replacement->second);
|
||||||
}
|
}
|
||||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
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);
|
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
|
||||||
return window ? chatKey(processName(window), chatTitle(window)) : L"";
|
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,
|
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
|
||||||
const std::vector<Layout> &layouts)
|
const std::vector<Layout> &layouts)
|
||||||
{
|
{
|
||||||
auto found = settings.chatCycles.find(key);
|
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;
|
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; }))
|
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
|
||||||
result.push_back(id);
|
result.push_back(id);
|
||||||
return result.empty() ? settings.cycle : result;
|
return result.empty() ? settings.cycle : result;
|
||||||
|
|||||||
+23
-1
@@ -52,6 +52,19 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
return FALSE;
|
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)
|
if (message == WM_APP + 20)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -102,7 +115,16 @@ void App::applySettings(const Settings &next)
|
|||||||
memcpy(payload.data(), &length, sizeof(length));
|
memcpy(payload.data(), &length, sizeof(length));
|
||||||
payload.insert(payload.end(), before.begin(), before.end());
|
payload.insert(payload.end(), before.begin(), before.end());
|
||||||
payload.insert(payload.end(), after.begin(), after.end());
|
payload.insert(payload.end(), after.begin(), after.end());
|
||||||
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
|
Bytes saved;
|
||||||
|
try {saved=request(*this,ConfigSave,payload);}
|
||||||
|
catch(const std::exception &error){
|
||||||
|
if(!std::string(error.what()).starts_with("Settings conflict: "))throw;
|
||||||
|
auto text=wide(error.what())+L"\n\nThese settings changed both here and in KSwitcher.\nKeep your edits for these settings? Other changes will be preserved.\n\nNo leaves this window open with all your edits.";
|
||||||
|
if(MessageBoxW(settingsWindow?settingsWindow:snippetsWindow,text.c_str(),L"Save conflicting settings",MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2)!=IDYES)
|
||||||
|
throw std::runtime_error("Not saved. Your edits are still in this window.");
|
||||||
|
saved=request(*this,ConfigSaveEdited,payload);
|
||||||
|
}
|
||||||
|
settings=decodeSettings(saved,layouts);
|
||||||
}
|
}
|
||||||
void App::reloadLayouts()
|
void App::reloadLayouts()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
|
#include "settings_merge.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
@@ -67,6 +68,7 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ConfigSave:
|
case ConfigSave:
|
||||||
|
case ConfigSaveEdited:
|
||||||
{
|
{
|
||||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||||
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
|
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
|
||||||
@@ -74,11 +76,10 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
memcpy(&length, message.lpData, sizeof(length));
|
memcpy(&length, message.lpData, sizeof(length));
|
||||||
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
|
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
|
||||||
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
|
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
|
||||||
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
|
|
||||||
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
|
|
||||||
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
|
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
|
||||||
if (next.empty()) throw std::runtime_error("Empty settings request.");
|
if (next.empty()) throw std::runtime_error("Empty settings request.");
|
||||||
app.applySettings(decodeSettings(next, app.layouts));
|
auto merged=mergeConfigurationSettings(Bytes(begin,begin+length),next,encodeSettings(app.settings),message.dwData==ConfigSaveEdited);
|
||||||
|
app.applySettings(decodeSettings(merged, app.layouts));
|
||||||
reply = encodeSettings(app.settings);
|
reply = encodeSettings(app.settings);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,12 +2,13 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
|
||||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||||
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||||
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigEnd = ConfigRead + 12;
|
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = ConfigRead + 13;
|
||||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
bool configurationPeer(HWND, const wchar_t *filename);
|
||||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||||
|
|||||||
@@ -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;
|
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
|
||||||
return 0;
|
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,
|
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
|
||||||
const std::vector<std::pair<int, Hotkey>> &bindings)
|
const std::vector<std::pair<int, Hotkey>> &bindings)
|
||||||
{
|
{
|
||||||
|
|||||||
+22
@@ -3,6 +3,7 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -19,6 +20,7 @@ struct Layout
|
|||||||
bool hasLetter(wchar_t ch) const;
|
bool hasLetter(wchar_t ch) const;
|
||||||
};
|
};
|
||||||
bool letter(wchar_t ch);
|
bool letter(wchar_t ch);
|
||||||
|
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet);
|
||||||
std::wstring lower(std::wstring text);
|
std::wstring lower(std::wstring text);
|
||||||
std::wstring upper(std::wstring text);
|
std::wstring upper(std::wstring text);
|
||||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||||
@@ -52,6 +54,22 @@ struct Hotkey
|
|||||||
static std::optional<Hotkey> parse(std::wstring);
|
static std::optional<Hotkey> parse(std::wstring);
|
||||||
std::wstring format() const;
|
std::wstring format() const;
|
||||||
};
|
};
|
||||||
|
struct AppLanguageSet
|
||||||
|
{
|
||||||
|
std::vector<std::wstring> languages, applications;
|
||||||
|
bool operator==(const AppLanguageSet &) const = default;
|
||||||
|
};
|
||||||
|
enum class MouseActionType { Standard, Shortcut, Back, Forward, VolumeUp, VolumeDown, Mute, Launch };
|
||||||
|
inline constexpr int DefaultMouseVolumeStep = 4;
|
||||||
|
struct MouseAction
|
||||||
|
{
|
||||||
|
MouseActionType type = MouseActionType::Standard;
|
||||||
|
Hotkey shortcut;
|
||||||
|
std::wstring executable;
|
||||||
|
std::set<std::wstring> applications{};
|
||||||
|
int volumeStep = DefaultMouseVolumeStep;
|
||||||
|
bool operator==(const MouseAction &) const = default;
|
||||||
|
};
|
||||||
struct Settings
|
struct Settings
|
||||||
{
|
{
|
||||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||||
@@ -81,6 +99,10 @@ struct Settings
|
|||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
// Exact application + visible chat name; an absent entry uses the global cycle.
|
// 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, std::vector<std::wstring>> chatCycles;
|
||||||
|
std::map<std::wstring, AppLanguageSet> appLanguageSets;
|
||||||
|
std::map<std::wstring, std::map<std::wstring, MouseAction>> mouseBindings;
|
||||||
|
std::set<std::wstring> disabledMouseProfiles;
|
||||||
|
std::map<std::wstring, std::set<std::wstring>> mouseProfileApplications;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
std::vector<std::wstring> snippetTombstones;
|
std::vector<std::wstring> snippetTombstones;
|
||||||
|
|||||||
+159
-73
@@ -3,7 +3,9 @@
|
|||||||
#include <iphlpapi.h>
|
#include <iphlpapi.h>
|
||||||
#include "lan.h"
|
#include "lan.h"
|
||||||
#include "lan_pairing.h"
|
#include "lan_pairing.h"
|
||||||
#include "lan_input.h"
|
#include "lan_mesh.h"
|
||||||
|
#include "lan_clipboard.h"
|
||||||
|
#include "lan_layout.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
@@ -50,8 +52,9 @@ struct LanService::Impl
|
|||||||
std::filesystem::path path;
|
std::filesystem::path path;
|
||||||
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
|
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
|
||||||
std::map<std::string,Peer> peers;
|
std::map<std::string,Peer> peers;
|
||||||
Json trusted=Json::object();
|
Json trusted=Json::object(),positions=Json::object();
|
||||||
std::unique_ptr<lan::Pairing> pending;
|
std::unique_ptr<lan::Pairing> pending;
|
||||||
|
std::unique_ptr<lan::ClipboardExchange> clipboard;
|
||||||
bool enabled{};
|
bool enabled{};
|
||||||
std::atomic<bool> stopping{false};
|
std::atomic<bool> stopping{false};
|
||||||
std::mutex mutex;
|
std::mutex mutex;
|
||||||
@@ -62,20 +65,25 @@ struct LanService::Impl
|
|||||||
std::atomic<bool> inputConnected{false},abortInput{false};
|
std::atomic<bool> inputConnected{false},abortInput{false};
|
||||||
std::atomic<uint64_t> inputEpoch{0};
|
std::atomic<uint64_t> inputEpoch{0};
|
||||||
std::atomic<uint64_t> inputConfiguration{0};
|
std::atomic<uint64_t> inputConfiguration{0};
|
||||||
uint64_t observedEpoch{},observedRevision{};
|
std::atomic<bool> layoutChanged{false};
|
||||||
|
uint64_t observedEpoch{},observedRevision{},observedConfiguration{};
|
||||||
std::mutex inputMutex;
|
std::mutex inputMutex;
|
||||||
std::vector<lan::InputFrame> inputOut,inputIn;
|
std::vector<lan::RoutedInput> inputOut,inputIn;
|
||||||
std::unique_ptr<lan::InputRouter> router;
|
std::map<std::string,uint64_t> inputReady;
|
||||||
|
std::unique_ptr<lan::InputMesh> router;
|
||||||
std::string inputStatus="Input sharing disabled.";
|
std::string inputStatus="Input sharing disabled.";
|
||||||
explicit Impl(const std::filesystem::path &root) : path(root/L"lan-pairing.bin")
|
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)
|
||||||
{
|
{
|
||||||
if(std::filesystem::exists(path))
|
if(std::filesystem::exists(path))
|
||||||
{
|
{
|
||||||
auto j=Json::parse(unprotect(readFile(path,256*1024)));
|
auto j=Json::parse(unprotect(readFile(path,256*1024)));
|
||||||
if(j.value("version",0)!=1 || !lan::identifier(j.value("id",""))) throw std::runtime_error("Invalid local LAN configuration.");
|
if(j.value("version",0)!=1 || !lan::identifier(j.value("id",""))) throw std::runtime_error("Invalid local LAN configuration.");
|
||||||
id=j.at("id"); enabled=j.value("enabled",false); trusted=j.at("trusted");
|
id=j.at("id"); enabled=j.value("enabled",false); trusted=j.at("trusted");
|
||||||
|
positions=j.value("positions",Json::object());
|
||||||
shareInput=j.value("shareInput",false);inputPeer=j.value("inputPeer","");inputEdge=j.value("inputEdge",1);
|
shareInput=j.value("shareInput",false);inputPeer=j.value("inputPeer","");inputEdge=j.value("inputEdge",1);
|
||||||
if(inputEdge<0||inputEdge>3||(shareInput&&!trusted.contains(inputPeer)))throw std::runtime_error("Invalid input sharing configuration.");
|
if(inputEdge<0||inputEdge>3||(shareInput&&trusted.empty()))throw std::runtime_error("Invalid input sharing configuration.");
|
||||||
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
|
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
|
||||||
for(auto &[key,entry]:trusted.items())
|
for(auto &[key,entry]:trusted.items())
|
||||||
if(!lan::identifier(key)||key==id||entry.at("key").get<std::string>().size()!=64||
|
if(!lan::identifier(key)||key==id||entry.at("key").get<std::string>().size()!=64||
|
||||||
@@ -83,41 +91,53 @@ struct LanService::Impl
|
|||||||
throw std::runtime_error("Invalid paired computer key.");
|
throw std::runtime_error("Invalid paired computer key.");
|
||||||
}
|
}
|
||||||
else id=lan::randomHex(16);
|
else id=lan::randomHex(16);
|
||||||
|
lan::fillPositions(positions,trusted,inputPeer,inputEdge);
|
||||||
|
clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||||
configureRouter();
|
configureRouter();
|
||||||
if(enabled) start();
|
if(enabled) start();
|
||||||
}
|
}
|
||||||
~Impl() { stop(); }
|
~Impl() { stop(); }
|
||||||
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
|
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
|
||||||
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge}}))); }
|
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||||
bool queue(lan::InputFrame frame)
|
bool queue(const std::string &peer,lan::InputFrame frame)
|
||||||
{
|
{
|
||||||
if(!inputConnected)return false;
|
|
||||||
std::lock_guard lock(inputMutex);
|
std::lock_guard lock(inputMutex);
|
||||||
|
if(!inputReady.contains(peer))return false;
|
||||||
if(inputOut.size()>=512){abortInput=true;return false;}
|
if(inputOut.size()>=512){abortInput=true;return false;}
|
||||||
inputOut.push_back(frame);return true;
|
if(!inputOut.empty()&&inputOut.back().peer==peer&&inputOut.back().epoch==inputReady.at(peer)){
|
||||||
|
std::vector<lan::InputFrame> tail{inputOut.back().frame};lan::enqueueInput(tail,frame);
|
||||||
|
if(tail.size()==1){inputOut.back().frame=tail.front();return true;}
|
||||||
|
}
|
||||||
|
inputOut.push_back({peer,frame,inputReady.at(peer)});return true;
|
||||||
}
|
}
|
||||||
void configureRouter()
|
void configureRouter()
|
||||||
{
|
{
|
||||||
router.reset();inputConnected=false;abortInput=true;++inputEpoch;++inputConfiguration;
|
if(router)router->cancel(GetTickCount64());
|
||||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
|
router.reset();++inputConfiguration;
|
||||||
if(enabled&&shareInput)router=std::make_unique<lan::InputRouter>(lan::windowsInputEnvironment(),[this](auto f){return queue(f);},inputEdge,id<inputPeer);
|
if(enabled&&shareInput){
|
||||||
inputStatus=enabled&&shareInput?"Connecting input channel...":"Input sharing disabled.";
|
std::map<std::string,POINT> layout;for(auto &[peer,cell]:positions.items())if(trusted.contains(peer))layout[peer]={cell[0].get<LONG>(),cell[1].get<LONG>()};
|
||||||
|
router=std::make_unique<lan::InputMesh>(id,lan::windowsInputEnvironment(),std::move(layout),[this](const auto &peer,auto f){return queue(peer,f);});
|
||||||
|
}
|
||||||
|
inputStatus=enabled&&shareInput?"Connecting input channels...":"Input sharing disabled.";
|
||||||
observedRevision=0;
|
observedRevision=0;
|
||||||
}
|
}
|
||||||
bool takeChange()
|
bool takeChange()
|
||||||
{
|
{
|
||||||
auto revision=router?router->revision():0;bool changed=revision!=observedRevision;observedRevision=revision;return changed;
|
auto revision=router?router->revision():0;auto configuration=inputConfiguration.load();
|
||||||
|
bool changed=revision!=observedRevision||configuration!=observedConfiguration;
|
||||||
|
observedRevision=revision;observedConfiguration=configuration;return changed;
|
||||||
}
|
}
|
||||||
bool tickInput(bool allowEnter)
|
bool tickInput(bool allowEnter)
|
||||||
{
|
{
|
||||||
|
inputNotification=false;
|
||||||
|
if(layoutChanged.exchange(false)){std::lock_guard lock(mutex);configureRouter();}
|
||||||
if(!router)return false;
|
if(!router)return false;
|
||||||
auto epoch=inputEpoch.load();if(epoch!=observedEpoch){router->cancel(GetTickCount64());observedEpoch=epoch;}
|
auto epoch=inputEpoch.load();if(epoch!=observedEpoch){router->cancel(GetTickCount64());observedEpoch=epoch;}
|
||||||
router->tick(inputConnected,GetTickCount64());
|
std::vector<lan::RoutedInput> incoming;std::map<std::string,uint64_t> ready;
|
||||||
std::vector<lan::InputFrame> incoming;
|
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);ready=inputReady;}
|
||||||
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);}
|
router->tick(ready,GetTickCount64());
|
||||||
for(auto &frame:incoming)
|
for(auto &frame:incoming)if(ready.contains(frame.peer)&&ready.at(frame.peer)==frame.epoch)
|
||||||
if(!allowEnter&&frame.type==lan::InputType::Enter)queue({lan::InputType::Return,frame.token,32768});
|
router->receive(frame.peer,frame.frame,GetTickCount64(),allowEnter);
|
||||||
else router->receive(frame,GetTickCount64());
|
|
||||||
return takeChange();
|
return takeChange();
|
||||||
}
|
}
|
||||||
void start()
|
void start()
|
||||||
@@ -131,8 +151,8 @@ struct LanService::Impl
|
|||||||
stopping=true;
|
stopping=true;
|
||||||
if(worker.joinable()) worker.join();
|
if(worker.joinable()) worker.join();
|
||||||
inputConnected=false;++inputEpoch;
|
inputConnected=false;++inputEpoch;
|
||||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
|
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();inputReady.clear();}
|
||||||
std::lock_guard lock(mutex); pending.reset(); peers.clear();
|
std::lock_guard lock(mutex); pending.reset(); peers.clear();clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||||
}
|
}
|
||||||
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
|
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
|
||||||
{
|
{
|
||||||
@@ -154,7 +174,12 @@ struct LanService::Impl
|
|||||||
// Discovery is untrusted and never grants input access.
|
// Discovery is untrusted and never grants input access.
|
||||||
if(!peers.contains(other) && peers.size()>=64) return;
|
if(!peers.contains(other) && peers.size()>=64) return;
|
||||||
auto &peer=peers[other];
|
auto &peer=peers[other];
|
||||||
if(peer.epoch!=session || peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
|
// A fresh announcement rotates the challenge every three seconds; it
|
||||||
|
// does not revoke a recent successful proof for the same address.
|
||||||
|
// Only a valid presence response renews verified, so untrusted/replayed
|
||||||
|
// announcements cannot extend its PeerLifetime expiry.
|
||||||
|
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
|
||||||
|
clipboard->peerSession(other,j.value("clipboardSession",""));
|
||||||
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
|
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
|
||||||
if(trusted.contains(other))
|
if(trusted.contains(other))
|
||||||
{
|
{
|
||||||
@@ -167,6 +192,14 @@ struct LanService::Impl
|
|||||||
if(!peers.contains(other)||j.value("to","")!=id) return;
|
if(!peers.contains(other)||j.value("to","")!=id) return;
|
||||||
auto &peer=peers.at(other);
|
auto &peer=peers.at(other);
|
||||||
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
|
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
|
||||||
|
if(type=="clipboard" && trusted.contains(other))
|
||||||
|
{
|
||||||
|
try {clipboard->receive(other,trusted.at(other).at("key"),j,now,GetClipboardSequenceNumber(),
|
||||||
|
[&](const auto &target,const auto &packet){send(socket,peers.at(target).address,packet);});}
|
||||||
|
catch(const std::exception &) {return;}
|
||||||
|
if(!clipboard->deliveries.empty()&&inputWindow)PostMessageW(inputWindow,LanClipboardReadyMessage,0,0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(type=="presence" && trusted.contains(other) && j.value("epoch","")==peer.epoch &&
|
if(type=="presence" && trusted.contains(other) && j.value("epoch","")==peer.epoch &&
|
||||||
lan::equal(j.value("proof",""),lan::mac(trusted[other].at("key"),heartbeat(other,id,peer.epoch,epoch))))
|
lan::equal(j.value("proof",""),lan::mac(trusted[other].at("key"),heartbeat(other,id,peer.epoch,epoch))))
|
||||||
{ peer.verified=now; return; }
|
{ peer.verified=now; return; }
|
||||||
@@ -175,10 +208,10 @@ struct LanService::Impl
|
|||||||
for(const auto &response:pending->receive(j,now)) send(socket,address,response);
|
for(const auto &response:pending->receive(j,now)) send(socket,address,response);
|
||||||
if(pending->confirmed && (!trusted.contains(other)||trusted[other].at("key")!=pending->derived))
|
if(pending->confirmed && (!trusted.contains(other)||trusted[other].at("key")!=pending->derived))
|
||||||
{
|
{
|
||||||
auto previous=trusted;
|
auto previous=trusted;auto previousPositions=positions;
|
||||||
trusted[other]={{"name",peer.name},{"key",pending->derived}};
|
trusted[other]={{"name",peer.name},{"key",pending->derived}};
|
||||||
try {save();} catch(...) {trusted=std::move(previous);throw;}
|
try {lan::fillPositions(positions,trusted,inputPeer,inputEdge);save();} catch(...) {trusted=std::move(previous);positions=std::move(previousPositions);throw;}
|
||||||
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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,27 +226,33 @@ struct LanService::Impl
|
|||||||
if(socket==INVALID_SOCKET || setsockopt(socket,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes)) ||
|
if(socket==INVALID_SOCKET || setsockopt(socket,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes)) ||
|
||||||
setsockopt(socket,SOL_SOCKET,SO_BROADCAST,(char*)&yes,sizeof(yes)) || ioctlsocket(socket,FIONBIO,&nonblock))
|
setsockopt(socket,SOL_SOCKET,SO_BROADCAST,(char*)&yes,sizeof(yes)) || ioctlsocket(socket,FIONBIO,&nonblock))
|
||||||
throw std::runtime_error("Cannot open LAN discovery socket.");
|
throw std::runtime_error("Cannot open LAN discovery socket.");
|
||||||
|
int clipboardBuffer=1024*1024;
|
||||||
|
setsockopt(socket,SOL_SOCKET,SO_RCVBUF,reinterpret_cast<const char*>(&clipboardBuffer),sizeof(clipboardBuffer));
|
||||||
|
setsockopt(socket,SOL_SOCKET,SO_SNDBUF,reinterpret_cast<const char*>(&clipboardBuffer),sizeof(clipboardBuffer));
|
||||||
sockaddr_in bound{}; bound.sin_family=AF_INET; bound.sin_port=htons(lan::Port);
|
sockaddr_in bound{}; bound.sin_family=AF_INET; bound.sin_port=htons(lan::Port);
|
||||||
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.");
|
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,lastChannelGeneration=0;
|
uint64_t nextHello=0,nextOffer=0;
|
||||||
std::unique_ptr<lan::InputChannel> channel;
|
lan::InputHub hub(id);
|
||||||
std::string channelPeer,channelSecret;int channelEdge=-1;
|
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)
|
while(!stopping)
|
||||||
{
|
{
|
||||||
auto now=GetTickCount64();
|
auto now=GetTickCount64();bool clipboardActive=false;
|
||||||
std::string selectedPeer,selectedSecret;int selectedEdge=-1;uint32_t selectedAddress=0;uint64_t configuration=0;
|
std::vector<lan::InputEndpoint> endpoints;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(mutex);
|
std::lock_guard lock(mutex);
|
||||||
configuration=inputConfiguration.load();
|
clipboard->poll(trusted,now,[&](const auto &target,const auto &packet){
|
||||||
if(shareInput&&trusted.contains(inputPeer))
|
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||||
{
|
});
|
||||||
selectedPeer=inputPeer;selectedSecret=trusted.at(inputPeer).at("key");selectedEdge=inputEdge;
|
clipboardActive=clipboard->active();
|
||||||
if(peers.contains(inputPeer))selectedAddress=peers.at(inputPeer).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)
|
if(now>=nextHello)
|
||||||
{
|
{
|
||||||
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
|
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
|
||||||
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;
|
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
|
||||||
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
|
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
|
||||||
nextHello=now+3000;
|
nextHello=now+3000;
|
||||||
}
|
}
|
||||||
@@ -228,30 +267,25 @@ struct LanService::Impl
|
|||||||
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
|
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(channelPeer!=selectedPeer||channelEdge!=selectedEdge||channelSecret!=selectedSecret)
|
std::vector<lan::RoutedInput> out,in;
|
||||||
{
|
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
|
||||||
inputConnected=false;++inputEpoch;channel.reset();
|
if(abortInput.exchange(false)){hub.disconnect();out.clear();}
|
||||||
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();}
|
hub.poll(endpoints,out,in,now);
|
||||||
channelPeer=selectedPeer;channelEdge=selectedEdge;channelSecret=selectedSecret;lastChannelGeneration=0;
|
auto readyPeers=hub.ready();inputConnected=!readyPeers.empty();
|
||||||
if(!selectedPeer.empty())channel=std::make_unique<lan::InputChannel>(id,selectedPeer,selectedSecret,selectedEdge);
|
{std::lock_guard lock(inputMutex);inputReady=std::move(readyPeers);
|
||||||
}
|
for(auto &frame:in){
|
||||||
if(channel)
|
if(!inputIn.empty()&&inputIn.back().peer==frame.peer&&inputIn.back().epoch==frame.epoch){
|
||||||
{
|
std::vector<lan::InputFrame> tail{inputIn.back().frame};lan::enqueueInput(tail,frame.frame);
|
||||||
std::vector<lan::InputFrame> out,in;
|
if(tail.size()==1){inputIn.back().frame=tail.front();continue;}
|
||||||
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
|
}
|
||||||
if(abortInput.exchange(false)){channel->disconnect();out.clear();}
|
inputIn.push_back(frame);
|
||||||
channel->poll(selectedAddress,out,in,now);
|
|
||||||
if(configuration!=inputConfiguration.load()){in.clear();channel->disconnect();}
|
|
||||||
if(lastChannelGeneration!=channel->generation())
|
|
||||||
{
|
|
||||||
lastChannelGeneration=channel->generation();inputConnected=false;++inputEpoch;
|
|
||||||
std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();
|
|
||||||
}
|
}
|
||||||
inputConnected=channel->ready()&&!abortInput.load()&&configuration==inputConfiguration.load();
|
if(inputIn.size()>512){inputIn.clear();abortInput=true;}
|
||||||
{std::lock_guard lock(inputMutex);if(inputIn.size()+in.size()>512)abortInput=true;else inputIn.insert(inputIn.end(),in.begin(),in.end());}
|
|
||||||
{std::lock_guard lock(mutex);inputStatus=channel->status();}
|
|
||||||
}
|
}
|
||||||
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,channel?5000:100000};
|
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
|
||||||
|
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
|
||||||
|
{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);
|
int ready=select(0,&read,nullptr,nullptr,&delay);
|
||||||
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
|
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
|
||||||
if(ready>0) for(int count=0;count<64;++count)
|
if(ready>0) for(int count=0;count<64;++count)
|
||||||
@@ -260,7 +294,14 @@ struct LanService::Impl
|
|||||||
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
|
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
|
||||||
if(received<=0) break;
|
if(received<=0) break;
|
||||||
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
|
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.
|
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,16 +324,36 @@ struct LanService::Impl
|
|||||||
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
|
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
|
||||||
}
|
}
|
||||||
std::lock_guard lock(mutex);
|
std::lock_guard lock(mutex);
|
||||||
|
if(action=="position")
|
||||||
|
{
|
||||||
|
auto peer=j.at("peer").get<std::string>();int x=j.at("x"),y=j.at("y");
|
||||||
|
if(!trusted.contains(peer))throw std::runtime_error("Pair this computer before arranging it.");
|
||||||
|
lan::validateCell(positions,peer,x,y);
|
||||||
|
auto before=positions;int beforeEdge=inputEdge;bool beforeActive=shareInput;
|
||||||
|
positions[peer]=Json::array({x,y});
|
||||||
|
if(peer==inputPeer){int edge=lan::cellEdge(positions[peer]);if(edge>=0)inputEdge=edge;}
|
||||||
|
try{save();}catch(...){positions=std::move(before);inputEdge=beforeEdge;shareInput=beforeActive;throw;}
|
||||||
|
configureRouter();
|
||||||
|
}
|
||||||
if(action=="input")
|
if(action=="input")
|
||||||
{
|
{
|
||||||
bool active=j.at("enabled");auto peer=j.value("peer",inputPeer);int edge=j.value("edge",inputEdge);
|
bool active=j.at("enabled");auto peer=j.value("peer",inputPeer);int edge=j.value("edge",inputEdge);
|
||||||
if(edge<0||edge>3||(active&&(!enabled||!trusted.contains(peer))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
|
if(edge<0||edge>3||(active&&(!enabled||trusted.empty()||(j.contains("peer")&&!trusted.contains(peer)))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
|
||||||
auto beforePeer=inputPeer;int beforeEdge=inputEdge;bool beforeActive=shareInput;
|
auto beforePeer=inputPeer;auto beforePositions=positions;int beforeEdge=inputEdge;bool beforeActive=shareInput;
|
||||||
|
if(j.contains("edge")&&trusted.contains(peer)){auto cell=lan::edgeCell(edge);lan::validateCell(positions,peer,cell[0],cell[1]);positions[peer]=cell;}
|
||||||
shareInput=active;inputPeer=peer;inputEdge=edge;
|
shareInput=active;inputPeer=peer;inputEdge=edge;
|
||||||
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;throw;}
|
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;positions=std::move(beforePositions);throw;}
|
||||||
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
|
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
|
||||||
}
|
}
|
||||||
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
|
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=="cancel") { pending.reset(); status="Pairing cancelled."; }
|
||||||
if(action=="pair")
|
if(action=="pair")
|
||||||
{
|
{
|
||||||
@@ -304,17 +365,21 @@ struct LanService::Impl
|
|||||||
}
|
}
|
||||||
if(action=="forget")
|
if(action=="forget")
|
||||||
{
|
{
|
||||||
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;bool oldSharing=shareInput;
|
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;auto oldPositions=positions;bool oldSharing=shareInput;
|
||||||
trusted.erase(peer);if(peer==inputPeer)shareInput=false;
|
trusted.erase(peer);positions.erase(peer);if(trusted.empty())shareInput=false;
|
||||||
try{save();}catch(...){trusted=std::move(oldTrusted);shareInput=oldSharing;throw;}
|
try{save();}catch(...){trusted=std::move(oldTrusted);positions=std::move(oldPositions);shareInput=oldSharing;throw;}
|
||||||
if(pending&&pending->peer==peer)pending.reset();
|
if(pending&&pending->peer==peer)pending.reset();
|
||||||
if(peer==inputPeer)configureRouter();
|
configureRouter();
|
||||||
status="Pair removed on this computer.";
|
status="Pair removed on this computer.";
|
||||||
}
|
}
|
||||||
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
|
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
|
||||||
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
|
{"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();
|
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)
|
for(const auto &[peer,p]:peers)
|
||||||
{
|
{
|
||||||
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
|
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
|
||||||
@@ -326,9 +391,30 @@ struct LanService::Impl
|
|||||||
return encoded(result);
|
return encoded(result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
LanService::LanService(const std::filesystem::path &path) : impl(std::make_unique<Impl>(path)) {}
|
LanService::LanService(const std::filesystem::path &path,HWND window) : impl(std::make_unique<Impl>(path,window)) {}
|
||||||
LanService::~LanService()=default;
|
LanService::~LanService()=default;
|
||||||
Bytes LanService::command(const Bytes &command) { return impl->command(command); }
|
Bytes LanService::command(const Bytes &command) { return impl->command(command); }
|
||||||
|
bool LanService::clipboardEnabled()const
|
||||||
|
{
|
||||||
|
std::lock_guard lock(impl->mutex);
|
||||||
|
if(!impl->enabled)return false;
|
||||||
|
for(auto &[id,entry]:impl->trusted.items())if(impl->peers.contains(id))return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void LanService::publishClipboard(Bytes bytes,const std::string &app)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(impl->mutex);if(!impl->enabled)return;
|
||||||
|
std::vector<std::string> targets;
|
||||||
|
for(auto &[id,entry]:impl->trusted.items())if(impl->peers.contains(id))targets.push_back(id);
|
||||||
|
if(!targets.empty())impl->clipboard->queue(targets,std::move(bytes),app,GetTickCount64());
|
||||||
|
}
|
||||||
|
std::vector<LanClipboard> LanService::takeClipboard()
|
||||||
|
{
|
||||||
|
std::lock_guard lock(impl->mutex);std::vector<LanClipboard> result;
|
||||||
|
for(auto &d:impl->clipboard->deliveries)if(impl->trusted.contains(d.peer))
|
||||||
|
result.push_back({d.peer,impl->trusted.at(d.peer).at("name"),d.app,std::move(d.bytes),d.sequence});
|
||||||
|
impl->clipboard->deliveries.clear();return result;
|
||||||
|
}
|
||||||
bool LanService::inputEnabled()const{return impl->enabled&&impl->shareInput;}
|
bool LanService::inputEnabled()const{return impl->enabled&&impl->shareInput;}
|
||||||
bool LanService::tickInput(bool allowEnter){return impl->tickInput(allowEnter);}
|
bool LanService::tickInput(bool allowEnter){return impl->tickInput(allowEnter);}
|
||||||
bool LanService::keyboard(const KBDLLHOOKSTRUCT &k,bool down){return impl->router&&impl->router->keyboard(k,down,GetTickCount64());}
|
bool LanService::keyboard(const KBDLLHOOKSTRUCT &k,bool down){return impl->router&&impl->router->keyboard(k,down,GetTickCount64());}
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
|
||||||
|
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
|
||||||
// Discovery, pairing and optional encrypted input within the resident process.
|
// Discovery, pairing and optional encrypted input within the resident process.
|
||||||
class LanService
|
class LanService
|
||||||
{
|
{
|
||||||
struct Impl;
|
struct Impl;
|
||||||
std::unique_ptr<Impl> impl;
|
std::unique_ptr<Impl> impl;
|
||||||
public:
|
public:
|
||||||
explicit LanService(const std::filesystem::path &);
|
explicit LanService(const std::filesystem::path &, HWND inputWindow=nullptr);
|
||||||
~LanService();
|
~LanService();
|
||||||
Bytes command(const Bytes &);
|
Bytes command(const Bytes &);
|
||||||
|
bool clipboardEnabled() const;
|
||||||
|
void publishClipboard(Bytes, const std::string &);
|
||||||
|
std::vector<LanClipboard> takeClipboard();
|
||||||
bool inputEnabled() const;
|
bool inputEnabled() const;
|
||||||
bool tickInput(bool allowEnter = true);
|
bool tickInput(bool allowEnter = true);
|
||||||
bool keyboard(const KBDLLHOOKSTRUCT &, bool down);
|
bool keyboard(const KBDLLHOOKSTRUCT &, bool down);
|
||||||
|
|||||||
+159
-20
@@ -1,6 +1,7 @@
|
|||||||
#include <winsock2.h>
|
#include <winsock2.h>
|
||||||
#include <ws2tcpip.h>
|
#include <ws2tcpip.h>
|
||||||
#include "lan_channel.h"
|
#include "lan_channel.h"
|
||||||
|
#include "lan_datagram.h"
|
||||||
#include <bcrypt.h>
|
#include <bcrypt.h>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
@@ -49,6 +50,16 @@ struct InputCipher::Impl
|
|||||||
};
|
};
|
||||||
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
|
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
|
||||||
InputCipher::~InputCipher()=default;
|
InputCipher::~InputCipher()=default;
|
||||||
|
Bytes InputCipher::encryptPayload(const Bytes &plain)
|
||||||
|
{
|
||||||
|
if(plain.empty()||plain.size()>1024)throw std::runtime_error("Invalid file packet size.");
|
||||||
|
Bytes tag(16);auto result=impl->crypt(plain,tag,true);result.insert(result.end(),tag.begin(),tag.end());return result;
|
||||||
|
}
|
||||||
|
Bytes InputCipher::decryptPayload(const Bytes &packet)
|
||||||
|
{
|
||||||
|
if(packet.size()<17||packet.size()>1040)throw std::runtime_error("Invalid file packet size.");
|
||||||
|
Bytes tag(packet.end()-16,packet.end());return impl->crypt(Bytes(packet.begin(),packet.end()-16),tag,false);
|
||||||
|
}
|
||||||
Bytes InputCipher::encrypt(const InputFrame &f)
|
Bytes InputCipher::encrypt(const InputFrame &f)
|
||||||
{
|
{
|
||||||
Bytes plain;put32(plain,uint32_t(f.type));put32(plain,f.token);put32(plain,uint32_t(f.a));put32(plain,uint32_t(f.b));put32(plain,uint32_t(f.c));
|
Bytes plain;put32(plain,uint32_t(f.type));put32(plain,f.token);put32(plain,uint32_t(f.a));put32(plain,uint32_t(f.b));put32(plain,uint32_t(f.c));
|
||||||
@@ -58,7 +69,7 @@ InputFrame InputCipher::decrypt(const Bytes &packet)
|
|||||||
{
|
{
|
||||||
if(packet.size()!=36)throw std::runtime_error("Invalid encrypted input frame.");
|
if(packet.size()!=36)throw std::runtime_error("Invalid encrypted input frame.");
|
||||||
Bytes tag(packet.begin()+20,packet.end());auto plain=impl->crypt(Bytes(packet.begin(),packet.begin()+20),tag,false);
|
Bytes tag(packet.begin()+20,packet.end());auto plain=impl->crypt(Bytes(packet.begin(),packet.begin()+20),tag,false);
|
||||||
auto type=get32(plain.data());if(type>uint32_t(InputType::Wheel))throw std::runtime_error("Unsupported input frame.");
|
auto type=get32(plain.data());if(type>uint32_t(InputType::Cross))throw std::runtime_error("Unsupported input frame.");
|
||||||
return {InputType(type),get32(plain.data()+4),int32_t(get32(plain.data()+8)),int32_t(get32(plain.data()+12)),int32_t(get32(plain.data()+16))};
|
return {InputType(type),get32(plain.data()+4),int32_t(get32(plain.data()+8)),int32_t(get32(plain.data()+12)),int32_t(get32(plain.data()+16))};
|
||||||
}
|
}
|
||||||
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,const std::string &a,const std::string &b,int edge)
|
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,const std::string &a,const std::string &b,int edge)
|
||||||
@@ -68,22 +79,23 @@ std::string inputSessionKey(const std::string &secret,const std::string &from,co
|
|||||||
}
|
}
|
||||||
struct InputChannel::Impl
|
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;
|
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
|
||||||
bool connecting{},authenticated{},helloRead{};
|
bool connecting{},authenticated{},helloRead{},managed{};
|
||||||
uint64_t changed{},lastRead{},lastPing{},retry{};
|
uint64_t changed{},lastRead{},lastPing{},retry{},waitingSince{};
|
||||||
|
bool reverse{};
|
||||||
Bytes incoming,outgoing;
|
Bytes incoming,outgoing;
|
||||||
std::unique_ptr<InputCipher> tx,rx;
|
std::unique_ptr<InputCipher> tx,rx;
|
||||||
Impl(std::string a,std::string b,std::string key,int side,uint16_t p):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p){}
|
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);}
|
~Impl(){closeSocket(socket);closeSocket(listener);}
|
||||||
void reset(uint64_t now,const std::string &reason)
|
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)
|
void begin(uint64_t now)
|
||||||
{
|
{
|
||||||
nonce=randomHex(16);lastRead=lastPing=now;
|
nonce=randomHex(16);lastRead=lastPing=now;
|
||||||
auto hello=Json{{"app","KSwitcher-input"},{"version",1},{"id",local},{"to",peer},{"nonce",nonce},{"edge",edge}}.dump();
|
auto hello=Json{{"app","KSwitcher-input"},{"version",managed?2:1},{"id",local},{"to",peer},{"nonce",nonce},{"edge",edge}}.dump();
|
||||||
put32(outgoing,uint32_t(hello.size()));outgoing.insert(outgoing.end(),hello.begin(),hello.end());
|
put32(outgoing,uint32_t(hello.size()));outgoing.insert(outgoing.end(),hello.begin(),hello.end());
|
||||||
state="Authenticating paired KSwitcher...";
|
state="Authenticating paired KSwitcher...";
|
||||||
}
|
}
|
||||||
@@ -91,8 +103,9 @@ struct InputChannel::Impl
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if(!waitingSince)waitingSince=now;
|
||||||
if(socket==INVALID_SOCKET&&now<retry)return;
|
if(socket==INVALID_SOCKET&&now<retry)return;
|
||||||
if(listener==INVALID_SOCKET && local>peer)
|
if(!managed && listener==INVALID_SOCKET && local>peer)
|
||||||
{
|
{
|
||||||
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;
|
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;
|
||||||
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)){closeSocket(listener);throw std::runtime_error("Cannot open input listener.");}
|
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)){closeSocket(listener);throw std::runtime_error("Cannot open input listener.");}
|
||||||
@@ -101,15 +114,17 @@ struct InputChannel::Impl
|
|||||||
}
|
}
|
||||||
if(socket==INVALID_SOCKET && address && now>=retry)
|
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.");
|
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;
|
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));
|
int result=connect(socket,(sockaddr*)&a,sizeof(a));
|
||||||
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
|
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
|
||||||
connecting=result!=0;begin(now);
|
connecting=result!=0;begin(now);
|
||||||
|
if(connecting)state=reverse?"Trying reverse TCP connection...":"Trying direct TCP connection...";
|
||||||
}
|
}
|
||||||
else
|
else if(!managed)
|
||||||
{
|
{
|
||||||
sockaddr_in a{};int length=sizeof(a);socket=accept(listener,(sockaddr*)&a,&length);
|
sockaddr_in a{};int length=sizeof(a);socket=accept(listener,(sockaddr*)&a,&length);
|
||||||
if(socket!=INVALID_SOCKET)
|
if(socket!=INVALID_SOCKET)
|
||||||
@@ -121,13 +136,20 @@ struct InputChannel::Impl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(socket==INVALID_SOCKET)return;
|
if(socket==INVALID_SOCKET)return;
|
||||||
if(now-lastRead>2000)throw std::runtime_error("Input connection timed out; control returned locally.");
|
// Give TCP establishment time for SYN retransmission / firewall approval.
|
||||||
|
// Keep the short fail-safe only for an already authenticated input session.
|
||||||
|
if(now-lastRead>(authenticated?2000:10000))
|
||||||
|
throw std::runtime_error(authenticated ? "Input connection lost (no heartbeat for 2 seconds); control returned locally." :
|
||||||
|
connecting ? "Cannot reach TCP 45884. Enable input on both PCs and check the receiving PC firewall." :
|
||||||
|
"TCP connected, but input authentication timed out. Check pairing and input setup on both PCs.");
|
||||||
if(connecting)
|
if(connecting)
|
||||||
{
|
{
|
||||||
fd_set writes,errors;FD_ZERO(&writes);FD_ZERO(&errors);FD_SET(socket,&writes);FD_SET(socket,&errors);timeval wait{};
|
fd_set writes,errors;FD_ZERO(&writes);FD_ZERO(&errors);FD_SET(socket,&writes);FD_SET(socket,&errors);timeval wait{};
|
||||||
if(select(0,nullptr,&writes,&errors,&wait)<=0)return;
|
if(select(0,nullptr,&writes,&errors,&wait)<=0)return;
|
||||||
int error=0;int size=sizeof(error);if(getsockopt(socket,SOL_SOCKET,SO_ERROR,(char*)&error,&size)||error)throw std::runtime_error("Cannot connect to paired KSwitcher.");
|
int error=0;int size=sizeof(error);
|
||||||
connecting=false;
|
if(getsockopt(socket,SOL_SOCKET,SO_ERROR,(char*)&error,&size))error=WSAGetLastError();
|
||||||
|
if(error)throw std::runtime_error("Cannot connect to TCP 45884 (Windows error "+std::to_string(error)+"). Enable input on both PCs and check the receiving PC firewall.");
|
||||||
|
connecting=false;state="TCP connected; authenticating paired KSwitcher...";
|
||||||
}
|
}
|
||||||
if(authenticated)
|
if(authenticated)
|
||||||
{
|
{
|
||||||
@@ -155,19 +177,19 @@ struct InputChannel::Impl
|
|||||||
size_t length=get32(incoming.data());if(length>512||length<32)throw std::runtime_error("Invalid input handshake.");
|
size_t length=get32(incoming.data());if(length>512||length<32)throw std::runtime_error("Invalid input handshake.");
|
||||||
if(incoming.size()<length+4)return;
|
if(incoming.size()<length+4)return;
|
||||||
auto hello=Json::parse(incoming.begin()+4,incoming.begin()+4+length);
|
auto hello=Json::parse(incoming.begin()+4,incoming.begin()+4+length);
|
||||||
if(hello.value("app","")!="KSwitcher-input"||hello.value("version",0)!=1||hello.value("id","")!=peer||hello.value("to","")!=local)
|
if(hello.value("app","")!="KSwitcher-input"||hello.value("version",0)!=(managed?2:1)||hello.value("id","")!=peer||hello.value("to","")!=local)
|
||||||
throw std::runtime_error("Unexpected input peer.");
|
throw std::runtime_error("Unexpected input peer.");
|
||||||
if(hello.value("edge",-1)!=(edge^1))throw std::runtime_error("Screen positions must be opposite on the two computers.");
|
if(!managed&&hello.value("edge",-1)!=(edge^1))throw std::runtime_error("Screen positions must be opposite on the two computers.");
|
||||||
auto remoteNonce=hello.value("nonce","");
|
auto remoteNonce=hello.value("nonce","");
|
||||||
tx=std::make_unique<InputCipher>(inputSessionKey(secret,local,peer,nonce,remoteNonce,edge));
|
tx=std::make_unique<InputCipher>(inputSessionKey(secret,local,peer,nonce,remoteNonce,managed?-1:edge));
|
||||||
rx=std::make_unique<InputCipher>(inputSessionKey(secret,peer,local,remoteNonce,nonce,edge^1));
|
rx=std::make_unique<InputCipher>(inputSessionKey(secret,peer,local,remoteNonce,nonce,managed?-1:(edge^1)));
|
||||||
incoming.erase(incoming.begin(),incoming.begin()+4+length);helloRead=true;
|
incoming.erase(incoming.begin(),incoming.begin()+4+length);helloRead=true;
|
||||||
auto bytes=tx->encrypt({InputType::Ready});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());
|
auto bytes=tx->encrypt({InputType::Ready});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());
|
||||||
}
|
}
|
||||||
while(helloRead && incoming.size()>=36)
|
while(helloRead && incoming.size()>=36)
|
||||||
{
|
{
|
||||||
auto f=rx->decrypt(Bytes(incoming.begin(),incoming.begin()+36));incoming.erase(incoming.begin(),incoming.begin()+36);lastRead=now;
|
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::Ready)throw std::runtime_error("Repeated input handshake.");
|
||||||
else if(f.type!=InputType::Ping)received.push_back(f);
|
else if(f.type!=InputType::Ping)received.push_back(f);
|
||||||
}
|
}
|
||||||
@@ -175,11 +197,128 @@ struct InputChannel::Impl
|
|||||||
catch(const std::exception &e){received.clear();reset(now,e.what());}
|
catch(const std::exception &e){received.clear();reset(now,e.what());}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
InputChannel::InputChannel(std::string a,std::string b,std::string key,int edge,uint16_t port):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port)){}
|
InputChannel::InputChannel(std::string a,std::string b,std::string key,int edge,uint16_t port,bool managed):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port,managed)){}
|
||||||
InputChannel::~InputChannel()=default;
|
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);}
|
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->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;}
|
bool InputChannel::ready()const{return impl->authenticated;}
|
||||||
uint64_t InputChannel::generation()const{return impl->changed;}
|
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.");}
|
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;std::unique_ptr<InputDatagram> udp;uint64_t generation{},epoch{},since{},failedSince{};bool udpReady{};std::string lastState; };
|
||||||
|
struct Pending {SOCKET socket;uint32_t address;uint64_t since;};
|
||||||
|
std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET;
|
||||||
|
std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{};
|
||||||
|
std::string error;
|
||||||
|
InputHub::SendDatagram sendDatagram;
|
||||||
|
uint64_t currentTime{};
|
||||||
|
Impl(std::string id,uint16_t p):local(std::move(id)),port(p){}
|
||||||
|
~Impl(){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);}
|
||||||
|
void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now)
|
||||||
|
{
|
||||||
|
currentTime=now;
|
||||||
|
for(auto it=links.begin();it!=links.end();){
|
||||||
|
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port;});
|
||||||
|
if(match==endpoints.end())it=links.erase(it);else ++it;
|
||||||
|
}
|
||||||
|
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
|
||||||
|
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);
|
||||||
|
link.udp=std::make_unique<InputDatagram>(local,e.id,e.key);link.since=link.failedSince=now;link.epoch=++epoch;links.emplace(e.id,std::move(link));
|
||||||
|
}
|
||||||
|
// Discovery is a hint, not the lifetime of authenticated input sessions.
|
||||||
|
// Keep the last usable address across missed announcements. A new address
|
||||||
|
// 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)){
|
||||||
|
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;
|
||||||
|
if(!nonblocking(sock)){closeSocket(sock);continue;}pending.push_back({sock,a.sin_addr.s_addr,now});
|
||||||
|
}
|
||||||
|
for(auto it=pending.begin();it!=pending.end();){
|
||||||
|
bool done=now-it->since>2000;unsigned char bytes[516];int count=recv(it->socket,(char*)bytes,sizeof(bytes),MSG_PEEK);
|
||||||
|
if(count==0||(count<0&&WSAGetLastError()!=WSAEWOULDBLOCK))done=true;
|
||||||
|
if(!done&&count>=4){
|
||||||
|
auto length=get32(bytes);if(length<32||length>512)done=true;
|
||||||
|
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(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 &){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(done){closeSocket(it->socket);it=pending.erase(it);}else ++it;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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()||l.udp->ready())result[id]=l.epoch;return result;}
|
||||||
|
std::string InputHub::status()const{
|
||||||
|
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->ready()&&!l.udp->ready())return l.lastState;
|
||||||
|
return "Waiting for paired computers...";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+42
-2
@@ -1,9 +1,11 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "lan_pairing.h"
|
#include "lan_pairing.h"
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <map>
|
||||||
|
#include <functional>
|
||||||
namespace ks::lan
|
namespace ks::lan
|
||||||
{
|
{
|
||||||
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel };
|
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
|
||||||
struct InputFrame
|
struct InputFrame
|
||||||
{
|
{
|
||||||
InputType type{};
|
InputType type{};
|
||||||
@@ -11,6 +13,23 @@ struct InputFrame
|
|||||||
int32_t a{},b{},c{};
|
int32_t a{},b{},c{};
|
||||||
bool operator==(const InputFrame &) const = default;
|
bool operator==(const InputFrame &) const = default;
|
||||||
};
|
};
|
||||||
|
// Collapse only contiguous, not-yet-delivered wheel events. A reversal replaces
|
||||||
|
// stale queued motion; key/button boundaries must never be crossed.
|
||||||
|
inline void enqueueInput(std::vector<InputFrame> &queue,const InputFrame &frame)
|
||||||
|
{
|
||||||
|
if(frame.type==InputType::Wheel&&!queue.empty())
|
||||||
|
{
|
||||||
|
auto &last=queue.back();
|
||||||
|
if(last.type==InputType::Wheel&&last.token==frame.token&&last.b==frame.b)
|
||||||
|
{
|
||||||
|
if((last.a<0&&frame.a>0)||(last.a>0&&frame.a<0)){last=frame;return;}
|
||||||
|
auto total=int64_t(last.a)+frame.a;
|
||||||
|
if(total>=-32768&&total<=32767){last.a=int32_t(total);return;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.push_back(frame);
|
||||||
|
|
||||||
|
}
|
||||||
// One directional AES-256-GCM key per session. Strict sequence numbers prevent replay.
|
// One directional AES-256-GCM key per session. Strict sequence numbers prevent replay.
|
||||||
class InputCipher
|
class InputCipher
|
||||||
{
|
{
|
||||||
@@ -20,6 +39,8 @@ class InputCipher
|
|||||||
~InputCipher();
|
~InputCipher();
|
||||||
Bytes encrypt(const InputFrame &);
|
Bytes encrypt(const InputFrame &);
|
||||||
InputFrame decrypt(const Bytes &);
|
InputFrame decrypt(const Bytes &);
|
||||||
|
Bytes encryptPayload(const Bytes &);
|
||||||
|
Bytes decryptPayload(const Bytes &);
|
||||||
};
|
};
|
||||||
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,
|
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,
|
||||||
const std::string &fromNonce,const std::string &toNonce,int fromEdge=-1);
|
const std::string &fromNonce,const std::string &toNonce,int fromEdge=-1);
|
||||||
@@ -29,12 +50,31 @@ class InputChannel
|
|||||||
struct Impl; std::unique_ptr<Impl> impl;
|
struct Impl; std::unique_ptr<Impl> impl;
|
||||||
public:
|
public:
|
||||||
InputChannel(std::string local,std::string peer,std::string secret,int edge,
|
InputChannel(std::string local,std::string peer,std::string secret,int edge,
|
||||||
uint16_t port=45884);
|
uint16_t port=45884,bool managed=false);
|
||||||
~InputChannel();
|
~InputChannel();
|
||||||
void poll(uint32_t peerAddress,const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now);
|
void poll(uint32_t peerAddress,const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now);
|
||||||
|
bool adopt(uintptr_t socket,uint64_t now);
|
||||||
bool ready() const;
|
bool ready() const;
|
||||||
uint64_t generation() const;
|
uint64_t generation() const;
|
||||||
std::string status() const;
|
std::string status() const;
|
||||||
void disconnect();
|
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{};};
|
||||||
|
class InputHub
|
||||||
|
{
|
||||||
|
struct Impl;std::unique_ptr<Impl> impl;
|
||||||
|
public:
|
||||||
|
explicit InputHub(std::string local,uint16_t port=45884);
|
||||||
|
~InputHub();
|
||||||
|
void poll(const std::vector<InputEndpoint> &,const std::vector<RoutedInput> &,std::vector<RoutedInput> &,uint64_t now);
|
||||||
|
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,133 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "lan_channel.h"
|
||||||
|
#include <map>
|
||||||
|
#include <deque>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
// Reliable bounded clipboard messages over the existing discovery socket.
|
||||||
|
// Every datagram has a fresh directional AES-GCM key; no plaintext clipboard
|
||||||
|
// metadata or contents appear outside the encrypted CBOR body.
|
||||||
|
class ClipboardExchange
|
||||||
|
{
|
||||||
|
static constexpr size_t Limit=32*1024*1024, Chunk=480, Window=32;
|
||||||
|
struct Item {std::string id,app;Bytes bytes;};
|
||||||
|
struct Sending {std::deque<std::shared_ptr<Item>> queue;size_t ack{};bool begun{};uint64_t last{},progress{};};
|
||||||
|
struct Receiving {std::string id,app;Bytes bytes;size_t size{};uint64_t last{};DWORD sequence{};};
|
||||||
|
std::string local, boot=randomHex(16);
|
||||||
|
std::map<std::string,std::string> sessions;
|
||||||
|
std::map<std::string,Sending> sending;
|
||||||
|
std::map<std::string,Receiving> receiving;
|
||||||
|
std::map<std::string,std::deque<std::pair<std::string,size_t>>> completed;
|
||||||
|
size_t incomingBytes{};uint64_t nextPacket{};
|
||||||
|
std::map<std::string,std::map<std::string,uint64_t>> receivedPackets;
|
||||||
|
static std::string hex(const Bytes &bytes){std::string s;for(auto b:bytes){s+="0123456789abcdef"[b>>4];s+="0123456789abcdef"[b&15];}return s;}
|
||||||
|
static Bytes unhex(const std::string &s)
|
||||||
|
{
|
||||||
|
if(s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid clipboard 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;
|
||||||
|
}
|
||||||
|
Json seal(const std::string &peer,const std::string &key,const Json &body)
|
||||||
|
{
|
||||||
|
auto nonce=randomHex(16);auto number=++nextPacket;
|
||||||
|
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
|
||||||
|
auto packet=envelope("clipboard",local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
|
||||||
|
packet["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));return packet;
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
struct Delivery {std::string peer,id,app;Bytes bytes;DWORD sequence{};};
|
||||||
|
std::vector<Delivery> deliveries;
|
||||||
|
std::string status="Clipboard sharing ready.";
|
||||||
|
explicit ClipboardExchange(std::string id):local(std::move(id)){}
|
||||||
|
const std::string &session()const{return boot;}
|
||||||
|
bool active()const{return !sending.empty();}
|
||||||
|
void peerSession(const std::string &peer,const std::string &value){
|
||||||
|
if(!identifier(value))return;
|
||||||
|
if(sessions.contains(peer)&&sessions.at(peer)!=value){
|
||||||
|
auto found=sending.find(peer);if(found!=sending.end()){found->second.ack=0;found->second.begun=false;found->second.last=0;}
|
||||||
|
}
|
||||||
|
sessions[peer]=value;
|
||||||
|
}
|
||||||
|
void queue(const std::vector<std::string> &peers,Bytes bytes,std::string app,uint64_t now)
|
||||||
|
{
|
||||||
|
if(bytes.empty()||bytes.size()>Limit)throw std::runtime_error("LAN clipboard limit is 32 MiB.");
|
||||||
|
size_t total=bytes.size();std::set<const Item*> counted;
|
||||||
|
for(auto &[peer,s]:sending)for(auto &item:s.queue)if(counted.insert(item.get()).second)total+=item->bytes.size();
|
||||||
|
if(total>2*Limit)throw std::runtime_error("LAN clipboard queue is full.");
|
||||||
|
auto item=std::make_shared<Item>(Item{randomHex(16),app.size()>128?std::string("Application"):app,std::move(bytes)});
|
||||||
|
for(auto &peer:peers){auto &s=sending[peer];if(s.queue.size()>=8)continue;if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
|
||||||
|
if(!peers.empty())status="Sending clipboard to "+std::to_string(peers.size())+" paired computers...";
|
||||||
|
}
|
||||||
|
template<class Send>void poll(const Json &trusted,uint64_t now,Send send)
|
||||||
|
{
|
||||||
|
for(auto it=receiving.begin();it!=receiving.end();)
|
||||||
|
if(!trusted.contains(it->first)||now-it->second.last>30000){incomingBytes-=it->second.size;it=receiving.erase(it);}else ++it;
|
||||||
|
for(auto it=sending.begin();it!=sending.end();)
|
||||||
|
{
|
||||||
|
auto &peer=it->first;auto &s=it->second;
|
||||||
|
if(!trusted.contains(peer)||s.queue.empty()){it=sending.erase(it);continue;}
|
||||||
|
if(now-s.progress>30000){status="Clipboard transfer timed out.";it=sending.erase(it);continue;}
|
||||||
|
if(sessions.contains(peer)&&(!s.last||now-s.last>=100))
|
||||||
|
{
|
||||||
|
auto &item=*s.queue.front();auto key=trusted.at(peer).at("key").template get<std::string>();
|
||||||
|
if(!s.begun)send(peer,seal(peer,key,{{"op","begin"},{"item",item.id},{"size",item.bytes.size()},{"app",item.app}}));
|
||||||
|
else for(size_t offset=s.ack,n=0;offset<item.bytes.size()&&n<Window;offset+=Chunk,++n)
|
||||||
|
send(peer,seal(peer,key,{{"op","chunk"},{"item",item.id},{"offset",offset},
|
||||||
|
{"bytes",Json::binary(Bytes(item.bytes.begin()+offset,item.bytes.begin()+std::min(offset+Chunk,item.bytes.size())))}}));
|
||||||
|
s.last=now;
|
||||||
|
}
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
template<class Send>void receive(const std::string &peer,const std::string &key,const Json &packet,uint64_t now,DWORD sequence,Send send)
|
||||||
|
{
|
||||||
|
if(packet.value("session","")!=boot||!sessions.contains(peer)||packet.value("to","")!=local||!identifier(packet.value("nonce","")))return;
|
||||||
|
auto senderBoot=packet.value("fromSession","");auto number=packet.value("number",uint64_t{});
|
||||||
|
if(!identifier(senderBoot)||!number)return;
|
||||||
|
auto &seen=receivedPackets[peer];
|
||||||
|
if((seen.contains(senderBoot)&&number<=seen.at(senderBoot))||(!seen.contains(senderBoot)&&seen.size()>=1024))return;
|
||||||
|
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
|
||||||
|
auto body=Json::from_cbor(cipher.decryptPayload(unhex(packet.at("data").get<std::string>())));
|
||||||
|
seen[senderBoot]=number; // Retransmissions use new numbers; reordered old packets are retried.
|
||||||
|
auto id=body.value("item","");if(!identifier(id))return;
|
||||||
|
auto ack=[&](size_t offset){send(peer,seal(peer,key,{{"op","ack"},{"item",id},{"offset",offset}}));};
|
||||||
|
auto op=body.value("op","");
|
||||||
|
if(op=="ack")
|
||||||
|
{
|
||||||
|
auto found=sending.find(peer);if(found==sending.end()||found->second.queue.empty())return;
|
||||||
|
auto &s=found->second;auto &item=*s.queue.front();auto offset=body.at("offset").get<size_t>();
|
||||||
|
if(item.id!=id||offset>item.bytes.size()||offset<s.ack||offset>s.ack+Window*Chunk||(offset!=item.bytes.size()&&offset%Chunk))return;
|
||||||
|
if(offset==s.ack&&s.begun)return;
|
||||||
|
s.begun=true;s.ack=offset;s.progress=now;s.last=0;
|
||||||
|
if(offset==item.bytes.size()){s.queue.pop_front();s.ack=0;s.begun=false;status="Clipboard delivered.";}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(auto &done:completed[peer])if(done.first==id){ack(done.second);return;}
|
||||||
|
if(op=="begin")
|
||||||
|
{
|
||||||
|
auto size=body.at("size").get<size_t>();auto app=body.value("app","");
|
||||||
|
if(!size||size>Limit||app.size()>128)return;
|
||||||
|
auto found=receiving.find(peer);
|
||||||
|
if(found!=receiving.end()){if(found->second.id==id)ack(found->second.bytes.size());return;}
|
||||||
|
size_t waiting=0;for(auto &d:deliveries)waiting+=d.bytes.size();
|
||||||
|
if(incomingBytes+waiting+size>2*Limit||deliveries.size()>=8)return;
|
||||||
|
receiving.emplace(peer,Receiving{id,app,{},size,now,sequence});incomingBytes+=size;ack(0);return;
|
||||||
|
}
|
||||||
|
if(op!="chunk")return;
|
||||||
|
auto found=receiving.find(peer);if(found==receiving.end()||found->second.id!=id)return;
|
||||||
|
auto &r=found->second;auto offset=body.at("offset").get<size_t>();
|
||||||
|
if(!body.at("bytes").is_binary())return;
|
||||||
|
const auto &bytes=body.at("bytes").get_binary();
|
||||||
|
if(bytes.empty()||bytes.size()>Chunk||offset>r.size||bytes.size()>r.size-offset)return;
|
||||||
|
if(offset==r.bytes.size()){r.bytes.insert(r.bytes.end(),bytes.begin(),bytes.end());r.last=now;}
|
||||||
|
ack(r.bytes.size());
|
||||||
|
if(r.bytes.size()==r.size)
|
||||||
|
{
|
||||||
|
deliveries.push_back({peer,r.id,r.app,std::move(r.bytes),r.sequence});
|
||||||
|
auto &done=completed[peer];done.push_back({id,r.size});if(done.size()>64)done.pop_front();
|
||||||
|
incomingBytes-=r.size;receiving.erase(found);status="Clipboard received.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ocr.h"
|
||||||
|
#include <algorithm>
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
// Send one lossless image, not PNG plus multiple uncompressed DIB copies.
|
||||||
|
inline Clip compactClipboardImage(Clip clip)
|
||||||
|
{
|
||||||
|
auto png=std::find_if(clip.formats.begin(),clip.formats.end(),[](const Format &f){return f.name==L"PNG";});
|
||||||
|
bool image=png!=clip.formats.end()||std::any_of(clip.formats.begin(),clip.formats.end(),[](const Format &f){return f.id==CF_DIB||f.id==CF_DIBV5;});
|
||||||
|
if(!image)return clip;
|
||||||
|
Bytes bytes=png!=clip.formats.end()?png->data:encodePng(decodeImage(clip));
|
||||||
|
std::erase_if(clip.formats,[](const Format &f){return f.id==CF_DIB||f.id==CF_DIBV5||f.name==L"PNG";});
|
||||||
|
clip.formats.push_back({RegisterClipboardFormatW(L"PNG"),L"PNG",std::move(bytes)});
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
// Older applications paste DIB rather than PNG; expand only at the destination.
|
||||||
|
inline Clip clipboardImageForPaste(Clip clip)
|
||||||
|
{
|
||||||
|
bool dib=false,png=false;
|
||||||
|
for(const auto &f:clip.formats){dib|=f.id==CF_DIB||f.id==CF_DIBV5;png|=f.name==L"PNG";}
|
||||||
|
if(png&&!dib)clip.formats.push_back(imageDib(decodeImage(clip)));
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+154
-39
@@ -1,5 +1,7 @@
|
|||||||
#include "lan_input.h"
|
#include "lan_input.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <bit>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
namespace ks::lan
|
namespace ks::lan
|
||||||
{
|
{
|
||||||
@@ -7,9 +9,30 @@ ULONG_PTR remoteInputMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e49);}
|
|||||||
ULONG_PTR cursorWarpMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e57);}
|
ULONG_PTR cursorWarpMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e57);}
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
uint32_t ownershipToken()
|
||||||
|
{
|
||||||
|
static std::atomic<uint32_t> next{1};
|
||||||
|
auto token=next.fetch_add(1);return token?token:next.fetch_add(1);
|
||||||
|
}
|
||||||
|
HCURSOR parkedCursor()
|
||||||
|
{
|
||||||
|
static HCURSOR cursor=[] {unsigned char mask[128],pixels[128]{};
|
||||||
|
std::fill(std::begin(mask),std::end(mask),0xff);
|
||||||
|
return CreateCursor(GetModuleHandleW(nullptr),0,0,32,32,mask,pixels);}();
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
int monitorDpi(POINT point)
|
||||||
|
{
|
||||||
|
static HMODULE module=LoadLibraryW(L"shcore.dll");
|
||||||
|
using Query=HRESULT(WINAPI *)(HMONITOR,int,UINT*,UINT*);
|
||||||
|
static Query query=module?std::bit_cast<Query>(GetProcAddress(module,"GetDpiForMonitor")):nullptr;
|
||||||
|
UINT x=96,y=96;if(query)query(MonitorFromPoint(point,MONITOR_DEFAULTTONEAREST),0,&x,&y);
|
||||||
|
return int(std::clamp<UINT>(x,48,768));
|
||||||
|
}
|
||||||
LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
|
LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
|
||||||
{
|
{
|
||||||
if(message==WM_SETCURSOR){SetCursor(nullptr);return TRUE;}
|
if(message==WM_NCCREATE)SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)((CREATESTRUCTW*)lp)->lpCreateParams);
|
||||||
|
if(message==WM_SETCURSOR){SetCursor(GetWindowLongPtrW(w,GWLP_USERDATA)?parkedCursor():LoadCursorW(nullptr,IDC_ARROW));return TRUE;}
|
||||||
if(message==WM_MOUSEACTIVATE)return MA_NOACTIVATE;
|
if(message==WM_MOUSEACTIVATE)return MA_NOACTIVATE;
|
||||||
if(message==WM_PAINT)
|
if(message==WM_PAINT)
|
||||||
{
|
{
|
||||||
@@ -23,23 +46,27 @@ LRESULT CALLBACK controlOverlay(HWND w,UINT message,WPARAM wp,LPARAM lp)
|
|||||||
}
|
}
|
||||||
struct ControlOverlay
|
struct ControlOverlay
|
||||||
{
|
{
|
||||||
HWND cover{},notice{};
|
HWND notice{},cover{};HCURSOR previous{};
|
||||||
~ControlOverlay(){hide();}
|
~ControlOverlay(){hide();}
|
||||||
void hide(){if(notice)DestroyWindow(notice);if(cover)DestroyWindow(cover);notice=cover=nullptr;}
|
void hide(){bool shown=notice||cover;if(cover)DestroyWindow(cover);if(notice)DestroyWindow(notice);notice=cover=nullptr;
|
||||||
|
if(shown)SetCursor(previous?previous:LoadCursorW(nullptr,IDC_ARROW));
|
||||||
|
previous=nullptr;}
|
||||||
void show()
|
void show()
|
||||||
{
|
{
|
||||||
if(cover)return;
|
if(notice)return;
|
||||||
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";
|
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);
|
||||||
RegisterClassW(&cls);
|
RegisterClassW(&cls);
|
||||||
auto style=WS_EX_TOPMOST|WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE;
|
auto style=WS_EX_TOPMOST|WS_EX_TOOLWINDOW|WS_EX_NOACTIVATE;
|
||||||
POINT cursor{};GetCursorPos(&cursor);MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTONEAREST),&monitor);
|
POINT cursor{};GetCursorPos(&cursor);MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||||
// Only cover the parked cursor, not a full-screen layered bitmap.
|
CURSORINFO ci{sizeof(ci)};if(GetCursorInfo(&ci))previous=ci.hCursor;
|
||||||
int x=(monitor.rcMonitor.left+monitor.rcMonitor.right)/2-16,y=(monitor.rcMonitor.top+monitor.rcMonitor.bottom)/2-16;
|
int size=MulDiv(64,monitorDpi(cursor),96);
|
||||||
cover=CreateWindowExW(style|WS_EX_LAYERED,cls.lpszClassName,L"",WS_POPUP,x,y,32,32,nullptr,nullptr,cls.hInstance,nullptr);
|
cover=CreateWindowExW(style|WS_EX_LAYERED,cls.lpszClassName,L"",WS_POPUP,
|
||||||
|
(monitor.rcMonitor.left+monitor.rcMonitor.right-size)/2,(monitor.rcMonitor.top+monitor.rcMonitor.bottom-size)/2,
|
||||||
|
size,size,nullptr,nullptr,cls.hInstance,(void*)1);
|
||||||
if(cover){SetLayeredWindowAttributes(cover,0,1,LWA_ALPHA);ShowWindow(cover,SW_SHOWNOACTIVATE);}
|
if(cover){SetLayeredWindowAttributes(cover,0,1,LWA_ALPHA);ShowWindow(cover,SW_SHOWNOACTIVATE);}
|
||||||
notice=CreateWindowExW(style,cls.lpszClassName,L"KSwitcher remote control",WS_POPUP,monitor.rcMonitor.left+20,monitor.rcMonitor.top+20,400,48,nullptr,nullptr,cls.hInstance,nullptr);
|
notice=CreateWindowExW(style,cls.lpszClassName,L"KSwitcher remote control",WS_POPUP,monitor.rcMonitor.left+20,monitor.rcMonitor.top+20,400,48,nullptr,nullptr,cls.hInstance,nullptr);
|
||||||
if(notice)ShowWindow(notice,SW_SHOWNOACTIVATE);
|
if(notice)ShowWindow(notice,SW_SHOWNOACTIVATE);
|
||||||
SetCursor(nullptr);
|
if(cover)SetCursor(parkedCursor());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
bool ordinaryDesktop()
|
bool ordinaryDesktop()
|
||||||
@@ -68,7 +95,8 @@ InputEnvironment windowsInputEnvironment()
|
|||||||
[]{POINT p{};GetCursorPos(&p);MONITORINFO info{sizeof(info)};
|
[]{POINT p{};GetCursorPos(&p);MONITORINFO info{sizeof(info)};
|
||||||
if(GetMonitorInfoW(MonitorFromPoint(p,MONITOR_DEFAULTTONEAREST),&info))p={(info.rcMonitor.left+info.rcMonitor.right)/2,(info.rcMonitor.top+info.rcMonitor.bottom)/2};
|
if(GetMonitorInfoW(MonitorFromPoint(p,MONITOR_DEFAULTTONEAREST),&info))p={(info.rcMonitor.left+info.rcMonitor.right)/2,(info.rcMonitor.top+info.rcMonitor.bottom)/2};
|
||||||
return p;},
|
return p;},
|
||||||
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();}
|
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();},
|
||||||
|
monitorDpi
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
bool atEdge(POINT p,RECT r,int side)
|
bool atEdge(POINT p,RECT r,int side)
|
||||||
@@ -87,11 +115,11 @@ POINT edgePoint(RECT r,int side,int position,int inset)
|
|||||||
POINT p=side==0?POINT{r.left+inset,y}:side==1?POINT{r.right-1-inset,y}:side==2?POINT{x,r.top+inset}:POINT{x,r.bottom-1-inset};
|
POINT p=side==0?POINT{r.left+inset,y}:side==1?POINT{r.right-1-inset,y}:side==2?POINT{x,r.top+inset}:POINT{x,r.bottom-1-inset};
|
||||||
p.x=std::clamp(p.x,r.left,r.right-1);p.y=std::clamp(p.y,r.top,r.bottom-1);return p;
|
p.x=std::clamp(p.x,r.left,r.right-1);p.y=std::clamp(p.y,r.top,r.bottom-1);return p;
|
||||||
}
|
}
|
||||||
InputRouter::InputRouter(InputEnvironment e,std::function<bool(InputFrame)> s,int side,bool prefer)
|
InputRouter::InputRouter(InputEnvironment e,std::function<bool(InputFrame)> s,int side,bool prefer,bool multiple)
|
||||||
:env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),nextToken(prefer?1:2)
|
:env(std::move(e)),send(std::move(s)),edge(side),preferLocal(prefer),multi(multiple)
|
||||||
{if(side<0||side>3)throw std::runtime_error("Invalid screen position.");}
|
{if(side<0||side>3)throw std::runtime_error("Invalid screen position.");}
|
||||||
InputRouter::~InputRouter(){cancel(GetTickCount64());release();}
|
InputRouter::~InputRouter(){cancel(GetTickCount64());release();}
|
||||||
void InputRouter::mode(ControlMode value){if(mode_!=value){mode_=value;++revision_;if(env.modeChanged)env.modeChanged(value);}}
|
void InputRouter::mode(ControlMode value){if(mode_!=value){mode_=value;++revision_;if(env.modeChanged&&!(value==ControlMode::Local&&returning)&&!(value==ControlMode::Requesting&&transit))env.modeChanged(value);}}
|
||||||
bool InputRouter::emit(INPUT i)
|
bool InputRouter::emit(INPUT i)
|
||||||
{
|
{
|
||||||
if(i.type==INPUT_KEYBOARD)i.ki.dwExtraInfo=remoteInputMarker();
|
if(i.type==INPUT_KEYBOARD)i.ki.dwExtraInfo=remoteInputMarker();
|
||||||
@@ -105,7 +133,11 @@ bool InputRouter::move(POINT p,bool warp)
|
|||||||
// Target pixel centres, avoiding a one-pixel bias on every relative mouse event.
|
// Target pixel centres, avoiding a one-pixel bias on every relative mouse event.
|
||||||
input.mi.dx=LONG((2*int64_t(std::clamp(p.x,r.left,r.right-1)-r.left)+1)*32768/(r.right-r.left));
|
input.mi.dx=LONG((2*int64_t(std::clamp(p.x,r.left,r.right-1)-r.left)+1)*32768/(r.right-r.left));
|
||||||
input.mi.dy=LONG((2*int64_t(std::clamp(p.y,r.top,r.bottom-1)-r.top)+1)*32768/(r.bottom-r.top));
|
input.mi.dy=LONG((2*int64_t(std::clamp(p.y,r.top,r.bottom-1)-r.top)+1)*32768/(r.bottom-r.top));
|
||||||
input.mi.dwExtraInfo=warp?cursorWarpMarker():remoteInputMarker();return env.desktop()&&env.inject(input);
|
input.mi.dwExtraInfo=warp?cursorWarpMarker():remoteInputMarker();
|
||||||
|
if(warp&&mode_==ControlMode::Sending)awaitingWarp=true;
|
||||||
|
bool ok=env.desktop()&&env.inject(input);
|
||||||
|
if(!ok)awaitingWarp=false;
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
bool InputRouter::post(InputFrame f){return connected&&send(f);}
|
bool InputRouter::post(InputFrame f){return connected&&send(f);}
|
||||||
bool InputRouter::held()const
|
bool InputRouter::held()const
|
||||||
@@ -119,20 +151,59 @@ void InputRouter::release()
|
|||||||
}
|
}
|
||||||
void InputRouter::local(uint64_t now,int position)
|
void InputRouter::local(uint64_t now,int position)
|
||||||
{
|
{
|
||||||
bool sending=mode_==ControlMode::Sending;
|
bool sending=mode_==ControlMode::Sending||transit;
|
||||||
if(position<0)position=edgePosition(returnPoint,env.bounds(),edge);
|
if(position<0)position=edgePosition(returnPoint,env.bounds(),edge);
|
||||||
release();mode(ControlMode::Local);cooldown=now+600;token=0;
|
if(sending){returning=true;returnTarget=edgePoint(env.bounds(),edge,position);returnStarted=now;}
|
||||||
if(sending)move(edgePoint(env.bounds(),edge,position),true);
|
release();mode(ControlMode::Local);cooldown=now+600;token=0;transit=false;crossEdge=-1;remainderX=remainderY=0;awaitingWarp=awaitingEntry=false;
|
||||||
|
if(sending&&!move(returnTarget,true)){returning=false;if(env.modeChanged)env.modeChanged(ControlMode::Local);}
|
||||||
}
|
}
|
||||||
void InputRouter::cancel(uint64_t now)
|
bool InputRouter::handoff(int side,int position,uint64_t now)
|
||||||
{
|
{
|
||||||
|
if(!multi||mode_!=ControlMode::Sending||held()||side<0||side>3)return false;
|
||||||
|
edge=side;transit=true;token=ownershipToken();
|
||||||
|
requested=now;mode(ControlMode::Requesting);
|
||||||
|
if(!post({InputType::Enter,token,edge,std::clamp(position,0,65535)})){cancel(now,"next computer unavailable");return false;}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void InputRouter::returnAt(int side,int position,uint64_t now)
|
||||||
|
{
|
||||||
|
if(side>=0&&side<4)edge=side;
|
||||||
|
local(now,std::clamp(position,0,65535));
|
||||||
|
}
|
||||||
|
void InputRouter::cancel(uint64_t now,const char *reason)
|
||||||
|
{
|
||||||
|
if(mode_!=ControlMode::Local)log(L"LAN input: "+wide(reason)+L"; mode="+std::to_wstring(int(mode_)));
|
||||||
if(mode_!=ControlMode::Local)post({InputType::Return,token,edgePosition(env.cursor(),env.bounds(),edge)});
|
if(mode_!=ControlMode::Local)post({InputType::Return,token,edgePosition(env.cursor(),env.bounds(),edge)});
|
||||||
local(now);
|
local(now);
|
||||||
}
|
}
|
||||||
void InputRouter::tick(bool ready,uint64_t now)
|
void InputRouter::tick(bool ready,uint64_t now)
|
||||||
{
|
{
|
||||||
|
if(returning)
|
||||||
|
{
|
||||||
|
auto p=env.cursor();
|
||||||
|
if((p.x==returnTarget.x&&p.y==returnTarget.y)||now-returnStarted>250)
|
||||||
|
{returning=false;if(env.modeChanged)env.modeChanged(ControlMode::Local);}
|
||||||
|
}
|
||||||
connected=ready;
|
connected=ready;
|
||||||
if(!ready||!env.desktop()||(mode_==ControlMode::Requesting&&now-requested>1000))cancel(now);
|
if(!ready)cancel(now,"channel unavailable");
|
||||||
|
else if(!env.desktop())cancel(now,"input desktop unavailable");
|
||||||
|
else if(mode_==ControlMode::Requesting&&now-requested>1000)cancel(now,"handoff acknowledgement timed out");
|
||||||
|
if(mode_==ControlMode::Sending&&awaitingWarp)
|
||||||
|
{
|
||||||
|
auto p=env.cursor();
|
||||||
|
if(p.x==anchor.x&&p.y==anchor.y)awaitingWarp=false;
|
||||||
|
else if(now-warpStarted>250)cancel(now,"source cursor parking did not complete");
|
||||||
|
}
|
||||||
|
if(mode_==ControlMode::Receiving&&awaitingEntry)
|
||||||
|
{
|
||||||
|
auto p=env.cursor();
|
||||||
|
if(p.x==receivedPoint.x&&p.y==receivedPoint.y)
|
||||||
|
{
|
||||||
|
awaitingEntry=false;
|
||||||
|
if(!post({InputType::Accept,token}))cancel(now,"cannot acknowledge cursor entry");
|
||||||
|
}
|
||||||
|
else if(now-entryStarted>250)cancel(now,"destination cursor entry did not complete");
|
||||||
|
}
|
||||||
if(mode_==ControlMode::Local)release(); // Retry releases blocked by a temporarily secure desktop.
|
if(mode_==ControlMode::Local)release(); // Retry releases blocked by a temporarily secure desktop.
|
||||||
}
|
}
|
||||||
void InputRouter::receive(const InputFrame &f,uint64_t now)
|
void InputRouter::receive(const InputFrame &f,uint64_t now)
|
||||||
@@ -140,40 +211,65 @@ void InputRouter::receive(const InputFrame &f,uint64_t now)
|
|||||||
if(!connected||!env.desktop()){cancel(now);return;}
|
if(!connected||!env.desktop()){cancel(now);return;}
|
||||||
if(f.type==InputType::Enter)
|
if(f.type==InputType::Enter)
|
||||||
{
|
{
|
||||||
if(!f.token||f.a!=(edge^1)||f.b<0||f.b>65535||held()||
|
if(!f.token||f.a<0||f.a>3||(!multi&&f.a!=(edge^1))||f.b<0||f.b>65535||held()||returning||
|
||||||
mode_==ControlMode::Sending||mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&preferLocal))
|
mode_==ControlMode::Sending||mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&preferLocal))
|
||||||
{post({InputType::Return,f.token,32768});return;}
|
{post({InputType::Return,f.token,32768});return;}
|
||||||
if(mode_==ControlMode::Requesting)cancel(now);
|
if(mode_==ControlMode::Requesting)cancel(now);
|
||||||
token=f.token;mode(ControlMode::Receiving);
|
if(multi)edge=f.a^1;
|
||||||
if(!move(edgePoint(env.bounds(),edge,f.b))||!post({InputType::Accept,token}))cancel(now);
|
crossEdge=-1;token=f.token;remainderX=remainderY=0;mode(ControlMode::Receiving);
|
||||||
|
receivedPoint=edgePoint(env.bounds(),edge,f.b);
|
||||||
|
awaitingEntry=true;entryStarted=now;
|
||||||
|
if(!move(receivedPoint))cancel(now,"destination cursor injection failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(f.type==InputType::Accept)
|
if(f.type==InputType::Accept)
|
||||||
{
|
{
|
||||||
if(mode_!=ControlMode::Requesting||f.token!=token||held()||!atEdge(env.cursor(),env.bounds(),edge)){post({InputType::Return,f.token,32768});if(f.token==token)cancel(now);return;}
|
if(mode_!=ControlMode::Requesting||f.token!=token||held()||(!transit&&!atEdge(env.cursor(),env.bounds(),edge))){post({InputType::Return,f.token,32768});if(f.token==token)cancel(now);return;}
|
||||||
auto r=env.bounds();anchor=env.anchor?env.anchor():POINT{(r.left+r.right)/2,(r.top+r.bottom)/2};mode(ControlMode::Sending);
|
transit=false;auto r=env.bounds();anchor=env.anchor?env.anchor():POINT{(r.left+r.right)/2,(r.top+r.bottom)/2};mode(ControlMode::Sending);
|
||||||
if(!move(anchor,true))cancel(now);
|
warpStarted=now;
|
||||||
|
if(!move(anchor,true))cancel(now,"source cursor parking failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(f.token!=token||!token)return;
|
if(f.token!=token||!token)return;
|
||||||
if(f.type==InputType::Return){local(now,std::clamp(f.a,0,65535));return;}
|
if(f.type==InputType::Return){log(L"LAN input: peer returned control");local(now,std::clamp(f.a,0,65535));return;}
|
||||||
if(mode_!=ControlMode::Receiving)return;
|
if(mode_!=ControlMode::Receiving)return;
|
||||||
bool ok=true;
|
bool ok=true;
|
||||||
if(f.type==InputType::Move)
|
if(f.type==InputType::Move)
|
||||||
{
|
{
|
||||||
if(std::abs(int64_t(f.a))>32768||std::abs(int64_t(f.b))>32768){cancel(now);return;}
|
if(std::abs(int64_t(f.a))>32768||std::abs(int64_t(f.b))>32768){cancel(now);return;}
|
||||||
auto p=env.cursor();auto r=env.bounds();p.x=LONG(std::clamp<int64_t>(int64_t(p.x)+f.a,r.left,r.right-1));p.y=LONG(std::clamp<int64_t>(int64_t(p.y)+f.b,r.top,r.bottom-1));
|
if(f.c!=0&&(f.c<48||f.c>768)){cancel(now);return;}
|
||||||
if(atEdge(p,r,edge)&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
|
// SendInput queues movement; GetCursorPos may still describe the old
|
||||||
|
// centre while several network frames are drained in one timer tick.
|
||||||
|
auto p=receivedPoint;auto r=env.bounds();
|
||||||
|
double scale=f.c&&env.dpi?double(std::clamp(env.dpi(p),48,768))/f.c:1.0;
|
||||||
|
double x=f.a*scale+remainderX,y=f.b*scale+remainderY;
|
||||||
|
auto dx=int64_t(x),dy=int64_t(y);remainderX=x-dx;remainderY=y-dy;
|
||||||
|
p.x=LONG(std::clamp<int64_t>(int64_t(p.x)+dx,r.left,r.right-1));p.y=LONG(std::clamp<int64_t>(int64_t(p.y)+dy,r.top,r.bottom-1));
|
||||||
|
if(!multi&&atEdge(p,r,edge)&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
|
||||||
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;}))
|
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;}))
|
||||||
{post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
|
{log(L"LAN input: cursor crossed return edge");post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
|
||||||
ok=move(p);
|
ok=move(p);if(ok)receivedPoint=p;
|
||||||
|
if(multi&&ok&&!std::any_of(remoteKeys.begin(),remoteKeys.end(),[](bool b){return b;})&&
|
||||||
|
!std::any_of(remoteButtons.begin(),remoteButtons.end(),[](bool b){return b;})){
|
||||||
|
int horizontal=dx<0?0:1,vertical=dy<0?2:3,side=-1;
|
||||||
|
if(std::abs(dy)>std::abs(dx)&&dy&&atEdge(p,r,vertical))side=vertical;
|
||||||
|
else if(dx&&atEdge(p,r,horizontal))side=horizontal;
|
||||||
|
else if(dy&&atEdge(p,r,vertical))side=vertical;
|
||||||
|
if(side>=0&&(side!=crossEdge||now-crossAt>=150)){
|
||||||
|
crossEdge=side;crossAt=now;ok=post({InputType::Cross,token,side,edgePosition(p,r,side)});
|
||||||
|
}else if(side<0)crossEdge=-1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if(f.type==InputType::Key)
|
else if(f.type==InputType::Key)
|
||||||
{
|
{
|
||||||
if(f.a<1||f.a>255||f.b<0||f.b>255||f.c<0||f.c>3){cancel(now);return;}
|
if(f.a<1||f.a>255||f.b<0||f.b>255||f.c<0||f.c>3){cancel(now);return;}
|
||||||
INPUT input{};input.type=INPUT_KEYBOARD;input.ki.wVk=WORD(f.a);input.ki.wScan=WORD(f.b);
|
INPUT input{};input.type=INPUT_KEYBOARD;input.ki.wVk=WORD(f.a);input.ki.wScan=WORD(f.b);
|
||||||
input.ki.dwFlags=(f.c&1?0:KEYEVENTF_KEYUP)|(f.c&2?KEYEVENTF_EXTENDEDKEY:0);
|
input.ki.dwFlags=(f.c&1?0:KEYEVENTF_KEYUP)|(f.c&2?KEYEVENTF_EXTENDEDKEY:0);
|
||||||
if(f.b&&f.a!=VK_PAUSE)input.ki.dwFlags|=KEYEVENTF_SCANCODE;
|
bool modifier=f.a==VK_SHIFT||f.a==VK_CONTROL||f.a==VK_MENU||
|
||||||
|
(f.a>=VK_LSHIFT&&f.a<=VK_RMENU)||f.a==VK_LWIN||f.a==VK_RWIN;
|
||||||
|
// Explicit modifier VKs preserve their Windows state independently of
|
||||||
|
// the sending keyboard's scan-code mapping. Text keys remain physical.
|
||||||
|
if(f.b&&f.a!=VK_PAUSE&&!modifier)input.ki.dwFlags|=KEYEVENTF_SCANCODE;
|
||||||
ok=emit(input);if(ok){remoteKeys[size_t(f.a)]=(f.c&1)!=0;input.ki.dwFlags|=KEYEVENTF_KEYUP;releases[size_t(f.a)]=input;}
|
ok=emit(input);if(ok){remoteKeys[size_t(f.a)]=(f.c&1)!=0;input.ki.dwFlags|=KEYEVENTF_KEYUP;releases[size_t(f.a)]=input;}
|
||||||
}
|
}
|
||||||
else if(f.type==InputType::Button)
|
else if(f.type==InputType::Button)
|
||||||
@@ -186,16 +282,22 @@ void InputRouter::receive(const InputFrame &f,uint64_t now)
|
|||||||
if(f.a<-32768||f.a>32767||f.b<0||f.b>1){cancel(now);return;}
|
if(f.a<-32768||f.a>32767||f.b<0||f.b>1){cancel(now);return;}
|
||||||
INPUT input{};input.type=INPUT_MOUSE;input.mi.dwFlags=f.b?MOUSEEVENTF_HWHEEL:MOUSEEVENTF_WHEEL;input.mi.mouseData=DWORD(f.a);ok=emit(input);
|
INPUT input{};input.type=INPUT_MOUSE;input.mi.dwFlags=f.b?MOUSEEVENTF_HWHEEL:MOUSEEVENTF_WHEEL;input.mi.mouseData=DWORD(f.a);ok=emit(input);
|
||||||
}
|
}
|
||||||
if(!ok)cancel(now);
|
if(!ok)cancel(now,"Windows input injection failed");
|
||||||
}
|
}
|
||||||
bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
|
bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
|
||||||
{
|
{
|
||||||
if(k.flags&LLKHF_INJECTED||k.vkCode>255||!k.vkCode)return false;
|
// Conversion/layout changes can produce synthetic modifier releases.
|
||||||
|
// Never reinterpret our own tagged input as a physical takeover, even if
|
||||||
|
// another hook has changed the injection flag.
|
||||||
|
if(k.flags&LLKHF_INJECTED||k.dwExtraInfo==inputMarker()||
|
||||||
|
k.dwExtraInfo==remoteInputMarker()||k.vkCode>255||!k.vkCode)return false;
|
||||||
auto key=size_t(k.vkCode);physical[key]=down;
|
auto key=size_t(k.vkCode);physical[key]=down;
|
||||||
if(mode_!=ControlMode::Local&&down&&key==VK_ESCAPE&&(physical[VK_LCONTROL]||physical[VK_RCONTROL]||physical[VK_CONTROL])&&
|
if(mode_!=ControlMode::Local&&down&&key==VK_ESCAPE&&(physical[VK_LCONTROL]||physical[VK_RCONTROL]||physical[VK_CONTROL])&&
|
||||||
(physical[VK_LMENU]||physical[VK_RMENU]||physical[VK_MENU]))
|
(physical[VK_LMENU]||physical[VK_RMENU]||physical[VK_MENU]))
|
||||||
{outKeys[key]=true;cancel(now);return true;}
|
{outKeys[key]=true;cancel(now);return true;}
|
||||||
if(mode_==ControlMode::Receiving||mode_==ControlMode::Requesting)cancel(now);
|
// A real local press takes over. A key-up alone is not evidence of intent:
|
||||||
|
// Windows may generate it while changing the target keyboard layout.
|
||||||
|
if(down&&(mode_==ControlMode::Receiving||mode_==ControlMode::Requesting))cancel(now,"local physical keyboard takeover");
|
||||||
if(mode_==ControlMode::Sending)
|
if(mode_==ControlMode::Sending)
|
||||||
{
|
{
|
||||||
outKeys[key]=down;
|
outKeys[key]=down;
|
||||||
@@ -207,22 +309,35 @@ bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
|
|||||||
}
|
}
|
||||||
bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool allowEnter)
|
bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool allowEnter)
|
||||||
{
|
{
|
||||||
|
if((m.flags&LLMHF_INJECTED)&&m.dwExtraInfo==cursorWarpMarker())
|
||||||
|
{awaitingWarp=false;return false;}
|
||||||
if(m.flags&LLMHF_INJECTED)return false;
|
if(m.flags&LLMHF_INJECTED)return false;
|
||||||
|
if(returning&&message==WM_MOUSEMOVE)return true;
|
||||||
int index=-1;bool down=false;
|
int index=-1;bool down=false;
|
||||||
switch(message){case WM_LBUTTONDOWN:down=true;[[fallthrough]];case WM_LBUTTONUP:index=0;break;
|
switch(message){case WM_LBUTTONDOWN:down=true;[[fallthrough]];case WM_LBUTTONUP:index=0;break;
|
||||||
case WM_RBUTTONDOWN:down=true;[[fallthrough]];case WM_RBUTTONUP:index=1;break;
|
case WM_RBUTTONDOWN:down=true;[[fallthrough]];case WM_RBUTTONUP:index=1;break;
|
||||||
case WM_MBUTTONDOWN:down=true;[[fallthrough]];case WM_MBUTTONUP:index=2;break;
|
case WM_MBUTTONDOWN:down=true;[[fallthrough]];case WM_MBUTTONUP:index=2;break;
|
||||||
case WM_XBUTTONDOWN:down=true;[[fallthrough]];case WM_XBUTTONUP:index=HIWORD(m.mouseData)==XBUTTON1?3:4;break;}
|
case WM_XBUTTONDOWN:down=true;[[fallthrough]];case WM_XBUTTONUP:index=HIWORD(m.mouseData)==XBUTTON1?3:4;break;}
|
||||||
if(mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&message!=WM_MOUSEMOVE))cancel(now);
|
if(mode_==ControlMode::Receiving&&message==WM_MOUSEMOVE)
|
||||||
if(mode_==ControlMode::Requesting&&message==WM_MOUSEMOVE&&!atEdge(m.pt,env.bounds(),edge))cancel(now);
|
{
|
||||||
|
if(m.pt.x==receivedPoint.x&&m.pt.y==receivedPoint.y)return false;
|
||||||
|
}
|
||||||
|
if(mode_==ControlMode::Receiving||(mode_==ControlMode::Requesting&&message!=WM_MOUSEMOVE))cancel(now,"local physical mouse takeover");
|
||||||
|
if(mode_==ControlMode::Requesting&&!transit&&message==WM_MOUSEMOVE&&!atEdge(m.pt,env.bounds(),edge))cancel(now);
|
||||||
|
if(mode_==ControlMode::Requesting&&transit&&message==WM_MOUSEMOVE)return true;
|
||||||
if(mode_==ControlMode::Sending)
|
if(mode_==ControlMode::Sending)
|
||||||
{
|
{
|
||||||
bool ok=true;
|
bool ok=true;
|
||||||
if(message==WM_MOUSEMOVE)
|
if(message==WM_MOUSEMOVE)
|
||||||
{
|
{
|
||||||
|
// Do not interpret pre-warp edge coordinates as a huge delta from
|
||||||
|
// the centre before Windows has actually parked the cursor.
|
||||||
|
if(awaitingWarp)return true;
|
||||||
auto dx=m.pt.x-anchor.x,dy=m.pt.y-anchor.y;
|
auto dx=m.pt.x-anchor.x,dy=m.pt.y-anchor.y;
|
||||||
if(dx||dy)ok=post({InputType::Move,token,dx,dy});
|
if(dx||dy)ok=post({InputType::Move,token,dx,dy,env.dpi?std::clamp(env.dpi(anchor),48,768):0});
|
||||||
ok=move(anchor,true)&&ok;
|
// The hook consumes this physical motion, so Windows keeps the
|
||||||
|
// cursor parked. Re-injecting an identical warp here can yield no
|
||||||
|
// notification and must never become a gate for the next movement.
|
||||||
}
|
}
|
||||||
else if(index>=0){outButtons[size_t(index)]=down;ok=post({InputType::Button,token,index,down?1:0});}
|
else if(index>=0){outButtons[size_t(index)]=down;ok=post({InputType::Button,token,index,down?1:0});}
|
||||||
else if(message==WM_MOUSEWHEEL||message==WM_MOUSEHWHEEL)ok=post({InputType::Wheel,token,int16_t(HIWORD(m.mouseData)),message==WM_MOUSEHWHEEL?1:0});
|
else if(message==WM_MOUSEWHEEL||message==WM_MOUSEHWHEEL)ok=post({InputType::Wheel,token,int16_t(HIWORD(m.mouseData)),message==WM_MOUSEHWHEEL?1:0});
|
||||||
@@ -233,7 +348,7 @@ bool InputRouter::mouse(UINT message,const MSLLHOOKSTRUCT &m,uint64_t now,bool a
|
|||||||
if(mode_==ControlMode::Local&&connected&&allowEnter&&message==WM_MOUSEMOVE&&now>=cooldown&&
|
if(mode_==ControlMode::Local&&connected&&allowEnter&&message==WM_MOUSEMOVE&&now>=cooldown&&
|
||||||
env.desktop()&&!held()&&atEdge(m.pt,env.bounds(),edge))
|
env.desktop()&&!held()&&atEdge(m.pt,env.bounds(),edge))
|
||||||
{
|
{
|
||||||
token=nextToken;nextToken+=2;if(!nextToken)nextToken=preferLocal?1:2;
|
token=ownershipToken();
|
||||||
returnPoint=m.pt;requested=now;mode(ControlMode::Requesting);
|
returnPoint=m.pt;requested=now;mode(ControlMode::Requesting);
|
||||||
if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now);
|
if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -13,6 +13,7 @@ struct InputEnvironment
|
|||||||
std::function<bool()> held;
|
std::function<bool()> held;
|
||||||
std::function<POINT()> anchor;
|
std::function<POINT()> anchor;
|
||||||
std::function<void(ControlMode)> modeChanged;
|
std::function<void(ControlMode)> modeChanged;
|
||||||
|
std::function<int(POINT)> dpi;
|
||||||
};
|
};
|
||||||
InputEnvironment windowsInputEnvironment();
|
InputEnvironment windowsInputEnvironment();
|
||||||
ULONG_PTR remoteInputMarker();
|
ULONG_PTR remoteInputMarker();
|
||||||
@@ -25,10 +26,15 @@ class InputRouter
|
|||||||
InputEnvironment env;
|
InputEnvironment env;
|
||||||
std::function<bool(InputFrame)> send;
|
std::function<bool(InputFrame)> send;
|
||||||
ControlMode mode_=ControlMode::Local;
|
ControlMode mode_=ControlMode::Local;
|
||||||
int edge{};bool preferLocal{},connected{};
|
int edge{};bool preferLocal{},connected{},multi{},transit{};
|
||||||
uint32_t nextToken{},token{};
|
int crossEdge=-1;uint64_t crossAt{};
|
||||||
|
uint32_t token{};
|
||||||
uint64_t cooldown{},requested{},revision_{};
|
uint64_t cooldown{},requested{},revision_{};
|
||||||
POINT anchor{},returnPoint{};
|
POINT anchor{},returnPoint{},receivedPoint{};
|
||||||
|
bool awaitingWarp{},awaitingEntry{};
|
||||||
|
uint64_t warpStarted{},entryStarted{};
|
||||||
|
bool returning{};POINT returnTarget{};uint64_t returnStarted{};
|
||||||
|
double remainderX{},remainderY{};
|
||||||
std::array<bool,256> physical{},outKeys{},remoteKeys{};
|
std::array<bool,256> physical{},outKeys{},remoteKeys{};
|
||||||
std::array<INPUT,256> releases{};
|
std::array<INPUT,256> releases{};
|
||||||
std::array<bool,5> outButtons{},remoteButtons{};
|
std::array<bool,5> outButtons{},remoteButtons{};
|
||||||
@@ -40,14 +46,19 @@ class InputRouter
|
|||||||
void release();
|
void release();
|
||||||
void local(uint64_t now,int position=-1);
|
void local(uint64_t now,int position=-1);
|
||||||
public:
|
public:
|
||||||
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal);
|
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal,bool multi=false);
|
||||||
~InputRouter();
|
~InputRouter();
|
||||||
|
void selectEdge(int side,bool prefer){edge=side;preferLocal=prefer;}
|
||||||
|
uint32_t sessionToken()const{return token;}
|
||||||
|
bool canHandoff()const{return mode_==ControlMode::Sending&&!held();}
|
||||||
|
bool handoff(int side,int position,uint64_t now);
|
||||||
|
void returnAt(int side,int position,uint64_t now);
|
||||||
ControlMode mode()const{return mode_;}
|
ControlMode mode()const{return mode_;}
|
||||||
uint64_t revision()const{return revision_;}
|
uint64_t revision()const{return revision_;}
|
||||||
void tick(bool ready,uint64_t now);
|
void tick(bool ready,uint64_t now);
|
||||||
void receive(const InputFrame &,uint64_t now);
|
void receive(const InputFrame &,uint64_t now);
|
||||||
bool keyboard(const KBDLLHOOKSTRUCT &,bool down,uint64_t now);
|
bool keyboard(const KBDLLHOOKSTRUCT &,bool down,uint64_t now);
|
||||||
bool mouse(UINT,const MSLLHOOKSTRUCT &,uint64_t now,bool allowEnter=true);
|
bool mouse(UINT,const MSLLHOOKSTRUCT &,uint64_t now,bool allowEnter=true);
|
||||||
void cancel(uint64_t now);
|
void cancel(uint64_t now,const char *reason="control cancelled");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "lan_pairing.h"
|
||||||
|
#include <set>
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
inline Json edgeCell(int edge)
|
||||||
|
{
|
||||||
|
switch(edge){case 0:return Json::array({-1,0});case 1:return Json::array({1,0});case 2:return Json::array({0,-1});default:return Json::array({0,1});}
|
||||||
|
}
|
||||||
|
inline int cellEdge(const Json &cell)
|
||||||
|
{
|
||||||
|
for(int edge=0;edge<4;++edge)if(cell==edgeCell(edge))return edge;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
inline void validateCell(const Json &positions,const std::string &peer,int x,int y)
|
||||||
|
{
|
||||||
|
if(x < -8||x > 8||y < -8||y > 8)throw std::runtime_error("Keep computers within the layout grid.");
|
||||||
|
if(!x&&!y)throw std::runtime_error("This cell belongs to this computer.");
|
||||||
|
for(auto &[id,cell]:positions.items())if(id!=peer&&cell==Json::array({x,y}))throw std::runtime_error("Another computer already occupies this cell.");
|
||||||
|
}
|
||||||
|
inline void fillPositions(Json &positions,const Json &trusted,const std::string &active,int edge)
|
||||||
|
{
|
||||||
|
if(!positions.is_object())throw std::runtime_error("Invalid computer layout.");
|
||||||
|
for(auto it=positions.begin();it!=positions.end();)if(!trusted.contains(it.key()))it=positions.erase(it);else ++it;
|
||||||
|
for(auto &[peer,cell]:positions.items()){
|
||||||
|
if(!cell.is_array()||cell.size()!=2||!cell[0].is_number_integer()||!cell[1].is_number_integer())throw std::runtime_error("Invalid computer position.");
|
||||||
|
validateCell(positions,peer,cell[0].get<int>(),cell[1].get<int>());
|
||||||
|
}
|
||||||
|
if(trusted.contains(active)&&!positions.contains(active)){
|
||||||
|
auto cell=edgeCell(edge);bool free=true;for(auto &[peer,other]:positions.items())if(other==cell)free=false;
|
||||||
|
if(free)positions[active]=cell;
|
||||||
|
}
|
||||||
|
for(auto &[peer,value]:trusted.items())if(!positions.contains(peer)){
|
||||||
|
bool placed=false;
|
||||||
|
for(int radius=1;radius<=8&&!placed;++radius)for(int y=-radius;y<=radius&&!placed;++y)for(int x=-radius;x<=radius&&!placed;++x){
|
||||||
|
if(std::abs(x)+std::abs(y)!=radius)continue;
|
||||||
|
bool free=true;for(auto &[id,cell]:positions.items())if(cell==Json::array({x,y}))free=false;
|
||||||
|
if(free){positions[peer]=Json::array({x,y});placed=true;}
|
||||||
|
}
|
||||||
|
if(!placed)throw std::runtime_error("Computer layout is full.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "lan_input.h"
|
||||||
|
#include <map>
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
// The physical input source routes directly to the current destination. A
|
||||||
|
// destination only reports edge crossings; it never relays injected key events.
|
||||||
|
class InputMesh
|
||||||
|
{
|
||||||
|
std::string local,active;
|
||||||
|
InputEnvironment env;
|
||||||
|
std::map<std::string,POINT> positions;
|
||||||
|
std::map<std::string,uint64_t> ready;
|
||||||
|
std::function<bool(const std::string &,InputFrame)> send;
|
||||||
|
uint64_t activeGeneration{},routeRevision{};
|
||||||
|
InputRouter router;
|
||||||
|
std::string neighbor(const std::string &from,int side)const
|
||||||
|
{
|
||||||
|
POINT p{};if(from!=local){auto found=positions.find(from);if(found==positions.end())return {};p=found->second;}
|
||||||
|
if(side==0)--p.x;else if(side==1)++p.x;else if(side==2)--p.y;else if(side==3)++p.y;else return {};
|
||||||
|
if(!p.x&&!p.y)return local;
|
||||||
|
for(auto &[id,cell]:positions)if(cell.x==p.x&&cell.y==p.y)return id;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
void select(const std::string &peer,int side,uint64_t now)
|
||||||
|
{
|
||||||
|
if(active!=peer){active=peer;++routeRevision;}
|
||||||
|
activeGeneration=ready.at(peer);router.selectEdge(side,local<peer);router.tick(true,now);
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
InputMesh(std::string id,InputEnvironment environment,std::map<std::string,POINT> layout,
|
||||||
|
std::function<bool(const std::string &,InputFrame)> sender)
|
||||||
|
:local(std::move(id)),env(std::move(environment)),positions(std::move(layout)),send(std::move(sender)),
|
||||||
|
router(env,[this](InputFrame f){return !active.empty()&&ready.contains(active)&&send(active,f);},1,true,true){}
|
||||||
|
ControlMode mode()const{return router.mode();}
|
||||||
|
uint64_t revision()const{return router.revision()+routeRevision;}
|
||||||
|
const std::string &peer()const{return active;}
|
||||||
|
void cancel(uint64_t now){router.cancel(now);}
|
||||||
|
void tick(std::map<std::string,uint64_t> connections,uint64_t now)
|
||||||
|
{
|
||||||
|
ready=std::move(connections);
|
||||||
|
if(!active.empty()){
|
||||||
|
bool connected=ready.contains(active);
|
||||||
|
if(connected&&activeGeneration!=ready.at(active)){router.cancel(now,"peer reconnected");activeGeneration=ready.at(active);}
|
||||||
|
router.tick(connected,now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void receive(const std::string &peer,const InputFrame &f,uint64_t now,bool allowEnter=true)
|
||||||
|
{
|
||||||
|
if(!ready.contains(peer)||!positions.contains(peer))return;
|
||||||
|
if(f.type==InputType::Enter){
|
||||||
|
if(!allowEnter||f.a<0||f.a>3||(router.mode()!=ControlMode::Local&&active!=peer)){
|
||||||
|
send(peer,{InputType::Return,f.token,32768});return;
|
||||||
|
}
|
||||||
|
if(router.mode()==ControlMode::Local)select(peer,f.a^1,now);
|
||||||
|
}
|
||||||
|
if(active!=peer)return;
|
||||||
|
if(f.type==InputType::Cross){
|
||||||
|
if(f.token!=router.sessionToken()||!router.canHandoff()||f.a<0||f.a>3||f.b<0||f.b>65535)return;
|
||||||
|
auto target=neighbor(peer,f.a);
|
||||||
|
if(target.empty()||(target!=local&&!ready.contains(target)))return;
|
||||||
|
if(!send(peer,{InputType::Return,f.token,f.b})){router.cancel(now,"cannot release previous computer");return;}
|
||||||
|
if(target==local){router.returnAt(f.a^1,f.b,now);++routeRevision;return;}
|
||||||
|
active=target;activeGeneration=ready.at(target);++routeRevision;
|
||||||
|
router.handoff(f.a,f.b,now);return;
|
||||||
|
}
|
||||||
|
router.receive(f,now);
|
||||||
|
}
|
||||||
|
bool keyboard(const KBDLLHOOKSTRUCT &key,bool down,uint64_t now){return router.keyboard(key,down,now);}
|
||||||
|
bool mouse(UINT message,const MSLLHOOKSTRUCT &mouse,uint64_t now,bool allowEnter=true)
|
||||||
|
{
|
||||||
|
bool enter=false;
|
||||||
|
if(router.mode()==ControlMode::Local&&allowEnter&&message==WM_MOUSEMOVE&&!(mouse.flags&LLMHF_INJECTED)){
|
||||||
|
for(int side=0;side<4;++side)if(atEdge(mouse.pt,env.bounds(),side)){
|
||||||
|
auto target=neighbor(local,side);
|
||||||
|
if(!target.empty()&&ready.contains(target)){select(target,side,now);enter=true;break;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return router.mouse(message,mouse,now,enter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "core.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
inline constexpr const wchar_t *MouseButtonNames[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2"};
|
||||||
|
inline constexpr const wchar_t *MouseEvents[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2",
|
||||||
|
L"Double left", L"Double right", L"Double middle"};
|
||||||
|
inline constexpr const wchar_t *MouseActions[] = {L"Standard behavior", L"Send key combination", L"Back", L"Forward",
|
||||||
|
L"Volume up", L"Volume down", L"Mute", L"Launch application"};
|
||||||
|
inline constexpr wchar_t GlobalMouseProfile[] = L"*";
|
||||||
|
inline constexpr std::wstring_view NamedMouseProfilePrefix = L"name:";
|
||||||
|
bool validMouseApplication(const std::wstring &app);
|
||||||
|
|
||||||
|
inline bool isNamedMouseProfile(std::wstring_view profile)
|
||||||
|
{
|
||||||
|
return profile.starts_with(NamedMouseProfilePrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::wstring mouseProfileName(const std::wstring &profile)
|
||||||
|
{
|
||||||
|
if (profile == GlobalMouseProfile) return L"Global";
|
||||||
|
return isNamedMouseProfile(profile) ? profile.substr(NamedMouseProfilePrefix.size()) : profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool mouseActionApplies(const MouseAction &action, const std::wstring &app)
|
||||||
|
{
|
||||||
|
return action.applications.empty() || action.applications.contains(app);
|
||||||
|
}
|
||||||
|
inline const std::set<std::wstring> &mouseActionScope(const Settings &settings, const std::wstring &profile,
|
||||||
|
const MouseAction &action)
|
||||||
|
{
|
||||||
|
const auto scope = settings.mouseProfileApplications.find(profile);
|
||||||
|
return scope == settings.mouseProfileApplications.end() ? action.applications : scope->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool isMouseVolumeAction(const MouseAction *action)
|
||||||
|
{
|
||||||
|
return action && (action->type == MouseActionType::VolumeUp ||
|
||||||
|
action->type == MouseActionType::VolumeDown || action->type == MouseActionType::Mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool isMouseVolumeStep(MouseActionType action)
|
||||||
|
{
|
||||||
|
return action == MouseActionType::VolumeUp || action == MouseActionType::VolumeDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr UINT MouseVolumeHoldDelay = 350;
|
||||||
|
|
||||||
|
inline UINT mouseVolumeRepeatInterval(ULONGLONG heldFor)
|
||||||
|
{
|
||||||
|
if (heldFor < 1000) return 140;
|
||||||
|
if (heldFor < 2000) return 80;
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const MouseAction *mouseBinding(const Settings &settings, const std::wstring &app, int event)
|
||||||
|
{
|
||||||
|
auto profile = settings.mouseBindings.find(app);
|
||||||
|
if (profile == settings.mouseBindings.end()) return nullptr;
|
||||||
|
auto action = profile->second.find(MouseEvents[event]);
|
||||||
|
return action == profile->second.end() ? nullptr : &action->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const MouseAction *effectiveMouseAction(const Settings &settings, const std::wstring &app, int event,
|
||||||
|
bool foreground = true, bool *profileScoped = nullptr)
|
||||||
|
{
|
||||||
|
if (profileScoped) *profileScoped = false;
|
||||||
|
auto *action = mouseBinding(settings, app, event);
|
||||||
|
if (!action)
|
||||||
|
{
|
||||||
|
const MouseAction *unrestricted = nullptr;
|
||||||
|
for (auto profile = settings.mouseBindings.lower_bound(std::wstring(NamedMouseProfilePrefix));
|
||||||
|
profile != settings.mouseBindings.end() && isNamedMouseProfile(profile->first); ++profile)
|
||||||
|
{
|
||||||
|
if (settings.disabledMouseProfiles.contains(profile->first)) continue;
|
||||||
|
const auto binding = profile->second.find(MouseEvents[event]);
|
||||||
|
if (binding == profile->second.end()) continue;
|
||||||
|
const auto &candidate = binding->second;
|
||||||
|
const auto &scope = mouseActionScope(settings, profile->first, candidate);
|
||||||
|
if (foreground && scope.contains(app))
|
||||||
|
{
|
||||||
|
if (profileScoped) *profileScoped = true;
|
||||||
|
return candidate.type == MouseActionType::Standard ? nullptr : &candidate;
|
||||||
|
}
|
||||||
|
if (!unrestricted && scope.empty()) unrestricted = &candidate;
|
||||||
|
}
|
||||||
|
const auto *global = mouseBinding(settings, GlobalMouseProfile, event);
|
||||||
|
if (foreground && global && global->applications.contains(app)) action = global;
|
||||||
|
else if (unrestricted) action = unrestricted;
|
||||||
|
else if (global && global->applications.empty()) action = global;
|
||||||
|
}
|
||||||
|
return action && action->type != MouseActionType::Standard && mouseActionApplies(*action, app) ? action : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool doubleMouseClick(POINT first, POINT second, ULONGLONG elapsed)
|
||||||
|
{
|
||||||
|
return elapsed <= GetDoubleClickTime() &&
|
||||||
|
std::abs(static_cast<long long>(first.x) - second.x) * 2 <= GetSystemMetrics(SM_CXDOUBLECLK) &&
|
||||||
|
std::abs(static_cast<long long>(first.y) - second.y) * 2 <= GetSystemMetrics(SM_CYDOUBLECLK);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int mouseButton(UINT message, WORD xbutton)
|
||||||
|
{
|
||||||
|
switch (message)
|
||||||
|
{
|
||||||
|
case WM_LBUTTONDOWN: case WM_LBUTTONUP: return 0;
|
||||||
|
case WM_RBUTTONDOWN: case WM_RBUTTONUP: return 1;
|
||||||
|
case WM_MBUTTONDOWN: case WM_MBUTTONUP: return 2;
|
||||||
|
case WM_XBUTTONDOWN: case WM_XBUTTONUP:
|
||||||
|
if (xbutton == XBUTTON1) return 3;
|
||||||
|
if (xbutton == XBUTTON2) return 4;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool mouseButtonDown(UINT message)
|
||||||
|
{
|
||||||
|
return message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN ||
|
||||||
|
message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
-1
@@ -4,12 +4,19 @@
|
|||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <imm.h>
|
#include <imm.h>
|
||||||
|
#include <endpointvolume.h>
|
||||||
|
#include <mmdeviceapi.h>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <shellapi.h>
|
||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
|
#include <exdisp.h>
|
||||||
|
#include <shldisp.h>
|
||||||
#include <tlhelp32.h>
|
#include <tlhelp32.h>
|
||||||
#include <wincrypt.h>
|
#include <wincrypt.h>
|
||||||
|
#include <wrl/client.h>
|
||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
@@ -265,6 +272,83 @@ std::wstring processName(HWND window)
|
|||||||
CloseHandle(h);
|
CloseHandle(h);
|
||||||
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
|
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
|
||||||
}
|
}
|
||||||
|
std::wstring mouseApplicationName(HWND window)
|
||||||
|
{
|
||||||
|
auto name = lower(processName(window));
|
||||||
|
if (name != L"applicationframehost") return name;
|
||||||
|
std::wstring hosted;
|
||||||
|
EnumChildWindows(window, [](HWND child, LPARAM output) -> BOOL {
|
||||||
|
wchar_t kind[64]{};
|
||||||
|
if (!GetClassNameW(child, kind, std::size(kind)) || wcscmp(kind, L"Windows.UI.Core.CoreWindow") != 0)
|
||||||
|
return TRUE;
|
||||||
|
auto &app = *reinterpret_cast<std::wstring *>(output);
|
||||||
|
app = lower(processName(child));
|
||||||
|
return app.empty();
|
||||||
|
}, reinterpret_cast<LPARAM>(&hosted));
|
||||||
|
return hosted.empty() ? name : hosted;
|
||||||
|
}
|
||||||
|
bool launchThroughExplorer(const std::wstring &path)
|
||||||
|
{
|
||||||
|
HANDLE token{};
|
||||||
|
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false;
|
||||||
|
TOKEN_ELEVATION elevation{};
|
||||||
|
DWORD size{};
|
||||||
|
const auto queried = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size);
|
||||||
|
CloseHandle(token);
|
||||||
|
if (!queried) return false;
|
||||||
|
if (!elevation.TokenIsElevated)
|
||||||
|
{
|
||||||
|
SHELLEXECUTEINFOW launch{sizeof(launch)};
|
||||||
|
launch.fMask = SEE_MASK_FLAG_NO_UI;
|
||||||
|
launch.lpFile = path.c_str();
|
||||||
|
launch.nShow = SW_SHOWNORMAL;
|
||||||
|
return ShellExecuteExW(&launch) != FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An elevated resident delegates execution to the interactive desktop shell.
|
||||||
|
auto release = [](IUnknown *object) { if (object) object->Release(); };
|
||||||
|
IShellWindows *windowsRaw{};
|
||||||
|
auto hr = CoCreateInstance(CLSID_ShellWindows, nullptr, CLSCTX_ALL, IID_IShellWindows,
|
||||||
|
reinterpret_cast<void **>(&windowsRaw));
|
||||||
|
std::unique_ptr<IShellWindows, decltype(release)> windows(windowsRaw, release);
|
||||||
|
if (FAILED(hr) || !windows) return false;
|
||||||
|
VARIANT empty{}, desktop{};
|
||||||
|
desktop.vt = VT_I4;
|
||||||
|
long windowHandle{};
|
||||||
|
IDispatch *desktopRaw{};
|
||||||
|
hr = windows->FindWindowSW(&desktop, &empty, SWC_DESKTOP, &windowHandle, SWFO_NEEDDISPATCH, &desktopRaw);
|
||||||
|
std::unique_ptr<IDispatch, decltype(release)> dispatch(desktopRaw, release);
|
||||||
|
if (FAILED(hr) || !dispatch) return false;
|
||||||
|
IServiceProvider *providerRaw{};
|
||||||
|
hr = dispatch->QueryInterface(IID_IServiceProvider, reinterpret_cast<void **>(&providerRaw));
|
||||||
|
std::unique_ptr<IServiceProvider, decltype(release)> provider(providerRaw, release);
|
||||||
|
if (FAILED(hr) || !provider) return false;
|
||||||
|
IShellBrowser *browserRaw{};
|
||||||
|
hr = provider->QueryService(SID_STopLevelBrowser, IID_IShellBrowser, reinterpret_cast<void **>(&browserRaw));
|
||||||
|
std::unique_ptr<IShellBrowser, decltype(release)> browser(browserRaw, release);
|
||||||
|
if (FAILED(hr) || !browser) return false;
|
||||||
|
IShellView *viewRaw{};
|
||||||
|
hr = browser->QueryActiveShellView(&viewRaw);
|
||||||
|
std::unique_ptr<IShellView, decltype(release)> view(viewRaw, release);
|
||||||
|
if (FAILED(hr) || !view) return false;
|
||||||
|
IShellFolderViewDual *folderRaw{};
|
||||||
|
hr = view->GetItemObject(SVGIO_BACKGROUND, IID_IShellFolderViewDual, reinterpret_cast<void **>(&folderRaw));
|
||||||
|
std::unique_ptr<IShellFolderViewDual, decltype(release)> folder(folderRaw, release);
|
||||||
|
if (FAILED(hr) || !folder) return false;
|
||||||
|
IDispatch *applicationRaw{};
|
||||||
|
hr = folder->get_Application(&applicationRaw);
|
||||||
|
std::unique_ptr<IDispatch, decltype(release)> application(applicationRaw, release);
|
||||||
|
if (FAILED(hr) || !application) return false;
|
||||||
|
IShellDispatch2 *shellRaw{};
|
||||||
|
hr = application->QueryInterface(IID_IShellDispatch2, reinterpret_cast<void **>(&shellRaw));
|
||||||
|
std::unique_ptr<IShellDispatch2, decltype(release)> shell(shellRaw, release);
|
||||||
|
if (FAILED(hr) || !shell) return false;
|
||||||
|
BSTR executable = SysAllocString(path.c_str());
|
||||||
|
if (!executable) return false;
|
||||||
|
hr = shell->ShellExecute(executable, empty, empty, empty, empty);
|
||||||
|
SysFreeString(executable);
|
||||||
|
return SUCCEEDED(hr);
|
||||||
|
}
|
||||||
Clip readClipboard(HWND window, size_t limit, ClipboardRead mode, std::optional<DWORD> expected)
|
Clip readClipboard(HWND window, size_t limit, ClipboardRead mode, std::optional<DWORD> expected)
|
||||||
{
|
{
|
||||||
ClipboardLock lock(window);
|
ClipboardLock lock(window);
|
||||||
@@ -411,7 +495,9 @@ bool chord(WORD key, UINT mods)
|
|||||||
auto add = [&](WORD k, bool up)
|
auto add = [&](WORD k, bool up)
|
||||||
{
|
{
|
||||||
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
|
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
|
||||||
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
|
if ((k >= VK_PRIOR && k <= VK_DOWN) || k == VK_INSERT || k == VK_DELETE ||
|
||||||
|
k == VK_DIVIDE || k == VK_LWIN || k == VK_SNAPSHOT ||
|
||||||
|
(k >= VK_VOLUME_MUTE && k <= VK_VOLUME_UP))
|
||||||
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||||
inputs.push_back(i);
|
inputs.push_back(i);
|
||||||
};
|
};
|
||||||
@@ -431,6 +517,42 @@ bool chord(WORD key, UINT mods)
|
|||||||
add(k, true);
|
add(k, true);
|
||||||
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
|
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
|
||||||
}
|
}
|
||||||
|
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step)
|
||||||
|
{
|
||||||
|
using Microsoft::WRL::ComPtr;
|
||||||
|
ComPtr<IMMDeviceEnumerator> devices;
|
||||||
|
ComPtr<IMMDevice> output;
|
||||||
|
ComPtr<IAudioEndpointVolume> volume;
|
||||||
|
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&devices))) ||
|
||||||
|
FAILED(devices->GetDefaultAudioEndpoint(eRender, eMultimedia, &output)) ||
|
||||||
|
FAILED(output->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_INPROC_SERVER, nullptr,
|
||||||
|
reinterpret_cast<void **>(volume.GetAddressOf()))))
|
||||||
|
return std::nullopt;
|
||||||
|
float level{};
|
||||||
|
BOOL muted{};
|
||||||
|
HRESULT changed;
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case MouseActionType::VolumeUp:
|
||||||
|
case MouseActionType::VolumeDown:
|
||||||
|
{
|
||||||
|
if (FAILED(volume->GetMasterVolumeLevelScalar(&level))) return std::nullopt;
|
||||||
|
const float delta = (action == MouseActionType::VolumeUp ? step : -step) / 100.0f;
|
||||||
|
changed = volume->SetMasterVolumeLevelScalar(std::clamp(level + delta, 0.0f, 1.0f), nullptr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MouseActionType::Mute:
|
||||||
|
{
|
||||||
|
if (FAILED(volume->GetMute(&muted))) return std::nullopt;
|
||||||
|
changed = volume->SetMute(!muted, nullptr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: return std::nullopt;
|
||||||
|
}
|
||||||
|
if (FAILED(changed) || FAILED(volume->GetMasterVolumeLevelScalar(&level)) || FAILED(volume->GetMute(&muted)))
|
||||||
|
return std::nullopt;
|
||||||
|
return SystemVolume{std::clamp(static_cast<int>(level * 100 + 0.5f), 0, 100), muted != FALSE};
|
||||||
|
}
|
||||||
bool needsSelectionDelay(HWND target)
|
bool needsSelectionDelay(HWND target)
|
||||||
{
|
{
|
||||||
const auto name = lower(processName(target));
|
const auto name = lower(processName(target));
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ bool ownsClipboard(HWND);
|
|||||||
bool webViewDescendant(DWORD owner, DWORD host,
|
bool webViewDescendant(DWORD owner, DWORD host,
|
||||||
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
|
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
|
||||||
std::wstring processName(HWND);
|
std::wstring processName(HWND);
|
||||||
|
std::wstring mouseApplicationName(HWND);
|
||||||
|
bool launchThroughExplorer(const std::wstring &);
|
||||||
enum class ClipboardRead { All, Selection, WithoutImages };
|
enum class ClipboardRead { All, Selection, WithoutImages };
|
||||||
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024,
|
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024,
|
||||||
ClipboardRead = ClipboardRead::All, std::optional<DWORD> expected = {});
|
ClipboardRead = ClipboardRead::All, std::optional<DWORD> expected = {});
|
||||||
@@ -50,6 +52,12 @@ DWORD writeClipboard(HWND, const Clip &);
|
|||||||
DWORD writeClipboardGuarded(HWND, const Clip &, std::optional<DWORD> expected);
|
DWORD writeClipboardGuarded(HWND, const Clip &, std::optional<DWORD> expected);
|
||||||
Clip textClip(const std::wstring &);
|
Clip textClip(const std::wstring &);
|
||||||
bool chord(WORD key, UINT modifiers = 0);
|
bool chord(WORD key, UINT modifiers = 0);
|
||||||
|
struct SystemVolume
|
||||||
|
{
|
||||||
|
int percent{};
|
||||||
|
bool muted{};
|
||||||
|
};
|
||||||
|
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step);
|
||||||
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
|
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
|
||||||
ULONG_PTR inputMarker();
|
ULONG_PTR inputMarker();
|
||||||
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
|
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "sync.h"
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
// The open editor is a draft based on a snapshot, not a replacement for all
|
||||||
|
// settings that may have changed in the resident since the editor opened.
|
||||||
|
inline Bytes mergeConfigurationSettings(const Bytes &base,const Bytes &draft,const Bytes ¤t,bool preferDraft=false)
|
||||||
|
{
|
||||||
|
auto merged=mergeSyncProfile(base,draft,current);
|
||||||
|
if(!merged.conflicts.empty()){
|
||||||
|
if(!preferDraft){
|
||||||
|
std::string fields;
|
||||||
|
for(auto &c:merged.conflicts){if(!fields.empty())fields+=", ";fields+=utf8(c.label);}
|
||||||
|
throw std::runtime_error("Settings conflict: "+fields);
|
||||||
|
}
|
||||||
|
std::vector<SyncChoice> choices;
|
||||||
|
for(auto &c:merged.conflicts)choices.push_back({c,true});
|
||||||
|
merged=mergeSyncProfile(base,draft,current,choices);
|
||||||
|
if(!merged.conflicts.empty())throw std::runtime_error("Resolve conflicting settings in this window before saving.");
|
||||||
|
}
|
||||||
|
return merged.local;
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
-9
@@ -1,4 +1,5 @@
|
|||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
|
#include "mouse_buttons.h"
|
||||||
#include "json.hpp"
|
#include "json.hpp"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -58,9 +59,114 @@ bool validId(const std::wstring &id)
|
|||||||
{
|
{
|
||||||
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
|
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
|
} // namespace
|
||||||
|
bool validMouseApplication(const std::wstring &app)
|
||||||
|
{
|
||||||
|
return !app.empty() && app.size() <= 260 && app == lower(app) &&
|
||||||
|
app.find_first_of(L"\\/:*?\"<>|\r\n") == app.npos &&
|
||||||
|
app != L"kswitcher" && app != L"kswitcher.config";
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateMouseProfileConflicts(const Settings &s)
|
||||||
|
{
|
||||||
|
std::map<std::pair<std::wstring, std::wstring>, std::wstring> claimed;
|
||||||
|
for (const auto &[profile, bindings] : s.mouseBindings)
|
||||||
|
{
|
||||||
|
if (s.disabledMouseProfiles.contains(profile)) continue;
|
||||||
|
for (const auto &[event, action] : bindings)
|
||||||
|
{
|
||||||
|
auto claim = [&](const std::wstring &app)
|
||||||
|
{
|
||||||
|
const auto [other, added] = claimed.try_emplace({event, app}, profile);
|
||||||
|
if (!added && (isNamedMouseProfile(profile) || isNamedMouseProfile(other->second)))
|
||||||
|
throw std::runtime_error(utf8(L"Mouse action conflict: " + event + L" for " +
|
||||||
|
(app.empty() ? L"all applications" : app + L".exe") + L" in " +
|
||||||
|
mouseProfileName(other->second) + L" and " + mouseProfileName(profile) + L"."));
|
||||||
|
};
|
||||||
|
if (profile != GlobalMouseProfile && !isNamedMouseProfile(profile))
|
||||||
|
{
|
||||||
|
if (mouseActionApplies(action, profile)) claim(profile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const auto &scope = mouseActionScope(s, profile, action);
|
||||||
|
if (scope.empty()) claim(L"");
|
||||||
|
else for (const auto &app : scope) claim(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void validateSettings(const Settings &s)
|
void validateSettings(const Settings &s)
|
||||||
{
|
{
|
||||||
|
if (s.mouseBindings.size() > 1000) throw std::runtime_error("Too many mouse profiles.");
|
||||||
|
for (const auto &[app, bindings] : s.mouseBindings)
|
||||||
|
{
|
||||||
|
if (isNamedMouseProfile(app))
|
||||||
|
{
|
||||||
|
const auto name = mouseProfileName(app);
|
||||||
|
if (name.empty() || name.size() > 80 || lower(name) == L"global" || name.find_first_of(L"\r\n\t") != name.npos)
|
||||||
|
throw std::runtime_error("Enter a profile name other than Global (up to 80 characters).");
|
||||||
|
}
|
||||||
|
else if (app != GlobalMouseProfile && !validMouseApplication(app))
|
||||||
|
throw std::runtime_error("Enter the target application's executable name.");
|
||||||
|
for (const auto &[event, action] : bindings)
|
||||||
|
{
|
||||||
|
if (action.applications.size() > 1000 ||
|
||||||
|
!std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication))
|
||||||
|
throw std::runtime_error("Enter valid target application executable names.");
|
||||||
|
if (std::find(std::begin(MouseEvents), std::end(MouseEvents), event) == std::end(MouseEvents) ||
|
||||||
|
static_cast<size_t>(action.type) >= std::size(MouseActions))
|
||||||
|
throw std::runtime_error("Invalid mouse event or action.");
|
||||||
|
if (isMouseVolumeStep(action.type) && (action.volumeStep < 1 || action.volumeStep > 100))
|
||||||
|
throw std::runtime_error("Enter a volume step from 1 to 100.");
|
||||||
|
if (action.type == MouseActionType::Shortcut &&
|
||||||
|
(!action.shortcut.key || Hotkey::parse(action.shortcut.format()) != action.shortcut))
|
||||||
|
throw std::runtime_error("Enter a shortcut such as Ctrl+Shift+Tab.");
|
||||||
|
if (action.type == MouseActionType::Launch)
|
||||||
|
{
|
||||||
|
const std::filesystem::path path(action.executable);
|
||||||
|
const bool executableName = !path.has_root_path() && !path.has_parent_path() &&
|
||||||
|
action.executable.find_first_of(L"\\/:*?\"<>|\r\n") == std::wstring::npos;
|
||||||
|
if ((!executableName && !path.is_absolute()) || lower(path.extension().wstring()) != L".exe")
|
||||||
|
throw std::runtime_error("Enter an executable name such as calc.exe, or choose a file with Browse.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &profile : s.disabledMouseProfiles)
|
||||||
|
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile))
|
||||||
|
throw std::runtime_error("Unknown disabled mouse profile.");
|
||||||
|
for (const auto &[profile, apps] : s.mouseProfileApplications)
|
||||||
|
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile) || apps.size() > 1000 ||
|
||||||
|
!std::all_of(apps.begin(), apps.end(), validMouseApplication))
|
||||||
|
throw std::runtime_error("Invalid applications for the mouse profile.");
|
||||||
|
validateMouseProfileConflicts(s);
|
||||||
|
if (s.appLanguageSets.size() > 1000) throw std::runtime_error("Too many application language sets");
|
||||||
|
std::set<std::wstring> assigned;
|
||||||
|
for (const auto &[name, set] : s.appLanguageSets)
|
||||||
|
{
|
||||||
|
if (name.empty() || name.size() > 128 || name.find_first_of(L"\r\n") != name.npos)
|
||||||
|
throw std::runtime_error("Enter a language set name (up to 128 characters).");
|
||||||
|
if (set.applications.size() > 1000) throw std::runtime_error("Too many applications in a language set");
|
||||||
|
for (auto app : set.applications)
|
||||||
|
{
|
||||||
|
app = lower(app);
|
||||||
|
if (app.ends_with(L".exe")) app.resize(app.size() - 4);
|
||||||
|
if (app.empty() || app.size() > 260 || app.find_first_of(L"\\/:*?\"<>|\r\n") != app.npos)
|
||||||
|
throw std::runtime_error("Use an application executable name, not a path.");
|
||||||
|
if (!assigned.insert(app).second) throw std::runtime_error("An application can belong to only one language set.");
|
||||||
|
}
|
||||||
|
validateLanguageCycle(set.languages);
|
||||||
|
}
|
||||||
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
|
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
|
||||||
for (const auto &[key, cycle] : s.chatCycles)
|
for (const auto &[key, cycle] : s.chatCycles)
|
||||||
{
|
{
|
||||||
@@ -138,10 +244,9 @@ void validateSettings(const Settings &s)
|
|||||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||||
from == lower(from) && to == lower(to) && from != to &&
|
from == lower(from) && to == lower(to) && from != to &&
|
||||||
from.find_first_not_of(alphabet) == from.npos &&
|
from.find_first_not_of(alphabet) == from.npos &&
|
||||||
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
|
validAutoCorrection(to, alphabet);
|
||||||
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
|
|
||||||
if (!valid)
|
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)
|
for (const auto &[language, words] : s.autoWords)
|
||||||
{
|
{
|
||||||
@@ -264,6 +369,76 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.cycle.push_back(w);
|
s.cycle.push_back(w);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (j.contains("appLanguageSets"))
|
||||||
|
{
|
||||||
|
if (!j.at("appLanguageSets").is_object()) throw std::runtime_error("Invalid application language sets");
|
||||||
|
for (const auto &[name, item] : j.at("appLanguageSets").items())
|
||||||
|
{
|
||||||
|
AppLanguageSet set;
|
||||||
|
if (!item.at("languages").is_array() || !item.at("applications").is_array())
|
||||||
|
throw std::runtime_error("Invalid application language set");
|
||||||
|
for (const auto &id : item.at("languages")) set.languages.push_back(wide(id.get<std::string>()));
|
||||||
|
for (const auto &app : item.at("applications")) set.applications.push_back(wide(app.get<std::string>()));
|
||||||
|
s.appLanguageSets[wide(name)] = std::move(set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.contains("mouseBindings"))
|
||||||
|
{
|
||||||
|
const auto &profiles = j.at("mouseBindings");
|
||||||
|
if (!profiles.is_object()) throw std::runtime_error("Invalid mouse profiles.");
|
||||||
|
for (const auto &[app, bindings] : profiles.items())
|
||||||
|
{
|
||||||
|
if (!bindings.is_object()) throw std::runtime_error("Invalid mouse profile.");
|
||||||
|
if (isNamedMouseProfile(wide(app))) s.mouseBindings.try_emplace(wide(app));
|
||||||
|
for (const auto &[event, item] : bindings.items())
|
||||||
|
{
|
||||||
|
MouseAction action;
|
||||||
|
if (item.is_string())
|
||||||
|
{
|
||||||
|
auto shortcut = Hotkey::parse(wide(item.get<std::string>()));
|
||||||
|
if (!shortcut || !shortcut->key) throw std::runtime_error("Invalid mouse shortcut.");
|
||||||
|
action = {MouseActionType::Shortcut, *shortcut};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auto name = wide(item.at("action").get<std::string>());
|
||||||
|
if (name == L"Send shortcut") name = MouseActions[static_cast<size_t>(MouseActionType::Shortcut)];
|
||||||
|
const auto found = std::find(std::begin(MouseActions), std::end(MouseActions), name);
|
||||||
|
if (found == std::end(MouseActions)) throw std::runtime_error("Invalid mouse action.");
|
||||||
|
action.type = static_cast<MouseActionType>(found - std::begin(MouseActions));
|
||||||
|
if (action.type == MouseActionType::Shortcut)
|
||||||
|
action.shortcut = Hotkey::parse(wide(item.at("shortcut").get<std::string>())).value_or(Hotkey{});
|
||||||
|
if (action.type == MouseActionType::Launch)
|
||||||
|
action.executable = wide(item.at("executable").get<std::string>());
|
||||||
|
if (isMouseVolumeStep(action.type) && item.contains("volumeStep"))
|
||||||
|
{
|
||||||
|
const auto &step = item.at("volumeStep");
|
||||||
|
if (!step.is_number_integer() || step.get<double>() < 1 || step.get<double>() > 100)
|
||||||
|
throw std::runtime_error("Enter a volume step from 1 to 100.");
|
||||||
|
action.volumeStep = step.get<int>();
|
||||||
|
}
|
||||||
|
if (item.contains("applications"))
|
||||||
|
for (const auto &name : item.at("applications").get<std::vector<std::string>>())
|
||||||
|
action.applications.insert(wide(name));
|
||||||
|
}
|
||||||
|
s.mouseBindings[wide(app)][wide(event)] = std::move(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.contains("mouseProfileApplications"))
|
||||||
|
{
|
||||||
|
const auto &scopes = j.at("mouseProfileApplications");
|
||||||
|
if (!scopes.is_object()) throw std::runtime_error("Invalid mouse profile applications.");
|
||||||
|
for (const auto &[profile, names] : scopes.items())
|
||||||
|
{
|
||||||
|
if (names.is_null()) continue;
|
||||||
|
auto &scope = s.mouseProfileApplications[wide(profile)];
|
||||||
|
for (const auto &name : names.get<std::vector<std::string>>()) scope.insert(wide(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.contains("disabledMouseProfiles") && !j.at("disabledMouseProfiles").is_null())
|
||||||
|
for (const auto &profile : j.at("disabledMouseProfiles").get<std::vector<std::string>>())
|
||||||
|
s.disabledMouseProfiles.insert(wide(profile));
|
||||||
if (j.contains("chatCycles"))
|
if (j.contains("chatCycles"))
|
||||||
{
|
{
|
||||||
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
|
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
|
||||||
@@ -336,6 +511,47 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"screenshotHotkey", utf8(s.screenshot)},
|
{"screenshotHotkey", utf8(s.screenshot)},
|
||||||
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
||||||
j["layouts"] = Json::array();
|
j["layouts"] = Json::array();
|
||||||
|
j["appLanguageSets"] = Json::object();
|
||||||
|
for (const auto &[name, set] : s.appLanguageSets)
|
||||||
|
{
|
||||||
|
auto &item = j["appLanguageSets"][utf8(name)];
|
||||||
|
item["languages"] = Json::array();
|
||||||
|
item["applications"] = Json::array();
|
||||||
|
for (const auto &id : set.languages) item["languages"].push_back(utf8(id));
|
||||||
|
for (const auto &app : set.applications) item["applications"].push_back(utf8(app));
|
||||||
|
}
|
||||||
|
j["mouseBindings"] = Json::object();
|
||||||
|
for (const auto &[app, bindings] : s.mouseBindings)
|
||||||
|
{
|
||||||
|
if (isNamedMouseProfile(app)) j["mouseBindings"][utf8(app)] = Json::object();
|
||||||
|
for (const auto &[event, action] : bindings)
|
||||||
|
{
|
||||||
|
auto &item = j["mouseBindings"][utf8(app)][utf8(event)];
|
||||||
|
if (action.type == MouseActionType::Shortcut && action.applications.empty())
|
||||||
|
item = utf8(action.shortcut.format());
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item = {{"action", utf8(MouseActions[static_cast<size_t>(action.type)])}};
|
||||||
|
if (action.type == MouseActionType::Shortcut)
|
||||||
|
{
|
||||||
|
item["action"] = "Send shortcut";
|
||||||
|
item["shortcut"] = utf8(action.shortcut.format());
|
||||||
|
}
|
||||||
|
if (action.type == MouseActionType::Launch) item["executable"] = utf8(action.executable);
|
||||||
|
if (isMouseVolumeStep(action.type) && action.volumeStep != DefaultMouseVolumeStep)
|
||||||
|
item["volumeStep"] = action.volumeStep;
|
||||||
|
for (const auto &app : action.applications) item["applications"].push_back(utf8(app));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j["mouseProfileApplications"] = Json::object();
|
||||||
|
for (const auto &[profile, apps] : s.mouseProfileApplications)
|
||||||
|
{
|
||||||
|
auto &scope = j["mouseProfileApplications"][utf8(profile)] = Json::array();
|
||||||
|
for (const auto &app : apps) scope.push_back(utf8(app));
|
||||||
|
}
|
||||||
|
j["disabledMouseProfiles"] = Json::array();
|
||||||
|
for (const auto &profile : s.disabledMouseProfiles) j["disabledMouseProfiles"].push_back(utf8(profile));
|
||||||
j["chatCycles"] = Json::object();
|
j["chatCycles"] = Json::object();
|
||||||
for (const auto &[key, cycle] : s.chatCycles)
|
for (const auto &[key, cycle] : s.chatCycles)
|
||||||
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
|
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
|
||||||
@@ -473,6 +689,7 @@ void History::loadIndex()
|
|||||||
e.value("image", false),
|
e.value("image", false),
|
||||||
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
|
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
|
||||||
str(e, "ocrProfile", L"windows")};
|
str(e, "ocrProfile", L"windows")};
|
||||||
|
item.computer=e.value("computer",std::string{});item.computerName=str(e,"computerName");
|
||||||
if (e.value("fingerprintVersion", 0) == 1) {
|
if (e.value("fingerprintVersion", 0) == 1) {
|
||||||
item.contentHash = e.value("contentHash", std::string{});
|
item.contentHash = e.value("contentHash", std::string{});
|
||||||
item.imageHash = e.value("imageHash", std::string{});
|
item.imageHash = e.value("imageHash", std::string{});
|
||||||
@@ -644,7 +861,7 @@ void History::saveIndex()
|
|||||||
for (const auto &e : entries_)
|
for (const auto &e : entries_)
|
||||||
j.push_back({{"id", utf8(e.id)},
|
j.push_back({{"id", utf8(e.id)},
|
||||||
{"preview", utf8(e.preview)},
|
{"preview", utf8(e.preview)},
|
||||||
{"app", utf8(e.app)},
|
{"app", utf8(e.app)}, {"computer", e.computer}, {"computerName", utf8(e.computerName)},
|
||||||
{"bytes", e.bytes},
|
{"bytes", e.bytes},
|
||||||
{"at", e.at},
|
{"at", e.at},
|
||||||
{"pinned", e.pinned},
|
{"pinned", e.pinned},
|
||||||
@@ -753,7 +970,7 @@ bool sameClipboardImage(const Clip &a, const Clip &b)
|
|||||||
return false; // Undecodable data is not evidence of a duplicate.
|
return false; // Undecodable data is not evidence of a duplicate.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
void History::add(const Clip &clip, const std::wstring &app, const Settings &s, const std::string &computer, const std::wstring &computerName)
|
||||||
{
|
{
|
||||||
if (clip.formats.empty() || !clip.size())
|
if (clip.formats.empty() || !clip.size())
|
||||||
return;
|
return;
|
||||||
@@ -769,15 +986,15 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
|||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
Entry fingerprints;
|
Entry fingerprints;
|
||||||
fingerprints.contentHash = clipboardHash(data);
|
fingerprints.contentHash = clipboardHash(data);
|
||||||
if (!entries_.empty() && entries_.front().contentHash == fingerprints.contentHash)
|
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().contentHash == fingerprints.contentHash)
|
||||||
return;
|
return;
|
||||||
imageFingerprints(fingerprints, clip, false);
|
imageFingerprints(fingerprints, clip, false);
|
||||||
if (!entries_.empty() && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
|
if (!entries_.empty() && entries_.front().computer == computer && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
|
||||||
return;
|
return;
|
||||||
imageFingerprints(fingerprints, clip);
|
imageFingerprints(fingerprints, clip);
|
||||||
// Different payload kinds cannot be duplicates. Consult the index first so
|
// Different payload kinds cannot be duplicates. Consult the index first so
|
||||||
// copying text after a screenshot never loads/decrypts the previous image.
|
// copying text after a screenshot never loads/decrypts the previous image.
|
||||||
if (!entries_.empty() && entries_.front().image == image && entries_.front().files == files)
|
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().image == image && entries_.front().files == files)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -821,7 +1038,7 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
|||||||
Entry{id, text, app, encrypted.size(),
|
Entry{id, text, app, encrypted.size(),
|
||||||
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
|
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
|
||||||
image ? 1 : 0, ocrProfile(s), std::move(fingerprints.contentHash),
|
image ? 1 : 0, ocrProfile(s), std::move(fingerprints.contentHash),
|
||||||
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash)});
|
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash), computer, computerName});
|
||||||
prune(s);
|
prune(s);
|
||||||
saveIndex();
|
saveIndex();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -18,6 +18,8 @@ struct Entry
|
|||||||
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
|
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
|
||||||
std::wstring ocrProfile = L"windows";
|
std::wstring ocrProfile = L"windows";
|
||||||
std::string contentHash, imageHash, pixelHash;
|
std::string contentHash, imageHash, pixelHash;
|
||||||
|
std::string computer;
|
||||||
|
std::wstring computerName;
|
||||||
};
|
};
|
||||||
class History
|
class History
|
||||||
{
|
{
|
||||||
@@ -33,7 +35,7 @@ class History
|
|||||||
void reload();
|
void reload();
|
||||||
std::vector<Entry> entries() const;
|
std::vector<Entry> entries() const;
|
||||||
Clip load(const std::wstring &id) const;
|
Clip load(const std::wstring &id) const;
|
||||||
void add(const Clip &, const std::wstring &app, const Settings &);
|
void add(const Clip &, const std::wstring &app, const Settings &, const std::string &computer = {}, const std::wstring &computerName = {});
|
||||||
bool captureImage(DWORD sequence, const std::wstring &app, const Settings &);
|
bool captureImage(DWORD sequence, const std::wstring &app, const Settings &);
|
||||||
std::wstring recognizeImage(const std::wstring &id, const Settings &,
|
std::wstring recognizeImage(const std::wstring &id, const Settings &,
|
||||||
const std::function<bool()> &cancelled = {}) const;
|
const std::function<bool()> &cancelled = {}) const;
|
||||||
|
|||||||
+17
-7
@@ -1,4 +1,5 @@
|
|||||||
#include "sync.h"
|
#include "sync.h"
|
||||||
|
#include "mouse_buttons.h"
|
||||||
#include "json.hpp"
|
#include "json.hpp"
|
||||||
#include <winhttp.h>
|
#include <winhttp.h>
|
||||||
#include <wincred.h>
|
#include <wincred.h>
|
||||||
@@ -39,7 +40,7 @@ class OpenBao final : public SyncProvider
|
|||||||
SyncConfig config;
|
SyncConfig config;
|
||||||
SyncTransport transport;
|
SyncTransport transport;
|
||||||
std::wstring token;
|
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 (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||||
if (status == 404 && missing)
|
if (status == 404 && missing)
|
||||||
@@ -50,7 +51,14 @@ class OpenBao final : public SyncProvider
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
|
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) + ".");
|
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
|
||||||
return result.empty() ? Json::object() : parse(result);
|
return result.empty() ? Json::object() : parse(result);
|
||||||
}
|
}
|
||||||
@@ -59,7 +67,7 @@ class OpenBao final : public SyncProvider
|
|||||||
if (transport)
|
if (transport)
|
||||||
{
|
{
|
||||||
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
|
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_COMPONENTS url{sizeof(url)};
|
||||||
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
|
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
|
||||||
@@ -98,7 +106,7 @@ class OpenBao final : public SyncProvider
|
|||||||
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
|
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||||
result.insert(result.end(), chunk, chunk + received);
|
result.insert(result.end(), chunk, chunk + received);
|
||||||
}
|
}
|
||||||
return response(status, result, missing);
|
return response(status, result, missing, path, method);
|
||||||
}
|
}
|
||||||
public:
|
public:
|
||||||
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
|
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
|
||||||
@@ -219,7 +227,8 @@ Bytes sharedSyncSettings(const Settings &s)
|
|||||||
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
||||||
});
|
});
|
||||||
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
||||||
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
|
||||||
|
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications"}) j.erase(key);
|
||||||
return bytes(canonicalProfile(j));
|
return bytes(canonicalProfile(j));
|
||||||
}
|
}
|
||||||
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
||||||
@@ -306,9 +315,10 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
|
|||||||
auto revision = remote.revision;
|
auto revision = remote.revision;
|
||||||
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
|
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
|
||||||
return {true, merged.local, 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).",
|
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)
|
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ struct SyncResult
|
|||||||
std::wstring status;
|
std::wstring status;
|
||||||
Bytes base;
|
Bytes base;
|
||||||
std::vector<SyncConflict> conflicts;
|
std::vector<SyncConflict> conflicts;
|
||||||
|
bool downloaded{};
|
||||||
};
|
};
|
||||||
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
||||||
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
|
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
|
||||||
|
|||||||
+20
-2
@@ -58,6 +58,20 @@ Json flatProfile(Json j)
|
|||||||
for (auto it = chats.begin(); it != chats.end();)
|
for (auto it = chats.begin(); it != chats.end();)
|
||||||
if (it.value().is_null()) it = chats.erase(it);
|
if (it.value().is_null()) it = chats.erase(it);
|
||||||
else ++it;
|
else ++it;
|
||||||
|
if (j.contains("mouseBindings"))
|
||||||
|
{
|
||||||
|
auto &profiles = j.at("mouseBindings");
|
||||||
|
for (auto profile = profiles.begin(); profile != profiles.end();)
|
||||||
|
{
|
||||||
|
auto &buttons = profile.value();
|
||||||
|
for (auto button = buttons.begin(); button != buttons.end();)
|
||||||
|
if (button.value().is_null()) button = buttons.erase(button);
|
||||||
|
else ++button;
|
||||||
|
if (buttons.is_null() || (buttons.empty() && !isNamedMouseProfile(wide(profile.key()))))
|
||||||
|
profile = profiles.erase(profile);
|
||||||
|
else ++profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
Json snippets = Json::array(), deleted = Json::array();
|
Json snippets = Json::array(), deleted = Json::array();
|
||||||
for (const auto &[id, item] : j.at("snippets").items())
|
for (const auto &[id, item] : j.at("snippets").items())
|
||||||
{
|
{
|
||||||
@@ -99,7 +113,7 @@ std::wstring fieldLabel(const std::string &key)
|
|||||||
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
|
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
|
||||||
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
|
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
|
||||||
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
|
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
|
||||||
{"chatCycles", L"Chat languages"},
|
{"chatCycles", L"Chat languages"}, {"mouseBindings", L"Mouse buttons"},
|
||||||
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
||||||
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
||||||
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
||||||
@@ -133,7 +147,11 @@ MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::stri
|
|||||||
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
|
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
|
||||||
if (!forcedChild && l == b) return {r, r, r};
|
if (!forcedChild && l == b) return {r, r, r};
|
||||||
if (!forcedChild && r == b) return {l, l, l};
|
if (!forcedChild && r == b) return {l, l, l};
|
||||||
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
|
// A mouse action and its application scope must come from the same edit.
|
||||||
|
constexpr std::string_view mouseProfiles = "/mouseBindings/";
|
||||||
|
const bool mouseAction = path.starts_with(mouseProfiles) && path.find('/', mouseProfiles.size()) != std::string::npos;
|
||||||
|
if (!mouseAction && l.is_object() && r.is_object() && (b.is_object() || b.is_null()) &&
|
||||||
|
!tombstone(l) && !tombstone(r) && !tombstone(b))
|
||||||
{
|
{
|
||||||
MergeNode out{Json::object(), Json::object(), Json::object()};
|
MergeNode out{Json::object(), Json::object(), Json::object()};
|
||||||
std::set<std::string> keys;
|
std::set<std::string> keys;
|
||||||
|
|||||||
+254
-55
@@ -1,3 +1,4 @@
|
|||||||
|
#include "lan_layout.h"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include "history_preview.h"
|
#include "history_preview.h"
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
#include <richedit.h>
|
#include <richedit.h>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <shellapi.h>
|
#include <shellapi.h>
|
||||||
|
#include <shlobj.h>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <wrl/client.h>
|
||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
@@ -39,7 +42,12 @@ struct WindowLayout
|
|||||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||||
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
|
||||||
bool autoOptions{};
|
bool autoOptions{};
|
||||||
|
bool fixedClient{}, mouseSettings{};
|
||||||
|
HWND rowCombo{};
|
||||||
};
|
};
|
||||||
|
#ifndef KS_RESIDENT_APP
|
||||||
|
void scaleMouseTableColumns(HWND w, int previousDpi, int nextDpi);
|
||||||
|
#endif
|
||||||
WindowLayout *windowLayoutState(HWND w)
|
WindowLayout *windowLayoutState(HWND w)
|
||||||
{
|
{
|
||||||
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
|
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
|
||||||
@@ -56,24 +64,44 @@ void fitToMonitor(HWND w, RECT rect)
|
|||||||
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
|
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
|
||||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||||
}
|
}
|
||||||
|
void fitFixedClient(HWND w, WindowLayout &layout, RECT frame)
|
||||||
|
{
|
||||||
|
RECT current{}, client{};
|
||||||
|
GetWindowRect(w, ¤t);
|
||||||
|
GetClientRect(w, &client);
|
||||||
|
const auto style = GetWindowLongPtrW(w, GWL_STYLE);
|
||||||
|
const UINT dpi = GetDpiForWindow(w);
|
||||||
|
const int vertical = (style & WS_VSCROLL) ? GetSystemMetricsForDpi(SM_CXVSCROLL, dpi) : 0;
|
||||||
|
const int horizontal = (style & WS_HSCROLL) ? GetSystemMetricsForDpi(SM_CYHSCROLL, dpi) : 0;
|
||||||
|
frame.right = frame.left + scale(w, layout.width) + current.right - current.left - client.right - vertical;
|
||||||
|
frame.bottom = frame.top + scale(w, layout.height) + current.bottom - current.top - client.bottom - horizontal;
|
||||||
|
fitToMonitor(w, frame);
|
||||||
|
}
|
||||||
void arrangeWindow(HWND w)
|
void arrangeWindow(HWND w)
|
||||||
{
|
{
|
||||||
auto *layout = windowLayoutState(w);
|
auto *layout = windowLayoutState(w);
|
||||||
if (!layout || !layout->scrolling || layout->arranging) return;
|
if (!layout || !layout->scrolling || layout->arranging) return;
|
||||||
layout->arranging = true;
|
layout->arranging = true;
|
||||||
// Showing either scrollbar can make the other necessary. Let the client size settle.
|
RECT client{};
|
||||||
for (int pass = 0; pass < 3; ++pass)
|
GetClientRect(w, &client);
|
||||||
{
|
const auto style = GetWindowLongPtrW(w, GWL_STYLE);
|
||||||
RECT client{};
|
const UINT dpi = GetDpiForWindow(w);
|
||||||
GetClientRect(w, &client);
|
const int verticalBar = GetSystemMetricsForDpi(SM_CXVSCROLL, dpi);
|
||||||
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
|
const int horizontalBar = GetSystemMetricsForDpi(SM_CYHSCROLL, dpi);
|
||||||
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
|
// Start from the full client area so two obsolete bars cannot keep each other visible.
|
||||||
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
|
const int fullWidth = client.right + ((style & WS_VSCROLL) ? verticalBar : 0);
|
||||||
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
|
const int fullHeight = client.bottom + ((style & WS_HSCROLL) ? horizontalBar : 0);
|
||||||
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
const int width = scale(w, layout->width);
|
||||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
const int height = scale(w, layout->height);
|
||||||
}
|
const bool needsHorizontal = width > fullWidth || (height > fullHeight && width > fullWidth - verticalBar);
|
||||||
RECT client{}; GetClientRect(w, &client);
|
const bool needsVertical = height > fullHeight - (needsHorizontal ? horizontalBar : 0);
|
||||||
|
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS, 0, width - 1,
|
||||||
|
(UINT)std::max(1, fullWidth - (needsVertical ? verticalBar : 0)), layout->x};
|
||||||
|
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS, 0, height - 1,
|
||||||
|
(UINT)std::max(1, fullHeight - (needsHorizontal ? horizontalBar : 0)), layout->y};
|
||||||
|
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
||||||
|
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||||
|
GetClientRect(w, &client);
|
||||||
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
bool expandable = layout->history || layout->stretch || layout->autoOptions;
|
||||||
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||||
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||||
@@ -142,6 +170,15 @@ void arrangeWindow(HWND w)
|
|||||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (layout->rowCombo)
|
||||||
|
{
|
||||||
|
RECT bounds{};
|
||||||
|
GetWindowRect(layout->rowCombo, &bounds);
|
||||||
|
const int difference = scale(w, 25) - (bounds.bottom - bounds.top);
|
||||||
|
const auto itemHeight = SendMessageW(layout->rowCombo, CB_GETITEMHEIGHT, static_cast<WPARAM>(-1), 0);
|
||||||
|
if (difference && itemHeight != CB_ERR && itemHeight + difference > 0)
|
||||||
|
SendMessageW(layout->rowCombo, CB_SETITEMHEIGHT, static_cast<WPARAM>(-1), itemHeight + difference);
|
||||||
|
}
|
||||||
layout->arranging = false;
|
layout->arranging = false;
|
||||||
}
|
}
|
||||||
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
|
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
|
||||||
@@ -190,7 +227,8 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
|||||||
{
|
{
|
||||||
RECT rect{};
|
RECT rect{};
|
||||||
GetWindowRect(w, &rect);
|
GetWindowRect(w, &rect);
|
||||||
fitToMonitor(w, rect);
|
if (layout->fixedClient) fitFixedClient(w, *layout, rect);
|
||||||
|
else fitToMonitor(w, rect);
|
||||||
arrangeWindow(w);
|
arrangeWindow(w);
|
||||||
}
|
}
|
||||||
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
|
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
|
||||||
@@ -339,7 +377,7 @@ void error(HWND w, const std::exception &e)
|
|||||||
MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING);
|
MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING);
|
||||||
}
|
}
|
||||||
HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height,
|
HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height,
|
||||||
DWORD extra = 0, bool resizable = true)
|
DWORD extra = 0, bool resizable = true, HWND owner = nullptr)
|
||||||
{
|
{
|
||||||
WNDCLASSW wc{};
|
WNDCLASSW wc{};
|
||||||
wc.hInstance = app.instance;
|
wc.hInstance = app.instance;
|
||||||
@@ -353,22 +391,25 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
|||||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
|
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
|
||||||
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
||||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
const DWORD exStyle = owner ? WS_EX_DLGMODALFRAME : WS_EX_APPWINDOW;
|
||||||
|
AdjustWindowRectExForDpi(&rect, style, FALSE, exStyle, dpi);
|
||||||
POINT cursor{};
|
POINT cursor{};
|
||||||
GetCursorPos(&cursor);
|
GetCursorPos(&cursor);
|
||||||
MONITORINFO monitor{sizeof(monitor)};
|
MONITORINFO monitor{sizeof(monitor)};
|
||||||
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
|
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
|
||||||
HWND w =
|
HWND w =
|
||||||
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
|
CreateWindowExW(exStyle, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
|
||||||
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
|
rect.right - rect.left, rect.bottom - rect.top, owner, nullptr, app.instance, &app);
|
||||||
if (!w)
|
if (!w)
|
||||||
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
|
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
|
||||||
// The system DPI may still describe the old primary display. The HWND tells
|
// The system DPI may still describe the old primary display. The HWND tells
|
||||||
// us the actual destination DPI, and must size the frame AND its controls.
|
// us the actual destination DPI, and must size the frame AND its controls.
|
||||||
dpi = GetDpiForWindow(w);
|
dpi = GetDpiForWindow(w);
|
||||||
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
RECT frame{}, client{};
|
||||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
GetWindowRect(w, &frame);
|
||||||
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
|
GetClientRect(w, &client);
|
||||||
|
int outerWidth = scale(w, width) + (frame.right - frame.left) - client.right;
|
||||||
|
int outerHeight = scale(w, height) + (frame.bottom - frame.top) - client.bottom;
|
||||||
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
|
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
|
||||||
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
||||||
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
||||||
@@ -440,7 +481,17 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
|||||||
if (layout && layout->history)
|
if (layout && layout->history)
|
||||||
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||||
fitToMonitor(w, *(RECT *)lp);
|
fitToMonitor(w, *(RECT *)lp);
|
||||||
|
if (layout && layout->fixedClient)
|
||||||
|
{
|
||||||
|
RECT frame{};
|
||||||
|
GetWindowRect(w, &frame);
|
||||||
|
fitFixedClient(w, *layout, frame);
|
||||||
|
}
|
||||||
arrangeWindow(w);
|
arrangeWindow(w);
|
||||||
|
#ifndef KS_RESIDENT_APP
|
||||||
|
if (layout && layout->mouseSettings && previous != next)
|
||||||
|
scaleMouseTableColumns(w, previous, next);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
|
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
|
||||||
{
|
{
|
||||||
@@ -520,11 +571,13 @@ void search(HWND w, HistoryView *v)
|
|||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
auto *app = v->app;
|
auto *app = v->app;
|
||||||
app->enqueueHistorySearch(
|
app->enqueueHistorySearch(
|
||||||
[w, app, q, generation, pinnedOnly]
|
[w, app, q, generation, pinnedOnly, computers=app->historyComputers]
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto result = new SearchResult{generation, app->history->search(q, pinnedOnly)};
|
auto result = new SearchResult{generation, app->history->search(q, pinnedOnly)};
|
||||||
|
if(!computers.empty())std::erase_if(result->items,[&](const Entry &e){return
|
||||||
|
std::find(computers.begin(),computers.end(),e.computer)==computers.end();});
|
||||||
if (!PostMessageW(w, ResultsMessage, 0, (LPARAM)result))
|
if (!PostMessageW(w, ResultsMessage, 0, (LPARAM)result))
|
||||||
delete result;
|
delete result;
|
||||||
}
|
}
|
||||||
@@ -534,6 +587,79 @@ void search(HWND w, HistoryView *v)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
struct ComputerFilter
|
||||||
|
{
|
||||||
|
HWND owner{},list{};HistoryView *view{};bool filling{};
|
||||||
|
std::vector<std::pair<std::string,std::wstring>> computers;
|
||||||
|
};
|
||||||
|
LRESULT CALLBACK filterKeys(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||||
|
{
|
||||||
|
if(msg==WM_KEYDOWN&&wp==VK_ESCAPE){DestroyWindow(GetParent(w));return 0;}
|
||||||
|
if(msg==WM_NCDESTROY)RemoveWindowSubclass(w,filterKeys,1);
|
||||||
|
return DefSubclassProc(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK computerFilterProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *f=reinterpret_cast<ComputerFilter*>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
||||||
|
if(msg==WM_NCCREATE){f=static_cast<ComputerFilter*>(reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);SetWindowLongPtrW(w,GWLP_USERDATA,reinterpret_cast<LONG_PTR>(f));}
|
||||||
|
if(f&&msg==WM_NOTIFY&&!f->filling&&reinterpret_cast<NMHDR*>(lp)->code==LVN_ITEMCHANGED){
|
||||||
|
auto *change=reinterpret_cast<NMLISTVIEW*>(lp);
|
||||||
|
if(change->iItem>=0&&(change->uChanged&LVIF_STATE)&&((change->uOldState^change->uNewState)&LVIS_STATEIMAGEMASK)){
|
||||||
|
f->filling=true;
|
||||||
|
bool checked=ListView_GetCheckState(f->list,change->iItem)!=0;
|
||||||
|
if(change->iItem==0)for(int i=1;i<=int(f->computers.size());++i)ListView_SetCheckState(f->list,i,checked);
|
||||||
|
bool all=true;auto &chosen=f->view->app->historyComputers;chosen.clear();
|
||||||
|
for(int i=0;i<int(f->computers.size());++i){if(ListView_GetCheckState(f->list,i+1))chosen.push_back(f->computers[i].first);else all=false;}
|
||||||
|
ListView_SetCheckState(f->list,0,all);
|
||||||
|
if(all)chosen.clear();else if(chosen.empty())chosen.push_back("__none");
|
||||||
|
f->filling=false;
|
||||||
|
SetDlgItemTextW(f->owner,118,all?L" Computers: All ▾":L" Computers: Selected ▾");
|
||||||
|
search(f->owner,f->view);
|
||||||
|
f->view->app->saveHistoryView(f->view->rememberedSelection,f->view->rememberedTop,value(f->owner,101),SendDlgItemMessageW(f->owner,117,BM_GETCHECK,0,0)==BST_CHECKED);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_ACTIVATE&&LOWORD(wp)==WA_INACTIVE){DestroyWindow(w);return 0;}
|
||||||
|
if(msg==WM_CLOSE){DestroyWindow(w);return 0;}
|
||||||
|
if(msg==WM_NCDESTROY){SetWindowLongPtrW(w,GWLP_USERDATA,0);delete f;}
|
||||||
|
return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
void showComputerFilter(HWND owner,HistoryView *v)
|
||||||
|
{
|
||||||
|
std::map<std::string,std::wstring> names{{"",L"This computer"}};
|
||||||
|
for(auto &entry:v->app->history->entries())if(!entry.computer.empty())names[entry.computer]=entry.computerName;
|
||||||
|
try{
|
||||||
|
auto path=v->app->data/L"lan-pairing.bin";
|
||||||
|
if(std::filesystem::exists(path)){
|
||||||
|
auto config=nlohmann::json::parse(unprotect(readFile(path,256*1024)));
|
||||||
|
for(auto &[id,peer]:config.at("trusted").items())names[id]=wide(peer.at("name").get<std::string>());
|
||||||
|
}
|
||||||
|
}catch(...){}
|
||||||
|
auto *f=new ComputerFilter{owner,nullptr,v,true,{}};
|
||||||
|
for(auto &[id,name]:names)f->computers.push_back({id,name});
|
||||||
|
WNDCLASSW cls{};cls.lpfnWndProc=computerFilterProc;cls.hInstance=v->app->instance;cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);cls.hbrBackground=(HBRUSH)(COLOR_WINDOW+1);cls.lpszClassName=L"KSwitcher.ComputerFilter";RegisterClassW(&cls);
|
||||||
|
RECT r{};GetWindowRect(GetDlgItem(owner,118),&r);
|
||||||
|
int width=scale(owner,300),height=scale(owner,std::min(280,32+int(names.size()+1)*26));
|
||||||
|
MONITORINFO monitor{sizeof(monitor)};GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||||
|
int x=std::clamp(int(r.left),int(monitor.rcWork.left),std::max(int(monitor.rcWork.left),int(monitor.rcWork.right)-width));
|
||||||
|
int y=r.bottom+height>monitor.rcWork.bottom?std::max(int(monitor.rcWork.top),int(r.top)-height):int(r.bottom);
|
||||||
|
auto popup=CreateWindowExW(WS_EX_TOOLWINDOW,cls.lpszClassName,L"Computers",WS_POPUP|WS_BORDER,x,y,width,height,owner,nullptr,v->app->instance,f);
|
||||||
|
if(!popup){delete f;return;}
|
||||||
|
RECT client{};GetClientRect(popup,&client);
|
||||||
|
f->list=CreateWindowExW(0,WC_LISTVIEWW,L"Computers",WS_CHILD|WS_VISIBLE|WS_TABSTOP|LVS_REPORT|LVS_NOCOLUMNHEADER|LVS_SINGLESEL,0,0,client.right,client.bottom,popup,nullptr,v->app->instance,nullptr);
|
||||||
|
SendMessageW(f->list,WM_SETFONT,SendDlgItemMessageW(owner,118,WM_GETFONT,0,0),TRUE);
|
||||||
|
ListView_SetExtendedListViewStyle(f->list,LVS_EX_CHECKBOXES|LVS_EX_FULLROWSELECT|LVS_EX_DOUBLEBUFFER);
|
||||||
|
LVCOLUMNW column{LVCF_WIDTH};column.cx=client.right-scale(owner,24);ListView_InsertColumn(f->list,0,&column);
|
||||||
|
auto &chosen=v->app->historyComputers;
|
||||||
|
for(int i=0;i<=int(f->computers.size());++i){
|
||||||
|
std::wstring label=i?f->computers[i-1].second:L"All computers";
|
||||||
|
LVITEMW item{LVIF_TEXT};item.iItem=i;item.pszText=label.data();ListView_InsertItem(f->list,&item);
|
||||||
|
bool checked=chosen.empty()||(i&&std::find(chosen.begin(),chosen.end(),f->computers[i-1].first)!=chosen.end());
|
||||||
|
ListView_SetCheckState(f->list,i,checked);
|
||||||
|
}
|
||||||
|
f->filling=false;SetWindowSubclass(f->list,filterKeys,1,0);
|
||||||
|
ShowWindow(popup,SW_SHOW);SetFocus(f->list);
|
||||||
|
}
|
||||||
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
void requestPreview(HWND w, HistoryView *v, const Entry &entry)
|
||||||
{
|
{
|
||||||
if(v->scrolling) return;
|
if(v->scrolling) return;
|
||||||
@@ -659,7 +785,7 @@ void drawHistoryRow(HWND w, HistoryView *v, const DRAWITEMSTRUCT &draw)
|
|||||||
DrawTextW(dc, text.c_str(), -1, &title, DT_WORDBREAK | DT_END_ELLIPSIS | DT_NOPREFIX);
|
DrawTextW(dc, text.c_str(), -1, &title, DT_WORDBREAK | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||||
RECT detail{title.left, box.bottom - scale(w, 26), title.right, box.bottom - scale(w, 8)};
|
RECT detail{title.left, box.bottom - scale(w, 26), title.right, box.bottom - scale(w, 8)};
|
||||||
SetTextColor(dc, GetSysColor(selected ? COLOR_HIGHLIGHTTEXT : COLOR_GRAYTEXT));
|
SetTextColor(dc, GetSysColor(selected ? COLOR_HIGHLIGHTTEXT : COLOR_GRAYTEXT));
|
||||||
auto subtitle = (entry.pinned ? L"★ " : L"") + std::to_wstring(draw.itemID + 1) + L" · " + entry.app;
|
auto subtitle = (entry.pinned ? L"★ " : L"") + std::to_wstring(draw.itemID + 1) + L" · " + entry.app + L" · " + (entry.computer.empty()?L"This computer":entry.computerName);
|
||||||
DrawTextW(dc, subtitle.c_str(), -1, &detail, DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
|
DrawTextW(dc, subtitle.c_str(), -1, &detail, DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||||
if (draw.itemState & ODS_FOCUS)
|
if (draw.itemState & ODS_FOCUS)
|
||||||
{
|
{
|
||||||
@@ -741,6 +867,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
|
if(id==118&&HIWORD(wp)==BN_CLICKED){showComputerFilter(w,v);return 0;}
|
||||||
if (id == 117 && HIWORD(wp) == BN_CLICKED)
|
if (id == 117 && HIWORD(wp) == BN_CLICKED)
|
||||||
{
|
{
|
||||||
KillTimer(w, 1);
|
KillTimer(w, 1);
|
||||||
@@ -968,7 +1095,10 @@ struct SettingsView
|
|||||||
int selection = -1;
|
int selection = -1;
|
||||||
bool filling{};
|
bool filling{};
|
||||||
std::vector<std::vector<HWND>> pages;
|
std::vector<std::vector<HWND>> pages;
|
||||||
bool ready{}, refreshing{};
|
bool ready{}, refreshing{}, visitedLan{};
|
||||||
|
std::vector<std::wstring> mouseProfiles;
|
||||||
|
unsigned mouseEvents{}, rawMouseEvents{};
|
||||||
|
bool rawMouseRegistered{};
|
||||||
};
|
};
|
||||||
void updateSettingsState(HWND w, SettingsView *v);
|
void updateSettingsState(HWND w, SettingsView *v);
|
||||||
void selectSettingsPage(HWND w, SettingsView *v)
|
void selectSettingsPage(HWND w, SettingsView *v)
|
||||||
@@ -983,12 +1113,27 @@ struct ChatLanguagesView
|
|||||||
HWND parent{};
|
HWND parent{};
|
||||||
SettingsView *settings{};
|
SettingsView *settings{};
|
||||||
std::vector<std::wstring> keys;
|
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)
|
void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
||||||
{
|
{
|
||||||
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||||
if (index < 0 || index >= (int)v->keys.size()) return;
|
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]);
|
auto saved = draft.chatCycles.find(v->keys[index]);
|
||||||
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
||||||
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
@@ -998,7 +1143,7 @@ void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
|||||||
std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end(), i);
|
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." :
|
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());
|
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
|
||||||
}
|
}
|
||||||
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
@@ -1010,25 +1155,29 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (msg == WM_COMMAND)
|
if (msg == WM_COMMAND)
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
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)
|
if (id == IDOK || id == 713)
|
||||||
{
|
{
|
||||||
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||||
if (selected < 0 || selected >= (int)v->keys.size())
|
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.");
|
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
|
||||||
auto next = v->settings->draft;
|
if (id == 713)
|
||||||
if (id == 713) next.chatCycles.erase(v->keys[selected]);
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
std::vector<std::wstring> cycle;
|
v->draft.chatCycles.erase(v->keys[selected]);
|
||||||
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
v->dirty = false;
|
||||||
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
|
fillChatLanguages(w, v);
|
||||||
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
|
return 0;
|
||||||
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
|
|
||||||
next.chatCycles[v->keys[selected]] = std::move(cycle);
|
|
||||||
}
|
}
|
||||||
validateSettings(next);
|
collectChatLanguages(w, v);
|
||||||
v->settings->draft.chatCycles = std::move(next.chatCycles);
|
validateSettings(v->draft);
|
||||||
|
v->settings->draft.chatCycles = std::move(v->draft.chatCycles);
|
||||||
DestroyWindow(w);
|
DestroyWindow(w);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1063,6 +1212,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
}, (LPARAM)&context);
|
}, (LPARAM)&context);
|
||||||
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
|
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()}};
|
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
||||||
|
v->draft = settings->draft;
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
||||||
@@ -1076,12 +1226,12 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
}
|
}
|
||||||
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
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);
|
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);
|
711, 18, 256, 604, 132);
|
||||||
for (int i : settings->order)
|
for (int i : settings->order)
|
||||||
SendDlgItemMessageW(w, 711, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
|
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"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"&Remove profile", 713, 18, 476, 170);
|
||||||
button(w, L"&Use languages", IDOK, 300, 476, 170);
|
button(w, L"&Use languages", IDOK, 300, 476, 170);
|
||||||
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
|
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
|
||||||
@@ -1092,6 +1242,8 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetFocus(GetDlgItem(w, 710));
|
SetFocus(GetDlgItem(w, 710));
|
||||||
}
|
}
|
||||||
|
#include "ui_app_languages.inl"
|
||||||
|
#include "ui_mouse_buttons.inl"
|
||||||
void collectLayout(HWND w, SettingsView *v)
|
void collectLayout(HWND w, SettingsView *v)
|
||||||
{
|
{
|
||||||
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
||||||
@@ -1170,6 +1322,7 @@ void fillSettings(HWND w, SettingsView *v)
|
|||||||
v->order.push_back(i);
|
v->order.push_back(i);
|
||||||
v->selection = 0;
|
v->selection = 0;
|
||||||
fillLayouts(w, v);
|
fillLayouts(w, v);
|
||||||
|
fillMouseProfiles(w, *v);
|
||||||
v->filling = false;
|
v->filling = false;
|
||||||
updateSettingsState(w, v);
|
updateSettingsState(w, v);
|
||||||
}
|
}
|
||||||
@@ -1548,7 +1701,8 @@ void updateSettingsState(HWND w, SettingsView *v)
|
|||||||
v->refreshing = true;
|
v->refreshing = true;
|
||||||
bool changed = settingsChanged(w, v);
|
bool changed = settingsChanged(w, v);
|
||||||
EnableWindow(GetDlgItem(w, IDOK), changed);
|
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);
|
if (value(w, 261) != text) value(w, 261, text);
|
||||||
v->refreshing = false;
|
v->refreshing = false;
|
||||||
}
|
}
|
||||||
@@ -1611,6 +1765,20 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
switch (msg)
|
switch (msg)
|
||||||
{
|
{
|
||||||
|
case WM_DRAWITEM:
|
||||||
|
if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile)
|
||||||
|
{
|
||||||
|
drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp));
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case WM_INPUT:
|
||||||
|
readMouseTestInput(w, *v, reinterpret_cast<HRAWINPUT>(lp));
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
case ConfigSettingsDownloaded:
|
||||||
|
v->draft = v->app->settings;
|
||||||
|
fillSettings(w, v);
|
||||||
|
return 0;
|
||||||
case WM_ENABLE:
|
case WM_ENABLE:
|
||||||
if (wp) updateSettingsState(w, v);
|
if (wp) updateSettingsState(w, v);
|
||||||
break;
|
break;
|
||||||
@@ -1618,6 +1786,24 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (LOWORD(wp) != WA_INACTIVE) updateSettingsState(w, v);
|
if (LOWORD(wp) != WA_INACTIVE) updateSettingsState(w, v);
|
||||||
break;
|
break;
|
||||||
case WM_NOTIFY:
|
case WM_NOTIFY:
|
||||||
|
if (((NMHDR *)lp)->idFrom == 731)
|
||||||
|
{
|
||||||
|
if (((NMHDR *)lp)->code == LVN_ITEMCHANGING)
|
||||||
|
return blockMouseProfileStateChange(w, *v, *reinterpret_cast<const NMLISTVIEW *>(lp));
|
||||||
|
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
|
||||||
|
{
|
||||||
|
const auto &change = *reinterpret_cast<const NMLISTVIEW *>(lp);
|
||||||
|
if (!(change.uOldState & LVIS_SELECTED) && (change.uNewState & LVIS_SELECTED))
|
||||||
|
fillMouseEvents(w, *v);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (((NMHDR *)lp)->idFrom == 733 && ((NMHDR *)lp)->code == NM_DBLCLK)
|
||||||
|
{
|
||||||
|
const auto &click = *reinterpret_cast<const NMITEMACTIVATE *>(lp);
|
||||||
|
showMouseActionEditor(w, *v, click.iItem);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
|
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
|
||||||
{
|
{
|
||||||
selectSettingsPage(w, v);
|
selectSettingsPage(w, v);
|
||||||
@@ -1641,7 +1827,9 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||||
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||||
if (id == 262) { showLan(w, *v->app); return 0; }
|
if (id == 262) { v->visitedLan=true; showLan(w, *v->app); return 0; }
|
||||||
|
if (id == 263) { collectLayout(w, v); showAppLanguages(w, v); return 0; }
|
||||||
|
if (handleMouseBindings(w, *v, wp)) return 0;
|
||||||
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
|
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
@@ -1726,6 +1914,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
closeSettings(w, v);
|
closeSettings(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
case WM_NCDESTROY:
|
case WM_NCDESTROY:
|
||||||
|
if (v->rawMouseRegistered) registerMouseTestInput(nullptr);
|
||||||
disposeFont(w);
|
disposeFont(w);
|
||||||
v->app->settingsWindow = nullptr;
|
v->app->settingsWindow = nullptr;
|
||||||
delete v;
|
delete v;
|
||||||
@@ -1968,8 +2157,11 @@ void showHistory(App &app, bool visible)
|
|||||||
auto filter = control(w, L"BUTTON", L"★ Pinned only", WS_TABSTOP | BS_AUTOCHECKBOX,
|
auto filter = control(w, L"BUTTON", L"★ Pinned only", WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||||
117, 16, 38, 112, 25);
|
117, 16, 38, 112, 25);
|
||||||
SendMessageW(filter, BM_SETCHECK, app.historyPinnedOnly ? BST_CHECKED : BST_UNCHECKED, 0);
|
SendMessageW(filter, BM_SETCHECK, app.historyPinnedOnly ? BST_CHECKED : BST_UNCHECKED, 0);
|
||||||
label(w, L"&Search clipboard history", 136, 12, 500);
|
auto computers = control(w,L"BUTTON",app.historyComputers.empty()?L" Computers: All ▾":L" Computers: Selected ▾",
|
||||||
edit(w, 101, 136, 38, 608);
|
WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,118,136,36,180,28);
|
||||||
|
(void)computers;
|
||||||
|
label(w, L"&Search clipboard history", 328, 12, 400);
|
||||||
|
edit(w, 101, 328, 38, 416);
|
||||||
auto list = control(w, L"LISTBOX", L"",
|
auto list = control(w, L"LISTBOX", L"",
|
||||||
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER |
|
||||||
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
LBS_OWNERDRAWFIXED | LBS_HASSTRINGS,
|
||||||
@@ -2027,13 +2219,16 @@ void showSettings(App &app, bool visible)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 720, 510, 0, false);
|
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 720, 510, 0, false);
|
||||||
|
auto &layout = *windowLayoutState(w);
|
||||||
|
layout.fixedClient = true;
|
||||||
|
layout.mouseSettings = true;
|
||||||
app.settingsWindow = w;
|
app.settingsWindow = w;
|
||||||
auto *v = (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
auto *v = (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||||
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
|
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
|
||||||
InitCommonControlsEx(&tabsInit);
|
InitCommonControlsEx(&tabsInit);
|
||||||
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
|
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
|
||||||
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools"};
|
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools", L"Mouse"};
|
||||||
for (int i = 0; i < 5; ++i)
|
for (int i = 0; i < (int)std::size(tabNames); ++i)
|
||||||
{
|
{
|
||||||
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
|
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
|
||||||
TabCtrl_InsertItem(tabs, i, &item);
|
TabCtrl_InsertItem(tabs, i, &item);
|
||||||
@@ -2069,7 +2264,7 @@ void showSettings(App &app, bool visible)
|
|||||||
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
||||||
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
||||||
button(w, L"Languages for a &chat...", 259, 24, 378, 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();
|
page();
|
||||||
|
|
||||||
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||||
@@ -2098,14 +2293,18 @@ void showSettings(App &app, bool visible)
|
|||||||
label(w, L"Additional tools and preferences", 24, 76, 660);
|
label(w, L"Additional tools and preferences", 24, 76, 660);
|
||||||
button(w, L"S&nippets...", 252, 24, 116, 210);
|
button(w, L"S&nippets...", 252, 24, 116, 210);
|
||||||
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
|
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
|
||||||
button(w, L"OCR op&tions...", 257, 24, 180, 210);
|
button(w, L"OCR op&tions...", 257, 24, 170, 210);
|
||||||
label(w, L"Configure text recognition from images.", 254, 184, 430);
|
label(w, L"Configure text recognition from images.", 254, 174, 430);
|
||||||
button(w, L"S&ync...", 258, 24, 244, 210);
|
button(w, L"S&ync...", 258, 24, 224, 210);
|
||||||
label(w, L"Synchronize preferences between computers.", 254, 248, 430);
|
label(w, L"Synchronize preferences between computers.", 254, 228, 430);
|
||||||
button(w, L"View l&og...", 256, 24, 308, 210);
|
button(w, L"KSwitcher &computers...", 262, 24, 278, 210);
|
||||||
label(w, L"Inspect recent operations and diagnostics.", 254, 312, 430);
|
label(w, L"Share mouse and keyboard with paired computers.", 254, 282, 430);
|
||||||
|
button(w, L"View l&og...", 256, 24, 332, 210);
|
||||||
|
label(w, L"Inspect recent operations and diagnostics.", 254, 336, 430);
|
||||||
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
|
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
|
||||||
button(w, L"KSwitcher &computers...", 262, 254, 374, 260);
|
page();
|
||||||
|
|
||||||
|
createMousePage(w, *v);
|
||||||
page();
|
page();
|
||||||
|
|
||||||
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
|
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
// Included inside ui.cpp's anonymous namespace, alongside the settings editors.
|
||||||
|
std::wstring trimApplicationText(const std::wstring &text)
|
||||||
|
{
|
||||||
|
auto first = text.find_first_not_of(L" \t\r\n");
|
||||||
|
return first == text.npos ? L"" : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
|
||||||
|
}
|
||||||
|
struct AppLanguagesView
|
||||||
|
{
|
||||||
|
HWND parent{};
|
||||||
|
SettingsView *settings{};
|
||||||
|
std::map<std::wstring, std::vector<std::wstring>> profiles;
|
||||||
|
std::vector<std::wstring> applications;
|
||||||
|
int selection = -1;
|
||||||
|
bool dirty{};
|
||||||
|
HWND pickerOutline{};
|
||||||
|
};
|
||||||
|
constexpr UINT AppWindowPicked = WM_APP + 78;
|
||||||
|
void showAppLanguageStatus(HWND w, const AppLanguagesView &v)
|
||||||
|
{
|
||||||
|
if (v.selection < 0)
|
||||||
|
value(w, 721, L"Select an application, drag the crosshair, or enter an executable name.");
|
||||||
|
else if (v.dirty || v.profiles.contains(v.applications[v.selection]))
|
||||||
|
value(w, 721, L"Custom languages for this application. Other applications are unchanged.");
|
||||||
|
else
|
||||||
|
value(w, 721, L"Using default languages. Click languages to customize this application.");
|
||||||
|
}
|
||||||
|
void fillAppLanguages(HWND w, AppLanguagesView *v)
|
||||||
|
{
|
||||||
|
bool selected = v->selection >= 0 && v->selection < (int)v->applications.size();
|
||||||
|
EnableWindow(GetDlgItem(w, 722), selected);
|
||||||
|
SendDlgItemMessageW(w, 722, LB_SETSEL, FALSE, -1);
|
||||||
|
if (!selected)
|
||||||
|
{
|
||||||
|
EnableWindow(GetDlgItem(w, 725), FALSE);
|
||||||
|
showAppLanguageStatus(w, *v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto found = v->profiles.find(v->applications[v->selection]);
|
||||||
|
const auto &cycle = found == v->profiles.end() ? v->settings->draft.cycle : found->second;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
{
|
||||||
|
const auto &id = v->settings->app->layouts[v->settings->order[i]].id;
|
||||||
|
SendDlgItemMessageW(w, 722, LB_SETSEL,
|
||||||
|
std::find(cycle.begin(), cycle.end(), id) != cycle.end(), i);
|
||||||
|
}
|
||||||
|
showAppLanguageStatus(w, *v);
|
||||||
|
EnableWindow(GetDlgItem(w, 725), found != v->profiles.end());
|
||||||
|
}
|
||||||
|
void collectAppLanguages(HWND w, AppLanguagesView *v)
|
||||||
|
{
|
||||||
|
if (v->selection < 0 || !v->dirty) return;
|
||||||
|
std::vector<std::wstring> cycle;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
if (SendDlgItemMessageW(w, 722, LB_GETSEL, i, 0) > 0)
|
||||||
|
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
|
||||||
|
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
|
||||||
|
v->profiles[v->applications[v->selection]] = std::move(cycle);
|
||||||
|
v->dirty = false;
|
||||||
|
}
|
||||||
|
void selectAppLanguageProfile(HWND w, AppLanguagesView *v, const std::wstring &app)
|
||||||
|
{
|
||||||
|
collectAppLanguages(w, v);
|
||||||
|
auto found = std::find(v->applications.begin(), v->applications.end(), app);
|
||||||
|
int index = (int)(found - v->applications.begin());
|
||||||
|
if (found == v->applications.end())
|
||||||
|
{
|
||||||
|
auto row = SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
|
||||||
|
if (row < 0) throw std::runtime_error("Could not add the application to the list.");
|
||||||
|
v->applications.push_back(app);
|
||||||
|
}
|
||||||
|
v->selection = index;
|
||||||
|
SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0);
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
}
|
||||||
|
HWND appPickerTarget(HWND button, LPARAM position)
|
||||||
|
{
|
||||||
|
POINT point{(short)LOWORD(position), (short)HIWORD(position)};
|
||||||
|
ClientToScreen(button, &point);
|
||||||
|
auto target = GetAncestor(WindowFromPoint(point), GA_ROOT);
|
||||||
|
auto dialog = GetParent(button);
|
||||||
|
return target && target != dialog && target != GetWindow(dialog, GW_OWNER) ? target : nullptr;
|
||||||
|
}
|
||||||
|
void updatePickerOutline(HWND dialog, HWND &outline, HWND target)
|
||||||
|
{
|
||||||
|
RECT bounds{};
|
||||||
|
if (!target || !GetWindowRect(target, &bounds) || IsRectEmpty(&bounds))
|
||||||
|
{
|
||||||
|
if (outline) ShowWindow(outline, SW_HIDE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!outline)
|
||||||
|
{
|
||||||
|
WNDCLASSW windowClass{};
|
||||||
|
windowClass.lpfnWndProc = DefWindowProcW;
|
||||||
|
windowClass.hInstance = GetModuleHandleW(nullptr);
|
||||||
|
windowClass.hbrBackground = GetSysColorBrush(COLOR_HIGHLIGHT);
|
||||||
|
windowClass.lpszClassName = L"KSwitcher.AppPickerOutline";
|
||||||
|
RegisterClassW(&windowClass);
|
||||||
|
outline = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
|
||||||
|
windowClass.lpszClassName, nullptr, WS_POPUP, 0, 0, 0, 0, dialog, nullptr, windowClass.hInstance, nullptr);
|
||||||
|
if (!outline) return;
|
||||||
|
if (!SetLayeredWindowAttributes(outline, 0, 255, LWA_ALPHA))
|
||||||
|
{
|
||||||
|
DestroyWindow(outline);
|
||||||
|
outline = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RECT previous{};
|
||||||
|
if (IsWindowVisible(outline) && GetWindowRect(outline, &previous) && EqualRect(&bounds, &previous)) return;
|
||||||
|
int width = bounds.right - bounds.left, height = bounds.bottom - bounds.top;
|
||||||
|
int stroke = scale(target, 4);
|
||||||
|
HRGN frame = CreateRectRgn(0, 0, width, height);
|
||||||
|
if (!frame) return;
|
||||||
|
if (width > 2 * stroke && height > 2 * stroke)
|
||||||
|
{
|
||||||
|
HRGN opening = CreateRectRgn(stroke, stroke, width - stroke, height - stroke);
|
||||||
|
if (opening)
|
||||||
|
{
|
||||||
|
CombineRgn(frame, frame, opening, RGN_DIFF);
|
||||||
|
DeleteObject(opening);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!SetWindowRgn(outline, frame, TRUE))
|
||||||
|
{
|
||||||
|
DeleteObject(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SetWindowPos(outline, HWND_TOPMOST, bounds.left, bounds.top, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR data)
|
||||||
|
{
|
||||||
|
HWND dialog = GetParent(button);
|
||||||
|
auto &outline = *reinterpret_cast<HWND *>(data);
|
||||||
|
if (msg == WM_LBUTTONDOWN)
|
||||||
|
{
|
||||||
|
SetFocus(button);
|
||||||
|
SetCapture(button);
|
||||||
|
if (GetCapture() == button)
|
||||||
|
{
|
||||||
|
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
|
||||||
|
SendMessageW(dialog, AppWindowPicked, 0, -1);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_CAPTURECHANGED || msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
if (outline)
|
||||||
|
{
|
||||||
|
DestroyWindow(outline);
|
||||||
|
outline = nullptr;
|
||||||
|
}
|
||||||
|
if (msg == WM_CAPTURECHANGED) SendMessageW(dialog, AppWindowPicked, 0, 0);
|
||||||
|
}
|
||||||
|
if (GetCapture() == button)
|
||||||
|
{
|
||||||
|
if (msg == WM_GETDLGCODE && wp == VK_ESCAPE) return DLGC_WANTMESSAGE;
|
||||||
|
if (msg == WM_CANCELMODE || msg == WM_RBUTTONDOWN || (msg == WM_KEYDOWN && wp == VK_ESCAPE))
|
||||||
|
{
|
||||||
|
ReleaseCapture();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_MOUSEMOVE || msg == WM_SETCURSOR)
|
||||||
|
{
|
||||||
|
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
|
||||||
|
if (msg == WM_MOUSEMOVE) updatePickerOutline(dialog, outline, appPickerTarget(button, lp));
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if (msg == WM_LBUTTONUP)
|
||||||
|
{
|
||||||
|
HWND target = appPickerTarget(button, lp);
|
||||||
|
ReleaseCapture();
|
||||||
|
SendMessageW(dialog, AppWindowPicked, (WPARAM)target, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg == WM_NCDESTROY) RemoveWindowSubclass(button, appWindowPickerProc, id);
|
||||||
|
return DefSubclassProc(button, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void createAppWindowPicker(HWND w, int id, int x, int y, int width, HWND &outline)
|
||||||
|
{
|
||||||
|
control(w, L"BUTTON", L" ⊕ Drag to window", WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,
|
||||||
|
id, x, y, width, 28);
|
||||||
|
SetWindowSubclass(GetDlgItem(w, id), appWindowPickerProc, 1, reinterpret_cast<DWORD_PTR>(&outline));
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *v = (AppLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||||
|
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == AppWindowPicked)
|
||||||
|
{
|
||||||
|
if (lp == -1)
|
||||||
|
{
|
||||||
|
value(w, 721, L"Drag to the application's window and release. Esc cancels.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!lp) { showAppLanguageStatus(w, *v); return 0; }
|
||||||
|
HWND target = (HWND)wp;
|
||||||
|
auto app = target && target != w && target != v->parent ? applicationName(processName(target)) : L"";
|
||||||
|
if (app.empty()) value(w, 721, L"No application found. Release the crosshair over another window.");
|
||||||
|
else selectAppLanguageProfile(w, v, app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id = LOWORD(wp);
|
||||||
|
if (id == 722 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
|
{
|
||||||
|
v->dirty = true;
|
||||||
|
EnableWindow(GetDlgItem(w, 725), TRUE);
|
||||||
|
showAppLanguageStatus(w, *v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 720 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
|
{
|
||||||
|
int next = (int)SendDlgItemMessageW(w, 720, LB_GETCURSEL, 0, 0);
|
||||||
|
try { collectAppLanguages(w, v); }
|
||||||
|
catch (...) { SendDlgItemMessageW(w, 720, LB_SETCURSEL, v->selection, 0); throw; }
|
||||||
|
v->selection = next;
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 725 && v->selection >= 0)
|
||||||
|
{
|
||||||
|
v->profiles.erase(v->applications[v->selection]);
|
||||||
|
v->dirty = false;
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 727)
|
||||||
|
{
|
||||||
|
auto app = applicationName(trimApplicationText(value(w, 726)));
|
||||||
|
Settings check;
|
||||||
|
check.appLanguageSets[L"check"] = {{L"win:04090409"}, {app}};
|
||||||
|
validateSettings(check);
|
||||||
|
selectAppLanguageProfile(w, v, app);
|
||||||
|
value(w, 726, L"");
|
||||||
|
SetFocus(GetDlgItem(w, 722));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDOK)
|
||||||
|
{
|
||||||
|
collectAppLanguages(w, v);
|
||||||
|
auto next = v->settings->draft;
|
||||||
|
next.appLanguageSets.clear();
|
||||||
|
// Keep the existing on-disk format, but save independent application profiles.
|
||||||
|
for (const auto &[app, cycle] : v->profiles) next.appLanguageSets[app] = {cycle, {app}};
|
||||||
|
validateSettings(next);
|
||||||
|
v->settings->draft.appLanguageSets = std::move(next.appLanguageSets);
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
EnableWindow(v->parent, TRUE);
|
||||||
|
SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w);
|
||||||
|
delete v;
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void showAppLanguages(HWND parent, SettingsView *settings)
|
||||||
|
{
|
||||||
|
std::set<std::wstring> apps;
|
||||||
|
for (const auto &[name, set] : settings->draft.appLanguageSets)
|
||||||
|
for (const auto &app : set.applications) apps.insert(applicationName(app));
|
||||||
|
EnumWindows([](HWND candidate, LPARAM context) -> BOOL {
|
||||||
|
if (IsWindowVisible(candidate) && !chatTitle(candidate).empty())
|
||||||
|
{
|
||||||
|
auto name = applicationName(processName(candidate));
|
||||||
|
if (!name.empty()) ((std::set<std::wstring> *)context)->insert(name);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}, (LPARAM)&apps);
|
||||||
|
HWND w = makeWindow(*settings->app, L"KSwitcher.AppLanguages", L"Languages for applications", appLanguagesProc, 720, 500);
|
||||||
|
auto *v = new AppLanguagesView{parent, settings};
|
||||||
|
for (const auto &[name, set] : settings->draft.appLanguageSets)
|
||||||
|
for (const auto &app : set.applications) v->profiles[applicationName(app)] = set.languages;
|
||||||
|
v->applications.assign(apps.begin(), apps.end());
|
||||||
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||||
|
label(w, L"Application", 18, 16, 108);
|
||||||
|
createAppWindowPicker(w, 728, 132, 10, 164, v->pickerOutline);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
|
720, 18, 44, 278, 280);
|
||||||
|
label(w, L"Allowed languages (click to include)", 316, 16, 386);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
|
722, 316, 44, 386, 280);
|
||||||
|
for (int i : settings->order)
|
||||||
|
SendDlgItemMessageW(w, 722, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
|
||||||
|
for (const auto &app : v->applications) SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
|
||||||
|
control(w, L"STATIC", L"", 0, 721, 18, 338, 684, 22);
|
||||||
|
label(w, L"Add an executable name (e.g. Codex.exe)", 18, 372, 684);
|
||||||
|
edit(w, 726, 18, 400, 526);
|
||||||
|
button(w, L"&Add application", 727, 558, 398, 144);
|
||||||
|
label(w, L"Switch applications freely. Use languages, then Save settings. Chat profiles take priority.", 18, 436, 684);
|
||||||
|
button(w, L"Use &defaults", 725, 18, 466, 170);
|
||||||
|
button(w, L"&Use languages", IDOK, 414, 466, 144);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 572, 466, 130);
|
||||||
|
if (!v->applications.empty())
|
||||||
|
{
|
||||||
|
v->selection = 0;
|
||||||
|
SendDlgItemMessageW(w, 720, LB_SETCURSEL, 0, 0);
|
||||||
|
}
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
EnableWindow(parent, FALSE);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
SetFocus(GetDlgItem(w, v->selection < 0 ? 726 : 720));
|
||||||
|
}
|
||||||
+113
-46
@@ -3,10 +3,12 @@ struct LanView
|
|||||||
{
|
{
|
||||||
HWND parent{};
|
HWND parent{};
|
||||||
App *app{};
|
App *app{};
|
||||||
nlohmann::json snapshot;
|
nlohmann::json snapshot=nlohmann::json::object();
|
||||||
std::vector<std::string> ids;
|
std::vector<std::string> ids;
|
||||||
std::wstring error;
|
std::wstring error;
|
||||||
bool positionEdited{};
|
std::string dragPeer;POINT dragStart{},dragPoint{};bool dragMoved{};
|
||||||
|
std::string gridState;
|
||||||
|
std::vector<std::wstring> rows;
|
||||||
};
|
};
|
||||||
nlohmann::json lanRequest(App &app,const nlohmann::json &request)
|
nlohmann::json lanRequest(App &app,const nlohmann::json &request)
|
||||||
{
|
{
|
||||||
@@ -18,55 +20,118 @@ std::string selectedLan(HWND w,LanView &v)
|
|||||||
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
|
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
|
||||||
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
|
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)
|
void refreshLan(HWND w,LanView &v)
|
||||||
{
|
{
|
||||||
auto selected=selectedLan(w,v);
|
auto selected=selectedLan(w,v);
|
||||||
auto next=lanRequest(*v.app,{{"action","status"}});
|
auto next=lanRequest(*v.app,{{"action","status"}});
|
||||||
if(v.snapshot.is_null()||v.snapshot["peers"]!=next["peers"])
|
if(selected.empty())
|
||||||
|
{
|
||||||
|
std::string onlyPaired;size_t pairedCount=0;
|
||||||
|
for(const auto &peer:next["peers"])if(peer.value("paired",false))
|
||||||
|
{onlyPaired=peer.at("id").get<std::string>();++pairedCount;}
|
||||||
|
if(pairedCount==1)selected=onlyPaired;
|
||||||
|
}
|
||||||
|
std::vector<std::string> ids;
|
||||||
|
std::vector<std::wstring> rows;
|
||||||
|
for(const auto &peer:next["peers"])
|
||||||
|
{
|
||||||
|
auto id=peer.at("id").get<std::string>();ids.push_back(id);
|
||||||
|
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.
|
||||||
|
if(v.ids!=ids||v.rows!=rows)
|
||||||
{
|
{
|
||||||
SendDlgItemMessageW(w,702,WM_SETREDRAW,FALSE,0);
|
SendDlgItemMessageW(w,702,WM_SETREDRAW,FALSE,0);
|
||||||
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids.clear();
|
SendDlgItemMessageW(w,702,LB_RESETCONTENT,0,0); v.ids=std::move(ids);v.rows=std::move(rows);
|
||||||
for(const auto &peer:next["peers"])
|
for(size_t i=0;i<v.ids.size();++i)
|
||||||
{
|
{
|
||||||
auto id=peer.at("id").get<std::string>(); v.ids.push_back(id);
|
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)v.rows[i].c_str());
|
||||||
std::wstring state=!peer.at("online").get<bool>() ? L"Offline" : peer.at("verified").get<bool>() ? L"Paired / verified" :
|
if(v.ids[i]==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
|
||||||
peer.at("paired").get<bool>() ? L"Paired / checking identity" : L"Available / not paired";
|
|
||||||
auto text=wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state;
|
|
||||||
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)text.c_str());
|
|
||||||
if(id==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
|
|
||||||
}
|
}
|
||||||
SendDlgItemMessageW(w,702,WM_SETREDRAW,TRUE,0); InvalidateRect(GetDlgItem(w,702),nullptr,TRUE);
|
SendDlgItemMessageW(w,702,WM_SETREDRAW,TRUE,0); InvalidateRect(GetDlgItem(w,702),nullptr,TRUE);
|
||||||
}
|
}
|
||||||
|
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);
|
v.snapshot=std::move(next);
|
||||||
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
|
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");}
|
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,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,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.");
|
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);
|
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
|
||||||
EnableWindow(GetDlgItem(w,706),pending);
|
EnableWindow(GetDlgItem(w,706),pending);
|
||||||
EnableWindow(GetDlgItem(w,707),paired);
|
EnableWindow(GetDlgItem(w,707),paired);
|
||||||
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending);
|
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending&&!paired);
|
||||||
EnableWindow(GetDlgItem(w,715),enabled&&paired);
|
auto positions=v.snapshot.value("positions",nlohmann::json::object());
|
||||||
|
EnableWindow(GetDlgItem(w,715),enabled&&!positions.empty()&&!v.snapshot.value("shareInput",false));
|
||||||
EnableWindow(GetDlgItem(w,716),v.snapshot.value("shareInput",false));
|
EnableWindow(GetDlgItem(w,716),v.snapshot.value("shareInput",false));
|
||||||
for(int id:{711,712,713,714})EnableWindow(GetDlgItem(w,id),paired);
|
auto gridState=positions.dump()+v.snapshot["peers"].dump()+selected+v.snapshot.value("controlPeer",std::string{})+v.snapshot.value("inputReady",nlohmann::json::object()).dump()+(v.snapshot.value("shareInput",false)?"1":"0");
|
||||||
if(!v.positionEdited)CheckRadioButton(w,711,714,711+v.snapshot.value("inputEdge",1));
|
if(gridState!=v.gridState){v.gridState=std::move(gridState);InvalidateRect(GetDlgItem(w,721),nullptr,FALSE);}
|
||||||
int position=1;for(int i=0;i<4;++i)if(checked(w,711+i))position=i;
|
std::wstring detail=L"Drag paired monitors to empty cells. This PC stays fixed. Arrow keys also work.";
|
||||||
const wchar_t *diagrams[]={L"[ Other PC ] ← [ This PC ]",L"[ This PC ] → [ Other PC ]",L"Other PC is ABOVE this PC",L"Other PC is BELOW this PC"};
|
detail=L"Drag monitors to neighboring cells. Mouse crosses shared edges; empty cells block passage.";
|
||||||
value(w,719,diagrams[position]);
|
value(w,719,v.error.empty()?detail:v.error);
|
||||||
auto inputStatus=wide(v.snapshot.value("inputStatus",std::string("Input sharing disabled.")));
|
auto inputStatus=wide(v.snapshot.value("inputStatus",std::string("Input sharing disabled.")));
|
||||||
|
if(!v.snapshot.value("shareInput",false))
|
||||||
|
inputStatus=!enabled ? L"Enable discovery to set up mouse and keyboard sharing." :
|
||||||
|
selected.empty() ? L"Select a computer in the list above to enable input." :
|
||||||
|
!paired ? L"Pair the selected computer first." :
|
||||||
|
L"Arrange the monitors, then enable input sharing on all PCs.";
|
||||||
auto mode=v.snapshot.value("controlMode",0);
|
auto mode=v.snapshot.value("controlMode",0);
|
||||||
if(mode==2)inputStatus=L"Mouse and keyboard control the OTHER PC. Ctrl+Alt+Esc returns here.";
|
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.";
|
else if(mode==3)inputStatus=L"This PC is controlled remotely. Local physical input takes control back.";
|
||||||
value(w,718,inputStatus);
|
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)
|
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||||
{
|
{
|
||||||
if(msg==WM_DPICHANGED) {resizeForDpi(w,wp,lp);return 0;}
|
if(msg==WM_DPICHANGED) {resizeForDpi(w,wp,lp);return 0;}
|
||||||
auto v=(LanView*)GetWindowLongPtrW(w,GWLP_USERDATA);
|
auto v=(LanView*)GetWindowLongPtrW(w,GWLP_USERDATA);
|
||||||
if(!v) return DefWindowProcW(w,msg,wp,lp);
|
if(!v) return DefWindowProcW(w,msg,wp,lp);
|
||||||
if(msg==WM_DESTROY) {KillTimer(w,1);EnableWindow(v->parent,TRUE);SetForegroundWindow(v->parent);delete v;SetWindowLongPtrW(w,GWLP_USERDATA,0);return 0;}
|
if(msg==WM_DESTROY) {DestroyWindow(GetDlgItem(w,721));KillTimer(w,1);EnableWindow(v->parent,TRUE);SetForegroundWindow(v->parent);delete v;SetWindowLongPtrW(w,GWLP_USERDATA,0);return 0;}
|
||||||
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)) {DestroyWindow(w);return 0;}
|
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)) {DestroyWindow(w);return 0;}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -74,9 +139,11 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
|||||||
if(msg==WM_COMMAND)
|
if(msg==WM_COMMAND)
|
||||||
{
|
{
|
||||||
int id=LOWORD(wp); auto peer=selectedLan(w,*v);
|
int id=LOWORD(wp); auto peer=selectedLan(w,*v);
|
||||||
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {v->positionEdited=false;refreshLan(w,*v);return 0;}
|
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {refreshLan(w,*v);return 0;}
|
||||||
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
|
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
|
||||||
v->error.clear();
|
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==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
|
||||||
if(id==704)
|
if(id==704)
|
||||||
{
|
{
|
||||||
@@ -84,15 +151,12 @@ 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];}
|
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);
|
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==706) lanRequest(*v->app,{{"action","cancel"}});
|
||||||
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
|
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
|
||||||
if(id>=711&&id<=714) v->positionEdited=true;
|
|
||||||
if(id==715)
|
if(id==715)
|
||||||
{
|
{
|
||||||
int position=1;for(int i=0;i<4;++i)if(checked(w,711+i))position=i;
|
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
|
||||||
lanRequest(*v->app,{{"action","input"},{"enabled",true},{"peer",peer},{"edge",position}});
|
|
||||||
v->positionEdited=false;
|
|
||||||
}
|
}
|
||||||
if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}});
|
if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}});
|
||||||
refreshLan(w,*v); return 0;
|
refreshLan(w,*v); return 0;
|
||||||
@@ -103,30 +167,33 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
|||||||
}
|
}
|
||||||
void showLan(HWND parent,App &app)
|
void showLan(HWND parent,App &app)
|
||||||
{
|
{
|
||||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,790);
|
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);
|
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
|
||||||
auto v=new LanView{parent,&app};SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)v);
|
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);
|
control(w,L"STATIC",L"",0,709,18,45,700,24);
|
||||||
button(w,L"&Enable discovery",701,18,78,210);
|
button(w,L"&Enable discovery",701,18,78,210);
|
||||||
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
|
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
|
||||||
control(w,L"LISTBOX",L"",WS_TABSTOP|WS_VSCROLL|WS_BORDER|LBS_NOTIFY|LBS_NOINTEGRALHEIGHT,702,18,120,700,150);
|
control(w,L"LISTBOX",L"",WS_TABSTOP|WS_VSCROLL|WS_BORDER|LBS_NOTIFY|LBS_NOINTEGRALHEIGHT,702,18,120,700,96);
|
||||||
control(w,L"STATIC",L"",0,710,18,278,700,40);
|
control(w,L"STATIC",L"",0,710,18,224,700,40);
|
||||||
label(w,L"Same pairing c&ode on both computers",18,327,700);
|
label(w,L"Same pairing c&ode on both computers",18,273,700);
|
||||||
edit(w,703,18,355,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
|
edit(w,703,18,301,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
|
||||||
button(w,L"&Generate code",704,504,354,214);
|
button(w,L"&Generate code",704,504,300,214);
|
||||||
label(w,L"Generate once, enter that code on both PCs, select each other, then click Pair on both.",18,392,700);
|
label(w,L"Generate once, enter that code on both PCs, select each other, then click Pair on both.",18,338,700);
|
||||||
button(w,L"&Pair",705,18,424,140);button(w,L"Cancel &pairing",706,174,424,160);button(w,L"&Forget pair",707,350,424,160);
|
button(w,L"&Pair",705,18,370,140);button(w,L"Cancel &pairing",706,174,370,160);button(w,L"&Forget pair",707,350,370,160);
|
||||||
control(w,L"STATIC",L"",0,708,18,466,700,40);
|
control(w,L"STATIC",L"",0,708,18,412,700,40);
|
||||||
label(w,L"Selected paired computer stands relative to this PC:",18,512,700);
|
label(w,L"Arrange your computers",18,458,700);
|
||||||
const wchar_t *positions[]={L"← &Left",L"&Right →",L"↑ &Above",L"↓ &Below"};
|
WNDCLASSW gridClass{};gridClass.lpfnWndProc=lanGridProc;gridClass.hInstance=app.instance;gridClass.hCursor=LoadCursorW(nullptr,IDC_ARROW);gridClass.lpszClassName=L"KSwitcher.LAN.Layout";RegisterClassW(&gridClass);
|
||||||
for(int i=0;i<4;++i)control(w,L"BUTTON",positions[i],WS_TABSTOP|BS_AUTORADIOBUTTON|(i==0?WS_GROUP:0),711+i,18+i*175,540,166,28);
|
auto grid=CreateWindowExW(0,gridClass.lpszClassName,L"Computer layout. Select a computer above, then drag its monitor or use arrow keys.",WS_CHILD|WS_VISIBLE|WS_TABSTOP|WS_BORDER,
|
||||||
control(w,L"STATIC",L"",SS_CENTER,719,18,578,700,28);
|
scale(w,18),scale(w,486),scale(w,700),scale(w,180),w,reinterpret_cast<HMENU>(721),app.instance,v);
|
||||||
button(w,L"Apply position / enable &input",715,18,620,330);
|
windowLayoutState(w)->controls.push_back({grid,{18,486,718,666}});
|
||||||
button(w,L"&Stop input sharing",716,370,620,240);
|
control(w,L"STATIC",L"",0,719,18,674,700,24);
|
||||||
control(w,L"STATIC",L"",0,718,18,660,700,40);
|
button(w,L"Enable &input sharing",715,18,706,330);
|
||||||
control(w,L"STATIC",L"Choose opposite sides on the two PCs (right ↔ left, above ↔ below). Changes apply immediately. Keyboard follows the mouse. Clipboard sharing is not included yet.",0,720,18,706,700,36);
|
button(w,L"&Stop input sharing",716,370,706,240);
|
||||||
button(w,L"&Close",IDCANCEL,618,748,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);
|
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
|
||||||
SetTimer(w,1,1000,nullptr);
|
SetTimer(w,1,1000,nullptr);
|
||||||
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
|
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
struct LanGridMetrics
|
||||||
|
{
|
||||||
|
int left=-1,right=1,top=-1,bottom=1,width{},height{};
|
||||||
|
RECT cell(int x,int y)const{return {(x-left)*width/(right-left+1),(y-top)*height/(bottom-top+1),(x-left+1)*width/(right-left+1),(y-top+1)*height/(bottom-top+1)};}
|
||||||
|
POINT point(POINT p)const{return {std::clamp(left+int(p.x)*(right-left+1)/std::max(1,width),left,right),std::clamp(top+int(p.y)*(bottom-top+1)/std::max(1,height),top,bottom)};}
|
||||||
|
};
|
||||||
|
LanGridMetrics lanGridMetrics(HWND w,const LanView &v)
|
||||||
|
{
|
||||||
|
RECT r{};GetClientRect(w,&r);LanGridMetrics g;g.width=r.right;g.height=r.bottom;
|
||||||
|
const auto positions=v.snapshot.value("positions",nlohmann::json::object());
|
||||||
|
for(auto &[id,cell]:positions.items()){
|
||||||
|
int x=cell[0],y=cell[1];g.left=std::min(g.left,x-1);g.right=std::max(g.right,x+1);g.top=std::min(g.top,y-1);g.bottom=std::max(g.bottom,y+1);
|
||||||
|
}
|
||||||
|
g.left=std::max(-8,g.left);g.right=std::min(8,g.right);g.top=std::max(-8,g.top);g.bottom=std::min(8,g.bottom);return g;
|
||||||
|
}
|
||||||
|
void moveLanMonitor(HWND parent,LanView &v,const std::string &peer,int x,int y)
|
||||||
|
{
|
||||||
|
try{lanRequest(*v.app,{{"action","position"},{"peer",peer},{"x",x},{"y",y}});v.error.clear();}
|
||||||
|
catch(const std::exception &e){v.error=wide(e.what());}
|
||||||
|
refreshLan(parent,v);
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK lanGridProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *v=reinterpret_cast<LanView*>(GetWindowLongPtrW(w,GWLP_USERDATA));
|
||||||
|
if(msg==WM_NCCREATE){v=static_cast<LanView*>(reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);SetWindowLongPtrW(w,GWLP_USERDATA,reinterpret_cast<LONG_PTR>(v));}
|
||||||
|
if(!v)return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
auto parent=GetParent(w);
|
||||||
|
if(msg==WM_ERASEBKGND)return 1;
|
||||||
|
if(msg==WM_GETDLGCODE)return DLGC_WANTARROWS;
|
||||||
|
if(msg==WM_SETFOCUS||msg==WM_KILLFOCUS){InvalidateRect(w,nullptr,FALSE);return 0;}
|
||||||
|
if(msg==WM_LBUTTONDOWN){
|
||||||
|
SetFocus(w);POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};auto g=lanGridMetrics(w,*v);
|
||||||
|
const auto positions=v->snapshot.value("positions",nlohmann::json::object());
|
||||||
|
for(auto &[id,cell]:positions.items()){
|
||||||
|
auto r=g.cell(cell[0],cell[1]);if(!PtInRect(&r,p))continue;
|
||||||
|
auto found=std::find(v->ids.begin(),v->ids.end(),id);if(found==v->ids.end())break;
|
||||||
|
SendDlgItemMessageW(parent,702,LB_SETCURSEL,std::distance(v->ids.begin(),found),0);
|
||||||
|
v->dragPeer=id;v->dragStart=p;v->dragPoint=p;v->dragMoved=false;SetCapture(w);refreshLan(parent,*v);break;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_MOUSEMOVE&&!v->dragPeer.empty()){
|
||||||
|
v->dragPoint={GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||||
|
if(std::abs(v->dragPoint.x-v->dragStart.x)>scale(w,4)||std::abs(v->dragPoint.y-v->dragStart.y)>scale(w,4))v->dragMoved=true;
|
||||||
|
InvalidateRect(w,nullptr,FALSE);return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_LBUTTONUP&&!v->dragPeer.empty()){
|
||||||
|
auto peer=v->dragPeer;auto p=lanGridMetrics(w,*v).point({GET_X_LPARAM(lp),GET_Y_LPARAM(lp)});bool moved=v->dragMoved;
|
||||||
|
RECT client{};GetClientRect(w,&client);POINT mouse{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
|
||||||
|
v->dragPeer.clear();v->dragMoved=false;ReleaseCapture();
|
||||||
|
if(moved&&PtInRect(&client,mouse))moveLanMonitor(parent,*v,peer,p.x,p.y);
|
||||||
|
InvalidateRect(w,nullptr,FALSE);return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_CAPTURECHANGED){v->dragPeer.clear();v->dragMoved=false;InvalidateRect(w,nullptr,FALSE);return 0;}
|
||||||
|
if(msg==WM_KEYDOWN){
|
||||||
|
if(wp==VK_ESCAPE&&!v->dragPeer.empty()){v->dragPeer.clear();ReleaseCapture();InvalidateRect(w,nullptr,FALSE);return 0;}
|
||||||
|
auto peer=selectedLan(parent,*v);auto positions=v->snapshot.value("positions",nlohmann::json::object());
|
||||||
|
if(positions.contains(peer)&&(wp==VK_LEFT||wp==VK_RIGHT||wp==VK_UP||wp==VK_DOWN)){
|
||||||
|
int x=positions[peer][0],y=positions[peer][1];x+=(wp==VK_RIGHT)-(wp==VK_LEFT);y+=(wp==VK_DOWN)-(wp==VK_UP);
|
||||||
|
moveLanMonitor(parent,*v,peer,x,y);return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(msg==WM_PAINT){
|
||||||
|
PAINTSTRUCT ps{};auto target=BeginPaint(w,&ps);RECT bounds{};GetClientRect(w,&bounds);
|
||||||
|
auto dc=CreateCompatibleDC(target);auto bitmap=CreateCompatibleBitmap(target,std::max(1L,bounds.right),std::max(1L,bounds.bottom));auto oldBitmap=SelectObject(dc,bitmap);
|
||||||
|
auto fill=CreateSolidBrush(RGB(247,249,251));FillRect(dc,&bounds,fill);DeleteObject(fill);
|
||||||
|
auto oldFont=SelectObject(dc,GetPropW(parent,L"Font"));SetBkMode(dc,TRANSPARENT);
|
||||||
|
auto gridPen=CreatePen(PS_DOT,1,RGB(214,222,230));auto oldPen=SelectObject(dc,gridPen);auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
|
||||||
|
auto g=lanGridMetrics(w,*v);
|
||||||
|
for(int x=g.left;x<=g.right+1;++x){int at=(x-g.left)*g.width/(g.right-g.left+1);MoveToEx(dc,at,0,nullptr);LineTo(dc,at,g.height);}
|
||||||
|
for(int y=g.top;y<=g.bottom+1;++y){int at=(y-g.top)*g.height/(g.bottom-g.top+1);MoveToEx(dc,0,at,nullptr);LineTo(dc,g.width,at);}
|
||||||
|
auto selected=selectedLan(parent,*v);
|
||||||
|
auto draw=[&](const std::string &id,const std::wstring &name,int x,int y,bool local){
|
||||||
|
auto cell=g.cell(x,y);int margin=scale(w,8),cellWidth=cell.right-cell.left;
|
||||||
|
TEXTMETRICW metrics{};GetTextMetricsW(dc,&metrics);
|
||||||
|
RECT title{cell.left+scale(w,3),cell.bottom-scale(w,3)-metrics.tmHeight,cell.right-scale(w,3),cell.bottom-scale(w,3)};
|
||||||
|
int top=cell.top+scale(w,4),height=std::min(scale(w,36),int(title.top)-top-scale(w,8));
|
||||||
|
int width=std::min({scale(w,110),cellWidth-2*margin,height*2});
|
||||||
|
if(width<8||height<8)return;
|
||||||
|
int left=(cell.left+cell.right-width)/2;
|
||||||
|
bool active=!local&&v->snapshot.value("shareInput",false)&&v->snapshot.value("controlMode",0)!=0&&v->snapshot.value("controlPeer",std::string{})==id;
|
||||||
|
COLORREF color=id==selected?RGB(0,105,190):active?RGB(30,130,90):RGB(105,120,133);
|
||||||
|
auto pen=CreatePen(PS_SOLID,scale(w,2),color);SelectObject(dc,pen);
|
||||||
|
auto brush=CreateSolidBrush(local?RGB(220,237,252):RGB(255,255,255));SelectObject(dc,brush);
|
||||||
|
RoundRect(dc,left,top,left+width,top+height,scale(w,4),scale(w,4));SelectObject(dc,GetStockObject(NULL_BRUSH));DeleteObject(brush);
|
||||||
|
int center=left+width/2;MoveToEx(dc,center,top+height,nullptr);LineTo(dc,center,top+height+scale(w,5));
|
||||||
|
MoveToEx(dc,center-scale(w,12),top+height+scale(w,5),nullptr);LineTo(dc,center+scale(w,12),top+height+scale(w,5));
|
||||||
|
RECT screen{left+scale(w,3),top+scale(w,2),left+width-scale(w,3),top+height-scale(w,2)};SetTextColor(dc,color);
|
||||||
|
const wchar_t *caption=local?L"Local":active?L"Active":L"Paired";
|
||||||
|
if(height>=metrics.tmHeight+scale(w,4))DrawTextW(dc,caption,-1,&screen,DT_CENTER|DT_VCENTER|DT_SINGLELINE|DT_END_ELLIPSIS);
|
||||||
|
SetTextColor(dc,RGB(30,40,50));
|
||||||
|
DrawTextW(dc,name.c_str(),-1,&title,DT_CENTER|DT_SINGLELINE|DT_END_ELLIPSIS|DT_NOPREFIX);
|
||||||
|
if(id==selected){RECT outline{cell.left+scale(w,2),cell.top+scale(w,2),cell.right-scale(w,2),cell.bottom-scale(w,2)};if(GetFocus()==w)DrawFocusRect(dc,&outline);}
|
||||||
|
SelectObject(dc,gridPen);DeleteObject(pen);
|
||||||
|
};
|
||||||
|
draw("",wide(v->snapshot.value("name",std::string("This PC"))),0,0,true);
|
||||||
|
for(const auto &peer:v->snapshot.value("peers",nlohmann::json::array())){
|
||||||
|
auto id=peer.at("id").get<std::string>();auto positions=v->snapshot.value("positions",nlohmann::json::object());
|
||||||
|
if(positions.contains(id))draw(id,wide(peer.at("name").get<std::string>()),positions[id][0],positions[id][1],false);
|
||||||
|
}
|
||||||
|
if(v->dragMoved&&!v->dragPeer.empty()){
|
||||||
|
auto point=g.point(v->dragPoint);auto r=g.cell(point.x,point.y);bool valid=true;
|
||||||
|
try{lan::validateCell(v->snapshot.at("positions"),v->dragPeer,point.x,point.y);}catch(...){valid=false;}
|
||||||
|
auto pen=CreatePen(PS_SOLID,scale(w,2),valid?RGB(0,105,190):RGB(190,50,40));SelectObject(dc,pen);
|
||||||
|
Rectangle(dc,r.left+2,r.top+2,r.right-2,r.bottom-2);SelectObject(dc,gridPen);DeleteObject(pen);
|
||||||
|
}
|
||||||
|
SelectObject(dc,oldPen);SelectObject(dc,oldBrush);SelectObject(dc,oldFont);DeleteObject(gridPen);
|
||||||
|
BitBlt(target,0,0,bounds.right,bounds.bottom,dc,0,0,SRCCOPY);SelectObject(dc,oldBitmap);DeleteObject(bitmap);DeleteDC(dc);EndPaint(w,&ps);return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
}
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
std::wstring selectedMouseProfile(HWND w, const SettingsView &view)
|
||||||
|
{
|
||||||
|
const auto row = ListView_GetNextItem(GetDlgItem(w, 731), -1, LVNI_SELECTED);
|
||||||
|
return row < 0 || static_cast<size_t>(row) >= view.mouseProfiles.size() ? GlobalMouseProfile : view.mouseProfiles[row];
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int RenameMouseProfile = 757, DeleteMouseProfile = 758;
|
||||||
|
|
||||||
|
std::wstring mouseActionLabel(const MouseAction &action)
|
||||||
|
{
|
||||||
|
if (action.type == MouseActionType::Shortcut) return action.shortcut.format();
|
||||||
|
if (action.type == MouseActionType::Launch)
|
||||||
|
return L"Launch " + std::filesystem::path(action.executable).filename().wstring();
|
||||||
|
return MouseActions[static_cast<size_t>(action.type)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectMouseEvent(HWND w, int event)
|
||||||
|
{
|
||||||
|
HWND list = GetDlgItem(w, 733);
|
||||||
|
ListView_SetItemState(list, event, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||||
|
ListView_EnsureVisible(list, event, FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fillMouseEvents(HWND w, SettingsView &view, int selection = 0)
|
||||||
|
{
|
||||||
|
const auto profile = selectedMouseProfile(w, view);
|
||||||
|
EnableWindow(GetDlgItem(w, RenameMouseProfile), isNamedMouseProfile(profile));
|
||||||
|
EnableWindow(GetDlgItem(w, DeleteMouseProfile), isNamedMouseProfile(profile));
|
||||||
|
HWND list = GetDlgItem(w, 733);
|
||||||
|
for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event)
|
||||||
|
{
|
||||||
|
auto *action = mouseBinding(view.draft, profile, event);
|
||||||
|
std::wstring text;
|
||||||
|
if (profile != GlobalMouseProfile && !action)
|
||||||
|
{
|
||||||
|
text += L"Global: ";
|
||||||
|
action = mouseBinding(view.draft, GlobalMouseProfile, event);
|
||||||
|
}
|
||||||
|
text += action ? mouseActionLabel(*action) : MouseActions[0];
|
||||||
|
ListView_SetItemText(list, event, 1, text.data());
|
||||||
|
}
|
||||||
|
selectMouseEvent(w, selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::set<std::wstring> profileApplications(const Settings &settings, const std::wstring &profile)
|
||||||
|
{
|
||||||
|
const auto scope = settings.mouseProfileApplications.find(profile);
|
||||||
|
if (scope != settings.mouseProfileApplications.end()) return scope->second;
|
||||||
|
if (profile != GlobalMouseProfile && !isNamedMouseProfile(profile)) return {profile};
|
||||||
|
std::set<std::wstring> names;
|
||||||
|
const auto bindings = settings.mouseBindings.find(profile);
|
||||||
|
if (bindings != settings.mouseBindings.end())
|
||||||
|
for (const auto &[event, action] : bindings->second)
|
||||||
|
names.insert(action.applications.begin(), action.applications.end());
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectMouseProfile(HWND w, int row)
|
||||||
|
{
|
||||||
|
auto list = GetDlgItem(w, 731);
|
||||||
|
ListView_SetItemState(list, row, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||||
|
ListView_EnsureVisible(list, row, FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendMouseProfile(HWND w, SettingsView &view, const std::wstring &profile)
|
||||||
|
{
|
||||||
|
auto list = GetDlgItem(w, 731);
|
||||||
|
std::wstring text;
|
||||||
|
for (const auto &app : profile == GlobalMouseProfile ? std::set<std::wstring>{} : profileApplications(view.draft, profile))
|
||||||
|
{
|
||||||
|
if (!text.empty()) text += L", ";
|
||||||
|
text += app + L".exe";
|
||||||
|
}
|
||||||
|
if (text.empty()) text = profile == GlobalMouseProfile ? L"Fallback" : L"All applications";
|
||||||
|
LVITEMW item{LVIF_TEXT};
|
||||||
|
item.iItem = static_cast<int>(view.mouseProfiles.size());
|
||||||
|
wchar_t activeLabel[] = L"";
|
||||||
|
item.pszText = activeLabel;
|
||||||
|
const int row = ListView_InsertItem(list, &item);
|
||||||
|
if (row < 0) throw std::runtime_error("Could not add the profile to the list.");
|
||||||
|
auto label = mouseProfileName(profile);
|
||||||
|
ListView_SetItemText(list, row, 1, label.data());
|
||||||
|
ListView_SetItemText(list, row, 2, text.data());
|
||||||
|
ListView_SetCheckState(list, row, !view.draft.disabledMouseProfiles.contains(profile));
|
||||||
|
view.mouseProfiles.push_back(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool blockMouseProfileStateChange(HWND w, SettingsView &view, const NMLISTVIEW &change)
|
||||||
|
{
|
||||||
|
const UINT previous = change.uOldState & LVIS_STATEIMAGEMASK;
|
||||||
|
const UINT next = change.uNewState & LVIS_STATEIMAGEMASK;
|
||||||
|
if (!(change.uChanged & LVIF_STATE) || !previous || !next || previous == next ||
|
||||||
|
change.iItem < 0 || static_cast<size_t>(change.iItem) >= view.mouseProfiles.size()) return false;
|
||||||
|
const auto &profile = view.mouseProfiles[change.iItem];
|
||||||
|
if (!isNamedMouseProfile(profile)) return true;
|
||||||
|
if (next == INDEXTOSTATEIMAGEMASK(2))
|
||||||
|
{
|
||||||
|
Settings candidate;
|
||||||
|
candidate.mouseBindings = view.draft.mouseBindings;
|
||||||
|
candidate.disabledMouseProfiles = view.draft.disabledMouseProfiles;
|
||||||
|
candidate.mouseProfileApplications = view.draft.mouseProfileApplications;
|
||||||
|
candidate.disabledMouseProfiles.erase(profile);
|
||||||
|
try { validateSettings(candidate); }
|
||||||
|
catch (const std::exception &e) { error(w, e); return true; }
|
||||||
|
view.draft.disabledMouseProfiles.erase(profile);
|
||||||
|
}
|
||||||
|
else view.draft.disabledMouseProfiles.insert(profile);
|
||||||
|
updateSettingsState(w, &view);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fillMouseProfiles(HWND w, SettingsView &view)
|
||||||
|
{
|
||||||
|
const auto previous = selectedMouseProfile(w, view);
|
||||||
|
ListView_DeleteAllItems(GetDlgItem(w, 731));
|
||||||
|
view.mouseProfiles.clear();
|
||||||
|
appendMouseProfile(w, view, GlobalMouseProfile);
|
||||||
|
for (const auto &[app, bindings] : view.draft.mouseBindings)
|
||||||
|
if (app != GlobalMouseProfile) appendMouseProfile(w, view, app);
|
||||||
|
auto selected = std::find(view.mouseProfiles.begin(), view.mouseProfiles.end(), previous);
|
||||||
|
selectMouseProfile(w, selected == view.mouseProfiles.end() ? 0 : static_cast<int>(selected - view.mouseProfiles.begin()));
|
||||||
|
fillMouseEvents(w, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LRESULT CALLBACK mouseButtonCaptureProc(HWND field, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR)
|
||||||
|
{
|
||||||
|
const bool doubleClick = msg == WM_LBUTTONDBLCLK || msg == WM_RBUTTONDBLCLK || msg == WM_MBUTTONDBLCLK;
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case WM_LBUTTONDBLCLK: msg = WM_LBUTTONDOWN; break;
|
||||||
|
case WM_RBUTTONDBLCLK: msg = WM_RBUTTONDOWN; break;
|
||||||
|
case WM_MBUTTONDBLCLK: msg = WM_MBUTTONDOWN; break;
|
||||||
|
case WM_XBUTTONDBLCLK: msg = WM_XBUTTONDOWN; break;
|
||||||
|
}
|
||||||
|
const int button = mouseButton(msg, HIWORD(wp));
|
||||||
|
if (button >= 0)
|
||||||
|
{
|
||||||
|
if (mouseButtonDown(msg))
|
||||||
|
{
|
||||||
|
SetFocus(field);
|
||||||
|
SetCapture(field);
|
||||||
|
HWND settings = GetParent(field);
|
||||||
|
auto *view = reinterpret_cast<SettingsView *>(GetWindowLongPtrW(settings, GWLP_USERDATA));
|
||||||
|
if (view)
|
||||||
|
selectMouseEvent(settings, doubleClick && button < 3 ? button + 5 : button);
|
||||||
|
if (view)
|
||||||
|
value(settings, 738, std::wstring(L"Window event: ") + MouseButtonNames[button] +
|
||||||
|
L" | Presses: " + std::to_wstring(++view->mouseEvents));
|
||||||
|
}
|
||||||
|
else if (GetCapture() == field)
|
||||||
|
ReleaseCapture();
|
||||||
|
return msg == WM_XBUTTONDOWN || msg == WM_XBUTTONUP ? TRUE : 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_NCDESTROY) RemoveWindowSubclass(field, mouseButtonCaptureProc, id);
|
||||||
|
return DefSubclassProc(field, msg, wp, lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool registerMouseTestInput(HWND w)
|
||||||
|
{
|
||||||
|
RAWINPUTDEVICE mouse{0x01, 0x02, w ? DWORD{0} : RIDEV_REMOVE, w};
|
||||||
|
return RegisterRawInputDevices(&mouse, 1, sizeof(mouse)) != FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void readMouseTestInput(HWND w, SettingsView &view, HRAWINPUT handle)
|
||||||
|
{
|
||||||
|
HWND capture = GetDlgItem(w, 737);
|
||||||
|
if (!IsWindowVisible(capture)) return;
|
||||||
|
RAWINPUT input{};
|
||||||
|
UINT size = sizeof(input);
|
||||||
|
if (GetRawInputData(handle, RID_INPUT, &input, &size, sizeof(RAWINPUTHEADER)) == UINT(-1) ||
|
||||||
|
input.header.dwType != RIM_TYPEMOUSE || !input.data.mouse.usButtonFlags)
|
||||||
|
return;
|
||||||
|
POINT point{};
|
||||||
|
if (!GetCursorPos(&point) || WindowFromPoint(point) != capture) return;
|
||||||
|
constexpr USHORT downFlags[] = {RI_MOUSE_LEFT_BUTTON_DOWN, RI_MOUSE_RIGHT_BUTTON_DOWN,
|
||||||
|
RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN};
|
||||||
|
static_assert(std::size(downFlags) == std::size(MouseButtonNames));
|
||||||
|
for (size_t i = 0; i < std::size(downFlags); ++i)
|
||||||
|
{
|
||||||
|
if (!(input.data.mouse.usButtonFlags & downFlags[i])) continue;
|
||||||
|
|
||||||
|
value(w, 739, std::wstring(L"Raw Input: ") + MouseButtonNames[i] +
|
||||||
|
L" | Presses: " + std::to_wstring(++view.rawMouseEvents));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int MouseVolumeStepField = 759;
|
||||||
|
constexpr int MouseVolumeStepSpin = 760, MouseVolumeStepLabel = 761;
|
||||||
|
constexpr int MouseActionEditorHeight = 194, CompactMouseActionEditorHeight = 138, MaxEditedMouseVolumeStep = 50;
|
||||||
|
|
||||||
|
void layoutMouseActionFields(HWND w, bool volume, bool extraRow)
|
||||||
|
{
|
||||||
|
auto &layout = *windowLayoutState(w);
|
||||||
|
const auto combo = GetDlgItem(w, 734);
|
||||||
|
const auto buttons = GetDlgItem(w, IDOK);
|
||||||
|
const int height = extraRow ? MouseActionEditorHeight : CompactMouseActionEditorHeight;
|
||||||
|
const int offset = height - layout.height;
|
||||||
|
bool footer = false;
|
||||||
|
for (auto &[child, rect] : layout.controls)
|
||||||
|
{
|
||||||
|
if (child == combo) rect.right = volume ? 368 : 462;
|
||||||
|
if (child == buttons) footer = true;
|
||||||
|
if (footer) OffsetRect(&rect, 0, offset);
|
||||||
|
}
|
||||||
|
if (offset)
|
||||||
|
{
|
||||||
|
RECT frame{};
|
||||||
|
GetWindowRect(w, &frame);
|
||||||
|
layout.height = height;
|
||||||
|
fitFixedClient(w, layout, frame);
|
||||||
|
}
|
||||||
|
arrangeWindow(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateMouseActionFields(HWND w)
|
||||||
|
{
|
||||||
|
const auto index = SendDlgItemMessageW(w, 734, CB_GETCURSEL, 0, 0);
|
||||||
|
const auto type = SendDlgItemMessageW(w, 734, CB_GETITEMDATA, index, 0);
|
||||||
|
const bool shortcut = type == static_cast<int>(MouseActionType::Shortcut);
|
||||||
|
const bool launch = type == static_cast<int>(MouseActionType::Launch);
|
||||||
|
const bool volume = isMouseVolumeStep(static_cast<MouseActionType>(type));
|
||||||
|
ShowWindow(GetDlgItem(w, 747), shortcut ? SW_SHOWNA : SW_HIDE);
|
||||||
|
ShowWindow(GetDlgItem(w, 742), launch ? SW_SHOWNA : SW_HIDE);
|
||||||
|
ShowWindow(GetDlgItem(w, 743), launch ? SW_SHOWNA : SW_HIDE);
|
||||||
|
ShowWindow(GetDlgItem(w, MouseVolumeStepField), volume ? SW_SHOWNA : SW_HIDE);
|
||||||
|
ShowWindow(GetDlgItem(w, MouseVolumeStepSpin), volume ? SW_SHOWNA : SW_HIDE);
|
||||||
|
ShowWindow(GetDlgItem(w, MouseVolumeStepLabel), volume ? SW_SHOWNA : SW_HIDE);
|
||||||
|
ShowWindow(GetDlgItem(w, 744), shortcut || launch ? SW_SHOWNA : SW_HIDE);
|
||||||
|
value(w, 744, shortcut ? L"Click the field and press the key combination" :
|
||||||
|
launch ? L"Application (e.g. calc.exe)" : L"");
|
||||||
|
layoutMouseActionFields(w, volume, shortcut || launch);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MouseActionEditor
|
||||||
|
{
|
||||||
|
HWND parent{};
|
||||||
|
SettingsView *view{};
|
||||||
|
std::wstring profile;
|
||||||
|
int event{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MouseProfileEditor
|
||||||
|
{
|
||||||
|
std::wstring previous;
|
||||||
|
std::set<std::wstring> applications;
|
||||||
|
HWND pickerOutline{};
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int ActionApplications = 748, ActionApplicationName = 749, AddActionApp = 750, RemoveActionApp = 751;
|
||||||
|
constexpr int BrowseActionApp = 752;
|
||||||
|
constexpr int PickActionApp = 753;
|
||||||
|
constexpr int AddMouseProfile = 754, MouseProfileName = 755;
|
||||||
|
|
||||||
|
void drawActionApplicationIcon(HWND editor, const DRAWITEMSTRUCT &item)
|
||||||
|
{
|
||||||
|
RECT bounds = item.rcItem;
|
||||||
|
const bool pressed = (item.itemState & ODS_SELECTED) != 0;
|
||||||
|
const bool disabled = (item.itemState & ODS_DISABLED) != 0;
|
||||||
|
DrawFrameControl(item.hDC, &bounds, DFC_BUTTON,
|
||||||
|
DFCS_BUTTONPUSH | (pressed ? DFCS_PUSHED : 0) | (disabled ? DFCS_INACTIVE : 0));
|
||||||
|
const int offset = pressed ? scale(editor, 1) : 0;
|
||||||
|
const int x = (bounds.left + bounds.right) / 2 + offset;
|
||||||
|
const int y = (bounds.top + bounds.bottom) / 2 + offset;
|
||||||
|
const int half = scale(editor, 6);
|
||||||
|
const int weight = std::max(1, scale(editor, 2));
|
||||||
|
HBRUSH ink = GetSysColorBrush(disabled ? COLOR_GRAYTEXT : COLOR_BTNTEXT);
|
||||||
|
if (item.CtlID == BrowseActionApp)
|
||||||
|
{
|
||||||
|
RECT folder{x - half - 1, y - half + weight, x + half + 1, y + half};
|
||||||
|
RECT tab{folder.left, folder.top - weight, x, folder.top};
|
||||||
|
FrameRect(item.hDC, &folder, ink);
|
||||||
|
FillRect(item.hDC, &tab, ink);
|
||||||
|
}
|
||||||
|
else if (item.CtlID == PickActionApp)
|
||||||
|
{
|
||||||
|
RECT target{x - half, y - half, x + half + 1, y + half + 1};
|
||||||
|
RECT horizontal{x - half - weight, y, x + half + weight + 1, y + 1};
|
||||||
|
RECT vertical{x, y - half - weight, x + 1, y + half + weight + 1};
|
||||||
|
const auto previousBrush = SelectObject(item.hDC, GetStockObject(HOLLOW_BRUSH));
|
||||||
|
SetDCPenColor(item.hDC, GetSysColor(disabled ? COLOR_GRAYTEXT : COLOR_BTNTEXT));
|
||||||
|
const auto previousPen = SelectObject(item.hDC, GetStockObject(DC_PEN));
|
||||||
|
Ellipse(item.hDC, target.left, target.top, target.right, target.bottom);
|
||||||
|
SelectObject(item.hDC, previousPen);
|
||||||
|
SelectObject(item.hDC, previousBrush);
|
||||||
|
FillRect(item.hDC, &horizontal, ink);
|
||||||
|
FillRect(item.hDC, &vertical, ink);
|
||||||
|
}
|
||||||
|
else if (item.CtlID == RenameMouseProfile)
|
||||||
|
{
|
||||||
|
POINT pencil[]{{x - half, y + half}, {x - half + weight, y + 1},
|
||||||
|
{x + half - weight, y - half}, {x + half, y - half + weight},
|
||||||
|
{x - 1, y + half - weight}};
|
||||||
|
const auto brush = SelectObject(item.hDC, ink);
|
||||||
|
const auto pen = SelectObject(item.hDC, GetStockObject(NULL_PEN));
|
||||||
|
Polygon(item.hDC, pencil, static_cast<int>(std::size(pencil)));
|
||||||
|
SelectObject(item.hDC, pen);
|
||||||
|
SelectObject(item.hDC, brush);
|
||||||
|
}
|
||||||
|
else if (item.CtlID == DeleteMouseProfile)
|
||||||
|
{
|
||||||
|
RECT bin{x - half + 2, y - half + weight, x + half - 1, y + half};
|
||||||
|
RECT lid{x - half, y - half, x + half + 1, y - half + 1};
|
||||||
|
FrameRect(item.hDC, &bin, ink);
|
||||||
|
FillRect(item.hDC, &lid, ink);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RECT horizontal{x - half, y - weight / 2, x + half, y - weight / 2 + weight};
|
||||||
|
FillRect(item.hDC, &horizontal, ink);
|
||||||
|
}
|
||||||
|
if (item.CtlID == AddActionApp || item.CtlID == AddMouseProfile)
|
||||||
|
{
|
||||||
|
RECT vertical{x - weight / 2, y - half, x - weight / 2 + weight, y + half};
|
||||||
|
FillRect(item.hDC, &vertical, ink);
|
||||||
|
}
|
||||||
|
if ((item.itemState & ODS_FOCUS) && !(item.itemState & ODS_NOFOCUSRECT))
|
||||||
|
{
|
||||||
|
InflateRect(&bounds, -scale(editor, 4), -scale(editor, 4));
|
||||||
|
DrawFocusRect(item.hDC, &bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addActionApplicationTooltip(HWND tips, HWND editor, int id, const wchar_t *text)
|
||||||
|
{
|
||||||
|
TOOLINFOW tool{sizeof(tool), TTF_IDISHWND | TTF_SUBCLASS, editor,
|
||||||
|
reinterpret_cast<UINT_PTR>(GetDlgItem(editor, id))};
|
||||||
|
tool.lpszText = const_cast<wchar_t *>(text);
|
||||||
|
SendMessageW(tips, TTM_ADDTOOLW, 0, reinterpret_cast<LPARAM>(&tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
void fillActionApplications(HWND w, const MouseProfileEditor &editor)
|
||||||
|
{
|
||||||
|
SendDlgItemMessageW(w, ActionApplications, LB_RESETCONTENT, 0, 0);
|
||||||
|
for (const auto &name : editor.applications)
|
||||||
|
{
|
||||||
|
const auto label = name + L".exe";
|
||||||
|
SendDlgItemMessageW(w, ActionApplications, LB_ADDSTRING, 0, reinterpret_cast<LPARAM>(label.c_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addActionApplication(HWND w, MouseProfileEditor &editor)
|
||||||
|
{
|
||||||
|
const auto name = applicationName(trimApplicationText(value(w, ActionApplicationName)));
|
||||||
|
if (!validMouseApplication(name)) throw std::runtime_error("Enter a valid application executable name.");
|
||||||
|
if (editor.applications.size() >= 1000 && !editor.applications.contains(name))
|
||||||
|
throw std::runtime_error("Too many applications in this profile.");
|
||||||
|
editor.applications.insert(name);
|
||||||
|
fillActionApplications(w, editor);
|
||||||
|
value(w, ActionApplicationName, L"");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring actionApplicationExecutable(std::filesystem::path path)
|
||||||
|
{
|
||||||
|
if (lower(path.extension().wstring()) == L".lnk")
|
||||||
|
{
|
||||||
|
Microsoft::WRL::ComPtr<IShellLinkW> link;
|
||||||
|
Microsoft::WRL::ComPtr<IPersistFile> file;
|
||||||
|
wchar_t target[32768]{};
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&link))) ||
|
||||||
|
FAILED(link.As(&file)) || FAILED(file->Load(path.c_str(), STGM_READ)) ||
|
||||||
|
link->GetPath(target, static_cast<int>(std::size(target)), nullptr, 0) != S_OK)
|
||||||
|
throw std::runtime_error("Could not read the shortcut target.");
|
||||||
|
path = target;
|
||||||
|
}
|
||||||
|
if (lower(path.extension().wstring()) != L".exe")
|
||||||
|
throw std::runtime_error("Choose an executable or a shortcut that points to one.");
|
||||||
|
return path.filename().wstring();
|
||||||
|
}
|
||||||
|
|
||||||
|
void browseActionApplication(HWND w, MouseProfileEditor &editor)
|
||||||
|
{
|
||||||
|
wchar_t path[32768]{};
|
||||||
|
OPENFILENAMEW picker{sizeof(picker)};
|
||||||
|
picker.hwndOwner = w;
|
||||||
|
picker.lpstrFilter = L"Applications and shortcuts (*.exe;*.lnk)\0*.exe;*.lnk\0";
|
||||||
|
picker.lpstrFile = path;
|
||||||
|
picker.nMaxFile = static_cast<DWORD>(std::size(path));
|
||||||
|
picker.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | OFN_NODEREFERENCELINKS;
|
||||||
|
if (!GetOpenFileNameW(&picker)) return;
|
||||||
|
value(w, ActionApplicationName, actionApplicationExecutable(path));
|
||||||
|
addActionApplication(w, editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveMouseAction(HWND w, MouseActionEditor &editor)
|
||||||
|
{
|
||||||
|
const auto choice = SendDlgItemMessageW(w, 734, CB_GETCURSEL, 0, 0);
|
||||||
|
if (choice == CB_ERR) throw std::runtime_error("Choose an action.");
|
||||||
|
const auto type = SendDlgItemMessageW(w, 734, CB_GETITEMDATA, choice, 0);
|
||||||
|
const auto &profile = editor.profile;
|
||||||
|
const auto *event = MouseEvents[editor.event];
|
||||||
|
Settings check;
|
||||||
|
check.mouseBindings = editor.view->draft.mouseBindings;
|
||||||
|
check.disabledMouseProfiles = editor.view->draft.disabledMouseProfiles;
|
||||||
|
check.mouseProfileApplications = editor.view->draft.mouseProfileApplications;
|
||||||
|
if (type < 0 || (type == static_cast<int>(MouseActionType::Standard) && profile == GlobalMouseProfile))
|
||||||
|
{
|
||||||
|
if (auto found = check.mouseBindings.find(profile); found != check.mouseBindings.end())
|
||||||
|
{
|
||||||
|
found->second.erase(event);
|
||||||
|
if (found->second.empty() && !isNamedMouseProfile(profile)) check.mouseBindings.erase(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MouseAction action{static_cast<MouseActionType>(type)};
|
||||||
|
if (action.type == MouseActionType::Shortcut)
|
||||||
|
{
|
||||||
|
auto shortcut = Hotkey::parse(value(w, 747));
|
||||||
|
if (!shortcut || !shortcut->key) throw std::runtime_error("Click the field and press a key combination.");
|
||||||
|
action.shortcut = *shortcut;
|
||||||
|
}
|
||||||
|
if (action.type == MouseActionType::Launch) action.executable = trimApplicationText(value(w, 742));
|
||||||
|
if (isMouseVolumeStep(action.type))
|
||||||
|
{
|
||||||
|
const auto text = value(w, MouseVolumeStepField);
|
||||||
|
if (text.empty() || text.size() > 3 || text.find_first_not_of(L"0123456789") != text.npos)
|
||||||
|
throw std::runtime_error("Enter a volume step from 1 to 50.");
|
||||||
|
action.volumeStep = std::stoi(text);
|
||||||
|
const auto *previous = mouseBinding(editor.view->draft, profile, editor.event);
|
||||||
|
const bool unchanged = previous && previous->type == action.type && previous->volumeStep == action.volumeStep;
|
||||||
|
if (action.volumeStep < 1 || (action.volumeStep > MaxEditedMouseVolumeStep && !unchanged))
|
||||||
|
throw std::runtime_error("Enter a volume step from 1 to 50.");
|
||||||
|
}
|
||||||
|
if (const auto *previous = mouseBinding(editor.view->draft, profile, editor.event))
|
||||||
|
action.applications = previous->applications;
|
||||||
|
check.mouseBindings[profile][event] = std::move(action);
|
||||||
|
}
|
||||||
|
validateSettings(check);
|
||||||
|
editor.view->draft.mouseBindings = std::move(check.mouseBindings);
|
||||||
|
fillMouseEvents(editor.parent, *editor.view, editor.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK mouseActionEditorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *editor = reinterpret_cast<MouseActionEditor *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (!editor) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
switch (LOWORD(wp))
|
||||||
|
{
|
||||||
|
case 734:
|
||||||
|
if (HIWORD(wp) == CBN_SELCHANGE)
|
||||||
|
updateMouseActionFields(w);
|
||||||
|
return 0;
|
||||||
|
case 743:
|
||||||
|
{
|
||||||
|
wchar_t path[32768]{};
|
||||||
|
OPENFILENAMEW picker{sizeof(picker)};
|
||||||
|
picker.hwndOwner = w;
|
||||||
|
picker.lpstrFilter = L"Applications (*.exe)\0*.exe\0";
|
||||||
|
picker.lpstrFile = path;
|
||||||
|
picker.nMaxFile = static_cast<DWORD>(std::size(path));
|
||||||
|
picker.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
|
||||||
|
if (GetOpenFileNameW(&picker)) value(w, 742, path);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case IDOK:
|
||||||
|
saveMouseAction(w, *editor);
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
case IDCANCEL:
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
auto parent = editor->parent;
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
disposeFont(w);
|
||||||
|
delete editor;
|
||||||
|
if (IsWindow(parent))
|
||||||
|
{
|
||||||
|
EnableWindow(parent, TRUE);
|
||||||
|
SetForegroundWindow(parent);
|
||||||
|
SetFocus(GetDlgItem(parent, 733));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showMouseActionEditor(HWND parent, SettingsView &view, int event)
|
||||||
|
{
|
||||||
|
if (event < 0 || event >= static_cast<int>(std::size(MouseEvents)) || !IsWindowEnabled(parent)) return;
|
||||||
|
const auto profile = selectedMouseProfile(parent, view);
|
||||||
|
const bool application = profile != GlobalMouseProfile;
|
||||||
|
const auto *action = mouseBinding(view.draft, profile, event);
|
||||||
|
const auto title = std::wstring(L"Edit mouse action");
|
||||||
|
HWND w = makeWindow(*view.app, L"KSwitcher.MouseAction", title.c_str(), mouseActionEditorProc,
|
||||||
|
480, MouseActionEditorHeight, 0, false, parent);
|
||||||
|
auto *editor = new MouseActionEditor{parent, &view, profile, event};
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(editor));
|
||||||
|
label(w, std::wstring(MouseEvents[event]).append(L" · ").append(mouseProfileName(profile)).c_str(),
|
||||||
|
18, 14, 444);
|
||||||
|
label(w, L"&Action", 18, 43, 330);
|
||||||
|
windowLayoutState(w)->fixedClient = true;
|
||||||
|
windowLayoutState(w)->rowCombo =
|
||||||
|
control(w, L"COMBOBOX", L"", WS_TABSTOP | WS_VSCROLL | CBS_DROPDOWNLIST, 734, 18, 65, 444, 215);
|
||||||
|
if (application)
|
||||||
|
{
|
||||||
|
SendDlgItemMessageW(w, 734, CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(L"Inherit from Global"));
|
||||||
|
SendDlgItemMessageW(w, 734, CB_SETITEMDATA, 0, -1);
|
||||||
|
}
|
||||||
|
for (size_t type = 0; type < std::size(MouseActions); ++type)
|
||||||
|
{
|
||||||
|
const auto row = SendDlgItemMessageW(w, 734, CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(MouseActions[type]));
|
||||||
|
if (row >= 0) SendDlgItemMessageW(w, 734, CB_SETITEMDATA, row, type);
|
||||||
|
}
|
||||||
|
control(w, L"STATIC", L"", 0, 744, 18, 101, 444, 21);
|
||||||
|
hotkeyEdit(*view.app, w, 747, 18, 125, 444);
|
||||||
|
edit(w, 742, 18, 125, 334);
|
||||||
|
button(w, L"&Browse…", 743, 362, 124, 100);
|
||||||
|
control(w, L"STATIC", L"Step (%)", 0, MouseVolumeStepLabel, 380, 43, 82, 22);
|
||||||
|
auto stepField = edit(w, MouseVolumeStepField, 380, 65, 64, 25, ES_NUMBER);
|
||||||
|
SendMessageW(stepField, EM_SETLIMITTEXT, 3, 0);
|
||||||
|
INITCOMMONCONTROLSEX spinControls{sizeof(spinControls), ICC_UPDOWN_CLASS};
|
||||||
|
InitCommonControlsEx(&spinControls);
|
||||||
|
HWND spin = control(w, UPDOWN_CLASSW, L"", UDS_SETBUDDYINT | UDS_ARROWKEYS | UDS_NOTHOUSANDS,
|
||||||
|
MouseVolumeStepSpin, 444, 65, 18, 25);
|
||||||
|
SendMessageW(spin, UDM_SETBUDDY, reinterpret_cast<WPARAM>(stepField), 0);
|
||||||
|
SendMessageW(spin, UDM_SETRANGE32, 1, MaxEditedMouseVolumeStep);
|
||||||
|
SetDlgItemInt(w, MouseVolumeStepField,
|
||||||
|
action && isMouseVolumeStep(action->type) ? action->volumeStep : DefaultMouseVolumeStep, FALSE);
|
||||||
|
button(w, L"&Apply", IDOK, 248, 154, 100);
|
||||||
|
button(w, L"Cancel", IDCANCEL, 362, 154, 100);
|
||||||
|
SendDlgItemMessageW(w, 734, CB_SETCURSEL, action ? static_cast<int>(action->type) + application : 0, 0);
|
||||||
|
if (action && action->type == MouseActionType::Shortcut) value(w, 747, action->shortcut.format());
|
||||||
|
if (action && action->type == MouseActionType::Launch) value(w, 742, action->executable);
|
||||||
|
updateMouseActionFields(w);
|
||||||
|
EnableWindow(parent, FALSE);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
SetFocus(GetDlgItem(w, 734));
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int EditMouseEvent = 735, ResetMouseEvent = 746;
|
||||||
|
constexpr auto MouseEventHint = L"Double-click to edit. Right-click for more actions.";
|
||||||
|
|
||||||
|
int mouseProfileRowAt(HWND list, POINT point)
|
||||||
|
{
|
||||||
|
LVHITTESTINFO hit{};
|
||||||
|
hit.pt = point;
|
||||||
|
const int item = ListView_SubItemHitTest(list, &hit);
|
||||||
|
if (item >= 0) return item;
|
||||||
|
RECT client{};
|
||||||
|
if (!GetClientRect(list, &client) || !PtInRect(&client, point)) return -1;
|
||||||
|
const int top = ListView_GetTopIndex(list);
|
||||||
|
const int end = std::min(ListView_GetItemCount(list), top + ListView_GetCountPerPage(list) + 1);
|
||||||
|
for (int row = top; row < end; ++row)
|
||||||
|
{
|
||||||
|
RECT bounds{};
|
||||||
|
if (ListView_GetItemRect(list, row, &bounds, LVIR_BOUNDS) && point.y >= bounds.top && point.y < bounds.bottom)
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void showMouseListMenu(HWND list, POINT point, bool profiles)
|
||||||
|
{
|
||||||
|
HWND w = GetParent(list);
|
||||||
|
int event;
|
||||||
|
if (point.x == -1 && point.y == -1)
|
||||||
|
{
|
||||||
|
event = ListView_GetNextItem(list, -1, LVNI_SELECTED);
|
||||||
|
RECT row{};
|
||||||
|
if (event < 0 || !ListView_GetItemRect(list, event, &row, LVIR_BOUNDS)) return;
|
||||||
|
point = {row.left, row.bottom};
|
||||||
|
ClientToScreen(list, &point);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LVHITTESTINFO hit{};
|
||||||
|
hit.pt = point;
|
||||||
|
ScreenToClient(list, &hit.pt);
|
||||||
|
event = ListView_SubItemHitTest(list, &hit);
|
||||||
|
if (event < 0) return;
|
||||||
|
}
|
||||||
|
auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
HMENU menu = CreatePopupMenu();
|
||||||
|
if (!menu) return;
|
||||||
|
if (profiles)
|
||||||
|
{
|
||||||
|
selectMouseProfile(w, event);
|
||||||
|
const UINT flags = MF_STRING | (isNamedMouseProfile(selectedMouseProfile(w, view)) ? MF_ENABLED : MF_GRAYED);
|
||||||
|
AppendMenuW(menu, flags, RenameMouseProfile, L"&Edit…");
|
||||||
|
AppendMenuW(menu, flags, DeleteMouseProfile, L"&Delete…");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
selectMouseEvent(w, event);
|
||||||
|
const bool canReset = mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr;
|
||||||
|
AppendMenuW(menu, MF_STRING, EditMouseEvent, L"&Edit…");
|
||||||
|
AppendMenuW(menu, MF_STRING | (canReset ? MF_ENABLED : MF_GRAYED), ResetMouseEvent, L"&Reset to default");
|
||||||
|
}
|
||||||
|
const auto command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON,
|
||||||
|
point.x, point.y, 0, w, nullptr);
|
||||||
|
DestroyMenu(menu);
|
||||||
|
if (command) SendMessageW(w, WM_COMMAND, command, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK mouseEventListProc(HWND list, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR)
|
||||||
|
{
|
||||||
|
const bool profiles = GetDlgCtrlID(list) == 731;
|
||||||
|
if (profiles && msg == WM_LBUTTONDBLCLK)
|
||||||
|
{
|
||||||
|
const int row = mouseProfileRowAt(list, {GET_X_LPARAM(lp), GET_Y_LPARAM(lp)});
|
||||||
|
if (row >= 0)
|
||||||
|
{
|
||||||
|
const auto owner = GetParent(list);
|
||||||
|
selectMouseProfile(owner, row);
|
||||||
|
SendMessageW(owner, WM_COMMAND, RenameMouseProfile, 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_GETDLGCODE && wp == VK_RETURN) return DLGC_WANTMESSAGE;
|
||||||
|
if (msg == WM_KEYDOWN && (wp == VK_RETURN || wp == VK_F2))
|
||||||
|
{
|
||||||
|
SendMessageW(GetParent(list), WM_COMMAND, profiles ? RenameMouseProfile : EditMouseEvent, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_CONTEXTMENU)
|
||||||
|
{
|
||||||
|
showMouseListMenu(list, {GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}, profiles);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_NCDESTROY) RemoveWindowSubclass(list, mouseEventListProc, id);
|
||||||
|
return DefSubclassProc(list, msg, wp, lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK mouseProfileNameProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
const HWND owner = GetWindow(w, GW_OWNER);
|
||||||
|
auto *editor = reinterpret_cast<MouseProfileEditor *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (!editor) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == AppWindowPicked)
|
||||||
|
{
|
||||||
|
if (lp == 1)
|
||||||
|
{
|
||||||
|
const auto name = mouseApplicationName(reinterpret_cast<HWND>(wp));
|
||||||
|
if (name.empty()) throw std::runtime_error("Drop the crosshair onto an application window.");
|
||||||
|
value(w, ActionApplicationName, name);
|
||||||
|
addActionApplication(w, *editor);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_DRAWITEM && (wp == AddActionApp || wp == RemoveActionApp || wp == BrowseActionApp || wp == PickActionApp))
|
||||||
|
{
|
||||||
|
drawActionApplicationIcon(w, *reinterpret_cast<const DRAWITEMSTRUCT *>(lp));
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
if (LOWORD(wp) == BrowseActionApp) { browseActionApplication(w, *editor); return 0; }
|
||||||
|
if (LOWORD(wp) == AddActionApp) { addActionApplication(w, *editor); return 0; }
|
||||||
|
if (LOWORD(wp) == RemoveActionApp)
|
||||||
|
{
|
||||||
|
const auto row = SendDlgItemMessageW(w, ActionApplications, LB_GETCURSEL, 0, 0);
|
||||||
|
if (row >= 0 && static_cast<size_t>(row) < editor->applications.size())
|
||||||
|
{
|
||||||
|
editor->applications.erase(std::next(editor->applications.begin(), row));
|
||||||
|
fillActionApplications(w, *editor);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); return 0; }
|
||||||
|
if (msg == WM_COMMAND && LOWORD(wp) == IDOK)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto &view = *reinterpret_cast<SettingsView *>(GetWindowLongPtrW(owner, GWLP_USERDATA));
|
||||||
|
const auto &previous = editor->previous;
|
||||||
|
const auto name = trimApplicationText(value(w, MouseProfileName));
|
||||||
|
const auto duplicate = std::any_of(view.mouseProfiles.begin(), view.mouseProfiles.end(), [&](const auto &profile)
|
||||||
|
{
|
||||||
|
return profile != previous && lower(mouseProfileName(profile)) == lower(name);
|
||||||
|
});
|
||||||
|
if (duplicate) throw std::runtime_error("A profile with that name already exists.");
|
||||||
|
const auto key = std::wstring(NamedMouseProfilePrefix) + name;
|
||||||
|
Settings check;
|
||||||
|
check.mouseBindings = view.draft.mouseBindings;
|
||||||
|
check.disabledMouseProfiles = view.draft.disabledMouseProfiles;
|
||||||
|
check.mouseProfileApplications = view.draft.mouseProfileApplications;
|
||||||
|
if (!previous.empty() && key != previous)
|
||||||
|
{
|
||||||
|
auto profile = check.mouseBindings.extract(previous);
|
||||||
|
if (profile.empty()) throw std::runtime_error("The profile no longer exists.");
|
||||||
|
profile.key() = key;
|
||||||
|
check.mouseBindings.insert(std::move(profile));
|
||||||
|
if (check.disabledMouseProfiles.erase(previous)) check.disabledMouseProfiles.insert(key);
|
||||||
|
}
|
||||||
|
else check.mouseBindings.try_emplace(key);
|
||||||
|
check.mouseProfileApplications.erase(previous);
|
||||||
|
check.mouseProfileApplications[key] = editor->applications;
|
||||||
|
for (auto &[event, action] : check.mouseBindings.at(key)) action.applications.clear();
|
||||||
|
validateSettings(check);
|
||||||
|
view.draft.mouseBindings = std::move(check.mouseBindings);
|
||||||
|
view.draft.disabledMouseProfiles = std::move(check.disabledMouseProfiles);
|
||||||
|
view.draft.mouseProfileApplications = std::move(check.mouseProfileApplications);
|
||||||
|
fillMouseProfiles(owner, view);
|
||||||
|
const auto selected = std::find(view.mouseProfiles.begin(), view.mouseProfiles.end(), key);
|
||||||
|
selectMouseProfile(owner, static_cast<int>(selected - view.mouseProfiles.begin()));
|
||||||
|
fillMouseEvents(owner, view);
|
||||||
|
updateSettingsState(owner, &view);
|
||||||
|
DestroyWindow(w);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE || (msg == WM_COMMAND && LOWORD(wp) == IDCANCEL))
|
||||||
|
{
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
delete editor;
|
||||||
|
disposeFont(w);
|
||||||
|
EnableWindow(owner, TRUE);
|
||||||
|
SetForegroundWindow(owner);
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showMouseProfileName(HWND owner, SettingsView &view, bool renaming)
|
||||||
|
{
|
||||||
|
HWND w = makeWindow(*view.app, L"KSwitcher.MouseProfileName", renaming ? L"Edit profile" : L"Add profile", mouseProfileNameProc,
|
||||||
|
480, 296, 0, false, owner);
|
||||||
|
windowLayoutState(w)->fixedClient = true;
|
||||||
|
const auto previous = renaming ? selectedMouseProfile(owner, view) : std::wstring{};
|
||||||
|
auto *editor = new MouseProfileEditor{previous, renaming ? profileApplications(view.draft, previous) : std::set<std::wstring>{}};
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(editor));
|
||||||
|
label(w, L"&Name", 18, 12, 444);
|
||||||
|
edit(w, MouseProfileName, 18, 36, 444);
|
||||||
|
if (renaming) value(w, MouseProfileName, mouseProfileName(previous));
|
||||||
|
label(w, L"Applications (empty list means all applications)", 18, 75, 444);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_BORDER | WS_VSCROLL | LBS_NOINTEGRALHEIGHT,
|
||||||
|
ActionApplications, 18, 100, 404, 96);
|
||||||
|
control(w, L"BUTTON", L"&Remove application", WS_TABSTOP | BS_OWNERDRAW, RemoveActionApp, 430, 100, 32, 28);
|
||||||
|
HWND appName = edit(w, ActionApplicationName, 18, 207, 324);
|
||||||
|
SendMessageW(appName, EM_SETCUEBANNER, FALSE, reinterpret_cast<LPARAM>(L"Application executable (e.g. chrome.exe)"));
|
||||||
|
HWND picker = control(w, L"BUTTON", L"Drag to application window", WS_TABSTOP | BS_OWNERDRAW,
|
||||||
|
PickActionApp, 350, 205, 32, 28);
|
||||||
|
SetWindowSubclass(picker, appWindowPickerProc, 1, reinterpret_cast<DWORD_PTR>(&editor->pickerOutline));
|
||||||
|
control(w, L"BUTTON", L"&Browse for application", WS_TABSTOP | BS_OWNERDRAW, BrowseActionApp, 390, 205, 32, 28);
|
||||||
|
control(w, L"BUTTON", L"Add a&pplication", WS_TABSTOP | BS_OWNERDRAW, AddActionApp, 430, 205, 32, 28);
|
||||||
|
HWND tips = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, w, nullptr,
|
||||||
|
view.app->instance, nullptr);
|
||||||
|
addActionApplicationTooltip(tips, w, RemoveActionApp, L"Remove application");
|
||||||
|
addActionApplicationTooltip(tips, w, AddActionApp, L"Add application");
|
||||||
|
addActionApplicationTooltip(tips, w, BrowseActionApp, L"Browse for an executable or shortcut");
|
||||||
|
addActionApplicationTooltip(tips, w, PickActionApp, L"Drag to an application window");
|
||||||
|
fillActionApplications(w, *editor);
|
||||||
|
control(w, L"BUTTON", renaming ? L"&Apply" : L"&Add", WS_TABSTOP | BS_DEFPUSHBUTTON, IDOK, 248, 251, 100, 28);
|
||||||
|
button(w, L"Cancel", IDCANCEL, 362, 251, 100);
|
||||||
|
EnableWindow(owner, FALSE);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
SetFocus(GetDlgItem(w, MouseProfileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp)
|
||||||
|
{
|
||||||
|
const int id = LOWORD(wp);
|
||||||
|
if (id == AddMouseProfile) { showMouseProfileName(w, view, false); return true; }
|
||||||
|
if (id == RenameMouseProfile || id == DeleteMouseProfile)
|
||||||
|
{
|
||||||
|
const auto profile = selectedMouseProfile(w, view);
|
||||||
|
if (!isNamedMouseProfile(profile)) return true;
|
||||||
|
if (id == RenameMouseProfile) showMouseProfileName(w, view, true);
|
||||||
|
else if (MessageBoxW(w, (L"Delete " + mouseProfileName(profile) + L" and all its actions?").c_str(),
|
||||||
|
L"Delete profile", MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2) == IDYES)
|
||||||
|
{
|
||||||
|
view.draft.disabledMouseProfiles.erase(profile);
|
||||||
|
view.draft.mouseBindings.erase(profile);
|
||||||
|
view.draft.mouseProfileApplications.erase(profile);
|
||||||
|
fillMouseProfiles(w, view);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (id != EditMouseEvent && id != ResetMouseEvent) return false;
|
||||||
|
const int event = ListView_GetNextItem(GetDlgItem(w, 733), -1, LVNI_SELECTED);
|
||||||
|
if (event < 0) return true;
|
||||||
|
if (id == EditMouseEvent) showMouseActionEditor(w, view, event);
|
||||||
|
else if (auto profile = view.draft.mouseBindings.find(selectedMouseProfile(w, view));
|
||||||
|
profile != view.draft.mouseBindings.end())
|
||||||
|
{
|
||||||
|
profile->second.erase(MouseEvents[event]);
|
||||||
|
if (profile->second.empty() && !isNamedMouseProfile(profile->first)) view.draft.mouseBindings.erase(profile);
|
||||||
|
fillMouseEvents(w, view, event);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void scaleMouseTableColumns(HWND w, int previousDpi, int nextDpi)
|
||||||
|
{
|
||||||
|
for (int id : {731, 733})
|
||||||
|
{
|
||||||
|
const auto list = GetDlgItem(w, id);
|
||||||
|
const int count = Header_GetItemCount(ListView_GetHeader(list));
|
||||||
|
for (int column = 0; column < count; ++column)
|
||||||
|
ListView_SetColumnWidth(list, column, MulDiv(ListView_GetColumnWidth(list, column), nextDpi, previousDpi));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void createMousePage(HWND w, SettingsView &view)
|
||||||
|
{
|
||||||
|
constexpr int profilesLeft = 18, actionsLeft = 369, tableWidth = 333;
|
||||||
|
constexpr int profilesRight = profilesLeft + tableWidth;
|
||||||
|
label(w, L"Profiles", 18, 64, 120);
|
||||||
|
label(w, L"Mouse events", actionsLeft, 64, tableWidth);
|
||||||
|
control(w, L"BUTTON", L"Delete profile", WS_TABSTOP | BS_OWNERDRAW, DeleteMouseProfile, profilesRight - 80, 61, 24, 24);
|
||||||
|
control(w, L"BUTTON", L"Edit profile", WS_TABSTOP | BS_OWNERDRAW, RenameMouseProfile, profilesRight - 52, 61, 24, 24);
|
||||||
|
control(w, L"BUTTON", L"Add profile", WS_TABSTOP | BS_OWNERDRAW, AddMouseProfile, profilesRight - 24, 61, 24, 24);
|
||||||
|
HWND tips = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, nullptr, WS_POPUP | TTS_ALWAYSTIP | TTS_NOPREFIX,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, w, nullptr,
|
||||||
|
view.app->instance, nullptr);
|
||||||
|
addActionApplicationTooltip(tips, w, AddMouseProfile, L"Add profile");
|
||||||
|
addActionApplicationTooltip(tips, w, RenameMouseProfile, L"Edit profile");
|
||||||
|
addActionApplicationTooltip(tips, w, DeleteMouseProfile, L"Delete profile");
|
||||||
|
constexpr int listTop = 90, listHeight = 224;
|
||||||
|
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES};
|
||||||
|
InitCommonControlsEx(&controls);
|
||||||
|
constexpr DWORD listStyle = WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS | LVS_NOSORTHEADER;
|
||||||
|
HWND profiles = control(w, WC_LISTVIEWW, L"", listStyle, 731, profilesLeft, listTop, tableWidth, listHeight);
|
||||||
|
SetWindowSubclass(profiles, mouseEventListProc, 3, 0);
|
||||||
|
ListView_SetExtendedListViewStyle(profiles, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP | LVS_EX_CHECKBOXES);
|
||||||
|
wchar_t nameTitle[] = L"Name", activeTitle[] = L"Active", applicationsTitle[] = L"Applications";
|
||||||
|
LVCOLUMNW profileColumn{LVCF_TEXT | LVCF_WIDTH};
|
||||||
|
profileColumn.pszText = activeTitle;
|
||||||
|
profileColumn.cx = scale(w, 56);
|
||||||
|
ListView_InsertColumn(profiles, 0, &profileColumn);
|
||||||
|
profileColumn.pszText = nameTitle;
|
||||||
|
profileColumn.cx = scale(w, 100);
|
||||||
|
ListView_InsertColumn(profiles, 1, &profileColumn);
|
||||||
|
profileColumn.pszText = applicationsTitle;
|
||||||
|
profileColumn.cx = scale(w, tableWidth - 160);
|
||||||
|
ListView_InsertColumn(profiles, 2, &profileColumn);
|
||||||
|
int order[]{1, 0, 2};
|
||||||
|
ListView_SetColumnOrderArray(profiles, 3, order);
|
||||||
|
HWND list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT |
|
||||||
|
LVS_SINGLESEL | LVS_SHOWSELALWAYS | LVS_NOSORTHEADER, 733, actionsLeft, listTop, tableWidth, listHeight);
|
||||||
|
SetWindowSubclass(list, mouseEventListProc, 3, 0);
|
||||||
|
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP);
|
||||||
|
wchar_t actionTitle[] = L"Action";
|
||||||
|
LVCOLUMNW column{LVCF_TEXT | LVCF_WIDTH};
|
||||||
|
column.pszText = nameTitle;
|
||||||
|
column.cx = scale(w, 116);
|
||||||
|
ListView_InsertColumn(list, 0, &column);
|
||||||
|
column.pszText = actionTitle;
|
||||||
|
column.cx = scale(w, tableWidth - 120);
|
||||||
|
ListView_InsertColumn(list, 1, &column);
|
||||||
|
for (int event = 0; event < static_cast<int>(std::size(MouseEvents)); ++event)
|
||||||
|
{
|
||||||
|
LVITEMW item{LVIF_TEXT};
|
||||||
|
item.iItem = event;
|
||||||
|
item.pszText = const_cast<wchar_t *>(MouseEvents[event]);
|
||||||
|
ListView_InsertItem(list, &item);
|
||||||
|
}
|
||||||
|
control(w, L"STATIC", MouseEventHint, 0, 740, actionsLeft, 324, tableWidth, 22);
|
||||||
|
control(w, L"BUTTON", L"Test mouse buttons here", WS_TABSTOP | BS_PUSHBUTTON,
|
||||||
|
737, profilesLeft, listTop + listHeight + 10, tableWidth, 56);
|
||||||
|
SetWindowSubclass(GetDlgItem(w, 737), mouseButtonCaptureProc, 1, 0);
|
||||||
|
control(w, L"STATIC", L"Window event: waiting for a press", 0, 738, actionsLeft, 370, tableWidth, 20);
|
||||||
|
view.rawMouseRegistered = registerMouseTestInput(w);
|
||||||
|
control(w, L"STATIC", view.rawMouseRegistered ? L"Raw Input: waiting for a press" : L"Raw Input: unavailable",
|
||||||
|
0, 739, actionsLeft, 394, tableWidth, 20);
|
||||||
|
}
|
||||||
+9
-4
@@ -1,5 +1,5 @@
|
|||||||
#include "ui_sync_conflicts.inl"
|
#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)
|
void updateSyncControls(HWND w, App &app)
|
||||||
{
|
{
|
||||||
bool enabled = checked(w, 601);
|
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.";
|
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
|
||||||
else
|
else
|
||||||
status = app.syncCommand();
|
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)
|
SyncConfig syncFields(HWND w)
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
if (id == 601 && HIWORD(wp) == BN_CLICKED)
|
if (id == 601 && HIWORD(wp) == BN_CLICKED)
|
||||||
{
|
{
|
||||||
|
v->actionError = false;
|
||||||
updateSyncControls(w, *v->app);
|
updateSyncControls(w, *v->app);
|
||||||
return 0;
|
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 == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
if (id == 609)
|
if (id == 609)
|
||||||
{
|
{
|
||||||
|
v->actionError = false;
|
||||||
auto c = loadSyncConfig(v->app->data);
|
auto c = loadSyncConfig(v->app->data);
|
||||||
c.enabled = false;
|
c.enabled = false;
|
||||||
saveSyncConfig(v->app->data, c);
|
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)
|
if (id == IDOK || id == 610 || id == 611)
|
||||||
{
|
{
|
||||||
|
v->actionError = false;
|
||||||
if (!checked(w, 601))
|
if (!checked(w, 601))
|
||||||
{
|
{
|
||||||
if (id != IDOK) return 0;
|
if (id != IDOK) return 0;
|
||||||
@@ -80,7 +84,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id != IDOK && MessageBoxW(w, id == 610 ?
|
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"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;
|
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
||||||
auto c = syncFields(w);
|
auto c = syncFields(w);
|
||||||
@@ -100,6 +104,7 @@ LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
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.
|
// Never display parser exceptions containing connection file contents.
|
||||||
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
|
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
|
||||||
if (dynamic_cast<const std::runtime_error *>(&e))
|
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"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"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"&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);
|
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||||
value(w, 608, app.syncCommand());
|
value(w, 608, app.syncCommand());
|
||||||
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
|
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"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.56";
|
inline constexpr wchar_t AppVersion[] = L"0.6.64";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
+166
-21
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
namespace
|
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{};
|
int passed{};
|
||||||
void require(bool ok, const char *message)
|
void require(bool ok, const char *message)
|
||||||
{
|
{
|
||||||
@@ -141,37 +150,37 @@ int main()
|
|||||||
SetDlgItemTextW(parent,240,originalLimit);
|
SetDlgItemTextW(parent,240,originalLimit);
|
||||||
require(!saveEnabled(),"restoring valid original text clears dirty state");
|
require(!saveEnabled(),"restoring valid original text clears dirty state");
|
||||||
SendMessageW(parent,WM_COMMAND,257,0);
|
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");
|
require(ocr!=nullptr,"OCR preferences open");
|
||||||
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
|
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
|
||||||
SetDlgItemTextW(ocr,503,L"eng");
|
SetDlgItemTextW(ocr,503,L"eng");
|
||||||
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
|
||||||
require(!saveEnabled(),"cancelled child changes leave Settings clean");
|
require(!saveEnabled(),"cancelled child changes leave Settings clean");
|
||||||
SendMessageW(parent,WM_COMMAND,257,0);
|
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");
|
SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng");
|
||||||
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||||
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
|
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
|
||||||
SendMessageW(parent,WM_COMMAND,257,0);
|
SendMessageW(parent,WM_COMMAND,257,0);
|
||||||
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
|
||||||
SetDlgItemTextW(ocr,503,languages);
|
SetDlgItemTextW(ocr,503,languages);
|
||||||
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||||
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
|
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
|
||||||
SendMessageW(parent,WM_COMMAND,255,0);
|
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");
|
require(automatic!=nullptr,"automatic correction preferences open");
|
||||||
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
|
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
|
||||||
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
|
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
|
||||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||||
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
|
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
|
||||||
SendMessageW(parent,WM_COMMAND,255,0);
|
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);
|
CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED);
|
||||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||||
require(!saveEnabled(),"reverting auto-correction draft disables Save");
|
require(!saveEnabled(),"reverting auto-correction draft disables Save");
|
||||||
HWND tabs = GetDlgItem(parent, 260);
|
HWND tabs = GetDlgItem(parent, 260);
|
||||||
require(tabs && TabCtrl_GetItemCount(tabs) == 5, "five settings categories");
|
require(tabs && TabCtrl_GetItemCount(tabs) == 6, "six settings categories");
|
||||||
for (int page = 0; page < 5; ++page)
|
for (int page = 0; page < TabCtrl_GetItemCount(tabs); ++page)
|
||||||
{
|
{
|
||||||
TabCtrl_SetCurSel(tabs, page);
|
TabCtrl_SetCurSel(tabs, page);
|
||||||
NMHDR notification{tabs, 260, TCN_SELCHANGE};
|
NMHDR notification{tabs, 260, TCN_SELCHANGE};
|
||||||
@@ -201,7 +210,7 @@ int main()
|
|||||||
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
||||||
require(!saveEnabled(),"changing tabs leaves Settings clean");
|
require(!saveEnabled(),"changing tabs leaves Settings clean");
|
||||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
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");
|
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
||||||
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||||
require(index >= 0, "saved chat listed");
|
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::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
|
||||||
ks::encodePng(ks::decodeImage(clip)));
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
||||||
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
||||||
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
||||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
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");
|
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
|
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
|
||||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||||
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
|
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
|
||||||
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
|
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
|
||||||
@@ -236,11 +247,13 @@ int main()
|
|||||||
parent = app.settingsWindow;
|
parent = app.settingsWindow;
|
||||||
require(!saveEnabled(),"reopened saved Settings is clean");
|
require(!saveEnabled(),"reopened saved Settings is clean");
|
||||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
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");
|
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, 713, 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");
|
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);
|
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
GetClientRect(parent,&rect); dc=GetDC(parent);
|
GetClientRect(parent,&rect); dc=GetDC(parent);
|
||||||
@@ -249,6 +262,97 @@ int main()
|
|||||||
ks::encodePng(ks::decodeImage(clip)));
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
|
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"}};
|
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
|
||||||
ks::showSettings(app); parent=app.settingsWindow;
|
ks::showSettings(app); parent=app.settingsWindow;
|
||||||
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
||||||
@@ -286,39 +390,62 @@ int main()
|
|||||||
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
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;
|
ks::showSettings(app);parent=app.settingsWindow;
|
||||||
SendMessageW(parent,WM_COMMAND,262,0);
|
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(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
|
||||||
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
|
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
|
||||||
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
|
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
|
||||||
require(!IsWindowEnabled(GetDlgItem(lan,707)),"cannot forget nonexistent pair");
|
require(!IsWindowEnabled(GetDlgItem(lan,707)),"cannot forget nonexistent pair");
|
||||||
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input sharing requires a paired computer");
|
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input sharing requires a paired computer");
|
||||||
require(!IsWindowEnabled(GetDlgItem(lan,716)),"stop input disabled when sharing is off");
|
require(!IsWindowEnabled(GetDlgItem(lan,716)),"stop input disabled when sharing is off");
|
||||||
for(int id:{711,712,713,714}) require(!IsWindowEnabled(GetDlgItem(lan,id)),"screen positions unavailable until peer selected");
|
require(GetDlgItem(lan,721)!=nullptr,"monitor arrangement grid is present");
|
||||||
require(IsDlgButtonChecked(lan,712)==BST_CHECKED,"default screen position is Right");
|
require(!(GetWindowLongPtrW(lan,GWL_STYLE)&(WS_THICKFRAME|WS_MAXIMIZEBOX)),"LAN window has a fixed size");
|
||||||
|
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(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");
|
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);
|
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
|
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
|
||||||
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
|
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)));
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
|
||||||
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
||||||
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
|
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);
|
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ks::lan::Json fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false},
|
ks::lan::Json fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false},{"inputPeer",std::string(32,'b')},{"inputEdge",1},
|
||||||
{"trusted",{{std::string(32,'b'),{{"name","Test paired KSwitcher"},{"key",std::string(64,'f')}}}}}};
|
{"trusted",{{std::string(32,'b'),{{"name","Test paired KSwitcher"},{"key",std::string(64,'f')}}}}}};
|
||||||
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
|
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);
|
ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp);
|
||||||
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0);
|
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0);
|
||||||
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
|
||||||
SendDlgItemMessageW(lan,702,LB_SETCURSEL,0,0);
|
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
|
||||||
SendMessageW(lan,WM_COMMAND,MAKEWPARAM(702,LBN_SELCHANGE),0);
|
wchar_t pairState[512]{};SendDlgItemMessageW(lan,702,LB_GETTEXT,0,(LPARAM)pairState);
|
||||||
for(int id:{711,712,713,714})require(IsWindowEnabled(GetDlgItem(lan,id)),"paired computer exposes all four layout positions");
|
require(std::wstring(pairState).find(L"Pair saved — input disabled here")!=std::wstring::npos,"saved pairing does not imply an active input connection");
|
||||||
SendDlgItemMessageW(lan,713,BM_CLICK,0,0);
|
require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls");
|
||||||
require(IsDlgButtonChecked(lan,713)==BST_CHECKED&&IsDlgButtonChecked(lan,712)==BST_UNCHECKED,"Above selection changes radio group");
|
SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0);
|
||||||
SendMessageW(lan,WM_TIMER,1,0);
|
SendMessageW(lan,WM_TIMER,1,0);
|
||||||
require(IsDlgButtonChecked(lan,713)==BST_CHECKED,"status refresh preserves unsaved position choice");
|
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"refresh restores single paired selection without a peer list change");
|
||||||
|
auto grid=GetDlgItem(lan,721);
|
||||||
|
SendMessageW(grid,WM_KEYDOWN,VK_UP,0);SendMessageW(grid,WM_KEYDOWN,VK_LEFT,0);
|
||||||
|
auto saved=ks::lan::Json::parse(ks::unprotect(ks::readFile(root/L"lan-pairing.bin",256*1024)));
|
||||||
|
require(saved["positions"][std::string(32,'b')]==ks::lan::Json::array({0,-1}),"keyboard moves monitor above this PC and saves immediately");
|
||||||
|
SendMessageW(lan,WM_TIMER,1,0);
|
||||||
|
require(saved["inputEdge"]==2,"monitor arrangement updates the existing input direction");
|
||||||
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input cannot start while discovery is disabled");
|
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input cannot start while discovery is disabled");
|
||||||
// Let the native radio-button theme animation finish before visual capture.
|
// Let the native radio-button theme animation finish before visual capture.
|
||||||
auto repaintUntil=GetTickCount64()+200;
|
auto repaintUntil=GetTickCount64()+200;
|
||||||
@@ -334,6 +461,24 @@ int main()
|
|||||||
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
||||||
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
using J=ks::lan::Json;const std::string b(32,'b'),c(32,'c');
|
||||||
|
J fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false},{"inputPeer",b},{"inputEdge",0},
|
||||||
|
{"trusted",{{b,{{"name","Left desktop"},{"key",std::string(64,'f')}}},{c,{{"name","Right laptop"},{"key",std::string(64,'e')}}}}},
|
||||||
|
{"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=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));
|
||||||
|
auto saved=J::parse(ks::unprotect(ks::readFile(root/L"lan-pairing.bin",256*1024)));
|
||||||
|
require(saved["positions"][b]==J::array({-1,-1})&&saved["positions"][c]==J::array({1,0}),"drag moves one monitor without moving another paired PC");
|
||||||
|
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
|
GetClientRect(lan,&r);auto dc=GetDC(lan);auto screenshot=ks::screenshotClip(dc,r.right,r.bottom,{});ReleaseDC(lan,dc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-three-computers.png",ks::encodePng(ks::decodeImage(screenshot)));
|
||||||
|
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);SendMessageW(layoutApp.settingsWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
|
}
|
||||||
std::filesystem::remove_all(root);
|
std::filesystem::remove_all(root);
|
||||||
std::cout << "Passed " << passed << " chat profile checks\n";
|
std::cout << "Passed " << passed << " chat profile checks\n";
|
||||||
CoUninitialize();
|
CoUninitialize();
|
||||||
|
|||||||
+253
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "configuration.h"
|
||||||
#include "ui_scroll.h"
|
#include "ui_scroll.h"
|
||||||
#include "ui_geometry.h"
|
#include "ui_geometry.h"
|
||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
@@ -124,6 +125,26 @@ struct InputRegressionAccess {
|
|||||||
return a.notifiedUpdate;
|
return a.notifiedUpdate;
|
||||||
}
|
}
|
||||||
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
||||||
|
static bool volumeHoldStopsOnRelease(App &app)
|
||||||
|
{
|
||||||
|
constexpr int button = 3;
|
||||||
|
auto &press = app.mousePresses[button];
|
||||||
|
press = {app.window, {}, GetTickCount64(), {MouseActionType::VolumeUp}, true, false};
|
||||||
|
app.beginMouseVolume(button);
|
||||||
|
const bool onDown = app.mouseActions.size() == 1 && app.volumeButtonHeld();
|
||||||
|
app.repeatMouseVolume(button);
|
||||||
|
const bool initialDelay = app.mouseActions.size() == 1;
|
||||||
|
press.started -= 2100;
|
||||||
|
app.repeatMouseVolume(button);
|
||||||
|
const bool repeated = app.mouseActions.size() == 2;
|
||||||
|
MSLLHOOKSTRUCT mouse{};
|
||||||
|
mouse.mouseData = MAKEWPARAM(0, XBUTTON1);
|
||||||
|
const bool consumed = App::mouseProc(HC_ACTION, WM_XBUTTONUP, reinterpret_cast<LPARAM>(&mouse)) == 1;
|
||||||
|
app.repeatMouseVolume(button);
|
||||||
|
const bool stopped = !app.volumeButtonHeld() && app.mouseActions.size() == 2;
|
||||||
|
app.mouseActions.clear();
|
||||||
|
return onDown && initialDelay && repeated && consumed && stopped;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
int main(int argc, char **argv) {
|
int main(int argc, char **argv) {
|
||||||
@@ -180,6 +201,12 @@ int main(int argc, char **argv) {
|
|||||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||||
require(app.window!=nullptr,"message-only test window");
|
require(app.window!=nullptr,"message-only test window");
|
||||||
|
require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app),
|
||||||
|
"volume hold repeats after its delay and stops on release without an extra step");
|
||||||
|
require(ks::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) &&
|
||||||
|
ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) &&
|
||||||
|
!ks::isMouseVolumeStep(ks::MouseActionType::Mute),
|
||||||
|
"volume repeat accelerates and excludes mute");
|
||||||
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
||||||
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
||||||
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
||||||
@@ -676,6 +703,19 @@ int main(int argc, char **argv) {
|
|||||||
};
|
};
|
||||||
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
|
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
|
||||||
auto f12=event(VK_F12);
|
auto f12=event(VK_F12);
|
||||||
|
{
|
||||||
|
auto localF12=f12;localF12.flags=0;localF12.dwExtraInfo=0;
|
||||||
|
app.handleKeyboard(localF12,true,MOD_CONTROL);commands();
|
||||||
|
app.resetLanInput();
|
||||||
|
require(app.handleKeyboard(localF12,true,MOD_CONTROL)&&commands()==1,
|
||||||
|
"local shortcut works after LAN handoff even when release happened on another PC");
|
||||||
|
app.handleKeyboard(localF12,false,0);
|
||||||
|
auto letter=event('A',30);letter.flags=0;letter.dwExtraInfo=0;
|
||||||
|
app.resetLanInput();app.handleKeyboard(letter,true,0);
|
||||||
|
require(!app.run.keys.empty(),"local typing tracking resumes after LAN ownership reset");
|
||||||
|
app.run.reset();
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const auto before = app.inputVersion.load();
|
const auto before = app.inputVersion.load();
|
||||||
const auto recorded = app.run.keys;
|
const auto recorded = app.run.keys;
|
||||||
@@ -739,6 +779,219 @@ int main(int argc, char **argv) {
|
|||||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||||
ks::showSettings(app,false);
|
ks::showSettings(app,false);
|
||||||
{
|
{
|
||||||
|
auto settings = app.settingsWindow;
|
||||||
|
auto tabs = GetDlgItem(settings, 260);
|
||||||
|
auto capture = GetDlgItem(settings, 737);
|
||||||
|
auto events = GetDlgItem(settings, 733);
|
||||||
|
require(!(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE),
|
||||||
|
"mouse detection is hidden on General");
|
||||||
|
TabCtrl_SetCurSel(tabs, 5);
|
||||||
|
NMHDR changed{tabs, 260, TCN_SELCHANGE};
|
||||||
|
SendMessageW(settings, WM_NOTIFY, 260, reinterpret_cast<LPARAM>(&changed));
|
||||||
|
require(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE,
|
||||||
|
"mouse detection is visible on Mouse");
|
||||||
|
RECT suggestedSettings{};
|
||||||
|
GetWindowRect(settings, &suggestedSettings);
|
||||||
|
suggestedSettings.right -= 12;
|
||||||
|
suggestedSettings.bottom -= 12;
|
||||||
|
const UINT settingsDpi = GetDpiForWindow(settings);
|
||||||
|
SendMessageW(settings, WM_DPICHANGED, MAKEWPARAM(settingsDpi, settingsDpi),
|
||||||
|
reinterpret_cast<LPARAM>(&suggestedSettings));
|
||||||
|
require(!(GetWindowLongPtrW(settings, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
|
||||||
|
!(GetWindowLongPtrW(events, GWL_STYLE) & WS_HSCROLL),
|
||||||
|
"a DPI resize keeps both settings and the action table free of unnecessary scrollbars");
|
||||||
|
RECT profilesBounds{}, eventsBounds{};
|
||||||
|
require(GetWindowRect(GetDlgItem(settings, 731), &profilesBounds) &&
|
||||||
|
GetWindowRect(events, &eventsBounds) &&
|
||||||
|
profilesBounds.top == eventsBounds.top && profilesBounds.bottom == eventsBounds.bottom &&
|
||||||
|
profilesBounds.right - profilesBounds.left == eventsBounds.right - eventsBounds.left,
|
||||||
|
"mouse profile and action tables have equal dimensions and aligned edges");
|
||||||
|
SendMessageW(events, WM_KEYDOWN, VK_RETURN, 0);
|
||||||
|
auto editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
|
||||||
|
require(editor && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
|
||||||
|
"Global actions do not include inheritance");
|
||||||
|
SendMessageW(editor, WM_COMMAND, IDCANCEL, 0);
|
||||||
|
require(!GetDlgItem(settings, 741) && !GetDlgItem(settings, 732) && !GetDlgItem(settings, 745),
|
||||||
|
"applications are chosen in the action editor only");
|
||||||
|
for (auto message : {WM_XBUTTONDOWN, WM_XBUTTONUP, WM_XBUTTONDBLCLK, WM_XBUTTONUP})
|
||||||
|
SendMessageW(capture, message, MAKEWPARAM(0, XBUTTON2), 0);
|
||||||
|
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 4,
|
||||||
|
"mouse button capture selects the second side button");
|
||||||
|
wchar_t detected[100]{};
|
||||||
|
GetDlgItemTextW(settings, 738, detected, 100);
|
||||||
|
require(std::wstring(detected) == L"Window event: XButton2 | Presses: 2",
|
||||||
|
"detection shows the last button and counts repeated presses");
|
||||||
|
SendMessageW(capture, WM_MBUTTONDBLCLK, 0, 0);
|
||||||
|
SendMessageW(capture, WM_MBUTTONUP, 0, 0);
|
||||||
|
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 7,
|
||||||
|
"a double middle click selects its own event");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)), "button detection is not a settings edit");
|
||||||
|
require(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2,
|
||||||
|
"mouse events have eight rows and two columns");
|
||||||
|
NMITEMACTIVATE click{};
|
||||||
|
click.hdr = {events, 733, NM_DBLCLK};
|
||||||
|
click.iItem = 7;
|
||||||
|
SendMessageW(settings, WM_NOTIFY, 733, reinterpret_cast<LPARAM>(&click));
|
||||||
|
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
|
||||||
|
require(editor && !IsWindowEnabled(settings) && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
|
||||||
|
"double-click opens the Global action editor");
|
||||||
|
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 1, 0);
|
||||||
|
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
|
||||||
|
SendDlgItemMessageW(editor, 747, ks::CaptureHotkeyMessage, VK_TAB, MOD_CONTROL);
|
||||||
|
require(!GetDlgItem(editor, 748), "the action editor has no application list");
|
||||||
|
SendMessageW(editor, WM_COMMAND, IDOK, 0);
|
||||||
|
const auto profiles = GetDlgItem(settings, 731);
|
||||||
|
require(ListView_GetItemCount(profiles) == 1 &&
|
||||||
|
IsWindowEnabled(GetDlgItem(settings, IDOK)), "adding a mouse binding enables Save");
|
||||||
|
wchar_t action[80]{};
|
||||||
|
ListView_GetItemText(events, 7, 1, action, 80);
|
||||||
|
require(std::wstring(action) == L"Ctrl+Tab", "the Action column reflects the selected event's binding");
|
||||||
|
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
|
||||||
|
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
|
||||||
|
require(editor != nullptr, "F2 opens the selected mouse action");
|
||||||
|
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
|
||||||
|
SendMessageW(editor, WM_COMMAND, IDOK, 0);
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)),
|
||||||
|
"removing the draft mouse binding clears pending changes");
|
||||||
|
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
|
||||||
|
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
|
||||||
|
require(editor != nullptr, "opens the action editor to configure volume");
|
||||||
|
RECT fullCombo{}, compactHeading{};
|
||||||
|
GetWindowRect(GetDlgItem(editor, 734), &fullCombo);
|
||||||
|
GetWindowRect(GetDlgItem(editor, IDOK), &compactHeading);
|
||||||
|
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
|
||||||
|
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
|
||||||
|
require(IsWindowVisible(GetDlgItem(editor, 759)) &&
|
||||||
|
GetDlgItemInt(editor, 759, nullptr, FALSE) == ks::DefaultMouseVolumeStep,
|
||||||
|
"volume actions show the default step");
|
||||||
|
RECT volumeCombo{}, volumeField{}, volumeSpin{}, volumeHeading{};
|
||||||
|
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
|
||||||
|
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
|
||||||
|
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
|
||||||
|
GetWindowRect(GetDlgItem(editor, IDOK), &volumeHeading);
|
||||||
|
int minimum{}, maximum{};
|
||||||
|
SendDlgItemMessageW(editor, 760, UDM_GETRANGE32,
|
||||||
|
reinterpret_cast<WPARAM>(&minimum), reinterpret_cast<LPARAM>(&maximum));
|
||||||
|
require(IsWindowVisible(GetDlgItem(editor, 760)) && minimum == 1 && maximum == 50 &&
|
||||||
|
volumeCombo.top == volumeField.top && volumeCombo.right < volumeField.left &&
|
||||||
|
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom &&
|
||||||
|
std::abs(volumeCombo.bottom - volumeField.bottom) <= 1 &&
|
||||||
|
volumeCombo.right < fullCombo.right && volumeHeading.top == compactHeading.top,
|
||||||
|
"volume step spinner is beside the shortened action dropdown");
|
||||||
|
RECT suggested{};
|
||||||
|
GetWindowRect(editor, &suggested);
|
||||||
|
suggested.right -= 12;
|
||||||
|
suggested.bottom -= 12;
|
||||||
|
const UINT editorDpi = GetDpiForWindow(editor);
|
||||||
|
SendMessageW(editor, WM_DPICHANGED, MAKEWPARAM(editorDpi, editorDpi),
|
||||||
|
reinterpret_cast<LPARAM>(&suggested));
|
||||||
|
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
|
||||||
|
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
|
||||||
|
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
|
||||||
|
require(!(GetWindowLongPtrW(editor, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
|
||||||
|
volumeCombo.top == volumeField.top && volumeCombo.bottom == volumeField.bottom &&
|
||||||
|
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom,
|
||||||
|
"a DPI resize keeps the action row aligned and restores the full client area");
|
||||||
|
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::Back), 0);
|
||||||
|
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
|
||||||
|
RECT backCombo{}, backHeading{};
|
||||||
|
GetWindowRect(GetDlgItem(editor, 734), &backCombo);
|
||||||
|
GetWindowRect(GetDlgItem(editor, IDOK), &backHeading);
|
||||||
|
require(EqualRect(&fullCombo, &backCombo) && EqualRect(&compactHeading, &backHeading) &&
|
||||||
|
!IsWindowVisible(GetDlgItem(editor, 760)), "back restores full width without an unused row");
|
||||||
|
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
|
||||||
|
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
|
||||||
|
SetDlgItemInt(editor, 759, 7, FALSE);
|
||||||
|
SendMessageW(editor, WM_COMMAND, IDOK, 0);
|
||||||
|
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
|
||||||
|
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
|
||||||
|
require(editor && GetDlgItemInt(editor, 759, nullptr, FALSE) == 7,
|
||||||
|
"reopening a volume action restores the configured step");
|
||||||
|
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
|
||||||
|
SendMessageW(editor, WM_COMMAND, IDOK, 0);
|
||||||
|
SendMessageW(settings, WM_COMMAND, 754, 0);
|
||||||
|
auto profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
|
||||||
|
require(profileDialog && GetDlgItem(profileDialog, 755), "add profile opens the Name dialog");
|
||||||
|
SetDlgItemTextW(profileDialog, 755, L"Browsing");
|
||||||
|
for (const auto *name : {L"chrome.exe", L"msedge.exe"})
|
||||||
|
{
|
||||||
|
SetDlgItemTextW(profileDialog, 749, name);
|
||||||
|
SendMessageW(profileDialog, WM_COMMAND, 750, 0);
|
||||||
|
}
|
||||||
|
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
|
||||||
|
wchar_t applications[80]{};
|
||||||
|
ListView_GetItemText(profiles, 1, 2, applications, 80);
|
||||||
|
require(std::wstring(applications) == L"chrome.exe, msedge.exe",
|
||||||
|
"applications are visible in the profile table");
|
||||||
|
require(ListView_GetNextItem(profiles, -1, LVNI_SELECTED) == 1 &&
|
||||||
|
IsWindowEnabled(GetDlgItem(settings, IDOK)), "new profile is selected and enables Save");
|
||||||
|
RECT profileRow{};
|
||||||
|
require(ListView_GetItemRect(profiles, 1, &profileRow, LVIR_BOUNDS),
|
||||||
|
"profile row bounds are available");
|
||||||
|
const int nameWidth = ListView_GetColumnWidth(profiles, 1);
|
||||||
|
const int rowCenter = (profileRow.top + profileRow.bottom) / 2;
|
||||||
|
int checkboxX = -1;
|
||||||
|
for (int x = nameWidth; x < profileRow.right; ++x)
|
||||||
|
{
|
||||||
|
LVHITTESTINFO hit{};
|
||||||
|
hit.pt = {x, rowCenter};
|
||||||
|
if (ListView_HitTest(profiles, &hit) == 1 && (hit.flags & LVHT_ONITEMSTATEICON))
|
||||||
|
{
|
||||||
|
checkboxX = x;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(checkboxX >= nameWidth && checkboxX < nameWidth + ListView_GetColumnWidth(profiles, 0) &&
|
||||||
|
ListView_GetCheckState(profiles, 1),
|
||||||
|
"the enabled checkbox is hit-testable in the second column");
|
||||||
|
SendMessageW(profiles, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(checkboxX, rowCenter));
|
||||||
|
SendMessageW(profiles, WM_LBUTTONUP, 0, MAKELPARAM(checkboxX, rowCenter));
|
||||||
|
require(!ListView_GetCheckState(profiles, 1), "clicking the row checkbox disables its profile");
|
||||||
|
ListView_SetItemState(profiles, 0, LVIS_SELECTED, LVIS_SELECTED);
|
||||||
|
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(nameWidth / 2, rowCenter));
|
||||||
|
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
|
||||||
|
require(profileDialog != nullptr, "double-clicking a profile name opens the clicked row");
|
||||||
|
SendMessageW(profileDialog, WM_COMMAND, IDCANCEL, 0);
|
||||||
|
const int applicationsX = nameWidth + ListView_GetColumnWidth(profiles, 0) + 4;
|
||||||
|
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(applicationsX, rowCenter));
|
||||||
|
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
|
||||||
|
require(profileDialog && SendDlgItemMessageW(profileDialog, 748, LB_GETCOUNT, 0, 0) == 2,
|
||||||
|
"double-click opens the profile editor with its applications");
|
||||||
|
SetDlgItemTextW(profileDialog, 755, L"Work browsing");
|
||||||
|
SendDlgItemMessageW(profileDialog, 748, LB_SETCURSEL, 0, 0);
|
||||||
|
SendMessageW(profileDialog, WM_COMMAND, 751, 0);
|
||||||
|
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
|
||||||
|
wchar_t renamed[80]{};
|
||||||
|
ListView_GetItemText(profiles, 1, 1, renamed, 80);
|
||||||
|
ListView_GetItemText(profiles, 1, 2, applications, 80);
|
||||||
|
require(std::wstring(renamed) == L"Work browsing" &&
|
||||||
|
std::wstring(applications) == L"msedge.exe" &&
|
||||||
|
!ListView_GetCheckState(profiles, 1),
|
||||||
|
"renaming keeps the profile selected and disabled");
|
||||||
|
const int appsWidth = ListView_GetColumnWidth(profiles, 2);
|
||||||
|
ListView_SetColumnWidth(profiles, 2, appsWidth + 100);
|
||||||
|
SendMessageW(settings, WM_SIZE, 0, 0);
|
||||||
|
require(ListView_GetColumnWidth(profiles, 2) == appsWidth + 100 &&
|
||||||
|
(GetWindowLongPtrW(profiles, GWL_STYLE) & WS_HSCROLL),
|
||||||
|
"the Applications column retains its wider size and can scroll");
|
||||||
|
SendMessageW(settings, ks::ConfigSettingsDownloaded, 0, 0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto window = app.settingsWindow;
|
||||||
|
auto previous = app.settings;
|
||||||
|
TabCtrl_SetCurSel(GetDlgItem(window,260),3);
|
||||||
|
app.settings.awake = !previous.awake;
|
||||||
|
app.settings.maxItems = previous.maxItems + 1;
|
||||||
|
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
|
||||||
|
wchar_t count[32]{};
|
||||||
|
GetDlgItemTextW(window,240,count,32);
|
||||||
|
require(app.settingsWindow == window &&
|
||||||
|
SendDlgItemMessageW(window,205,BM_GETCHECK,0,0) == (app.settings.awake ? BST_CHECKED : BST_UNCHECKED) &&
|
||||||
|
std::stoi(count) == app.settings.maxItems && TabCtrl_GetCurSel(GetDlgItem(window,260)) == 3,
|
||||||
|
"download refreshes existing settings controls while preserving window and active tab");
|
||||||
|
app.settings = previous;
|
||||||
|
SendMessageW(window,ks::ConfigSettingsDownloaded,0,0);
|
||||||
|
TabCtrl_SetCurSel(GetDlgItem(window,260),0);
|
||||||
auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose);
|
auto timer = SetTimer(nullptr, 0, 10, answerSettingsClose);
|
||||||
require(timer != 0, "settings close prompt responder");
|
require(timer != 0, "settings close prompt responder");
|
||||||
SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0);
|
SendMessageW(app.settingsWindow, WM_CLOSE, 0, 0);
|
||||||
|
|||||||
@@ -114,6 +114,54 @@ int main(int argc,char **argv)
|
|||||||
check(request(restored,{{"action","status"}})["peers"].empty(),"forget removes offline pair");
|
check(request(restored,{{"action","status"}})["peers"].empty(),"forget removes offline pair");
|
||||||
check(!request(restored,{{"action","status"}}).at("shareInput").get<bool>(),"forgetting the input peer revokes persisted input permission");
|
check(!request(restored,{{"action","status"}}).at("shareInput").get<bool>(),"forgetting the input peer revokes persisted input permission");
|
||||||
}
|
}
|
||||||
|
if(argc>1&&std::string(argv[1])=="--network")
|
||||||
|
{
|
||||||
|
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
|
||||||
|
saved["trusted"][b]={{"name","Restart peer"},{"key",left.derived}};
|
||||||
|
saved["enabled"]=true;saved["shareInput"]=true;saved["inputPeer"]=b;saved["inputEdge"]=3;
|
||||||
|
auto text=saved.dump();atomicWrite(root/L"lan-pairing.bin",protect(Bytes(text.begin(),text.end())));
|
||||||
|
for(int restart=0;restart<2;++restart)
|
||||||
|
{
|
||||||
|
LanService restored(root);Sleep(100);
|
||||||
|
auto state=request(restored,{{"action","status"}});
|
||||||
|
check(restored.inputEnabled()&&state.at("enabled")==true&&state.at("inputPeer")==b&&state.at("inputEdge")==3,
|
||||||
|
"restart restores discovery, pairing, input permission and screen edge without opening settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto folder=root/L"layout";std::filesystem::create_directories(folder);
|
||||||
|
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
|
||||||
|
saved["enabled"]=false;saved["shareInput"]=false;saved["inputPeer"]=b;saved["inputEdge"]=1;saved.erase("positions");
|
||||||
|
saved["trusted"]={{b,{{"name","Right PC"},{"key",left.derived}}},{c,{{"name","Third PC"},{"key",left.derived}}}};
|
||||||
|
auto text=saved.dump();atomicWrite(folder/L"lan-pairing.bin",protect(Bytes(text.begin(),text.end())));
|
||||||
|
{
|
||||||
|
LanService layout(folder);auto state=request(layout,{{"action","status"}});
|
||||||
|
check(state["positions"][b]==Json::array({1,0}),"legacy side migrates to monitor grid");
|
||||||
|
check(state["positions"].size()==2&&state["positions"][c]!=state["positions"][b],"three-PC layout has unique cells");
|
||||||
|
request(layout,{{"action","position"},{"peer",c},{"x",-1},{"y",0}});
|
||||||
|
request(layout,{{"action","position"},{"peer",b},{"x",0},{"y",-1}});
|
||||||
|
rejects([&]{request(layout,{{"action","position"},{"peer",c},{"x",0},{"y",-1}});},"occupied cell rejected");
|
||||||
|
rejects([&]{request(layout,{{"action","position"},{"peer",c},{"x",0},{"y",0}});},"local monitor cell protected");
|
||||||
|
rejects([&]{request(layout,{{"action","position"},{"peer",c},{"x",9},{"y",0}});},"out-of-grid position rejected");
|
||||||
|
check(request(layout,{{"action","status"}})["positions"][c]==Json::array({-1,0}),"failed drops preserve previous position");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
LanService layout(folder);auto state=request(layout,{{"action","status"}});
|
||||||
|
check(state["positions"][b]==Json::array({0,-1})&&state["positions"][c]==Json::array({-1,0}),"all monitor positions survive restart");
|
||||||
|
check(state["inputEdge"]==2,"active peer direction follows monitor position");
|
||||||
|
if(argc>1&&std::string(argv[1])=="--network"){
|
||||||
|
request(layout,{{"action","enable"},{"enabled",true}});
|
||||||
|
request(layout,{{"action","input"},{"enabled",true}});
|
||||||
|
request(layout,{{"action","position"},{"peer",c},{"x",0},{"y",-2}});
|
||||||
|
auto multi=request(layout,{{"action","status"}});
|
||||||
|
check(multi["shareInput"]==true&&multi["positions"][c]==Json::array({0,-2}),"multi-PC sharing remains enabled for a chained nonadjacent destination");
|
||||||
|
request(layout,{{"action","forget"},{"peer",b}});
|
||||||
|
check(request(layout,{{"action","status"}})["shareInput"]==true,"forgetting legacy selected pair keeps remaining pairs enabled");
|
||||||
|
}
|
||||||
|
request(layout,{{"action","forget"},{"peer",c}});
|
||||||
|
check(!request(layout,{{"action","status"}})["positions"].contains(c),"forgetting pair removes its monitor");
|
||||||
|
}
|
||||||
|
}
|
||||||
std::cout<<"Passed "<<passed<<" LAN discovery/pairing checks\n";
|
std::cout<<"Passed "<<passed<<" LAN discovery/pairing checks\n";
|
||||||
}
|
}
|
||||||
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#include <winsock2.h>
|
||||||
|
#include "lan_clipboard.h"
|
||||||
|
#include <iostream>
|
||||||
|
using namespace ks;using namespace ks::lan;
|
||||||
|
int checks{};
|
||||||
|
void check(bool b,const char *s){if(!b)throw std::runtime_error(s);++checks;}
|
||||||
|
int main(){try{
|
||||||
|
const std::string aid(32,'a'),bid(32,'b'),cid(32,'c'),key(64,'d');
|
||||||
|
ClipboardExchange a(aid),b(bid),c(cid);
|
||||||
|
a.peerSession(bid,b.session());a.peerSession(cid,c.session());b.peerSession(aid,a.session());c.peerSession(aid,a.session());
|
||||||
|
Json trusted{{bid,{{"key",key}}},{cid,{{"key",key}}}},back{{aid,{{"key",key}}}};
|
||||||
|
Bytes payload(13000);for(size_t i=0;i<payload.size();++i)payload[i]=static_cast<unsigned char>(i);
|
||||||
|
a.queue({bid,cid},payload,"editor.exe",1);
|
||||||
|
std::deque<std::pair<std::string,Json>> packets,acks;Json firstPacket;int sent=0;
|
||||||
|
auto output=[&](const std::string &to,const Json &p){check(p.dump().size()<=2048,"datagram fits discovery bound");if(firstPacket.is_null())firstPacket=p;packets.push_back({to,p});};
|
||||||
|
auto ack=[&](const std::string &,const Json &p){acks.push_back({p.at("id"),p});};
|
||||||
|
for(uint64_t now=10;now<10000;now+=20){
|
||||||
|
a.poll(trusted,now,output);
|
||||||
|
while(!packets.empty()){
|
||||||
|
auto [to,p]=std::move(packets.front());packets.pop_front();if(++sent%7==0)continue;
|
||||||
|
auto &receiver=to==bid?b:c;
|
||||||
|
receiver.receive(aid,key,p,now,123,ack);
|
||||||
|
receiver.receive(aid,key,p,now,123,ack); // duplicate datagrams do not duplicate history
|
||||||
|
}
|
||||||
|
while(!acks.empty()){
|
||||||
|
auto [from,p]=std::move(acks.front());acks.pop_front();if(++sent%11==0)continue;
|
||||||
|
a.receive(from,key,p,now,123,output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(b.deliveries.size()==1&&c.deliveries.size()==1,"all paired instances receive exactly once despite loss/duplicates");
|
||||||
|
check(b.deliveries[0].bytes==payload&&c.deliveries[0].bytes==payload,"binary clipboard survives retransmission");
|
||||||
|
check(b.deliveries[0].sequence==123&&b.deliveries[0].app=="editor.exe","capture sequence and origin retained");
|
||||||
|
b.receive(aid,key,firstPacket,10000,123,ack);
|
||||||
|
check(acks.empty(),"authenticated old packet replay is ignored");
|
||||||
|
ClipboardExchange restarted(bid);restarted.peerSession(aid,a.session());
|
||||||
|
restarted.receive(aid,key,firstPacket,10001,123,ack);
|
||||||
|
check(restarted.deliveries.empty()&&acks.empty(),"old datagrams rejected after recipient restart");
|
||||||
|
auto damaged=firstPacket;damaged["number"]=1000000;auto data=damaged["data"].get<std::string>();data[0]=data[0]=='0'?'1':'0';damaged["data"]=data;
|
||||||
|
bool rejected=false;try{b.receive(aid,key,damaged,10002,123,ack);}catch(...){rejected=true;}
|
||||||
|
check(rejected,"modified ciphertext rejected");
|
||||||
|
a.queue({bid},Bytes{1,2,3},"second",10003);
|
||||||
|
a.peerSession(bid,restarted.session());
|
||||||
|
for(uint64_t now=10004;now<11000;now+=20){
|
||||||
|
a.poll(trusted,now,output);
|
||||||
|
while(!packets.empty()){auto p=std::move(packets.front().second);packets.pop_front();restarted.receive(aid,key,p,now,456,ack);}
|
||||||
|
while(!acks.empty()){auto p=std::move(acks.front().second);acks.pop_front();a.receive(bid,key,p,now,456,output);}
|
||||||
|
}
|
||||||
|
check(restarted.deliveries.size()==1&&restarted.deliveries[0].bytes==Bytes({1,2,3}),"clipboard works after peer restart");
|
||||||
|
bool limited=false;try{a.queue({bid},Bytes(32*1024*1024+1),"large",12000);}catch(...){limited=true;}
|
||||||
|
check(limited,"oversized clipboard bounded");
|
||||||
|
{
|
||||||
|
WSADATA startup{};check(WSAStartup(MAKEWORD(2,2),&startup)==0,"UDP benchmark starts");
|
||||||
|
SOCKET sa=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP),sb=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
|
||||||
|
struct Cleanup {SOCKET a,b;~Cleanup(){closesocket(a);closesocket(b);WSACleanup();}} cleanup{sa,sb};
|
||||||
|
sockaddr_in aa{},ab{};aa.sin_family=ab.sin_family=AF_INET;aa.sin_addr.s_addr=ab.sin_addr.s_addr=htonl(INADDR_LOOPBACK);
|
||||||
|
check(bind(sa,reinterpret_cast<sockaddr*>(&aa),sizeof(aa))==0&&bind(sb,reinterpret_cast<sockaddr*>(&ab),sizeof(ab))==0,"UDP benchmark binds");
|
||||||
|
int length=sizeof(aa);getsockname(sa,reinterpret_cast<sockaddr*>(&aa),&length);length=sizeof(ab);getsockname(sb,reinterpret_cast<sockaddr*>(&ab),&length);
|
||||||
|
for(auto socket:{sa,sb}){u_long nonblock=1;ioctlsocket(socket,FIONBIO,&nonblock);int buffer=1024*1024;setsockopt(socket,SOL_SOCKET,SO_RCVBUF,reinterpret_cast<char*>(&buffer),sizeof(buffer));}
|
||||||
|
ClipboardExchange sender(aid),receiver(bid);sender.peerSession(bid,receiver.session());receiver.peerSession(aid,sender.session());
|
||||||
|
Bytes large(2*1024*1024);uint32_t random=123;for(auto &v:large){random=random*1664525+1013904223;v=static_cast<unsigned char>(random>>24);}
|
||||||
|
auto started=GetTickCount64();sender.queue({bid},large,"benchmark",started);
|
||||||
|
auto send=[&](SOCKET socket,const sockaddr_in &address,const Json &p){auto data=p.dump();if(sendto(socket,data.data(),int(data.size()),0,reinterpret_cast<const sockaddr*>(&address),sizeof(address))!=int(data.size()))throw std::runtime_error("UDP benchmark send failed");};
|
||||||
|
while(receiver.deliveries.empty()&&GetTickCount64()-started<15000){
|
||||||
|
auto now=GetTickCount64();sender.poll(trusted,now,[&](const auto &,const Json &p){send(sa,ab,p);});
|
||||||
|
bool progressed=false;
|
||||||
|
for(int n=0;n<64;++n){char buffer[2048];auto size=recv(sb,buffer,sizeof(buffer),0);if(size<=0)break;progressed=true;
|
||||||
|
receiver.receive(aid,key,Json::parse(buffer,buffer+size),now,123,[&](const auto &,const Json &p){send(sb,aa,p);});}
|
||||||
|
for(int n=0;n<64;++n){char buffer[2048];auto size=recv(sa,buffer,sizeof(buffer),0);if(size<=0)break;progressed=true;
|
||||||
|
sender.receive(bid,key,Json::parse(buffer,buffer+size),now,123,[&](const auto &,const Json &p){send(sa,ab,p);});}
|
||||||
|
if(!progressed)Sleep(1);
|
||||||
|
}
|
||||||
|
check(receiver.deliveries.size()==1&&receiver.deliveries[0].bytes==large,"2 MiB crosses actual encrypted UDP sockets intact");
|
||||||
|
std::cout<<"2 MiB encrypted UDP loopback: "<<(GetTickCount64()-started)<<" ms\n";
|
||||||
|
}
|
||||||
|
std::cout<<checks<<" LAN clipboard checks passed\n";return 0;
|
||||||
|
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
|
||||||
+314
-8
@@ -1,5 +1,7 @@
|
|||||||
#include <winsock2.h>
|
#include <winsock2.h>
|
||||||
#include "lan_input.h"
|
#include "lan_input.h"
|
||||||
|
#include "lan_datagram.h"
|
||||||
|
#include "lan_mesh.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
|
|
||||||
@@ -12,23 +14,31 @@ template<class F>void rejects(F f,const char *text){bool failed=false;try{f();}c
|
|||||||
struct Desktop
|
struct Desktop
|
||||||
{
|
{
|
||||||
RECT rect{-1920,-200,0,880};POINT point{-1000,300};bool available=true,physicalHeld=false,allow=true;
|
RECT rect{-1920,-200,0,880};POINT point{-1000,300};bool available=true,physicalHeld=false,allow=true;
|
||||||
std::vector<INPUT> events;
|
std::vector<INPUT> events;int dpi=96;bool delayed=false;InputRouter *router{};
|
||||||
InputEnvironment environment()
|
InputEnvironment environment()
|
||||||
{
|
{
|
||||||
return {[this]{return rect;},[this]{return point;},[this](const INPUT &i){
|
return {[this]{return rect;},[this]{return point;},[this](const INPUT &i){
|
||||||
if(!allow)return false;
|
if(!allow)return false;
|
||||||
events.push_back(i);
|
events.push_back(i);
|
||||||
if(i.type==INPUT_MOUSE&&(i.mi.dwFlags&MOUSEEVENTF_MOVE))
|
if(i.type==INPUT_MOUSE&&(i.mi.dwFlags&MOUSEEVENTF_MOVE)&&!delayed)
|
||||||
|
{
|
||||||
point={rect.left+LONG(int64_t(i.mi.dx)*(rect.right-rect.left)/65536),rect.top+LONG(int64_t(i.mi.dy)*(rect.bottom-rect.top)/65536)};
|
point={rect.left+LONG(int64_t(i.mi.dx)*(rect.right-rect.left)/65536),rect.top+LONG(int64_t(i.mi.dy)*(rect.bottom-rect.top)/65536)};
|
||||||
|
if(router){MSLLHOOKSTRUCT m{};m.pt=point;m.flags=LLMHF_INJECTED;m.dwExtraInfo=i.mi.dwExtraInfo;router->mouse(WM_MOUSEMOVE,m,1000);}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},[this]{return available;},[this]{return physicalHeld;}};
|
},[this]{return available;},[this]{return physicalHeld;},{},{},[this](POINT){return dpi;}};
|
||||||
}
|
}
|
||||||
unsigned keyEvents(bool down)const
|
unsigned keyEvents(bool down)const
|
||||||
{return unsigned(std::count_if(events.begin(),events.end(),[down](auto &i){return i.type==INPUT_KEYBOARD&&bool(i.ki.dwFlags&KEYEVENTF_KEYUP)!=down;}));}
|
{return unsigned(std::count_if(events.begin(),events.end(),[down](auto &i){return i.type==INPUT_KEYBOARD&&bool(i.ki.dwFlags&KEYEVENTF_KEYUP)!=down;}));}
|
||||||
};
|
};
|
||||||
KBDLLHOOKSTRUCT key(unsigned vk,unsigned scan=0){KBDLLHOOKSTRUCT k{};k.vkCode=vk;k.scanCode=scan;return k;}
|
KBDLLHOOKSTRUCT key(unsigned vk,unsigned scan=0){KBDLLHOOKSTRUCT k{};k.vkCode=vk;k.scanCode=scan;return k;}
|
||||||
bool motion(InputRouter &router,Desktop &desktop,POINT p,uint64_t now)
|
bool motion(InputRouter &router,Desktop &desktop,POINT p,uint64_t now)
|
||||||
{desktop.point=p;MSLLHOOKSTRUCT m{};m.pt=p;return router.mouse(WM_MOUSEMOVE,m,now);}
|
{
|
||||||
|
auto before=desktop.point;desktop.point=p;MSLLHOOKSTRUCT m{};m.pt=p;
|
||||||
|
bool consumed=router.mouse(WM_MOUSEMOVE,m,now);
|
||||||
|
if(consumed&&router.mode()==ControlMode::Sending)desktop.point=before;
|
||||||
|
return consumed;
|
||||||
|
}
|
||||||
void exchange(InputRouter &source,InputRouter &target,std::deque<InputFrame> &out,uint64_t now)
|
void exchange(InputRouter &source,InputRouter &target,std::deque<InputFrame> &out,uint64_t now)
|
||||||
{(void)source;while(!out.empty()){auto f=out.front();out.pop_front();target.receive(f,now);}}
|
{(void)source;while(!out.empty()){auto f=out.front();out.pop_front();target.receive(f,now);}}
|
||||||
struct Pair
|
struct Pair
|
||||||
@@ -37,8 +47,9 @@ struct Pair
|
|||||||
InputRouter left,right;
|
InputRouter left,right;
|
||||||
explicit Pair(int side):left(a.environment(),[this](auto f){ab.push_back(f);return true;},side,true),
|
explicit Pair(int side):left(a.environment(),[this](auto f){ab.push_back(f);return true;},side,true),
|
||||||
right(b.environment(),[this](auto f){ba.push_back(f);return true;},side^1,false)
|
right(b.environment(),[this](auto f){ba.push_back(f);return true;},side^1,false)
|
||||||
{b.rect={0,0,1600,900};b.point={600,400};left.tick(true,1000);right.tick(true,1000);}
|
{a.router=&left;b.router=&right;b.rect={0,0,1600,900};b.point={600,400};left.tick(true,1000);right.tick(true,1000);}
|
||||||
void deliver(uint64_t now=1100){exchange(left,right,ab,now);exchange(right,left,ba,now);}
|
~Pair(){a.router=b.router=nullptr;}
|
||||||
|
void deliver(uint64_t now=1100){exchange(left,right,ab,now);right.tick(true,now);exchange(right,left,ba,now);left.tick(true,now);}
|
||||||
void enter(int edge)
|
void enter(int edge)
|
||||||
{
|
{
|
||||||
motion(left,a,edgePoint(a.rect,edge,30000,0),1000);
|
motion(left,a,edgePoint(a.rect,edge,30000,0),1000);
|
||||||
@@ -48,6 +59,93 @@ struct Pair
|
|||||||
};
|
};
|
||||||
void routerChecks()
|
void routerChecks()
|
||||||
{
|
{
|
||||||
|
{
|
||||||
|
std::vector<InputFrame> queue;
|
||||||
|
for(int i=0;i<20;++i)enqueueInput(queue,{InputType::Wheel,1,120,0});
|
||||||
|
check(queue.size()==1&&queue[0].a==2400,"pending wheel burst coalesces without losing same-direction distance");
|
||||||
|
enqueueInput(queue,{InputType::Wheel,1,-120,0});
|
||||||
|
check(queue.size()==1&&queue[0].a==-120,"wheel reversal replaces undelivered stale direction");
|
||||||
|
enqueueInput(queue,{InputType::Key,1,VK_CONTROL,0,1});enqueueInput(queue,{InputType::Wheel,1,120,0});
|
||||||
|
check(queue.size()==3&&queue[1].type==InputType::Key,"wheel coalescing preserves modifier barriers");
|
||||||
|
enqueueInput(queue,{InputType::Wheel,1,120,1});enqueueInput(queue,{InputType::Wheel,2,120,1});
|
||||||
|
check(queue.size()==5,"wheel coalescing preserves axis and ownership boundaries");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.a.delayed=true;
|
||||||
|
p.left.cancel(1200);
|
||||||
|
auto before=p.a.point;
|
||||||
|
check(motion(p.left,p.a,{before.x+10,before.y},1201),"physical movement is gated while return-to-edge is queued");
|
||||||
|
p.a.point=edgePoint(p.a.rect,1,edgePosition(p.a.point,p.a.rect,1));p.left.tick(true,1500);
|
||||||
|
check(!motion(p.left,p.a,{p.a.point.x-10,p.a.point.y},1501),"return movement gate always clears within bounded timeout");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);
|
||||||
|
for(auto vk:{VK_SHIFT,VK_LSHIFT,VK_RSHIFT,VK_CONTROL,VK_MENU})
|
||||||
|
p.right.keyboard(key(vk),false,1200);
|
||||||
|
check(p.right.mode()==ControlMode::Receiving&&p.ba.empty(),"layout-generated unpaired modifier releases cannot return remote ownership");
|
||||||
|
auto own=key(VK_SHIFT);own.dwExtraInfo=inputMarker();
|
||||||
|
p.right.keyboard(own,true,1201);p.right.keyboard(own,false,1202);
|
||||||
|
check(p.right.mode()==ControlMode::Receiving&&p.ba.empty(),"own correction marker cannot trigger takeover even with injection flag stripped");
|
||||||
|
p.right.keyboard(key('B'),true,1203);
|
||||||
|
check(p.right.mode()==ControlMode::Local&&!p.ba.empty(),"real local key press still takes over after conversion release noise");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);MSLLHOOKSTRUCT mouse{};mouse.pt=p.b.point;
|
||||||
|
p.right.mouse(WM_MOUSEMOVE,mouse,1200);
|
||||||
|
check(p.right.mode()==ControlMode::Receiving,"stationary untagged mouse notification does not cancel remote control");
|
||||||
|
mouse.pt.x+=20;p.right.mouse(WM_MOUSEMOVE,mouse,1201);
|
||||||
|
check(p.right.mode()==ControlMode::Local,"actual local mouse movement still takes control back");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.b.delayed=true;auto before=p.b.events.back().mi.dx;
|
||||||
|
for(int i=0;i<5;++i){auto point=p.a.point;point.x+=10;motion(p.left,p.a,point,1300+i);p.deliver(1300+i);}
|
||||||
|
auto pixels=LONG(int64_t(p.b.events.back().mi.dx)*(p.b.rect.right-p.b.rect.left)/65536);
|
||||||
|
check(pixels==58&&p.b.point.x==8&&p.b.events.back().mi.dx>before,
|
||||||
|
"queued remote moves accumulate from entry edge even before Windows updates cursor position");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.b.delayed=true;
|
||||||
|
motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);p.deliver(1100);
|
||||||
|
check(p.left.mode()==ControlMode::Requesting,"source stays local until destination cursor actually reaches entry edge");
|
||||||
|
p.right.tick(true,1400);p.deliver(1400);
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"failed destination positioning returns both peers locally");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.a.delayed=true;p.enter(1);p.left.tick(true,1400);p.deliver(1400);
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"missing source warp acknowledgement cannot suppress movement indefinitely");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.a.delayed=true;p.enter(1);auto queued=p.ab.size();
|
||||||
|
motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1200);
|
||||||
|
check(p.ab.size()==queued,"pre-parking physical motion cannot become a giant centre-relative delta");
|
||||||
|
MSLLHOOKSTRUCT warp{};warp.flags=LLMHF_INJECTED;warp.dwExtraInfo=cursorWarpMarker();
|
||||||
|
p.left.mouse(WM_MOUSEMOVE,warp,1201);p.a.point={(p.a.rect.left+p.a.rect.right)/2,(p.a.rect.top+p.a.rect.bottom)/2};
|
||||||
|
auto point=p.a.point;++point.x;motion(p.left,p.a,point,1202);
|
||||||
|
check(p.ab.size()==queued+1,"source movement resumes after actual warp acknowledgement");
|
||||||
|
}
|
||||||
|
for(auto modifier:{VK_LCONTROL,VK_LMENU,VK_LSHIFT,VK_LWIN})
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.b.events.clear();
|
||||||
|
auto mod=key(modifier,modifier==VK_LCONTROL?0x1d:modifier==VK_LMENU?0x38:modifier==VK_LSHIFT?0x2a:0x5b);
|
||||||
|
if(modifier==VK_LWIN)mod.flags=LLKHF_EXTENDED;
|
||||||
|
p.left.keyboard(mod,true,1200);p.left.keyboard(key('C',0x2e),true,1201);
|
||||||
|
p.left.keyboard(key('C',0x2e),false,1202);p.left.keyboard(mod,false,1203);p.deliver(1204);
|
||||||
|
check(p.b.events.size()==4&&p.b.events[0].ki.wVk==modifier&&p.b.events[1].ki.wVk=='C'&&
|
||||||
|
!(p.b.events[0].ki.dwFlags&KEYEVENTF_KEYUP)&&(p.b.events[3].ki.dwFlags&KEYEVENTF_KEYUP),
|
||||||
|
"modifier chord delivered in order with matching release");
|
||||||
|
check(!(p.b.events[0].ki.dwFlags&KEYEVENTF_SCANCODE)&&!(p.b.events[3].ki.dwFlags&KEYEVENTF_SCANCODE),
|
||||||
|
"modifier down and up use explicit Windows key identities");
|
||||||
|
auto correction=key(VK_CONTROL,0x1d);correction.flags=LLKHF_INJECTED;correction.dwExtraInfo=ks::inputMarker();
|
||||||
|
p.right.keyboard(correction,true,1210);p.right.keyboard(correction,false,1211);p.right.tick(true,1220);
|
||||||
|
check(p.right.mode()==ControlMode::Receiving&&p.left.mode()==ControlMode::Sending,
|
||||||
|
"generated correction keys do not take back remote ownership");
|
||||||
|
}
|
||||||
|
for(auto dpis:{std::pair{96,192},std::pair{240,96}})
|
||||||
|
{
|
||||||
|
Pair p(1);p.a.dpi=dpis.first;p.b.dpi=dpis.second;p.enter(1);auto before=p.b.point;
|
||||||
|
for(int i=0;i<100;++i){auto point=p.a.point;++point.x;motion(p.left,p.a,point,1300+i);p.deliver(1300+i);}
|
||||||
|
check(std::abs(p.b.point.x-before.x-100*dpis.second/dpis.first)<=1,"mouse movement scales between monitor DPIs without losing small deltas");
|
||||||
|
}
|
||||||
for(int side=0;side<4;++side)
|
for(int side=0;side<4;++side)
|
||||||
{
|
{
|
||||||
Pair p(side);auto middle=edgePoint(p.a.rect,side,32768,0);
|
Pair p(side);auto middle=edgePoint(p.a.rect,side,32768,0);
|
||||||
@@ -145,6 +243,61 @@ void routerChecks()
|
|||||||
check(router.mode()==ControlMode::Local&&router.keyboard(key('Q'),false,1300),"queue failure returns control and consumes orphan release");
|
check(router.mode()==ControlMode::Local&&router.keyboard(key('Q'),false,1300),"queue failure returns control and consumes orphan release");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MeshFixture
|
||||||
|
{
|
||||||
|
struct Message{std::string from,to;InputFrame frame;};
|
||||||
|
std::deque<Message> messages;Desktop desktops[3];std::unique_ptr<InputMesh> meshes[3];uint64_t now=1000;
|
||||||
|
std::map<std::string,uint64_t> connections[3];
|
||||||
|
MeshFixture(bool vertical=false){
|
||||||
|
POINT cells[3]={{0,0},{1,0},vertical?POINT{1,-1}:POINT{2,0}};
|
||||||
|
for(int i=0;i<3;++i){
|
||||||
|
desktops[i].rect={0,0,800,600};desktops[i].point={400,300};std::map<std::string,POINT> positions;
|
||||||
|
for(int j=0;j<3;++j)if(i!=j){auto name=std::to_string(j);positions[name]={cells[j].x-cells[i].x,cells[j].y-cells[i].y};connections[i][name]=1;}
|
||||||
|
meshes[i]=std::make_unique<InputMesh>(std::to_string(i),desktops[i].environment(),positions,[this,i](auto &peer,auto f){messages.push_back({std::to_string(i),peer,f});return true;});
|
||||||
|
}pump();
|
||||||
|
}
|
||||||
|
~MeshFixture(){for(auto &m:meshes)m.reset();}
|
||||||
|
void pump(){for(int rounds=0;rounds<10;++rounds){
|
||||||
|
while(!messages.empty()){auto m=messages.front();messages.pop_front();meshes[std::stoi(m.to)]->receive(m.from,m.frame,now);}
|
||||||
|
for(int i=0;i<3;++i)meshes[i]->tick(connections[i],now);
|
||||||
|
}}
|
||||||
|
void move(int x,int y){++now;auto &d=desktops[0];auto old=d.point;MSLLHOOKSTRUCT m{};m.pt={old.x+x,old.y+y};d.point=m.pt;
|
||||||
|
bool consumed=meshes[0]->mouse(WM_MOUSEMOVE,m,now);if(consumed&&meshes[0]->mode()==ControlMode::Sending)d.point=old;pump();}
|
||||||
|
void enter(){desktops[0].point={798,300};move(1,0);check(meshes[0]->mode()==ControlMode::Sending&&meshes[1]->mode()==ControlMode::Receiving,"mesh enters first neighbor");}
|
||||||
|
};
|
||||||
|
void meshChecks()
|
||||||
|
{
|
||||||
|
{
|
||||||
|
MeshFixture f;f.enter();f.move(900,0);
|
||||||
|
check(f.meshes[0]->peer()=="2"&&f.meshes[2]->mode()==ControlMode::Receiving&&f.meshes[1]->mode()==ControlMode::Local,"A routes directly to C through B right edge");
|
||||||
|
f.desktops[1].events.clear();f.desktops[2].events.clear();
|
||||||
|
for(auto k:{VK_LSHIFT,int('A')})f.meshes[0]->keyboard(key(k),true,++f.now);
|
||||||
|
for(auto k:{int('A'),VK_LSHIFT})f.meshes[0]->keyboard(key(k),false,++f.now);
|
||||||
|
f.pump();
|
||||||
|
check(f.desktops[1].keyEvents(true)==0&&f.desktops[2].keyEvents(true)==2&&f.desktops[2].keyEvents(false)==2,"modifier chord goes only to current destination C");
|
||||||
|
f.move(-900,0);check(f.meshes[0]->peer()=="1"&&f.meshes[1]->mode()==ControlMode::Receiving,"C left edge returns through B");
|
||||||
|
f.move(-900,0);check(f.meshes[0]->mode()==ControlMode::Local&&f.desktops[0].point.x>780,"B left edge restores source at its right edge");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
MeshFixture f(true);f.enter();f.move(0,-900);
|
||||||
|
check(f.meshes[0]->peer()=="2"&&f.desktops[2].point.y>580,"vertical turn enters C at bottom edge");
|
||||||
|
f.move(0,900);check(f.meshes[0]->peer()=="1","vertical return routes back to B");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
MeshFixture f;f.connections[0].erase("2");f.pump();f.enter();f.move(900,0);
|
||||||
|
check(f.meshes[0]->peer()=="1"&&f.meshes[1]->mode()==ControlMode::Receiving,"offline neighbor blocks passage without losing current control");
|
||||||
|
f.meshes[0]->keyboard(key(VK_LSHIFT),true,++f.now);f.pump();f.connections[0].erase("1");f.connections[1].erase("0");f.pump();
|
||||||
|
check(f.meshes[0]->mode()==ControlMode::Local&&f.meshes[1]->mode()==ControlMode::Local&&f.desktops[1].keyEvents(false)==1,"disconnect restores local control and releases held modifier");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
MeshFixture f;f.enter();f.meshes[1]->receive("2",{InputType::Enter,99,1,20000},++f.now);
|
||||||
|
check(f.meshes[1]->peer()=="0"&&f.meshes[1]->mode()==ControlMode::Receiving,"another controller cannot steal receiving desktop");f.pump();
|
||||||
|
f.move(900,0);f.meshes[0]->receive("1",{InputType::Return,1,20000},++f.now);
|
||||||
|
check(f.meshes[0]->mode()==ControlMode::Sending&&f.meshes[0]->peer()=="2","late previous peer return cannot cancel current destination");
|
||||||
|
f.connections[0]["2"]=2;f.pump();check(f.meshes[0]->mode()==ControlMode::Local,"new connection generation does not continue stale ownership");
|
||||||
|
}
|
||||||
|
}
|
||||||
uint16_t freePort()
|
uint16_t freePort()
|
||||||
{
|
{
|
||||||
SOCKET s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(s==INVALID_SOCKET)throw std::runtime_error("test socket");
|
SOCKET s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(s==INVALID_SOCKET)throw std::runtime_error("test socket");
|
||||||
@@ -152,6 +305,122 @@ uint16_t freePort()
|
|||||||
if(bind(s,(sockaddr*)&a,sizeof(a))){closesocket(s);throw std::runtime_error("test bind");}
|
if(bind(s,(sockaddr*)&a,sizeof(a))){closesocket(s);throw std::runtime_error("test bind");}
|
||||||
int size=sizeof(a);getsockname(s,(sockaddr*)&a,&size);auto port=ntohs(a.sin_port);closesocket(s);return port;
|
int size=sizeof(a);getsockname(s,(sockaddr*)&a,&size);auto port=ntohs(a.sin_port);closesocket(s);return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void hubChecks()
|
||||||
|
{
|
||||||
|
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
|
||||||
|
struct Close{~Close(){WSACleanup();}} close;
|
||||||
|
std::string ids[3]={std::string(32,'a'),std::string(32,'b'),std::string(32,'c')},secret(64,'f');
|
||||||
|
uint16_t ports[3]={freePort(),freePort(),freePort()};
|
||||||
|
std::unique_ptr<InputHub> hubs[3];std::vector<InputEndpoint> endpoints[3];std::vector<RoutedInput> incoming[3];
|
||||||
|
for(int i=0;i<3;++i){hubs[i]=std::make_unique<InputHub>(ids[i],ports[i]);for(int j=0;j<3;++j)if(i!=j)endpoints[i].push_back({ids[j],secret,htonl(INADDR_LOOPBACK),ports[j]});}
|
||||||
|
auto pump=[&](uint64_t offset=0){for(int i=2;i>=0;--i)hubs[i]->poll(endpoints[i],{},incoming[i],GetTickCount64()+offset);Sleep(1);};
|
||||||
|
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();
|
||||||
|
check(incoming[1].size()==1&&incoming[1][0].peer==ids[0]&&incoming[1][0].frame==keyFrame&&incoming[2].size()==1&&incoming[2][0].frame==cross,"hub routes encrypted frames only to their named destinations");
|
||||||
|
for(auto &in:incoming)in.clear();
|
||||||
|
auto survivor=hubs[0]->ready().at(ids[2]),old=hubs[0]->ready().at(ids[1]);
|
||||||
|
hubs[1]->disconnect();for(int n=0;n<30;++n)pump();
|
||||||
|
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()
|
void channelChecks()
|
||||||
{
|
{
|
||||||
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
|
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
|
||||||
@@ -167,6 +436,35 @@ void channelChecks()
|
|||||||
InputCipher otherSession(inputSessionKey(secret,a,b,randomHex(16),nb));rejects([&]{otherSession.decrypt(packet);},"old-session input rejected");
|
InputCipher otherSession(inputSessionKey(secret,a,b,randomHex(16),nb));rejects([&]{otherSession.decrypt(packet);},"old-session input rejected");
|
||||||
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
|
WSADATA data{};if(WSAStartup(MAKEWORD(2,2),&data))throw std::runtime_error("WSAStartup");
|
||||||
struct Close{~Close(){WSACleanup();}} close;
|
struct Close{~Close(){WSACleanup();}} close;
|
||||||
|
{
|
||||||
|
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||||
|
std::vector<InputFrame> received;auto start=GetTickCount64();
|
||||||
|
server.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||||
|
client.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||||
|
auto generation=client.generation();
|
||||||
|
client.poll(htonl(INADDR_LOOPBACK),{},received,start+3000);
|
||||||
|
check(client.generation()==generation,"initial handshake allows more than the active heartbeat timeout");
|
||||||
|
for(int n=0;n<200&&(!client.ready()||!server.ready());++n)
|
||||||
|
{
|
||||||
|
auto now=GetTickCount64()+3000;
|
||||||
|
server.poll(htonl(INADDR_LOOPBACK),{},received,now);client.poll(htonl(INADDR_LOOPBACK),{},received,now);Sleep(1);
|
||||||
|
}
|
||||||
|
check(client.ready()&&server.ready(),"delayed initial handshake still authenticates");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||||
|
std::vector<InputFrame> received;auto start=GetTickCount64();
|
||||||
|
server.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||||
|
client.poll(htonl(INADDR_LOOPBACK),{},received,start);
|
||||||
|
client.poll(htonl(INADDR_LOOPBACK),{},received,start+100);
|
||||||
|
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);
|
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||||
auto deadline=GetTickCount64()+5000;std::vector<InputFrame> received;
|
auto deadline=GetTickCount64()+5000;std::vector<InputFrame> received;
|
||||||
@@ -182,7 +480,15 @@ void channelChecks()
|
|||||||
InputFrame reverse{InputType::Wheel,37,-120,1};server.poll(htonl(INADDR_LOOPBACK),{reverse},received,GetTickCount64());
|
InputFrame reverse{InputType::Wheel,37,-120,1};server.poll(htonl(INADDR_LOOPBACK),{reverse},received,GetTickCount64());
|
||||||
for(int n=0;n<100&&received.empty();++n){client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
|
for(int n=0;n<100&&received.empty();++n){client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
|
||||||
check(received.size()==1&&received[0]==reverse,"real input channel is bidirectional");
|
check(received.size()==1&&received[0]==reverse,"real input channel is bidirectional");
|
||||||
auto generation=client.generation();client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64()+3000);
|
received.clear();server.disconnect();
|
||||||
|
client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());
|
||||||
|
for(int n=0;n<300&&(!client.ready()||!server.ready());++n)
|
||||||
|
{
|
||||||
|
auto now=GetTickCount64()+1800;
|
||||||
|
server.poll(htonl(INADDR_LOOPBACK),{},received,now);client.poll(htonl(INADDR_LOOPBACK),{},received,now);Sleep(1);
|
||||||
|
}
|
||||||
|
check(client.ready()&&server.ready(),"paired channel reconnects automatically after peer disconnect without new pairing");
|
||||||
|
auto generation=client.generation();client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64()+5000);
|
||||||
check(!client.ready()&&client.generation()!=generation,"heartbeat timeout closes channel and changes generation");
|
check(!client.ready()&&client.generation()!=generation,"heartbeat timeout closes channel and changes generation");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -201,6 +507,6 @@ void channelChecks()
|
|||||||
}
|
}
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
try{channelChecks();routerChecks();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;}
|
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#include "settings_merge.h"
|
||||||
|
#include "mouse_buttons.h"
|
||||||
|
#include <iostream>
|
||||||
|
using namespace ks;
|
||||||
|
int main(){try{
|
||||||
|
Settings base;base.autoCorrect=false;base.toggle=L"Ctrl+Alt+F11";
|
||||||
|
auto draft=base,current=base;draft.toggle=L"Ctrl+Alt+F12";current.autoCorrect=true;
|
||||||
|
auto merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
|
||||||
|
if(merged.toggle!=draft.toggle||!merged.autoCorrect)throw std::runtime_error("independent changes lost");
|
||||||
|
current.toggle=L"Ctrl+Alt+F10";bool rejected=false;
|
||||||
|
try{mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current));}
|
||||||
|
catch(const std::exception &e){rejected=std::string(e.what()).starts_with("Settings conflict: ");}
|
||||||
|
if(!rejected)throw std::runtime_error("same-field conflict silently overwritten");
|
||||||
|
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current),true),{});
|
||||||
|
if(merged.toggle!=draft.toggle||!merged.autoCorrect)throw std::runtime_error("confirmed edits discarded unrelated current changes");
|
||||||
|
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(base),encodeSettings(current)),{});
|
||||||
|
if(merged.toggle!=current.toggle||!merged.autoCorrect)throw std::runtime_error("unchanged draft reverted current settings");
|
||||||
|
draft=base;current=base;draft.chatCycles[L"telegram\nfirst"]={L"win:04090409"};current.chatCycles[L"telegram\nsecond"]={L"win:04220422"};
|
||||||
|
merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
|
||||||
|
if(merged.chatCycles.size()!=2)throw std::runtime_error("independent nested changes lost");
|
||||||
|
draft = base;
|
||||||
|
current = base;
|
||||||
|
draft.mouseBindings[L"chrome"][L"XButton1"] = {MouseActionType::Shortcut, {MOD_CONTROL, VK_TAB}};
|
||||||
|
current.mouseBindings[L"chrome"][L"XButton2"] = {MouseActionType::Shortcut, {MOD_CONTROL | MOD_SHIFT, VK_TAB}};
|
||||||
|
merged = decodeSettings(mergeConfigurationSettings(encodeSettings(base), encodeSettings(draft),
|
||||||
|
encodeSettings(current)), {});
|
||||||
|
if (merged.mouseBindings.at(L"chrome").size() != 2)
|
||||||
|
throw std::runtime_error("independent mouse bindings lost");
|
||||||
|
auto shared = sharedSyncSettings(merged);
|
||||||
|
if (shared != sharedSyncSettings(base) ||
|
||||||
|
restoreSyncSettings(shared, merged, {}, false).mouseBindings != merged.mouseBindings)
|
||||||
|
throw std::runtime_error("mouse profiles must stay local during sync");
|
||||||
|
auto withButtons = merged;
|
||||||
|
draft = current = withButtons;
|
||||||
|
draft.mouseBindings.at(L"chrome").erase(L"XButton1");
|
||||||
|
current.mouseBindings.at(L"chrome")[L"XButton2"] = {MouseActionType::Shortcut, {MOD_CONTROL, VK_F4}};
|
||||||
|
merged = decodeSettings(mergeConfigurationSettings(encodeSettings(withButtons), encodeSettings(draft),
|
||||||
|
encodeSettings(current)), {});
|
||||||
|
const auto &buttons = merged.mouseBindings.at(L"chrome");
|
||||||
|
if (buttons.size() != 1 || buttons.at(L"XButton2") != MouseAction{MouseActionType::Shortcut, {MOD_CONTROL, VK_F4}})
|
||||||
|
throw std::runtime_error("mouse button removal lost during merge");
|
||||||
|
merged.mouseBindings[GlobalMouseProfile][L"Double middle"] = {MouseActionType::Mute};
|
||||||
|
merged.mouseBindings[L"chrome"][L"Double middle"] = {MouseActionType::Standard};
|
||||||
|
merged.mouseBindings[L"edge"][L"XButton1"] = {MouseActionType::Launch, {}, L"C:\\Tools\\app.exe"};
|
||||||
|
merged.mouseBindings[L"edge"][L"XButton2"] = {MouseActionType::Launch, {}, L"calc.exe"};
|
||||||
|
merged.mouseBindings[GlobalMouseProfile][L"Left"] = {MouseActionType::Shortcut, {MOD_CONTROL, 'W'}, {}, {L"chrome", L"msedge"}};
|
||||||
|
merged.mouseBindings[GlobalMouseProfile][L"Middle"] = {MouseActionType::Back, {}, {}, {L"chrome"}};
|
||||||
|
auto restored = decodeSettings(encodeSettings(merged), {});
|
||||||
|
if (restored.mouseBindings != merged.mouseBindings ||
|
||||||
|
effectiveMouseAction(restored, L"chrome", 7) ||
|
||||||
|
!effectiveMouseAction(restored, L"edge", 7) ||
|
||||||
|
effectiveMouseAction(restored, L"edge", 7)->type != MouseActionType::Mute)
|
||||||
|
throw std::runtime_error("mouse action override or inheritance failed");
|
||||||
|
if (!effectiveMouseAction(restored, L"chrome", 0) || !effectiveMouseAction(restored, L"msedge", 0) ||
|
||||||
|
effectiveMouseAction(restored, L"explorer", 0) || !effectiveMouseAction(restored, L"chrome", 2) ||
|
||||||
|
effectiveMouseAction(restored, L"msedge", 2) || !effectiveMouseAction(restored, L"explorer", 7))
|
||||||
|
throw std::runtime_error("application restrictions or unrestricted fallback failed");
|
||||||
|
draft = current = restored;
|
||||||
|
draft.mouseBindings[GlobalMouseProfile][L"Middle"].applications.clear();
|
||||||
|
current.mouseBindings[GlobalMouseProfile][L"Middle"].type = MouseActionType::Mute;
|
||||||
|
const auto competing = mergeSyncProfile(encodeSettings(restored), encodeSettings(draft), encodeSettings(current));
|
||||||
|
if (competing.conflicts.size() != 1)
|
||||||
|
throw std::runtime_error("competing edits to a mouse action and its scope must conflict");
|
||||||
|
Settings profiles;
|
||||||
|
profiles.mouseBindings[L"name:Browsing"][L"XButton1"] = {MouseActionType::Forward};
|
||||||
|
profiles.mouseBindings[L"name:Browsing"][L"XButton2"] = {MouseActionType::Back};
|
||||||
|
profiles.mouseProfileApplications[L"name:Browsing"] = {L"chrome", L"msedge"};
|
||||||
|
profiles.mouseBindings[L"name:Empty"];
|
||||||
|
profiles.mouseBindings[GlobalMouseProfile][L"XButton1"] = {MouseActionType::VolumeUp};
|
||||||
|
profiles.mouseBindings[GlobalMouseProfile][L"XButton2"] = {MouseActionType::VolumeDown};
|
||||||
|
profiles.mouseBindings[GlobalMouseProfile][L"XButton2"].volumeStep = 7;
|
||||||
|
auto resolve = [&](const wchar_t *app, int event, bool foreground = true)
|
||||||
|
{
|
||||||
|
const auto *action = effectiveMouseAction(profiles, app, event, foreground);
|
||||||
|
return action ? action->type : MouseActionType::Standard;
|
||||||
|
};
|
||||||
|
profiles = decodeSettings(mergeConfigurationSettings(encodeSettings(base), encodeSettings(profiles), encodeSettings(base)), {});
|
||||||
|
if (profiles.mouseBindings.at(GlobalMouseProfile).at(L"XButton1").volumeStep != DefaultMouseVolumeStep ||
|
||||||
|
profiles.mouseBindings.at(GlobalMouseProfile).at(L"XButton2").volumeStep != 7)
|
||||||
|
throw std::runtime_error("default and custom volume steps must round-trip independently");
|
||||||
|
auto invalidVolume = profiles;
|
||||||
|
invalidVolume.mouseBindings[GlobalMouseProfile][L"XButton1"].volumeStep = 0;
|
||||||
|
rejected = false;
|
||||||
|
try { validateSettings(invalidVolume); }
|
||||||
|
catch (const std::runtime_error &) { rejected = true; }
|
||||||
|
if (!rejected) throw std::runtime_error("invalid volume steps must be rejected");
|
||||||
|
if (!profiles.mouseBindings.contains(L"name:Empty") ||
|
||||||
|
resolve(L"chrome", 3) != MouseActionType::Forward || resolve(L"msedge", 4) != MouseActionType::Back ||
|
||||||
|
resolve(L"chrome", 3, false) != MouseActionType::VolumeUp ||
|
||||||
|
resolve(L"explorer", 4) != MouseActionType::VolumeDown)
|
||||||
|
throw std::runtime_error("named profiles must persist and override fallback only for the matching foreground app");
|
||||||
|
profiles.mouseBindings[L"name:Alternative"][L"XButton1"] = {MouseActionType::Back};
|
||||||
|
profiles.mouseProfileApplications[L"name:Alternative"] = {L"chrome"};
|
||||||
|
profiles.disabledMouseProfiles.insert(L"name:Browsing");
|
||||||
|
profiles = decodeSettings(encodeSettings(profiles), {});
|
||||||
|
if (resolve(L"chrome", 3) != MouseActionType::Back)
|
||||||
|
throw std::runtime_error("disabled profiles must not resolve actions");
|
||||||
|
profiles.disabledMouseProfiles.clear();
|
||||||
|
rejected = false;
|
||||||
|
try { validateSettings(profiles); }
|
||||||
|
catch (const std::runtime_error &e) { rejected = std::string(e.what()).starts_with("Mouse action conflict:"); }
|
||||||
|
if (!rejected) throw std::runtime_error("overlapping active profiles must be rejected");
|
||||||
|
std::cout<<"Settings-save merge checks passed\n";
|
||||||
|
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
|
||||||
+19
-1
@@ -298,6 +298,9 @@ int main()
|
|||||||
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
|
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
|
||||||
result = synchronize(provider, base, bytes(left), SyncAction::Download);
|
result = synchronize(provider, base, bytes(left), SyncAction::Download);
|
||||||
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
|
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;
|
provider.race = true;
|
||||||
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
||||||
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
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");
|
rejects([&] { bao->read(); }, "permission denied is not absent");
|
||||||
readStatus = 302;
|
readStatus = 302;
|
||||||
rejects([&] { bao->read(); }, "redirect response rejected");
|
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;
|
writeStatus = 400;
|
||||||
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
|
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);
|
forgetSyncPassword(config); forgetSyncPassword(config);
|
||||||
++checks;
|
++checks;
|
||||||
std::filesystem::remove_all(root);
|
std::filesystem::remove_all(root);
|
||||||
|
|||||||
+42
-1
@@ -1,6 +1,7 @@
|
|||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
#include "history_preview.h"
|
#include "history_preview.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
|
#include "lan_clipboard_image.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include "diagnostics.h"
|
#include "diagnostics.h"
|
||||||
@@ -591,6 +592,16 @@ int main(int argc, char **argv)
|
|||||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||||
options.autoNever.clear();
|
options.autoNever.clear();
|
||||||
options.autoReplacements[L"мамамила"] = L"мама мила";
|
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);
|
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
|
||||||
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
|
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
|
||||||
"joined words split while preserving capitalization, punctuation and layout");
|
"joined words split while preserving capitalization, punctuation and layout");
|
||||||
@@ -717,7 +728,8 @@ int main(int argc, char **argv)
|
|||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
settings.autoReplace = settings.autoTypos = 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.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
@@ -738,6 +750,8 @@ int main(int argc, char **argv)
|
|||||||
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
|
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
|
||||||
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
|
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
|
||||||
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
|
{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"}})
|
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
|
||||||
{
|
{
|
||||||
auto invalid = settings;
|
auto invalid = settings;
|
||||||
@@ -895,6 +909,33 @@ int main(int argc, char **argv)
|
|||||||
ks::validateTesseract(options);
|
ks::validateTesseract(options);
|
||||||
require(true, "Windows OCR works without optional installation");
|
require(true, "Windows OCR works without optional installation");
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
ks::History h(temp/L"computer-origins");auto clip=ks::textClip(L"shared text");
|
||||||
|
h.add(clip,L"editor",settings);
|
||||||
|
h.add(clip,L"editor",settings,"peer-a",L"Office PC");
|
||||||
|
h.add(clip,L"editor",settings,"peer-a",L"Office PC");
|
||||||
|
require(h.entries().size()==2,"duplicate suppression preserves different clipboard sources");
|
||||||
|
ks::History restored(temp/L"computer-origins");
|
||||||
|
require(restored.entries().front().computer=="peer-a"&&restored.entries().front().computerName==L"Office PC","computer metadata survives history reload");
|
||||||
|
require(restored.entries().back().computer.empty(),"legacy and local records remain local");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
ks::Pixels pixels{1920,1080,ks::Bytes(1920*1080*4,255)};
|
||||||
|
for(size_t i=0;i<pixels.bgra.size();i+=4){pixels.bgra[i]=static_cast<unsigned char>((i/4)%1920/16);pixels.bgra[i+1]=static_cast<unsigned char>((i/4)/1920/16);}
|
||||||
|
ks::Clip original;original.formats.push_back(ks::imageDib(pixels));
|
||||||
|
original.formats.push_back({RegisterClipboardFormatW(L"PNG"),L"PNG",ks::encodePng(pixels)});
|
||||||
|
auto packed=ks::lan::compactClipboardImage(original);
|
||||||
|
require(packed.formats.size()==1&&packed.formats[0].name==L"PNG","LAN sends only one image format");
|
||||||
|
require(packed.size()*10<original.size(),"screen-like image LAN payload reduced by more than 90 percent");
|
||||||
|
require(ks::decodeImage(packed).bgra==pixels.bgra,"LAN PNG preserves exact pixels");
|
||||||
|
auto paste=ks::lan::clipboardImageForPaste(packed);
|
||||||
|
require(std::any_of(paste.formats.begin(),paste.formats.end(),[](const auto &f){return f.id==CF_DIB||f.id==CF_DIBV5;}),"LAN receiver restores a DIB for ordinary paste");
|
||||||
|
original.formats.pop_back();
|
||||||
|
require(ks::decodeImage(ks::lan::compactClipboardImage(original)).bgra==pixels.bgra,"DIB-only clipboard is losslessly compressed");
|
||||||
|
auto text=ks::textClip(L"plain text");
|
||||||
|
require(ks::encodeClip(ks::lan::compactClipboardImage(text))==ks::encodeClip(text),"LAN text clipboard remains unchanged");
|
||||||
|
std::cout<<"LAN screenshot bytes: "<<original.size()<<" -> "<<packed.size()<<"\n";
|
||||||
|
}
|
||||||
// Test-owned directory only; never touch the user's clipboard or stored history.
|
// Test-owned directory only; never touch the user's clipboard or stored history.
|
||||||
std::filesystem::remove_all(temp);
|
std::filesystem::remove_all(temp);
|
||||||
CoUninitialize();
|
CoUninitialize();
|
||||||
|
|||||||
Reference in new issue
Block a user