Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de010ee44 | ||
|
|
82292c8a7e | ||
|
|
4fbeb56421 | ||
|
|
e0bfdd9a90 | ||
|
|
9b565dd55e | ||
|
|
346af2256f | ||
|
|
99db99e603 | ||
|
|
63b7b879a3 | ||
|
|
727dc96908 | ||
|
|
ede4ce47c0 |
No files matched your search
+37
-3
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.51 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.57 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()
|
||||||
@@ -19,10 +19,10 @@ foreach(utilityTarget StayHere StayHereTest)
|
|||||||
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
endforeach()
|
endforeach()
|
||||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
add_library(kscore src/lan_channel.cpp src/lan_input.cpp src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
||||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||||
target_link_libraries(kscore PUBLIC winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
target_link_libraries(kscore PUBLIC ws2_32 bcrypt iphlpapi winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||||
@@ -89,6 +89,22 @@ else()
|
|||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_executable(KSwitcherLanTests tests/lan.cpp)
|
||||||
|
target_link_libraries(KSwitcherLanTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherLanTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherLanTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME lan-pairing-regressions COMMAND KSwitcherLanTests)
|
||||||
|
add_executable(KSwitcherLanInputTests tests/lan_input.cpp)
|
||||||
|
target_link_libraries(KSwitcherLanInputTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherLanInputTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherLanInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME lan-input-regressions COMMAND KSwitcherLanInputTests)
|
||||||
add_executable(KSwitcherChatProfileTests tests/chat_profiles.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherChatProfileTests tests/chat_profiles.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherChatProfileTests PRIVATE kscore)
|
target_link_libraries(KSwitcherChatProfileTests PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
@@ -162,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)
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
# LAN mouse, keyboard and clipboard — v0.6.57 (experimental)
|
||||||
|
|
||||||
|
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
|
||||||
|
code on both computers, and shares mouse and keyboard through a screen edge.
|
||||||
|
The module runs inside the resident process. Multiple paired computers can be
|
||||||
|
arranged on a monitor grid. Mouse and keyboard follow neighboring cells, including
|
||||||
|
A → B → C, without relaying input through B. New text and images are also shared
|
||||||
|
with all online paired computers: history and the active clipboard. File transfer
|
||||||
|
is not implemented by clipboard synchronization.
|
||||||
|
|
||||||
|
## Set up multiple computers
|
||||||
|
|
||||||
|
1. Install the v0.6.57 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
|
||||||
|
2. Open Settings → Tools → KSwitcher computers and enable discovery on both PCs.
|
||||||
|
Only running LAN-enabled KSwitcher instances appear, not generic network devices.
|
||||||
|
3. Match the computer name and instance-ID prefix displayed on the other PC.
|
||||||
|
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.
|
||||||
|
4. Pair each physical input source with every destination it must control. For
|
||||||
|
unrestricted control from any PC, pair every pair of PCs. Drag monitors into
|
||||||
|
neighboring grid cells and arrange the same physical layout relative to each
|
||||||
|
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
|
||||||
|
KSwitcher input sharing, to avoid two tools forwarding the same events.
|
||||||
|
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
|
||||||
|
computer acknowledges the handoff, both mouse and keyboard control that PC.
|
||||||
|
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
|
||||||
|
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 source suppresses forwarded events before its local hotkeys, snippets and
|
||||||
|
other low-level hook consumers. Received input is marked so it is not forwarded
|
||||||
|
again. Source recent-input tracking and shortcut latches reset when ownership changes.
|
||||||
|
|
||||||
|
**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
|
||||||
|
sharing, disabling discovery, forgetting the currently controlled pair, suspension, secure
|
||||||
|
desktop changes and connection loss cancel remote ownership. TCP heartbeat loss
|
||||||
|
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
|
||||||
|
retried when the ordinary desktop is available. Source key-ups for keys already
|
||||||
|
sent remotely are consumed after return, avoiding stray local modifier events.
|
||||||
|
|
||||||
|
## Layout and limitations
|
||||||
|
|
||||||
|
- Positions are relative to this PC. All peers have independent authenticated
|
||||||
|
connections; a failed peer does not disconnect the others. The physical source
|
||||||
|
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
|
||||||
|
monitor coordinates. Internal monitor boundaries do not transfer control.
|
||||||
|
Different resolutions map the entry position proportionally. Irregular layouts
|
||||||
|
can have gaps on an outer edge; this is not a per-monitor arrangement editor.
|
||||||
|
- Transferring while a key or mouse button is held is intentionally blocked.
|
||||||
|
Dragging works within the controlled computer; dragging objects across PCs is
|
||||||
|
not implemented. There is a short return cooldown to avoid bouncing.
|
||||||
|
- Windows can block SendInput to elevated apps and secure desktops. Run both
|
||||||
|
applications at suitable privilege levels for the target programs. The preview
|
||||||
|
does not install a service, bypass UAC or implement remote Ctrl+Alt+Delete.
|
||||||
|
- This is Windows input injection, not a virtual USB HID device. Raw-input games,
|
||||||
|
exclusive full-screen applications and special device keys need separate testing.
|
||||||
|
|
||||||
|
## Network, authentication and storage
|
||||||
|
|
||||||
|
Discovery broadcasts on private IPv4 and link-local interfaces using UDP 45883.
|
||||||
|
Input uses TCP 45884. Allow KSwitcher on your private network when Windows asks;
|
||||||
|
the preview does not change firewall rules. Broadcast-blocked networks, separate
|
||||||
|
VLANs, public IPv4 and IPv6-only networks are not supported by discovery yet.
|
||||||
|
A second LAN-enabled KSwitcher cannot use the same ports on one computer.
|
||||||
|
|
||||||
|
The instance ID belongs to a data directory, not a hostname/IP address. Discovered
|
||||||
|
names and addresses are untrusted. Pairing uses a generated 128-bit code and
|
||||||
|
mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never
|
||||||
|
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
|
||||||
|
session challenges. Directional AES-256-GCM session keys bind the identities,
|
||||||
|
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
|
||||||
|
until authentication succeeds. This PSK channel does not provide forward secrecy.
|
||||||
|
Queues and frame sizes are bounded; overflow disconnects rather than dropping a
|
||||||
|
key-up and leaving a key pressed. The input worker never injects Windows input;
|
||||||
|
the resident's main thread owns the input state and hooks.
|
||||||
|
|
||||||
|
Identity, trust, enablement and screen position stay in DPAPI-protected
|
||||||
|
`data/lan-pairing.bin`, outside settings export and OpenBao sync. Do not clone this
|
||||||
|
file between installations. Forget a pair on both PCs for bilateral removal.
|
||||||
|
Closing the settings window does not cancel pairing or disable sharing.
|
||||||
|
|
||||||
|
Disabled discovery has no socket or worker. Input sharing reuses the single LAN
|
||||||
|
worker; there is no extra resident executable or service. The main input timer
|
||||||
|
runs only while sharing is enabled. The existing configurator talks to the
|
||||||
|
resident through local IPC.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Tests cover pairing, encrypted frames, replay/tamper rejection, actual loopback
|
||||||
|
TCP authentication and bidirectional delivery, all four edges, ownership races,
|
||||||
|
source suppression, scan codes, clicks/wheel, emergency return, held-key release,
|
||||||
|
blocked injection, secure desktop recovery, exact one-pixel movement and timeout.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Three physical computers still need an end-to-end trial for their adapters,
|
||||||
|
firewalls, displays and application privilege levels. The automated TCP tests
|
||||||
|
use local sockets, and input-state tests use simulated desktop environments;
|
||||||
|
they do not claim a completed three-machine hardware test.
|
||||||
@@ -10,6 +10,29 @@ 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.57 (experimental)
|
||||||
|
|
||||||
|
Arrange multiple paired PCs on a monitor grid. Mouse and keyboard follow shared
|
||||||
|
edges, including A → B → C and back. New text and images go to every online paired
|
||||||
|
PC's history and active clipboard. The history Computers menu filters by multiple
|
||||||
|
sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md).
|
||||||
|
|
||||||
|
**Update every participating PC to v0.6.57:** input protocol v2 is incompatible
|
||||||
|
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.57, залишивши кожному
|
||||||
|
власну папку 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.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
|
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
|
||||||
@@ -19,6 +42,11 @@ Double-click the tray icon to open Settings, the bold default menu item.
|
|||||||
Closing Settings with unsaved changes (including accepted auto-correction or OCR options)
|
Closing Settings with unsaved changes (including accepted auto-correction or OCR options)
|
||||||
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
|
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.
|
||||||
|
**Unsaved changes — click Save to apply** appears for pending edits, including accepted
|
||||||
|
chat-language, auto-correction and OCR changes. Cancelling a child dialog or restoring
|
||||||
|
the original values clears its pending changes. Snippets saved in their own window
|
||||||
|
remain saved independently; the Settings indicator reflects any other pending edits.
|
||||||
Menus support access keys: press the underlined letter while the menu is open (for example,
|
Menus support access keys: press the underlined letter while the menu is open (for example,
|
||||||
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
||||||
to activate a button, toggle a checkbox or focus a labeled field. Dialog underline visibility
|
to activate a button, toggle a checkbox or focus a labeled field. Dialog underline visibility
|
||||||
@@ -202,6 +230,22 @@ snapping and shape constraints are unchanged.
|
|||||||
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||||
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||||
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||||
|
**Arc (U)** draws an open half ellipse inside the dragged rectangle. Use the corner handles
|
||||||
|
to resize it and the round handle to rotate it (Shift snaps to 15 degrees). Arcs support
|
||||||
|
color, thickness, line style, moving, cloning, undo/redo and PNG export.
|
||||||
|
|
||||||
|
**Smoothing (0–100%)** on the screenshot toolbar adjusts pencil paths. With a pencil
|
||||||
|
stroke selected it affects that stroke; otherwise it sets the default for new strokes
|
||||||
|
(initially 25%). **Apply to all strokes** applies the displayed amount to all pencil
|
||||||
|
strokes in the current image and sets the default for new ones. Shapes, text and
|
||||||
|
highlights are unchanged. Zero restores the original path; higher values reduce more
|
||||||
|
jitter. The original samples are retained through moving, endpoint editing and cloning,
|
||||||
|
so lowering the amount does not accumulate distortion. Each slider drag or bulk change
|
||||||
|
is one undo step, and PNG export uses the displayed geometry. Very short strokes remain
|
||||||
|
unchanged. Shift-constrained and linked strokes start without automatic smoothing;
|
||||||
|
you can explicitly adjust them afterwards. Shape/straight-line recognition is separate
|
||||||
|
and does not turn recognized shapes back into pencil strokes.
|
||||||
|
|
||||||
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
||||||
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
||||||
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||||
@@ -297,8 +341,7 @@ Identically named chats in one application share a profile; renamed chats need a
|
|||||||
profile. Telegram unread counters are ignored. Teams currently recognizes the desktop
|
profile. Telegram unread counters are ignored. Teams currently recognizes the desktop
|
||||||
`Chat | name | Microsoft Teams` title format; other Teams title formats are not supported.
|
`Chat | name | Microsoft Teams` title format; other Teams title formats are not supported.
|
||||||
Experimental Viber profiles introduced in v0.6.49 are disabled in v0.6.50 because repeated OCR caused delays. There is no background Viber OCR, recognition during language switching, or recognition when opening profiles. Saved Viber profiles are retained and marked inactive; Windows switching and Next layout use their normal/global behavior in Viber. Clipboard and image OCR are unchanged.
|
Experimental Viber profiles introduced in v0.6.49 are disabled in v0.6.50 because repeated OCR caused delays. There is no background Viber OCR, recognition during language switching, or recognition when opening profiles. Saved Viber profiles are retained and marked inactive; Windows switching and Next layout use their normal/global behavior in Viber. Clipboard and image OCR are unchanged.
|
||||||
Profiles stay on this PC (excluded from cloud sync), and are included in manual settings
|
From v0.6.53, chat profiles (application, visible chat name and ordered language IDs) are included in settings synchronization and manual export/import. Update every synchronizing PC to v0.6.53 or newer first: older clients reject this shared field. Profiles for different chats merge independently; conflicting edits or deletion versus editing of one chat require a choice. Missing Windows layouts stay saved and are skipped during switching, with global-cycle fallback if none are installed. Keep this PC's layouts preserves the global cycle and shortcuts while chat profiles still synchronize. Downloading a pre-v0.6.53 profile preserves existing chat profiles. Chat matching is unchanged; Viber profiles remain inactive.
|
||||||
export/import. Available from v0.6.48.
|
|
||||||
|
|
||||||
**Українською.** Налаштування розділено на вкладки General, Languages, Shortcuts, Clipboard
|
**Українською.** Налаштування розділено на вкладки General, Languages, Shortcuts, Clipboard
|
||||||
і Tools. Перехід між ними зберігає незбережені зміни; Save, Cancel, Import і Export доступні
|
і Tools. Перехід між ними зберігає незбережені зміни; Save, Cancel, Import і Export доступні
|
||||||
@@ -326,6 +369,12 @@ Recognition uses Chrome accessibility: the address bar and active document must
|
|||||||
|
|
||||||
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
|
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
|
||||||
|
|
||||||
|
### Desktop chat snippet selection — v0.6.55
|
||||||
|
|
||||||
|
ChatGPT and Codex desktop editors use paced selection for snippet triggers. If only a verified suffix is selected, KSwitcher extends the selection and verifies the full trigger before replacement. Unrelated text or changed focus cancels the operation. Triggers remain recognized across keyboard layouts. One snippet can target multiple applications: add each with **Add app**, or enter comma-separated process names. These existing application scopes also synchronize.
|
||||||
|
|
||||||
|
**Українською.** У настільних ChatGPT і Codex тригер виділяється з паузами між натисканнями. Якщо виділилася лише перевірена кінцева частина, KSwitcher доповнює виділення й перевіряє весь тригер перед заміною. Інший текст або зміна фокусу скасовують операцію. Тригери розпізнаються в різних розкладках. Один сніпет можна прив’язати до кількох програм через **Add app** або назви процесів через кому; ці прив’язки вже підтримують синхронізацію.
|
||||||
|
|
||||||
### Application-specific snippets — v0.6.51
|
### Application-specific snippets — v0.6.51
|
||||||
|
|
||||||
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
|
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
|
||||||
|
|||||||
Binary file not shown.
+11
-8
@@ -1,22 +1,25 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.51 — 2026-11-06
|
## v0.6.57 — 2026-11-06
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Add Telegram Web K chat language profiles in Google Chrome. Open a chat at `https://web.telegram.org/k/`, then **Settings → Languages → Languages for a chat...** and select **Telegram Web: name**. Choose languages, then **Use languages → Save**. Applies to Alt+Shift, Win+Space, reverse Win+Shift+Space and Next layout.
|
- Settings Save merges independent edits with newer resident settings. Same-field conflicts offer a choice; declining keeps the window and draft open.
|
||||||
- Read the trusted Telegram address and chat header through Chrome accessibility. Refresh on foreground/focus and address/header events, without OCR, a polling timer or a browser extension. Web profiles are separate from Telegram Desktop profiles; identical chat names across accounts/browser profiles share a web profile. Web A and other browsers are not advertised as supported. Telegram Desktop and Teams remain unchanged; Viber OCR remains disabled.
|
- Multiple paired PCs on a draggable monitor grid; mouse and keyboard follow neighboring edges, including A → B → C and back. Connections reconnect independently.
|
||||||
- Add application-specific snippets. In **Tools → Snippets**, enable **Only in these applications**, enter comma-separated executable names or select an open application and choose **Add app**. Unchecked means everywhere. Matching ignores case and the optional `.exe` suffix. Chrome scope applies to the whole browser.
|
- New text, formatted text and screenshots synchronize to all online paired PCs: history and active clipboard. Newer local copies are preserved during incoming transfers.
|
||||||
- Prefer an application-specific snippet over a global snippet with the same trigger. Allow the same trigger in disjoint application scopes and reject overlapping scopes. Keep existing snippets global. Preserve scopes in manual import/export and synchronization, with scope-aware merge conflicts. Validate the target application before insertion.
|
- Clipboard history has a persistent Computers checkbox filter and source labels. Lossless PNG transport avoids duplicate image payloads.
|
||||||
- Center **Add app** against its application selector.
|
- LAN modifier chords, DPI-scaled motion, cursor parking/return, automatic conversion ownership and queued wheel reversal fixes. Local shortcut latches reset on ownership changes.
|
||||||
|
- Stable paired status, pairing/input button states, persistent selection and a fixed-size LAN window with scrolling where needed.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Passed 674 chat-profile/snippet UI checks, 568 input checks (including app-specific precedence and global fallback), 108 sync checks, 332 native checks, complete dictionary equivalence (140,939 words), wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress checks. Inspected the snippet editor at 250% scaling. Live Chrome checks read a Telegram chat name directly and observed cache activation/invalidation. Release binaries, package manifest, published assets and update selection are checked during publication.
|
Passed: 5 settings-save merge checks, 203 LAN input/three-peer TCP, 289 clipboard transfer, 43 pairing, 828 settings/chat UI, 570 input, 341 native and 126 sync checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Three physical PCs have not been verified together. LAN remains experimental; follow LAN-PREVIEW.md.
|
||||||
|
|
||||||
|
**Update ALL LAN participants to v0.6.57. Input protocol v2 cannot connect to older one-pair builds.** Pair every physical input source with each destination. Configure the same physical arrangement on each PC; layouts are not automatically synchronized. File transfer, offline clipboard backfill, cross-PC dragging and secure desktops remain unsupported.
|
||||||
|
|
||||||
## Upgrade and compatibility
|
## 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.
|
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 remain local, included in manual export/import and excluded from cloud synchronization.
|
**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.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0ee6426bf1715ee3156a1f8dbb2d213a9e09a49b2fcfec553d6049dc6a868877 KSwitcher-win-x64.zip
|
f3ec4f7a62b4479929d94d6d6f7f68edb8b4a47b84edac7258afb64dbe07aef2 KSwitcher-win-x64.zip
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"version": "0.6.51",
|
"version": "0.6.57",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"size": 1847296,
|
"size": 2106368,
|
||||||
"path": "KSwitcher.exe",
|
"path": "KSwitcher.exe",
|
||||||
"sha256": "c40f90944169ec6f1b92803ccaec734573bf798939f13ab3553b982b709e479c"
|
"sha256": "3b7315e1545f70739fd1a7c099ddeab28362b323521d93879fa43fdd76133f8e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": 1716224,
|
"size": 2003456,
|
||||||
"path": "KSwitcher.Config.exe",
|
"path": "KSwitcher.Config.exe",
|
||||||
"sha256": "8e8a5fee9be8f318b78516b82415c6b098ac67f7c374f4667ef271576a77f9fa"
|
"sha256": "a819ec42030990725326730396680388cbcb870dd00c0a97856cb1615f9db27b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": 2897408,
|
"size": 3151360,
|
||||||
"path": "KSwitcher.Screenshot.exe",
|
"path": "KSwitcher.Screenshot.exe",
|
||||||
"sha256": "bb5ffc3a26955c752d593ba06f69476985d9902f2ed0345fe3efe9bfc2eb63c8"
|
"sha256": "8473697245c2c961496135eaaff6de9e9a19e917f2fd7299a16068210223d396"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": 4401,
|
"size": 4401,
|
||||||
@@ -28,9 +28,14 @@
|
|||||||
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
|
"sha256": "61e08da79c44061654015ce6b0c4061c23ee1dd18237fbf09e9bfda3c0ea2831"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": 74601,
|
"size": 79896,
|
||||||
"path": "README.md",
|
"path": "README.md",
|
||||||
"sha256": "818f81e6c4bfaa6b014ac50351ec22f68ee6eb3c094ad5ecb7bac98d605e0918"
|
"sha256": "8b4b1e905a2f0a39d532e61136ec7eca473492a39a538eb073ed829307adefae"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size": 7921,
|
||||||
|
"path": "LAN-PREVIEW.md",
|
||||||
|
"sha256": "2467b86bbad7d7dce1e0c65c8c0faf2320e4804e90a85784a058afa357127b6b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size": 732485,
|
"size": 732485,
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -
|
|||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
try {
|
||||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||||
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-lws2_32','-lbcrypt','-liphlpapi','-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
|
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
|
||||||
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
|
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
|
||||||
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
|
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
|
||||||
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
|
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
|
||||||
if ($LASTEXITCODE) { throw 'StayHere build failed' }
|
if ($LASTEXITCODE) { throw 'StayHere build failed' }
|
||||||
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
|
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
foreach ($name in @('lan_channel','lan_input','lan','lan_pairing','core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
}
|
}
|
||||||
@@ -35,18 +35,34 @@ try {
|
|||||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
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
|
||||||
|
if ($LASTEXITCODE) { throw 'LAN input test build failed' }
|
||||||
|
& .\build\KSwitcherLanInputTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'LAN input tests failed' }
|
||||||
|
& $compiler @common -Werror tests/lan.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'LAN test build failed' }
|
||||||
|
& .\build\KSwitcherLanTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'LAN pairing tests failed' }
|
||||||
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
|
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
|
||||||
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
|
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
|
||||||
@@ -55,11 +71,11 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
|
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
|
||||||
& .\build\KSwitcherUtilitiesTests.exe
|
& .\build\KSwitcherUtilitiesTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
|
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
|
||||||
& $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
& $compiler @common tests/dictionary.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
|
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
|
||||||
& .\build\KSwitcherDictionaryTests.exe
|
& .\build\KSwitcherDictionaryTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
|
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
|
||||||
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
& $compiler @common tests/sync.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||||
& .\build\KSwitcherSyncTests.exe
|
& .\build\KSwitcherSyncTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
||||||
@@ -67,19 +83,19 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||||
& .\build\KSwitcherScrollTests.exe
|
& .\build\KSwitcherScrollTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
& $compiler @common tests/tests.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
& $compiler @common tests/input.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
& $compiler @common tests/chat_profiles.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
|
& $compiler @common tests/chat_profiles.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Chat profile test build failed' }
|
if ($LASTEXITCODE) { throw 'Chat profile test build failed' }
|
||||||
& .\build\KSwitcherChatProfileTests.exe
|
& .\build\KSwitcherChatProfileTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Chat profile tests failed' }
|
if ($LASTEXITCODE) { throw 'Chat profile tests failed' }
|
||||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
& $compiler @common tests/screenshot_ui.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||||
& .\build\KSwitcherScreenshotUITests.exe
|
& .\build\KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||||
@@ -87,7 +103,7 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||||
try {
|
try {
|
||||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
& $compiler @common tests/screenshot_registration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
||||||
& $registrationTest
|
& $registrationTest
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
||||||
@@ -99,7 +115,7 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||||
}
|
}
|
||||||
if ($Far) {
|
if ($Far) {
|
||||||
& $compiler @common tests/far_snippets.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
|
& $compiler @common tests/far_snippets.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Far snippet test build failed' }
|
if ($LASTEXITCODE) { throw 'Far snippet test build failed' }
|
||||||
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
|
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
|
||||||
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
|
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
|
||||||
@@ -111,28 +127,28 @@ try {
|
|||||||
Copy-Item $executablePath,(Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe') -Destination $historyFixture
|
Copy-Item $executablePath,(Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe') -Destination $historyFixture
|
||||||
Set-Content (Join-Path $historyFixture '.local-dev') 'Isolated history integration test'
|
Set-Content (Join-Path $historyFixture '.local-dev') 'Isolated history integration test'
|
||||||
$historyTest = Join-Path $historyFixture 'KSwitcherHistoryHostTests.exe'
|
$historyTest = Join-Path $historyFixture 'KSwitcherHistoryHostTests.exe'
|
||||||
& $compiler @common tests/history_host.cpp build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
|
& $compiler @common tests/history_host.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
|
||||||
if ($LASTEXITCODE) { throw 'History host test build failed' }
|
if ($LASTEXITCODE) { throw 'History host test build failed' }
|
||||||
& $historyTest
|
& $historyTest
|
||||||
if ($LASTEXITCODE) { throw 'History host tests failed' }
|
if ($LASTEXITCODE) { throw 'History host tests failed' }
|
||||||
$snapshotTest = Join-Path (Split-Path $executablePath) 'KSwitcherClipboardSnapshotTests.exe'
|
$snapshotTest = Join-Path (Split-Path $executablePath) 'KSwitcherClipboardSnapshotTests.exe'
|
||||||
& $compiler @common tests/clipboard_snapshot.cpp build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
|
& $compiler @common tests/clipboard_snapshot.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
|
||||||
if ($LASTEXITCODE) { throw 'Clipboard snapshot test build failed' }
|
if ($LASTEXITCODE) { throw 'Clipboard snapshot test build failed' }
|
||||||
& $snapshotTest
|
& $snapshotTest
|
||||||
if ($LASTEXITCODE) { throw 'Clipboard snapshot tests failed' }
|
if ($LASTEXITCODE) { throw 'Clipboard snapshot tests failed' }
|
||||||
$captureTest = Join-Path (Split-Path $executablePath) 'KSwitcherImageCaptureTests.exe'
|
$captureTest = Join-Path (Split-Path $executablePath) 'KSwitcherImageCaptureTests.exe'
|
||||||
& $compiler @common tests/image_capture.cpp build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
|
& $compiler @common tests/image_capture.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
|
||||||
if ($LASTEXITCODE) { throw 'Image capture test build failed' }
|
if ($LASTEXITCODE) { throw 'Image capture test build failed' }
|
||||||
& $captureTest
|
& $captureTest
|
||||||
if ($LASTEXITCODE) { throw 'Image capture tests failed' }
|
if ($LASTEXITCODE) { throw 'Image capture tests failed' }
|
||||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
& $compiler @common tests/integration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
}
|
}
|
||||||
if ($Ocr) {
|
if ($Ocr) {
|
||||||
$ocrTest = Join-Path (Split-Path $executablePath) 'KSwitcherOcrTests.exe'
|
$ocrTest = Join-Path (Split-Path $executablePath) 'KSwitcherOcrTests.exe'
|
||||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest
|
& $compiler @common tests/ocr.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest
|
||||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||||
& $ocrTest
|
& $ocrTest
|
||||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||||
|
|||||||
+2
-1
@@ -9,13 +9,14 @@ Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe'
|
|||||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'LAN-PREVIEW.md') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||||
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
|
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
|
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
|
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
|
||||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||||
$entries = foreach ($path in $files) {
|
$entries = foreach ($path in $files) {
|
||||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
||||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||||
|
|||||||
+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,51,0
|
FILEVERSION 0,6,57,0
|
||||||
PRODUCTVERSION 0,6,51,0
|
PRODUCTVERSION 0,6,57,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.51"
|
VALUE "FileVersion", "0.6.57"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+130
-6
@@ -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"
|
||||||
@@ -559,6 +560,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
|
|||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
lanService.reset();
|
||||||
stopBrowserChatMonitor();
|
stopBrowserChatMonitor();
|
||||||
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
|
if (auto host = historyController(historyProcess)) PostMessageW(host, WM_CLOSE, 0, 0);
|
||||||
if (historyProcess) CloseHandle(historyProcess);
|
if (historyProcess) CloseHandle(historyProcess);
|
||||||
@@ -721,6 +723,15 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Bytes App::lanCommand(const Bytes &command)
|
||||||
|
{
|
||||||
|
if (!lanService) lanService = std::make_unique<LanService>(data,window);
|
||||||
|
auto reply = lanService->command(command);
|
||||||
|
if (lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
||||||
|
else KillTimer(window, 7);
|
||||||
|
if (lanService->takeInputChange()) resetLanInput();
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
void App::applySettings(const Settings &requested)
|
void App::applySettings(const Settings &requested)
|
||||||
{
|
{
|
||||||
Settings s = requested;
|
Settings s = requested;
|
||||||
@@ -830,6 +841,14 @@ 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 = {};
|
||||||
|
resetInput();
|
||||||
|
}
|
||||||
void App::resetInput()
|
void App::resetInput()
|
||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
@@ -1118,6 +1137,13 @@ void App::replayTyping()
|
|||||||
}
|
}
|
||||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
|
if (code == HC_ACTION && current && current->lanService &&
|
||||||
|
(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);
|
||||||
|
if (current->lanService->takeInputChange()) current->resetLanInput();
|
||||||
|
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
|
||||||
// downstream hooks route the event before tracking it or starting a snippet.
|
// downstream hooks route the event before tracking it or starting a snippet.
|
||||||
// Call the chain exactly once; a local event can still be consumed below.
|
// Call the chain exactly once; a local event can still be consumed below.
|
||||||
@@ -1147,6 +1173,13 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
if (code == HC_ACTION && current)
|
if (code == HC_ACTION && current)
|
||||||
{
|
{
|
||||||
|
if (current->lanService)
|
||||||
|
{
|
||||||
|
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
|
||||||
|
!current->busy && !current->suspended && !current->hotkeyCapture);
|
||||||
|
if (current->lanService->takeInputChange()) current->resetLanInput();
|
||||||
|
if (consumed) return 1;
|
||||||
|
}
|
||||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||||
// range just like local clicks, including while a transaction is pending.
|
// 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 (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
||||||
@@ -1490,11 +1523,17 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
Run tail = recent;
|
Run tail = recent;
|
||||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||||
if (!tx.select(sn.trigger.size()))
|
|
||||||
return;
|
|
||||||
autoSelected = true;
|
autoSelected = true;
|
||||||
reason = L"snippet-verification-mismatch";
|
reason = L"snippet-verification-mismatch";
|
||||||
text = tx.copy();
|
if (farSnippet)
|
||||||
|
{
|
||||||
|
if (!tx.select(sn.trigger.size())) return;
|
||||||
|
text = tx.copy();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
text = tx.copyRange(sn.trigger.size(), tail.keys,
|
||||||
|
[&](const std::wstring &candidate) { return tail.match(candidate, available, physicalLayout()) >= 0; },
|
||||||
|
[&](const std::wstring &candidate) { return tail.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
||||||
if (tail.match(text, available, physicalLayout()) < 0)
|
if (tail.match(text, available, physicalLayout()) < 0)
|
||||||
return;
|
return;
|
||||||
automatic = true;
|
automatic = true;
|
||||||
@@ -1775,15 +1814,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
|
||||||
{
|
{
|
||||||
@@ -1791,19 +1871,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);
|
||||||
}
|
}
|
||||||
@@ -1818,6 +1907,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);
|
||||||
}
|
}
|
||||||
@@ -2115,6 +2205,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 WM_POWERBROADCAST:
|
||||||
|
if (wp == PBT_APMSUSPEND && app->lanService)
|
||||||
|
{
|
||||||
|
app->lanService->cancelInput();
|
||||||
|
app->resetInput();
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
case ReplayTypingMessage:
|
case ReplayTypingMessage:
|
||||||
app->replayTyping();
|
app->replayTyping();
|
||||||
return 0;
|
return 0;
|
||||||
@@ -2185,7 +2282,29 @@ 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 == 7)
|
||||||
|
{
|
||||||
|
if (app->lanService)
|
||||||
|
{
|
||||||
|
// suspended also guards clipboard/text transactions (correction,
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
if (wp == 6)
|
if (wp == 6)
|
||||||
{
|
{
|
||||||
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
|
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
|
||||||
@@ -2300,6 +2419,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)));
|
||||||
@@ -2326,6 +2446,10 @@ int App::execute()
|
|||||||
scheduleHistoryOcr(settings);
|
scheduleHistoryOcr(settings);
|
||||||
addTray();
|
addTray();
|
||||||
if (!smoke) startBrowserChatMonitor();
|
if (!smoke) startBrowserChatMonitor();
|
||||||
|
if (!smoke && std::filesystem::exists(data / L"lan-pairing.bin"))
|
||||||
|
try { lanService = std::make_unique<LanService>(data,window); }
|
||||||
|
catch (const std::exception &e) { report(L"LAN module: " + wide(e.what())); }
|
||||||
|
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);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "update.h"
|
#include "update.h"
|
||||||
#include "sync.h"
|
#include "sync.h"
|
||||||
#include "chat_context.h"
|
#include "chat_context.h"
|
||||||
|
#include "lan.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -39,6 +40,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;
|
||||||
@@ -47,7 +49,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{};
|
||||||
@@ -69,6 +71,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 &);
|
||||||
@@ -89,9 +93,11 @@ 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();
|
||||||
|
Bytes lanCommand(const Bytes &);
|
||||||
void pollSync();
|
void pollSync();
|
||||||
static App *current;
|
static App *current;
|
||||||
|
|
||||||
@@ -99,6 +105,7 @@ class App
|
|||||||
friend struct InputRegressionAccess;
|
friend struct InputRegressionAccess;
|
||||||
void releaseIdleMemory(ULONGLONG now);
|
void releaseIdleMemory(ULONGLONG now);
|
||||||
HHOOK keyboardHook{}, mouseHook{};
|
HHOOK keyboardHook{}, mouseHook{};
|
||||||
|
std::unique_ptr<LanService> lanService;
|
||||||
HANDLE historyProcess{};
|
HANDLE historyProcess{};
|
||||||
std::mutex jobsMutex;
|
std::mutex jobsMutex;
|
||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
|
|||||||
+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.
|
||||||
}
|
}
|
||||||
+11
-1
@@ -102,7 +102,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()
|
||||||
{
|
{
|
||||||
@@ -112,6 +121,7 @@ void App::reloadLayouts()
|
|||||||
}
|
}
|
||||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
||||||
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
|
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
|
||||||
|
Bytes App::lanCommand(const Bytes &bytes) { return request(*this, ConfigLan, bytes); }
|
||||||
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||||
{
|
{
|
||||||
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
|
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
|
||||||
|
|||||||
+11
-3
@@ -1,4 +1,5 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
|
#include "settings_merge.h"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,13 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
{
|
{
|
||||||
switch (message.dwData)
|
switch (message.dwData)
|
||||||
{
|
{
|
||||||
|
case ConfigLan:
|
||||||
|
{
|
||||||
|
if (!message.cbData || message.cbData > 4096) throw std::runtime_error("Invalid LAN request.");
|
||||||
|
auto first = static_cast<const unsigned char *>(message.lpData);
|
||||||
|
reply = app.lanCommand(Bytes(first, first + message.cbData));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case ConfigSyncConflicts:
|
case ConfigSyncConflicts:
|
||||||
reply = app.syncConflictData();
|
reply = app.syncConflictData();
|
||||||
break;
|
break;
|
||||||
@@ -60,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.");
|
||||||
@@ -67,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
|||||||
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, ConfigEnd = ConfigRead + 11;
|
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);
|
||||||
|
|||||||
+401
@@ -0,0 +1,401 @@
|
|||||||
|
#include <winsock2.h>
|
||||||
|
#include <ws2tcpip.h>
|
||||||
|
#include <iphlpapi.h>
|
||||||
|
#include "lan.h"
|
||||||
|
#include "lan_pairing.h"
|
||||||
|
#include "lan_mesh.h"
|
||||||
|
#include "lan_clipboard.h"
|
||||||
|
#include "lan_layout.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
using lan::Json;
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
Bytes encoded(const Json &j) { auto s=j.dump(); return Bytes(s.begin(),s.end()); }
|
||||||
|
bool localAddress(ULONG network)
|
||||||
|
{
|
||||||
|
auto ip=ntohl(network);
|
||||||
|
return (ip>>24)==10 || (ip>>20)==0xac1 || (ip>>16)==0xc0a8 || (ip>>16)==0xa9fe;
|
||||||
|
}
|
||||||
|
std::string hostName()
|
||||||
|
{
|
||||||
|
wchar_t name[256]{}; DWORD size=256;
|
||||||
|
return GetComputerNameW(name,&size) ? utf8(std::wstring(name,size)) : "KSwitcher";
|
||||||
|
}
|
||||||
|
std::vector<ULONG> broadcasts()
|
||||||
|
{
|
||||||
|
ULONG size=16384; Bytes data(size);
|
||||||
|
auto flags=GAA_FLAG_SKIP_ANYCAST|GAA_FLAG_SKIP_MULTICAST|GAA_FLAG_SKIP_DNS_SERVER;
|
||||||
|
auto result=GetAdaptersAddresses(AF_INET,flags,nullptr,(IP_ADAPTER_ADDRESSES*)data.data(),&size);
|
||||||
|
if(result==ERROR_BUFFER_OVERFLOW) { data.resize(size); result=GetAdaptersAddresses(AF_INET,flags,nullptr,(IP_ADAPTER_ADDRESSES*)data.data(),&size); }
|
||||||
|
std::vector<ULONG> out;
|
||||||
|
if(result!=NO_ERROR) return out;
|
||||||
|
for(auto a=(IP_ADAPTER_ADDRESSES*)data.data();a;a=a->Next)
|
||||||
|
if(a->OperStatus==IfOperStatusUp)
|
||||||
|
for(auto u=a->FirstUnicastAddress;u;u=u->Next)
|
||||||
|
if(u->Address.lpSockaddr->sa_family==AF_INET && u->OnLinkPrefixLength>0 && u->OnLinkPrefixLength<31)
|
||||||
|
{
|
||||||
|
auto ip=((sockaddr_in*)u->Address.lpSockaddr)->sin_addr.s_addr;
|
||||||
|
if(localAddress(ip)) out.push_back(htonl(ntohl(ip) | (0xffffffffu >> u->OnLinkPrefixLength)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct LanService::Impl
|
||||||
|
{
|
||||||
|
struct Peer { std::string name,epoch; sockaddr_in address{}; uint64_t seen{},verified{}; };
|
||||||
|
std::filesystem::path path;
|
||||||
|
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
|
||||||
|
std::map<std::string,Peer> peers;
|
||||||
|
Json trusted=Json::object(),positions=Json::object();
|
||||||
|
std::unique_ptr<lan::Pairing> pending;
|
||||||
|
std::unique_ptr<lan::ClipboardExchange> clipboard;
|
||||||
|
bool enabled{};
|
||||||
|
std::atomic<bool> stopping{false};
|
||||||
|
std::mutex mutex;
|
||||||
|
std::thread worker;
|
||||||
|
bool shareInput{};
|
||||||
|
std::string inputPeer;
|
||||||
|
int inputEdge=1;
|
||||||
|
std::atomic<bool> inputConnected{false},abortInput{false};
|
||||||
|
std::atomic<uint64_t> inputEpoch{0};
|
||||||
|
std::atomic<uint64_t> inputConfiguration{0};
|
||||||
|
std::atomic<bool> layoutChanged{false};
|
||||||
|
uint64_t observedEpoch{},observedRevision{},observedConfiguration{};
|
||||||
|
std::mutex inputMutex;
|
||||||
|
std::vector<lan::RoutedInput> inputOut,inputIn;
|
||||||
|
std::map<std::string,uint64_t> inputReady;
|
||||||
|
std::unique_ptr<lan::InputMesh> router;
|
||||||
|
std::string inputStatus="Input sharing disabled.";
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
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);
|
||||||
|
if(inputEdge<0||inputEdge>3||(shareInput&&trusted.empty()))throw std::runtime_error("Invalid input sharing configuration.");
|
||||||
|
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
|
||||||
|
for(auto &[key,entry]:trusted.items())
|
||||||
|
if(!lan::identifier(key)||key==id||entry.at("key").get<std::string>().size()!=64||
|
||||||
|
!entry.at("name").is_string()||entry.at("name").get<std::string>().size()>128)
|
||||||
|
throw std::runtime_error("Invalid paired computer key.");
|
||||||
|
}
|
||||||
|
else id=lan::randomHex(16);
|
||||||
|
lan::fillPositions(positions,trusted,inputPeer,inputEdge);
|
||||||
|
clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||||
|
configureRouter();
|
||||||
|
if(enabled) start();
|
||||||
|
}
|
||||||
|
~Impl() { stop(); }
|
||||||
|
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
|
||||||
|
{"shareInput",shareInput},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||||
|
bool queue(const std::string &peer,lan::InputFrame frame)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(inputMutex);
|
||||||
|
if(!inputReady.contains(peer))return false;
|
||||||
|
if(inputOut.size()>=512){abortInput=true;return false;}
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
if(router)router->cancel(GetTickCount64());
|
||||||
|
router.reset();++inputConfiguration;
|
||||||
|
if(enabled&&shareInput){
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
bool takeChange()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
inputNotification=false;
|
||||||
|
if(layoutChanged.exchange(false)){std::lock_guard lock(mutex);configureRouter();}
|
||||||
|
if(!router)return false;
|
||||||
|
auto epoch=inputEpoch.load();if(epoch!=observedEpoch){router->cancel(GetTickCount64());observedEpoch=epoch;}
|
||||||
|
std::vector<lan::RoutedInput> incoming;std::map<std::string,uint64_t> ready;
|
||||||
|
{std::lock_guard lock(inputMutex);incoming.swap(inputIn);ready=inputReady;}
|
||||||
|
router->tick(ready,GetTickCount64());
|
||||||
|
for(auto &frame:incoming)if(ready.contains(frame.peer)&&ready.at(frame.peer)==frame.epoch)
|
||||||
|
router->receive(frame.peer,frame.frame,GetTickCount64(),allowEnter);
|
||||||
|
return takeChange();
|
||||||
|
}
|
||||||
|
void start()
|
||||||
|
{
|
||||||
|
stopping=false; epoch=lan::randomHex(16); status="Searching for KSwitcher computers...";
|
||||||
|
worker=std::thread([this]{ run(); });
|
||||||
|
}
|
||||||
|
void stop()
|
||||||
|
{
|
||||||
|
if(router)router->cancel(GetTickCount64());
|
||||||
|
stopping=true;
|
||||||
|
if(worker.joinable()) worker.join();
|
||||||
|
inputConnected=false;++inputEpoch;
|
||||||
|
{std::lock_guard lock(inputMutex);inputOut.clear();inputIn.clear();inputReady.clear();}
|
||||||
|
std::lock_guard lock(mutex); pending.reset(); peers.clear();clipboard=std::make_unique<lan::ClipboardExchange>(id);
|
||||||
|
}
|
||||||
|
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
|
||||||
|
{
|
||||||
|
auto bytes=j.dump(); if(bytes.size()>2048) return;
|
||||||
|
sendto(socket,bytes.data(),int(bytes.size()),0,(const sockaddr*)&address,sizeof(address));
|
||||||
|
}
|
||||||
|
std::string heartbeat(const std::string &from,const std::string &to,const std::string &a,const std::string &b)
|
||||||
|
{ return "KSwitcher LAN v1|presence|"+from+"|"+to+"|"+a+"|"+b; }
|
||||||
|
void receive(SOCKET socket,const sockaddr_in &address,const Json &j,uint64_t now)
|
||||||
|
{
|
||||||
|
if(j.value("app","")!="KSwitcher-LAN" || j.value("version",0)!=1) return;
|
||||||
|
auto other=j.value("id",""); if(!lan::identifier(other)||other==id) return;
|
||||||
|
auto type=j.value("type","");
|
||||||
|
if(type=="hello")
|
||||||
|
{
|
||||||
|
auto display=j.value("name",""); auto session=j.value("epoch","");
|
||||||
|
if(display.empty()||display.size()>128||!lan::identifier(session)||
|
||||||
|
std::any_of(display.begin(),display.end(),[](unsigned char c){return c<32||c==127;})) return;
|
||||||
|
// Discovery is untrusted and never grants input access.
|
||||||
|
if(!peers.contains(other) && peers.size()>=64) return;
|
||||||
|
auto &peer=peers[other];
|
||||||
|
// 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;
|
||||||
|
if(trusted.contains(other))
|
||||||
|
{
|
||||||
|
auto response=lan::envelope("presence",id); response["to"]=other; response["epoch"]=epoch;
|
||||||
|
response["proof"]=lan::mac(trusted[other].at("key"),heartbeat(id,other,epoch,session));
|
||||||
|
send(socket,address,response);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!peers.contains(other)||j.value("to","")!=id) return;
|
||||||
|
auto &peer=peers.at(other);
|
||||||
|
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) return;
|
||||||
|
if(type=="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 &&
|
||||||
|
lan::equal(j.value("proof",""),lan::mac(trusted[other].at("key"),heartbeat(other,id,peer.epoch,epoch))))
|
||||||
|
{ peer.verified=now; return; }
|
||||||
|
if(pending && pending->peer==other)
|
||||||
|
{
|
||||||
|
for(const auto &response:pending->receive(j,now)) send(socket,address,response);
|
||||||
|
if(pending->confirmed && (!trusted.contains(other)||trusted[other].at("key")!=pending->derived))
|
||||||
|
{
|
||||||
|
auto previous=trusted;auto previousPositions=positions;
|
||||||
|
trusted[other]={{"name",peer.name},{"key",pending->derived}};
|
||||||
|
try {lan::fillPositions(positions,trusted,inputPeer,inputEdge);save();} catch(...) {trusted=std::move(previous);positions=std::move(previousPositions);throw;}
|
||||||
|
layoutChanged=true;peer.verified=now; status="Paired. Select a screen position and enable mouse and keyboard on both PCs.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void run() noexcept
|
||||||
|
{
|
||||||
|
WSADATA startup{}; SOCKET socket=INVALID_SOCKET; bool winsock=false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(WSAStartup(MAKEWORD(2,2),&startup)) throw std::runtime_error("Cannot start Windows networking.");
|
||||||
|
winsock=true; socket=::socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
|
||||||
|
BOOL yes=TRUE; u_long nonblock=1;
|
||||||
|
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))
|
||||||
|
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);
|
||||||
|
if(bind(socket,(sockaddr*)&bound,sizeof(bound))) throw std::runtime_error("UDP port 45883 is in use. Only one LAN-enabled KSwitcher may run per computer.");
|
||||||
|
uint64_t nextHello=0,nextOffer=0;
|
||||||
|
lan::InputHub hub(id);
|
||||||
|
while(!stopping)
|
||||||
|
{
|
||||||
|
auto now=GetTickCount64();bool clipboardActive=false;
|
||||||
|
std::vector<lan::InputEndpoint> endpoints;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
clipboard->poll(trusted,now,[&](const auto &target,const auto &packet){
|
||||||
|
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||||
|
});
|
||||||
|
clipboardActive=clipboard->active();
|
||||||
|
if(shareInput)for(auto &[peer,entry]:trusted.items())if(peers.contains(peer))
|
||||||
|
endpoints.push_back({peer,entry.at("key"),peers.at(peer).address.sin_addr.s_addr});
|
||||||
|
if(now>=nextHello)
|
||||||
|
{
|
||||||
|
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
|
||||||
|
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
|
||||||
|
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
|
||||||
|
nextHello=now+3000;
|
||||||
|
}
|
||||||
|
for(auto it=peers.begin();it!=peers.end();) {
|
||||||
|
if(now-it->second.seen>lan::PeerLifetime) it=peers.erase(it); else ++it;
|
||||||
|
}
|
||||||
|
if(pending && pending->expired(now)) {
|
||||||
|
status=pending->confirmed ? "Pair saved. Select a computer to manage it." : "Pairing expired. Enter the same code and click Pair on both computers.";
|
||||||
|
pending.reset();
|
||||||
|
}
|
||||||
|
if(pending && now>=nextOffer && peers.contains(pending->peer)) {
|
||||||
|
send(socket,peers.at(pending->peer).address,pending->offer()); nextOffer=now+1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<lan::RoutedInput> out,in;
|
||||||
|
{std::lock_guard lock(inputMutex);out.swap(inputOut);}
|
||||||
|
if(abortInput.exchange(false)){hub.disconnect();out.clear();}
|
||||||
|
hub.poll(endpoints,out,in,now);
|
||||||
|
auto readyPeers=hub.ready();inputConnected=!readyPeers.empty();
|
||||||
|
{std::lock_guard lock(inputMutex);inputReady=std::move(readyPeers);
|
||||||
|
for(auto &frame:in){
|
||||||
|
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);
|
||||||
|
if(tail.size()==1){inputIn.back().frame=tail.front();continue;}
|
||||||
|
}
|
||||||
|
inputIn.push_back(frame);
|
||||||
|
}
|
||||||
|
if(inputIn.size()>512){inputIn.clear();abortInput=true;}
|
||||||
|
}
|
||||||
|
if(!in.empty()&&inputWindow&&!inputNotification.exchange(true))
|
||||||
|
if(!PostMessageW(inputWindow,LanInputReadyMessage,0,0))inputNotification=false;
|
||||||
|
{std::lock_guard lock(mutex);inputStatus=hub.status();}
|
||||||
|
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,!endpoints.empty()||clipboardActive?5000:100000};
|
||||||
|
int ready=select(0,&read,nullptr,nullptr,&delay);
|
||||||
|
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
|
||||||
|
if(ready>0) for(int count=0;count<64;++count)
|
||||||
|
{
|
||||||
|
char buffer[2049]; sockaddr_in sender{}; int size=sizeof(sender);
|
||||||
|
int received=recvfrom(socket,buffer,sizeof(buffer),0,(sockaddr*)&sender,&size);
|
||||||
|
if(received<=0) break;
|
||||||
|
if(received>2048||sender.sin_port!=htons(lan::Port)||!localAddress(sender.sin_addr.s_addr)) continue;
|
||||||
|
try { auto j=Json::parse(buffer,buffer+received); std::lock_guard lock(mutex); receive(socket,sender,j,GetTickCount64()); }
|
||||||
|
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(const std::exception &e) { std::lock_guard lock(mutex); status=std::string("LAN stopped: ")+e.what(); }
|
||||||
|
inputConnected=false;++inputEpoch;
|
||||||
|
{std::lock_guard lock(mutex);if(shareInput)inputStatus="Input connection closed.";}
|
||||||
|
if(socket!=INVALID_SOCKET) closesocket(socket);
|
||||||
|
if(winsock) WSACleanup();
|
||||||
|
}
|
||||||
|
Bytes command(const Bytes &input)
|
||||||
|
{
|
||||||
|
auto j=Json::parse(input); auto action=j.value("action","status");
|
||||||
|
if(action=="enable")
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
bool previous=enabled; enabled=j.at("enabled").get<bool>();
|
||||||
|
try {save();} catch(...) {enabled=previous;if(enabled) start();throw;}
|
||||||
|
configureRouter();
|
||||||
|
if(enabled) start(); else status="Disabled. No discovery sockets or worker thread.";
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
{
|
||||||
|
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.empty()||(j.contains("peer")&&!trusted.contains(peer)))))throw std::runtime_error("Enable discovery and select a paired KSwitcher first.");
|
||||||
|
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;
|
||||||
|
try{save();}catch(...){shareInput=beforeActive;inputPeer=beforePeer;inputEdge=beforeEdge;positions=std::move(beforePositions);throw;}
|
||||||
|
configureRouter();inputStatus=active?"Connecting input channel...":"Input sharing disabled.";
|
||||||
|
}
|
||||||
|
if(action=="code") return encoded({{"code",lan::randomHex(16)}});
|
||||||
|
if(action=="cancel") { pending.reset(); status="Pairing cancelled."; }
|
||||||
|
if(action=="pair")
|
||||||
|
{
|
||||||
|
auto peer=j.at("peer").get<std::string>();
|
||||||
|
if(!enabled||!peers.contains(peer)) throw std::runtime_error("Select an available KSwitcher computer first.");
|
||||||
|
if(!trusted.contains(peer)&&trusted.size()>=64) throw std::runtime_error("Remove an old pair before adding another computer.");
|
||||||
|
pending=std::make_unique<lan::Pairing>(id,peer,j.at("code").get<std::string>(),GetTickCount64());
|
||||||
|
status="Waiting for the same code and Pair on the other KSwitcher (2 minutes)...";
|
||||||
|
}
|
||||||
|
if(action=="forget")
|
||||||
|
{
|
||||||
|
auto peer=j.at("peer").get<std::string>();auto oldTrusted=trusted;auto oldPositions=positions;bool oldSharing=shareInput;
|
||||||
|
trusted.erase(peer);positions.erase(peer);if(trusted.empty())shareInput=false;
|
||||||
|
try{save();}catch(...){trusted=std::move(oldTrusted);positions=std::move(oldPositions);shareInput=oldSharing;throw;}
|
||||||
|
if(pending&&pending->peer==peer)pending.reset();
|
||||||
|
configureRouter();
|
||||||
|
status="Pair removed on this computer.";
|
||||||
|
}
|
||||||
|
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
|
||||||
|
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
|
||||||
|
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"controlMode",router?int(router->mode()):0}};
|
||||||
|
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
|
||||||
|
auto now=GetTickCount64();
|
||||||
|
for(const auto &[peer,p]:peers)
|
||||||
|
{
|
||||||
|
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
|
||||||
|
result["peers"].push_back({{"id",peer},{"name",p.name},{"address",ip},{"paired",trusted.contains(peer)},
|
||||||
|
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true}});
|
||||||
|
}
|
||||||
|
for(const auto &[peer,p]:trusted.items()) if(!peers.contains(peer))
|
||||||
|
result["peers"].push_back({{"id",peer},{"name",p.at("name")},{"address",""},{"paired",true},{"verified",false},{"online",false}});
|
||||||
|
return encoded(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
LanService::LanService(const std::filesystem::path &path,HWND window) : impl(std::make_unique<Impl>(path,window)) {}
|
||||||
|
LanService::~LanService()=default;
|
||||||
|
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::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::mouse(UINT message,const MSLLHOOKSTRUCT &m,bool allowEnter){return impl->router&&impl->router->mouse(message,m,GetTickCount64(),allowEnter);}
|
||||||
|
bool LanService::takeInputChange(){return impl->takeChange();}
|
||||||
|
void LanService::cancelInput(){if(impl->router)impl->router->cancel(GetTickCount64());impl->abortInput=true;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
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.
|
||||||
|
class LanService
|
||||||
|
{
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl;
|
||||||
|
public:
|
||||||
|
explicit LanService(const std::filesystem::path &, HWND inputWindow=nullptr);
|
||||||
|
~LanService();
|
||||||
|
Bytes command(const Bytes &);
|
||||||
|
bool clipboardEnabled() const;
|
||||||
|
void publishClipboard(Bytes, const std::string &);
|
||||||
|
std::vector<LanClipboard> takeClipboard();
|
||||||
|
bool inputEnabled() const;
|
||||||
|
bool tickInput(bool allowEnter = true);
|
||||||
|
bool keyboard(const KBDLLHOOKSTRUCT &, bool down);
|
||||||
|
bool mouse(UINT, const MSLLHOOKSTRUCT &, bool allowEnter);
|
||||||
|
bool takeInputChange();
|
||||||
|
void cancelInput();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#include <winsock2.h>
|
||||||
|
#include <ws2tcpip.h>
|
||||||
|
#include "lan_channel.h"
|
||||||
|
#include <bcrypt.h>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void checked(NTSTATUS s){if(s<0) throw std::runtime_error("Input channel authentication failed.");}
|
||||||
|
void put32(Bytes &b,uint32_t v){for(int n=24;n>=0;n-=8)b.push_back(static_cast<unsigned char>(v>>n));}
|
||||||
|
uint32_t get32(const unsigned char *p){return (uint32_t(p[0])<<24)|(uint32_t(p[1])<<16)|(uint32_t(p[2])<<8)|p[3];}
|
||||||
|
Bytes keyBytes(const std::string &key)
|
||||||
|
{
|
||||||
|
if(key.size()!=64||key.find_first_not_of("0123456789abcdef")!=key.npos) throw std::runtime_error("Invalid input key.");
|
||||||
|
Bytes bytes;for(size_t i=0;i<key.size();i+=2)bytes.push_back(static_cast<unsigned char>(std::stoul(key.substr(i,2),nullptr,16)));return bytes;
|
||||||
|
}
|
||||||
|
bool nonblocking(SOCKET s){u_long value=1;BOOL yes=TRUE;return !ioctlsocket(s,FIONBIO,&value)&&!setsockopt(s,IPPROTO_TCP,TCP_NODELAY,(char*)&yes,sizeof(yes));}
|
||||||
|
void closeSocket(SOCKET &s){if(s!=INVALID_SOCKET){closesocket(s);s=INVALID_SOCKET;}}
|
||||||
|
}
|
||||||
|
struct InputCipher::Impl
|
||||||
|
{
|
||||||
|
BCRYPT_ALG_HANDLE alg{};BCRYPT_KEY_HANDLE key{};uint64_t sequence{};
|
||||||
|
explicit Impl(const std::string &secret)
|
||||||
|
{
|
||||||
|
auto bytes=keyBytes(secret);
|
||||||
|
try {
|
||||||
|
checked(BCryptOpenAlgorithmProvider(&alg,BCRYPT_AES_ALGORITHM,nullptr,0));
|
||||||
|
checked(BCryptSetProperty(alg,BCRYPT_CHAINING_MODE,(PUCHAR)BCRYPT_CHAIN_MODE_GCM,sizeof(BCRYPT_CHAIN_MODE_GCM),0));
|
||||||
|
checked(BCryptGenerateSymmetricKey(alg,&key,nullptr,0,bytes.data(),ULONG(bytes.size()),0));
|
||||||
|
} catch(...) {if(key)BCryptDestroyKey(key);if(alg)BCryptCloseAlgorithmProvider(alg,0);throw;}
|
||||||
|
SecureZeroMemory(bytes.data(),bytes.size());
|
||||||
|
}
|
||||||
|
~Impl(){if(key)BCryptDestroyKey(key);if(alg)BCryptCloseAlgorithmProvider(alg,0);}
|
||||||
|
Bytes crypt(Bytes payload,Bytes &tag,bool encrypt)
|
||||||
|
{
|
||||||
|
if(sequence==std::numeric_limits<uint64_t>::max())throw std::runtime_error("Input session exhausted.");
|
||||||
|
unsigned char nonce[12]{};for(int i=0;i<8;++i)nonce[11-i]=static_cast<unsigned char>(sequence>>(i*8));
|
||||||
|
BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info;BCRYPT_INIT_AUTH_MODE_INFO(info);
|
||||||
|
info.pbNonce=nonce;info.cbNonce=sizeof(nonce);info.pbTag=tag.data();info.cbTag=ULONG(tag.size());
|
||||||
|
// Protocol/direction are bound by key derivation; sequence and payload length by GCM.
|
||||||
|
Bytes result(payload.size());ULONG written{};
|
||||||
|
auto fn=encrypt?BCryptEncrypt:BCryptDecrypt;
|
||||||
|
checked(fn(key,payload.data(),ULONG(payload.size()),&info,nullptr,0,result.data(),ULONG(result.size()),&written,0));
|
||||||
|
if(written!=payload.size())throw std::runtime_error("Invalid input frame size.");
|
||||||
|
++sequence;return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
|
||||||
|
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 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 tag(16);auto result=impl->crypt(std::move(plain),tag,true);result.insert(result.end(),tag.begin(),tag.end());return result;
|
||||||
|
}
|
||||||
|
InputFrame InputCipher::decrypt(const Bytes &packet)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
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))};
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if(!identifier(from)||!identifier(to)||from==to||!identifier(a)||!identifier(b))throw std::runtime_error("Invalid session identity.");
|
||||||
|
return mac(secret,"KSwitcher input v1|"+from+"|"+to+"|"+a+"|"+b+"|"+std::to_string(edge));
|
||||||
|
}
|
||||||
|
struct InputChannel::Impl
|
||||||
|
{
|
||||||
|
std::string local,peer,secret,nonce,state="Waiting for the paired computer...";
|
||||||
|
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
|
||||||
|
bool connecting{},authenticated{},helloRead{},managed{};
|
||||||
|
uint64_t changed{},lastRead{},lastPing{},retry{};
|
||||||
|
Bytes incoming,outgoing;
|
||||||
|
std::unique_ptr<InputCipher> tx,rx;
|
||||||
|
Impl(std::string a,std::string b,std::string key,int side,uint16_t p,bool multiple):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p),managed(multiple){}
|
||||||
|
~Impl(){closeSocket(socket);closeSocket(listener);}
|
||||||
|
void reset(uint64_t now,const std::string &reason)
|
||||||
|
{
|
||||||
|
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;state=reason;
|
||||||
|
}
|
||||||
|
void begin(uint64_t now)
|
||||||
|
{
|
||||||
|
nonce=randomHex(16);lastRead=lastPing=now;
|
||||||
|
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());
|
||||||
|
state="Authenticating paired KSwitcher...";
|
||||||
|
}
|
||||||
|
void poll(uint32_t address,const std::vector<InputFrame> &frames,std::vector<InputFrame> &received,uint64_t now)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(socket==INVALID_SOCKET&&now<retry)return;
|
||||||
|
if(!managed && listener==INVALID_SOCKET && local>peer)
|
||||||
|
{
|
||||||
|
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.");}
|
||||||
|
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
|
||||||
|
if(bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,1)) {closeSocket(listener);throw std::runtime_error("TCP input port is unavailable.");}
|
||||||
|
}
|
||||||
|
if(socket==INVALID_SOCKET && address && now>=retry)
|
||||||
|
{
|
||||||
|
if(local<peer)
|
||||||
|
{
|
||||||
|
socket=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(socket==INVALID_SOCKET||!nonblocking(socket))throw std::runtime_error("Cannot open input connection.");
|
||||||
|
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);a.sin_addr.s_addr=address;
|
||||||
|
int result=connect(socket,(sockaddr*)&a,sizeof(a));
|
||||||
|
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
|
||||||
|
connecting=result!=0;begin(now);
|
||||||
|
if(connecting)state="Connecting to paired PC on TCP 45884...";
|
||||||
|
}
|
||||||
|
else if(!managed)
|
||||||
|
{
|
||||||
|
sockaddr_in a{};int length=sizeof(a);socket=accept(listener,(sockaddr*)&a,&length);
|
||||||
|
if(socket!=INVALID_SOCKET)
|
||||||
|
{
|
||||||
|
if(a.sin_addr.s_addr!=address){closeSocket(socket);return;}
|
||||||
|
if(!nonblocking(socket))throw std::runtime_error("Cannot configure input connection.");
|
||||||
|
begin(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(socket==INVALID_SOCKET)return;
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
int error=0;int size=sizeof(error);
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
for(auto &f:frames){auto bytes=tx->encrypt(f);outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());}
|
||||||
|
if(now-lastPing>=400){auto bytes=tx->encrypt({InputType::Ping});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());lastPing=now;}
|
||||||
|
}
|
||||||
|
if(outgoing.size()>32768)throw std::runtime_error("Input queue overflow; control returned locally.");
|
||||||
|
if(!outgoing.empty())
|
||||||
|
{
|
||||||
|
int count=::send(socket,(char*)outgoing.data(),int(outgoing.size()),0);
|
||||||
|
if(count>0)outgoing.erase(outgoing.begin(),outgoing.begin()+count);
|
||||||
|
else if(count==0||WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Input send failed.");
|
||||||
|
}
|
||||||
|
unsigned char data[4096];
|
||||||
|
for(int batch=0;batch<8;++batch)
|
||||||
|
{
|
||||||
|
int count=recv(socket,(char*)data,sizeof(data),0);
|
||||||
|
if(count==0)throw std::runtime_error("Other KSwitcher disconnected.");
|
||||||
|
if(count<0){if(WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Input receive failed.");break;}
|
||||||
|
incoming.insert(incoming.end(),data,data+count);
|
||||||
|
}
|
||||||
|
if(incoming.size()>32768)throw std::runtime_error("Input receive queue overflow.");
|
||||||
|
if(!helloRead && incoming.size()>=4)
|
||||||
|
{
|
||||||
|
size_t length=get32(incoming.data());if(length>512||length<32)throw std::runtime_error("Invalid input handshake.");
|
||||||
|
if(incoming.size()<length+4)return;
|
||||||
|
auto hello=Json::parse(incoming.begin()+4,incoming.begin()+4+length);
|
||||||
|
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.");
|
||||||
|
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","");
|
||||||
|
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,managed?-1:(edge^1)));
|
||||||
|
incoming.erase(incoming.begin(),incoming.begin()+4+length);helloRead=true;
|
||||||
|
auto bytes=tx->encrypt({InputType::Ready});outgoing.insert(outgoing.end(),bytes.begin(),bytes.end());
|
||||||
|
}
|
||||||
|
while(helloRead && incoming.size()>=36)
|
||||||
|
{
|
||||||
|
auto f=rx->decrypt(Bytes(incoming.begin(),incoming.begin()+36));incoming.erase(incoming.begin(),incoming.begin()+36);lastRead=now;
|
||||||
|
if(!authenticated){if(f.type!=InputType::Ready)throw std::runtime_error("Input received before authentication.");authenticated=true;state="Input connected. Move through the configured screen edge.";}
|
||||||
|
else if(f.type==InputType::Ready)throw std::runtime_error("Repeated input handshake.");
|
||||||
|
else if(f.type!=InputType::Ping)received.push_back(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,bool managed):impl(std::make_unique<Impl>(std::move(a),std::move(b),std::move(key),edge,port,managed)){}
|
||||||
|
InputChannel::~InputChannel()=default;
|
||||||
|
void InputChannel::poll(uint32_t ip,const std::vector<InputFrame>&out,std::vector<InputFrame>&in,uint64_t now){impl->poll(ip,out,in,now);}
|
||||||
|
bool InputChannel::adopt(uintptr_t accepted,uint64_t now)
|
||||||
|
{
|
||||||
|
if(!impl->managed||impl->socket!=INVALID_SOCKET||!nonblocking(SOCKET(accepted)))return false;
|
||||||
|
impl->connecting=false;impl->begin(now);impl->socket=SOCKET(accepted);return true;
|
||||||
|
}
|
||||||
|
bool InputChannel::ready()const{return impl->authenticated;}
|
||||||
|
uint64_t InputChannel::generation()const{return impl->changed;}
|
||||||
|
std::string InputChannel::status()const{return impl->state;}
|
||||||
|
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
|
||||||
|
struct InputHub::Impl
|
||||||
|
{
|
||||||
|
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;uint64_t generation{},epoch{}; };
|
||||||
|
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;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
for(auto it=links.begin();it!=links.end();){
|
||||||
|
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port&&e.address==it->second.endpoint.address;});
|
||||||
|
if(match==endpoints.end())it=links.erase(it);else ++it;
|
||||||
|
}
|
||||||
|
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
|
||||||
|
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);link.epoch=++epoch;links.emplace(e.id,std::move(link));
|
||||||
|
}
|
||||||
|
bool listenNeeded=std::any_of(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id<local;});
|
||||||
|
if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();}
|
||||||
|
if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){
|
||||||
|
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
|
||||||
|
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){
|
||||||
|
closeSocket(listener);retry=now+1500;error="TCP input port is unavailable.";
|
||||||
|
}else error.clear();
|
||||||
|
}
|
||||||
|
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(id<local&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&link->second.endpoint.address==it->address&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
|
||||||
|
}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);
|
||||||
|
link.channel->poll(link.endpoint.address,frames,received,now);
|
||||||
|
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())result[id]=l.epoch;return result;}
|
||||||
|
std::string InputHub::status()const{
|
||||||
|
auto count=ready().size();if(count)return "Input connected to "+std::to_string(count)+" computer(s). Move through a neighboring screen edge.";
|
||||||
|
if(!impl->error.empty())return impl->error;
|
||||||
|
for(auto &[id,l]:impl->links)if(!l.channel->status().empty())return l.channel->status();
|
||||||
|
return "Waiting for paired computers...";
|
||||||
|
}
|
||||||
|
void InputHub::disconnect(){for(auto &[id,l]:impl->links)l.channel->disconnect();}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "lan_pairing.h"
|
||||||
|
#include <memory>
|
||||||
|
#include <map>
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
|
||||||
|
struct InputFrame
|
||||||
|
{
|
||||||
|
InputType type{};
|
||||||
|
uint32_t token{};
|
||||||
|
int32_t a{},b{},c{};
|
||||||
|
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.
|
||||||
|
class InputCipher
|
||||||
|
{
|
||||||
|
struct Impl; std::unique_ptr<Impl> impl;
|
||||||
|
public:
|
||||||
|
explicit InputCipher(const std::string &hexKey);
|
||||||
|
~InputCipher();
|
||||||
|
Bytes encrypt(const InputFrame &);
|
||||||
|
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,
|
||||||
|
const std::string &fromNonce,const std::string &toNonce,int fromEdge=-1);
|
||||||
|
// Network-thread-only nonblocking TCP channel. No Windows input is injected here.
|
||||||
|
class InputChannel
|
||||||
|
{
|
||||||
|
struct Impl; std::unique_ptr<Impl> impl;
|
||||||
|
public:
|
||||||
|
InputChannel(std::string local,std::string peer,std::string secret,int edge,
|
||||||
|
uint16_t port=45884,bool managed=false);
|
||||||
|
~InputChannel();
|
||||||
|
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;
|
||||||
|
uint64_t generation() const;
|
||||||
|
std::string status() const;
|
||||||
|
void disconnect();
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,357 @@
|
|||||||
|
#include "lan_input.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <bit>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
ULONG_PTR remoteInputMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e49);}
|
||||||
|
ULONG_PTR cursorWarpMarker(){return inputMarker() ^ ULONG_PTR(0x4c414e57);}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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_PAINT)
|
||||||
|
{
|
||||||
|
PAINTSTRUCT paint{};HDC dc=BeginPaint(w,&paint);RECT r{};GetClientRect(w,&r);
|
||||||
|
FillRect(dc,&r,(HBRUSH)(COLOR_INFOBK+1));SetBkMode(dc,TRANSPARENT);SetTextColor(dc,GetSysColor(COLOR_INFOTEXT));
|
||||||
|
auto previous=SelectObject(dc,GetStockObject(DEFAULT_GUI_FONT));
|
||||||
|
DrawTextW(dc,L"KSwitcher: mouse + keyboard on the other PC\nCtrl+Alt+Esc returns control here",-1,&r,DT_CENTER|DT_WORDBREAK);
|
||||||
|
SelectObject(dc,previous);EndPaint(w,&paint);return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w,message,wp,lp);
|
||||||
|
}
|
||||||
|
struct ControlOverlay
|
||||||
|
{
|
||||||
|
HWND notice{},cover{};HCURSOR previous{};
|
||||||
|
~ControlOverlay(){hide();}
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
if(notice)return;
|
||||||
|
WNDCLASSW cls{};cls.hInstance=GetModuleHandleW(nullptr);cls.lpfnWndProc=controlOverlay;cls.lpszClassName=L"KSwitcher.RemoteControlOverlay";cls.hCursor=LoadCursorW(nullptr,IDC_ARROW);
|
||||||
|
RegisterClassW(&cls);
|
||||||
|
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);
|
||||||
|
CURSORINFO ci{sizeof(ci)};if(GetCursorInfo(&ci))previous=ci.hCursor;
|
||||||
|
int size=MulDiv(64,monitorDpi(cursor),96);
|
||||||
|
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);}
|
||||||
|
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(cover)SetCursor(parkedCursor());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bool ordinaryDesktop()
|
||||||
|
{
|
||||||
|
HDESK d=OpenInputDesktop(0,FALSE,DESKTOP_READOBJECTS);if(!d)return false;
|
||||||
|
wchar_t name[256]{};DWORD count{};bool ok=GetUserObjectInformationW(d,UOI_NAME,name,sizeof(name),&count)!=0;
|
||||||
|
CloseDesktop(d);return ok&&_wcsicmp(name,L"Default")==0;
|
||||||
|
}
|
||||||
|
INPUT button(unsigned index,bool down)
|
||||||
|
{
|
||||||
|
INPUT input{};input.type=INPUT_MOUSE;
|
||||||
|
static constexpr DWORD flags[5][2]={{MOUSEEVENTF_LEFTUP,MOUSEEVENTF_LEFTDOWN},{MOUSEEVENTF_RIGHTUP,MOUSEEVENTF_RIGHTDOWN},
|
||||||
|
{MOUSEEVENTF_MIDDLEUP,MOUSEEVENTF_MIDDLEDOWN},{MOUSEEVENTF_XUP,MOUSEEVENTF_XDOWN},{MOUSEEVENTF_XUP,MOUSEEVENTF_XDOWN}};
|
||||||
|
input.mi.dwFlags=flags[index][down];if(index>=3)input.mi.mouseData=index==3?XBUTTON1:XBUTTON2;return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputEnvironment windowsInputEnvironment()
|
||||||
|
{
|
||||||
|
auto overlay=std::make_shared<ControlOverlay>();
|
||||||
|
return {
|
||||||
|
[]{LONG x=GetSystemMetrics(SM_XVIRTUALSCREEN),y=GetSystemMetrics(SM_YVIRTUALSCREEN);return RECT{x,y,x+GetSystemMetrics(SM_CXVIRTUALSCREEN),y+GetSystemMetrics(SM_CYVIRTUALSCREEN)};},
|
||||||
|
[]{POINT p{};GetCursorPos(&p);return p;},
|
||||||
|
[](const INPUT &i){INPUT input=i;return SendInput(1,&input,sizeof(input))==1;},
|
||||||
|
ordinaryDesktop,
|
||||||
|
[]{for(int key=1;key<256;++key)if(GetAsyncKeyState(key)&0x8000)return true;return false;},
|
||||||
|
[]{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};
|
||||||
|
return p;},
|
||||||
|
[overlay](ControlMode mode){if(mode==ControlMode::Sending)overlay->show();else overlay->hide();},
|
||||||
|
monitorDpi
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bool atEdge(POINT p,RECT r,int side)
|
||||||
|
{return side==0?p.x<=r.left:side==1?p.x>=r.right-1:side==2?p.y<=r.top:p.y>=r.bottom-1;}
|
||||||
|
int edgePosition(POINT p,RECT r,int side)
|
||||||
|
{
|
||||||
|
auto length=side<2?r.bottom-r.top:r.right-r.left;
|
||||||
|
auto offset=side<2?p.y-r.top:p.x-r.left;
|
||||||
|
return int(std::clamp<int64_t>(int64_t(offset)*65535/std::max<LONG>(1,length-1),0,65535));
|
||||||
|
}
|
||||||
|
POINT edgePoint(RECT r,int side,int position,int inset)
|
||||||
|
{
|
||||||
|
position=std::clamp(position,0,65535);
|
||||||
|
LONG x=r.left+LONG(int64_t(position)*std::max<LONG>(0,r.right-r.left-1)/65535);
|
||||||
|
LONG y=r.top+LONG(int64_t(position)*std::max<LONG>(0,r.bottom-r.top-1)/65535);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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),multi(multiple)
|
||||||
|
{if(side<0||side>3)throw std::runtime_error("Invalid screen position.");}
|
||||||
|
InputRouter::~InputRouter(){cancel(GetTickCount64());release();}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if(i.type==INPUT_KEYBOARD)i.ki.dwExtraInfo=remoteInputMarker();
|
||||||
|
else i.mi.dwExtraInfo=remoteInputMarker();
|
||||||
|
return env.desktop()&&env.inject(i);
|
||||||
|
}
|
||||||
|
bool InputRouter::move(POINT p,bool warp)
|
||||||
|
{
|
||||||
|
auto r=env.bounds();if(r.right<=r.left||r.bottom<=r.top)return false;
|
||||||
|
INPUT input{};input.type=INPUT_MOUSE;input.mi.dwFlags=MOUSEEVENTF_MOVE|MOUSEEVENTF_ABSOLUTE|MOUSEEVENTF_VIRTUALDESK;
|
||||||
|
// 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.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();
|
||||||
|
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::held()const
|
||||||
|
{
|
||||||
|
return std::any_of(physical.begin(),physical.end(),[](bool b){return b;})||env.held();
|
||||||
|
}
|
||||||
|
void InputRouter::release()
|
||||||
|
{
|
||||||
|
for(size_t i=0;i<remoteKeys.size();++i)if(remoteKeys[i]&&emit(releases[i]))remoteKeys[i]=false;
|
||||||
|
for(unsigned i=0;i<5;++i)if(remoteButtons[i]&&emit(button(i,false)))remoteButtons[i]=false;
|
||||||
|
}
|
||||||
|
void InputRouter::local(uint64_t now,int position)
|
||||||
|
{
|
||||||
|
bool sending=mode_==ControlMode::Sending||transit;
|
||||||
|
if(position<0)position=edgePosition(returnPoint,env.bounds(),edge);
|
||||||
|
if(sending){returning=true;returnTarget=edgePoint(env.bounds(),edge,position);returnStarted=now;}
|
||||||
|
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);}
|
||||||
|
}
|
||||||
|
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)});
|
||||||
|
local(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;
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
void InputRouter::receive(const InputFrame &f,uint64_t now)
|
||||||
|
{
|
||||||
|
if(!connected||!env.desktop()){cancel(now);return;}
|
||||||
|
if(f.type==InputType::Enter)
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
{post({InputType::Return,f.token,32768});return;}
|
||||||
|
if(mode_==ControlMode::Requesting)cancel(now);
|
||||||
|
if(multi)edge=f.a^1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if(f.type==InputType::Accept)
|
||||||
|
{
|
||||||
|
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;}
|
||||||
|
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);
|
||||||
|
warpStarted=now;
|
||||||
|
if(!move(anchor,true))cancel(now,"source cursor parking failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(f.token!=token||!token)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;
|
||||||
|
bool ok=true;
|
||||||
|
if(f.type==InputType::Move)
|
||||||
|
{
|
||||||
|
if(std::abs(int64_t(f.a))>32768||std::abs(int64_t(f.b))>32768){cancel(now);return;}
|
||||||
|
if(f.c!=0&&(f.c<48||f.c>768)){cancel(now);return;}
|
||||||
|
// 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;}))
|
||||||
|
{log(L"LAN input: cursor crossed return edge");post({InputType::Return,token,edgePosition(p,r,edge)});local(now);return;}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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.ki.dwFlags=(f.c&1?0:KEYEVENTF_KEYUP)|(f.c&2?KEYEVENTF_EXTENDEDKEY:0);
|
||||||
|
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;}
|
||||||
|
}
|
||||||
|
else if(f.type==InputType::Button)
|
||||||
|
{
|
||||||
|
if(f.a<0||f.a>4||f.b<0||f.b>1){cancel(now);return;}
|
||||||
|
ok=emit(button(unsigned(f.a),f.b!=0));if(ok)remoteButtons[size_t(f.a)]=f.b!=0;
|
||||||
|
}
|
||||||
|
else if(f.type==InputType::Wheel)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if(!ok)cancel(now,"Windows input injection failed");
|
||||||
|
}
|
||||||
|
bool InputRouter::keyboard(const KBDLLHOOKSTRUCT &k,bool down,uint64_t now)
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
|
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]))
|
||||||
|
{outKeys[key]=true;cancel(now);return true;}
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
outKeys[key]=down;
|
||||||
|
if(!post({InputType::Key,token,int32_t(k.vkCode),int32_t(k.scanCode),int32_t((down?1:0)|((k.flags&LLKHF_EXTENDED)?2:0))}))cancel(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if(outKeys[key]){if(!down)outKeys[key]=false;return true;}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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(returning&&message==WM_MOUSEMOVE)return true;
|
||||||
|
int index=-1;bool down=false;
|
||||||
|
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_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;}
|
||||||
|
if(mode_==ControlMode::Receiving&&message==WM_MOUSEMOVE)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
bool ok=true;
|
||||||
|
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;
|
||||||
|
if(dx||dy)ok=post({InputType::Move,token,dx,dy,env.dpi?std::clamp(env.dpi(anchor),48,768):0});
|
||||||
|
// 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(message==WM_MOUSEWHEEL||message==WM_MOUSEHWHEEL)ok=post({InputType::Wheel,token,int16_t(HIWORD(m.mouseData)),message==WM_MOUSEHWHEEL?1:0});
|
||||||
|
if(!ok)cancel(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if(index>=0&&outButtons[size_t(index)]){if(!down)outButtons[size_t(index)]=false;return true;}
|
||||||
|
if(mode_==ControlMode::Local&&connected&&allowEnter&&message==WM_MOUSEMOVE&&now>=cooldown&&
|
||||||
|
env.desktop()&&!held()&&atEdge(m.pt,env.bounds(),edge))
|
||||||
|
{
|
||||||
|
token=ownershipToken();
|
||||||
|
returnPoint=m.pt;requested=now;mode(ControlMode::Requesting);
|
||||||
|
if(!post({InputType::Enter,token,edge,edgePosition(m.pt,env.bounds(),edge)}))cancel(now);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "lan_channel.h"
|
||||||
|
#include <array>
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
enum class ControlMode { Local, Requesting, Sending, Receiving };
|
||||||
|
struct InputEnvironment
|
||||||
|
{
|
||||||
|
std::function<RECT()> bounds;
|
||||||
|
std::function<POINT()> cursor;
|
||||||
|
std::function<bool(const INPUT &)> inject;
|
||||||
|
std::function<bool()> desktop;
|
||||||
|
std::function<bool()> held;
|
||||||
|
std::function<POINT()> anchor;
|
||||||
|
std::function<void(ControlMode)> modeChanged;
|
||||||
|
std::function<int(POINT)> dpi;
|
||||||
|
};
|
||||||
|
InputEnvironment windowsInputEnvironment();
|
||||||
|
ULONG_PTR remoteInputMarker();
|
||||||
|
ULONG_PTR cursorWarpMarker();
|
||||||
|
bool atEdge(POINT,RECT,int edge);
|
||||||
|
int edgePosition(POINT,RECT,int edge);
|
||||||
|
POINT edgePoint(RECT,int edge,int position,int inset=8);
|
||||||
|
class InputRouter
|
||||||
|
{
|
||||||
|
InputEnvironment env;
|
||||||
|
std::function<bool(InputFrame)> send;
|
||||||
|
ControlMode mode_=ControlMode::Local;
|
||||||
|
int edge{};bool preferLocal{},connected{},multi{},transit{};
|
||||||
|
int crossEdge=-1;uint64_t crossAt{};
|
||||||
|
uint32_t token{};
|
||||||
|
uint64_t cooldown{},requested{},revision_{};
|
||||||
|
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<INPUT,256> releases{};
|
||||||
|
std::array<bool,5> outButtons{},remoteButtons{};
|
||||||
|
void mode(ControlMode);
|
||||||
|
bool emit(INPUT);
|
||||||
|
bool move(POINT,bool warp=false);
|
||||||
|
bool post(InputFrame);
|
||||||
|
bool held() const;
|
||||||
|
void release();
|
||||||
|
void local(uint64_t now,int position=-1);
|
||||||
|
public:
|
||||||
|
InputRouter(InputEnvironment,std::function<bool(InputFrame)>,int edge,bool preferLocal,bool multi=false);
|
||||||
|
~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_;}
|
||||||
|
uint64_t revision()const{return revision_;}
|
||||||
|
void tick(bool ready,uint64_t now);
|
||||||
|
void receive(const InputFrame &,uint64_t now);
|
||||||
|
bool keyboard(const KBDLLHOOKSTRUCT &,bool down,uint64_t now);
|
||||||
|
bool mouse(UINT,const MSLLHOOKSTRUCT &,uint64_t now,bool allowEnter=true);
|
||||||
|
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,97 @@
|
|||||||
|
#include "lan_pairing.h"
|
||||||
|
#include <bcrypt.h>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void checked(NTSTATUS s) { if (s < 0) throw std::runtime_error("Windows cryptography failed."); }
|
||||||
|
std::string hex(const Bytes &data)
|
||||||
|
{
|
||||||
|
std::string out; out.reserve(data.size()*2);
|
||||||
|
for (auto b : data) { out += "0123456789abcdef"[b>>4]; out += "0123456789abcdef"[b&15]; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
std::string proof(const char *kind, const std::string &from, const std::string &to,
|
||||||
|
const std::string &a, const std::string &b = {})
|
||||||
|
{ return std::string("KSwitcher LAN v1|") + kind + "|" + from + "|" + to + "|" + a + "|" + b; }
|
||||||
|
}
|
||||||
|
std::string randomHex(size_t size)
|
||||||
|
{
|
||||||
|
if (size > 64) throw std::runtime_error("Invalid random size.");
|
||||||
|
Bytes bytes(size); checked(BCryptGenRandom(nullptr, bytes.data(), ULONG(size), BCRYPT_USE_SYSTEM_PREFERRED_RNG));
|
||||||
|
return hex(bytes);
|
||||||
|
}
|
||||||
|
bool identifier(const std::string &s)
|
||||||
|
{ return s.size()==32 && s.find_first_not_of("0123456789abcdef")==s.npos; }
|
||||||
|
std::string normalizeCode(const std::string &input)
|
||||||
|
{
|
||||||
|
std::string result;
|
||||||
|
for (auto c : input)
|
||||||
|
{
|
||||||
|
if (c=='-' || c==' ') continue;
|
||||||
|
if (c>='A' && c<='F') c+= 'a'-'A';
|
||||||
|
result+=c;
|
||||||
|
}
|
||||||
|
if (!identifier(result)) throw std::runtime_error("Use the same generated 32-character pairing code on both computers.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::string mac(const std::string &key, const std::string &text)
|
||||||
|
{
|
||||||
|
BCRYPT_ALG_HANDLE alg{}; BCRYPT_HASH_HANDLE hash{};
|
||||||
|
checked(BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, nullptr, BCRYPT_ALG_HANDLE_HMAC_FLAG));
|
||||||
|
struct Close { BCRYPT_ALG_HANDLE a; BCRYPT_HASH_HANDLE &h; ~Close(){if(h) BCryptDestroyHash(h); BCryptCloseAlgorithmProvider(a,0);} } close{alg,hash};
|
||||||
|
checked(BCryptCreateHash(alg,&hash,nullptr,0,(PUCHAR)key.data(),ULONG(key.size()),0));
|
||||||
|
checked(BCryptHashData(hash,(PUCHAR)text.data(),ULONG(text.size()),0));
|
||||||
|
Bytes out(32); checked(BCryptFinishHash(hash,out.data(),ULONG(out.size()),0)); return hex(out);
|
||||||
|
}
|
||||||
|
bool equal(const std::string &a, const std::string &b)
|
||||||
|
{
|
||||||
|
if (a.size()!=b.size()) return false;
|
||||||
|
unsigned diff=0; for(size_t i=0;i<a.size();++i) diff |= unsigned(a[i]^b[i]); return diff==0;
|
||||||
|
}
|
||||||
|
Json envelope(const std::string &type, const std::string &id)
|
||||||
|
{ return {{"app","KSwitcher-LAN"},{"version",1},{"type",type},{"id",id}}; }
|
||||||
|
Pairing::Pairing(std::string a,std::string b,const std::string &key,uint64_t now)
|
||||||
|
: local(std::move(a)),peer(std::move(b)),code(normalizeCode(key)),nonce(randomHex(16)),started(now)
|
||||||
|
{
|
||||||
|
if(!identifier(local)||!identifier(peer)||local==peer) throw std::runtime_error("Invalid KSwitcher peer.");
|
||||||
|
}
|
||||||
|
bool Pairing::expired(uint64_t now) const { return now-started >= PairLifetime; }
|
||||||
|
Json Pairing::offer() const
|
||||||
|
{
|
||||||
|
auto j=envelope("offer",local); j["to"]=peer; j["nonce"]=nonce;
|
||||||
|
j["proof"]=mac(code,proof("offer",local,peer,nonce)); return j;
|
||||||
|
}
|
||||||
|
std::vector<Json> Pairing::receive(const Json &j,uint64_t now)
|
||||||
|
{
|
||||||
|
if(expired(now)||j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||
|
||||||
|
j.value("id","")!=peer||j.value("to","")!=local) return {};
|
||||||
|
auto type=j.value("type",""); auto other=j.value("nonce","");
|
||||||
|
if(!identifier(other)) return {};
|
||||||
|
if(type=="offer")
|
||||||
|
{
|
||||||
|
if(!equal(j.value("proof",""),mac(code,proof("offer",peer,local,other)))) return {};
|
||||||
|
// A new attempt cannot replace a nonce already authenticated in this attempt.
|
||||||
|
if(!remoteNonce.empty() && remoteNonce!=other) return {};
|
||||||
|
remoteNonce=other;
|
||||||
|
derived=local<peer ? mac(code,proof("key",local,peer,nonce,other)) : mac(code,proof("key",peer,local,other,nonce));
|
||||||
|
auto ack=envelope("ack",local); ack["to"]=peer; ack["nonce"]=nonce; ack["echo"]=other;
|
||||||
|
ack["proof"]=mac(code,proof("ack",local,peer,other,nonce)); return {ack};
|
||||||
|
}
|
||||||
|
if(type=="ack" && j.value("echo","")==nonce)
|
||||||
|
{
|
||||||
|
if(!equal(j.value("proof",""),mac(code,proof("ack",peer,local,nonce,other)))) return {};
|
||||||
|
if(!remoteNonce.empty()&&remoteNonce!=other) return {};
|
||||||
|
remoteNonce=other;
|
||||||
|
derived=local<peer ? mac(code,proof("key",local,peer,nonce,other)) : mac(code,proof("key",peer,local,other,nonce));
|
||||||
|
acknowledged=true;
|
||||||
|
auto done=envelope("confirm",local); done["to"]=peer; done["nonce"]=nonce;
|
||||||
|
done["proof"]=mac(derived,proof("confirm",local,peer,nonce,other)); return {done};
|
||||||
|
}
|
||||||
|
if(type=="confirm" && acknowledged && other==remoteNonce &&
|
||||||
|
equal(j.value("proof",""),mac(derived,proof("confirm",peer,local,other,nonce)))) confirmed=true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
#include "../vendor/json.hpp"
|
||||||
|
|
||||||
|
namespace ks::lan
|
||||||
|
{
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
inline constexpr unsigned Port = 45883;
|
||||||
|
inline constexpr uint64_t PairLifetime = 120000, PeerLifetime = 12000;
|
||||||
|
std::string randomHex(size_t bytes);
|
||||||
|
std::string normalizeCode(const std::string &);
|
||||||
|
std::string mac(const std::string &key, const std::string &text);
|
||||||
|
bool equal(const std::string &, const std::string &);
|
||||||
|
bool identifier(const std::string &);
|
||||||
|
Json envelope(const std::string &type, const std::string &id);
|
||||||
|
struct Pairing
|
||||||
|
{
|
||||||
|
std::string local, peer, code, nonce, remoteNonce, derived;
|
||||||
|
uint64_t started{};
|
||||||
|
bool acknowledged{}, confirmed{};
|
||||||
|
Pairing(std::string local, std::string peer, const std::string &code, uint64_t now);
|
||||||
|
bool expired(uint64_t now) const;
|
||||||
|
Json offer() const;
|
||||||
|
std::vector<Json> receive(const Json &, uint64_t now);
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -435,7 +435,7 @@ bool needsSelectionDelay(HWND target)
|
|||||||
{
|
{
|
||||||
const auto name = lower(processName(target));
|
const auto name = lower(processName(target));
|
||||||
return name == L"ms-teams" || name == L"msedgewebview2" ||
|
return name == L"ms-teams" || name == L"msedgewebview2" ||
|
||||||
name == L"chrome" || name == L"msedge";
|
name == L"chrome" || name == L"msedge" || name == L"chatgpt" || name == L"codex";
|
||||||
}
|
}
|
||||||
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||||
{
|
{
|
||||||
|
|||||||
+106
-21
@@ -54,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
|
|||||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||||
return SUCCEEDED(result) && button==IDYES;
|
return SUCCEEDED(result) && button==IDYES;
|
||||||
}
|
}
|
||||||
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,21,19,26,20,12,18,13};
|
constexpr int ToolIds[]{27,10,11,22,23,24,25,36,37,38,21,19,26,20,12,18,13};
|
||||||
struct Surface
|
struct Surface
|
||||||
{
|
{
|
||||||
HDC dc = CreateCompatibleDC(nullptr);
|
HDC dc = CreateCompatibleDC(nullptr);
|
||||||
@@ -124,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
|
|||||||
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,19};
|
constexpr int CursorTools[]{10,11,22,23,24,25,36,37,38,19};
|
||||||
HCURSOR makeToolCursor(int tool,int size)
|
HCURSOR makeToolCursor(int tool,int size)
|
||||||
{
|
{
|
||||||
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
|
||||||
@@ -148,6 +148,7 @@ HCURSOR makeToolCursor(int tool,int size)
|
|||||||
case 22: line(18,29,29,18); break;
|
case 22: line(18,29,29,18); break;
|
||||||
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
|
||||||
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
|
||||||
|
case 38: Arc(dc,x(16),y(18),x(31),y(38),x(31),y(28),x(16),y(28)); break;
|
||||||
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
|
||||||
case 36: { POINT p[]{{x(23),y(17)},{x(31),y(30)},{x(16),y(30)},{x(23),y(17)}}; Polyline(dc,p,4); break; }
|
case 36: { POINT p[]{{x(23),y(17)},{x(31),y(30)},{x(16),y(30)},{x(23),y(17)}}; Polyline(dc,p,4); break; }
|
||||||
case 37: { POINT p[]{{x(20),y(18)},{x(27),y(18)},{x(31),y(30)},{x(16),y(30)},{x(20),y(18)}}; Polyline(dc,p,5); break; }
|
case 37: { POINT p[]{{x(20),y(18)},{x(27),y(18)},{x(31),y(30)},{x(16),y(30)},{x(20),y(18)}}; Polyline(dc,p,5); break; }
|
||||||
@@ -177,6 +178,8 @@ struct Capture
|
|||||||
Mark textDefaults{};
|
Mark textDefaults{};
|
||||||
ScreenshotShape shape=ScreenshotShape::None;
|
ScreenshotShape shape=ScreenshotShape::None;
|
||||||
LineStyle lineStyle=LineStyle::Solid;
|
LineStyle lineStyle=LineStyle::Solid;
|
||||||
|
int smoothing=25;
|
||||||
|
bool smoothingDrag=false;
|
||||||
LineEnds arrowEnds=LineEnds::End, freehandEnds=LineEnds::None;
|
LineEnds arrowEnds=LineEnds::End, freehandEnds=LineEnds::None;
|
||||||
COLORREF currentColor=RGB(225,42,50);
|
COLORREF currentColor=RGB(225,42,50);
|
||||||
COLORREF customColors[16]{};
|
COLORREF customColors[16]{};
|
||||||
@@ -268,6 +271,7 @@ void updateStatus(Capture &c)
|
|||||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||||
else if(c.freehand) text=L"Freehand (F): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | Ctrl = draw over objects";
|
else if(c.freehand) text=L"Freehand (F): auto-smooth / shapes; Ctrl+Z restores original | Shift = 45-degree steps | Ctrl = draw over objects";
|
||||||
|
else if(c.shape==ScreenshotShape::Arc) text=L"Arc (U): drag to draw a half ellipse | Handles = resize / rotate | V = pointer";
|
||||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
||||||
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||||
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
text=L"Zoom: "+std::to_wstring(int(std::lround(100.0*(c.view.right-c.view.left)/c.image->width)))+
|
||||||
@@ -409,6 +413,14 @@ std::vector<POINT> shapeOutline(const Mark &m)
|
|||||||
auto r=region(m.from,m.to);
|
auto r=region(m.from,m.to);
|
||||||
if(m.shape==ScreenshotShape::Rectangle)
|
if(m.shape==ScreenshotShape::Rectangle)
|
||||||
points={{r.left,r.top},{r.right,r.top},{r.right,r.bottom},{r.left,r.bottom},{r.left,r.top}};
|
points={{r.left,r.top},{r.right,r.top},{r.right,r.bottom},{r.left,r.bottom},{r.left,r.top}};
|
||||||
|
else if(m.shape==ScreenshotShape::Arc) {
|
||||||
|
// An open half ellipse spanning the drag rectangle, with no closing chord.
|
||||||
|
for(int i=0;i<=90;++i) {
|
||||||
|
double a=i*3.141592653589793/90;
|
||||||
|
points.push_back({LONG(std::lround((r.left+r.right)/2.0-(r.right-r.left)/2.0*std::cos(a))),
|
||||||
|
LONG(std::lround(r.bottom-(r.bottom-r.top)*std::sin(a)))});
|
||||||
|
}
|
||||||
|
}
|
||||||
else if(m.shape==ScreenshotShape::Triangle)
|
else if(m.shape==ScreenshotShape::Triangle)
|
||||||
points={{(r.left+r.right)/2,r.top},{r.right,r.bottom},{r.left,r.bottom},{(r.left+r.right)/2,r.top}};
|
points={{(r.left+r.right)/2,r.top},{r.right,r.bottom},{r.left,r.bottom},{(r.left+r.right)/2,r.top}};
|
||||||
else if(m.shape==ScreenshotShape::Trapezoid)
|
else if(m.shape==ScreenshotShape::Trapezoid)
|
||||||
@@ -550,7 +562,7 @@ void drawMarks(HDC dc, const std::vector<Mark> &marks, const Mark *preview = nul
|
|||||||
if(m.shape!=ScreenshotShape::None) {
|
if(m.shape!=ScreenshotShape::None) {
|
||||||
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
|
||||||
auto r=region(m.from,m.to);
|
auto r=region(m.from,m.to);
|
||||||
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid) {
|
if(!m.points.empty() || m.rotation!=0 || m.shape==ScreenshotShape::Triangle || m.shape==ScreenshotShape::Trapezoid || m.shape==ScreenshotShape::Arc) {
|
||||||
auto points=shapeOutline(m); Polyline(dc,points.data(),int(points.size()));
|
auto points=shapeOutline(m); Polyline(dc,points.data(),int(points.size()));
|
||||||
}
|
}
|
||||||
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
|
||||||
@@ -620,6 +632,13 @@ bool selectedLine(const Capture &c)
|
|||||||
}
|
}
|
||||||
void syncLineOptions(HWND w,const Capture &c)
|
void syncLineOptions(HWND w,const Capture &c)
|
||||||
{
|
{
|
||||||
|
bool pencil=c.selected>=0 && c.marks[c.selected].freehand;
|
||||||
|
int amount=pencil?c.marks[c.selected].smoothing:c.smoothing;
|
||||||
|
SendDlgItemMessageW(w,41,TBM_SETPOS,TRUE,amount);
|
||||||
|
auto smoothingText=L"Smoothing: "+std::to_wstring(amount)+L"%";
|
||||||
|
SetDlgItemTextW(w,42,smoothingText.c_str());
|
||||||
|
SetDlgItemTextW(w,43,pencil?L"Selected stroke":L"New pencil strokes");
|
||||||
|
EnableWindow(GetDlgItem(w,44),std::any_of(c.marks.begin(),c.marks.end(),[](const Mark &m){return m.freehand;}));
|
||||||
EnableWindow(GetDlgItem(w,34),c.textTool || (c.selected>=0 && (c.marks[c.selected].isText() || !c.marks[c.selected].label.empty())));
|
EnableWindow(GetDlgItem(w,34),c.textTool || (c.selected>=0 && (c.marks[c.selected].isText() || !c.marks[c.selected].label.empty())));
|
||||||
EnableWindow(GetDlgItem(w,35),c.selected>=0 && c.marks[c.selected].isLine());
|
EnableWindow(GetDlgItem(w,35),c.selected>=0 && c.marks[c.selected].isLine());
|
||||||
bool selected=selectedLine(c),enabled=selected || c.arrow || c.freehand;
|
bool selected=selectedLine(c),enabled=selected || c.arrow || c.freehand;
|
||||||
@@ -668,7 +687,7 @@ void layout(HWND w, Capture &c)
|
|||||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||||
r.bottom-=c.statusHeight;
|
r.bottom-=c.statusHeight;
|
||||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,488,524,572};
|
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,404,440,476,524,560,608};
|
||||||
static_assert(std::size(positions)==std::size(ToolIds));
|
static_assert(std::size(positions)==std::size(ToolIds));
|
||||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||||
{
|
{
|
||||||
@@ -677,10 +696,14 @@ void layout(HWND w, Capture &c)
|
|||||||
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
MulDiv(32,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||||
}
|
}
|
||||||
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
SendDlgItemMessageW(w,28,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||||
MoveWindow(GetDlgItem(w,28),MulDiv(612,dpi,96),MulDiv(6,dpi,96),
|
MoveWindow(GetDlgItem(w,28),MulDiv(648,dpi,96),MulDiv(6,dpi,96),
|
||||||
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
MulDiv(78,dpi,96),MulDiv(32,dpi,96),TRUE);
|
||||||
c.panelHeight=c.unit+MulDiv(40,dpi,96);
|
c.panelHeight=c.unit+MulDiv(80,dpi,96);
|
||||||
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
for(int id:{30,31,32,33,34,35,41,42,43,44}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
|
||||||
|
MoveWindow(GetDlgItem(w,42),MulDiv(12,dpi,96),c.unit+MulDiv(44,dpi,96),MulDiv(128,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||||
|
MoveWindow(GetDlgItem(w,41),MulDiv(142,dpi,96),c.unit+MulDiv(40,dpi,96),MulDiv(200,dpi,96),MulDiv(30,dpi,96),TRUE);
|
||||||
|
MoveWindow(GetDlgItem(w,43),MulDiv(350,dpi,96),c.unit+MulDiv(44,dpi,96),MulDiv(150,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||||
|
MoveWindow(GetDlgItem(w,44),MulDiv(516,dpi,96),c.unit+MulDiv(40,dpi,96),MulDiv(210,dpi,96),MulDiv(30,dpi,96),TRUE);
|
||||||
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
MoveWindow(GetDlgItem(w,32),MulDiv(12,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(64,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||||
MoveWindow(GetDlgItem(w,30),MulDiv(80,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(150,dpi,96),MulDiv(180,dpi,96),TRUE);
|
MoveWindow(GetDlgItem(w,30),MulDiv(80,dpi,96),c.unit+MulDiv(2,dpi,96),MulDiv(150,dpi,96),MulDiv(180,dpi,96),TRUE);
|
||||||
MoveWindow(GetDlgItem(w,33),MulDiv(248,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(40,dpi,96),MulDiv(24,dpi,96),TRUE);
|
MoveWindow(GetDlgItem(w,33),MulDiv(248,dpi,96),c.unit+MulDiv(6,dpi,96),MulDiv(40,dpi,96),MulDiv(24,dpi,96),TRUE);
|
||||||
@@ -771,7 +794,7 @@ HCURSOR toolCursor(const Capture &c)
|
|||||||
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
|
||||||
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
|
||||||
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
if(c.freehand && c.pencilCursor) return c.pencilCursor;
|
||||||
int tool=c.highlight?19:c.straight?22:c.shape==ScreenshotShape::Trapezoid?37:c.shape==ScreenshotShape::Triangle?36:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
int tool=c.highlight?19:c.straight?22:c.shape==ScreenshotShape::Arc?38:c.shape==ScreenshotShape::Trapezoid?37:c.shape==ScreenshotShape::Triangle?36:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
|
||||||
for(size_t i=0;i<std::size(CursorTools);++i)
|
for(size_t i=0;i<std::size(CursorTools);++i)
|
||||||
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
|
||||||
return LoadCursorW(nullptr,IDC_CROSS);
|
return LoadCursorW(nullptr,IDC_CROSS);
|
||||||
@@ -883,7 +906,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
||||||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
||||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool)||
|
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool)||
|
||||||
(item.CtlID==36 && c.shape==ScreenshotShape::Triangle)||(item.CtlID==37 && c.shape==ScreenshotShape::Trapezoid);
|
(item.CtlID==36 && c.shape==ScreenshotShape::Triangle)||(item.CtlID==37 && c.shape==ScreenshotShape::Trapezoid)||(item.CtlID==38 && c.shape==ScreenshotShape::Arc);
|
||||||
bool disabled=item.itemState&ODS_DISABLED;
|
bool disabled=item.itemState&ODS_DISABLED;
|
||||||
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
|
||||||
bool hover=PtInRect(&r,mouse) && !disabled;
|
bool hover=PtInRect(&r,mouse) && !disabled;
|
||||||
@@ -897,13 +920,14 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||||
auto icon=(item.CtlID==36 || item.CtlID==37)?nullptr:item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
auto icon=(item.CtlID==36 || item.CtlID==37 || item.CtlID==38)?nullptr:item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||||
if(item.CtlID==36 || item.CtlID==37) {
|
if(item.CtlID==36 || item.CtlID==37 || item.CtlID==38) {
|
||||||
int cx=(r.left+r.right)/2,cy=(r.top+r.bottom)/2,s=c.iconSize/2;
|
int cx=(r.left+r.right)/2,cy=(r.top+r.bottom)/2,s=c.iconSize/2;
|
||||||
auto trianglePen=CreatePen(PS_SOLID,std::max(1,MulDiv(2,GetDpiForWindow(item.hwndItem),96)),
|
auto trianglePen=CreatePen(PS_SOLID,std::max(1,MulDiv(2,GetDpiForWindow(item.hwndItem),96)),
|
||||||
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
|
||||||
auto previous=SelectObject(dc,trianglePen);
|
auto previous=SelectObject(dc,trianglePen);
|
||||||
if(item.CtlID==36) { POINT vertices[]{{cx,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx,cy-s}}; Polyline(dc,vertices,4); }
|
if(item.CtlID==38) Arc(dc,cx-s,cy-s,cx+s,cy+3*s,cx+s,cy+s,cx-s,cy+s);
|
||||||
|
else if(item.CtlID==36) { POINT vertices[]{{cx,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx,cy-s}}; Polyline(dc,vertices,4); }
|
||||||
else { POINT vertices[]{{cx-s/2,cy-s},{cx+s/2,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx-s/2,cy-s}}; Polyline(dc,vertices,5); }
|
else { POINT vertices[]{{cx-s/2,cy-s},{cx+s/2,cy-s},{cx+s,cy+s},{cx-s,cy+s},{cx-s/2,cy-s}}; Polyline(dc,vertices,5); }
|
||||||
SelectObject(dc,previous); DeleteObject(trianglePen);
|
SelectObject(dc,previous); DeleteObject(trianglePen);
|
||||||
}
|
}
|
||||||
@@ -1025,7 +1049,7 @@ void edit(HWND w, Capture &c)
|
|||||||
InitCommonControlsEx(&statusControls);
|
InitCommonControlsEx(&statusControls);
|
||||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||||
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Triangle (G): drag to draw; drag each vertex to reshape", L"Trapezoid (B): drag to draw", L"Arc (U): drag to draw a half ellipse; use handles to resize or rotate", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||||
static_assert(std::size(labels)==std::size(ToolIds));
|
static_assert(std::size(labels)==std::size(ToolIds));
|
||||||
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||||
@@ -1061,6 +1085,14 @@ void edit(HWND w, Capture &c)
|
|||||||
SetWindowSubclass(labelButton,toolHover,1,0);
|
SetWindowSubclass(labelButton,toolHover,1,0);
|
||||||
fontTip.uId=(UINT_PTR)labelButton; fontTip.lpszText=const_cast<wchar_t *>(L"Arrow label (F2 / double-click); drag label to reposition");
|
fontTip.uId=(UINT_PTR)labelButton; fontTip.lpszText=const_cast<wchar_t *>(L"Arrow label (F2 / double-click); drag label to reposition");
|
||||||
SendMessageW(tips,TTM_ADDTOOLW,0,(LPARAM)&fontTip);
|
SendMessageW(tips,TTM_ADDTOOLW,0,(LPARAM)&fontTip);
|
||||||
|
INITCOMMONCONTROLSEX sliders{sizeof(sliders),ICC_BAR_CLASSES}; InitCommonControlsEx(&sliders);
|
||||||
|
CreateWindowW(TRACKBAR_CLASSW,L"Pencil smoothing",WS_CHILD|WS_VISIBLE|WS_TABSTOP|TBS_NOTICKS,
|
||||||
|
0,0,0,0,w,(HMENU)41,c.app.instance,nullptr);
|
||||||
|
SendDlgItemMessageW(w,41,TBM_SETRANGE,TRUE,MAKELPARAM(0,100));
|
||||||
|
SendDlgItemMessageW(w,41,TBM_SETPAGESIZE,0,10);
|
||||||
|
for(int id:{42,43}) CreateWindowW(L"STATIC",L"",WS_CHILD|WS_VISIBLE,0,0,0,0,w,(HMENU)(INT_PTR)id,c.app.instance,nullptr);
|
||||||
|
CreateWindowW(L"BUTTON",L"Apply to all strokes",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
|
||||||
|
0,0,0,0,w,(HMENU)44,c.app.instance,nullptr);
|
||||||
c.syncHistory();
|
c.syncHistory();
|
||||||
auto work = monitor.rcWork;
|
auto work = monitor.rcWork;
|
||||||
SetWindowPos(w,HWND_NOTOPMOST,work.left+20,work.top+20,
|
SetWindowPos(w,HWND_NOTOPMOST,work.left+20,work.top+20,
|
||||||
@@ -1120,7 +1152,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case WM_DRAWITEM:
|
case WM_DRAWITEM:
|
||||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35 || wp==36 || wp==37) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35 || wp==36 || wp==37 || wp==38) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||||
break;
|
break;
|
||||||
case WM_CTLCOLORSTATIC:
|
case WM_CTLCOLORSTATIC:
|
||||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||||
@@ -1129,6 +1161,21 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_SETCURSOR:
|
case WM_SETCURSOR:
|
||||||
if ((HWND)wp==w && LOWORD(lp)==HTCLIENT) { captureCursor(w,*c); return TRUE; }
|
if ((HWND)wp==w && LOWORD(lp)==HTCLIENT) { captureCursor(w,*c); return TRUE; }
|
||||||
break;
|
break;
|
||||||
|
case WM_HSCROLL:
|
||||||
|
if((HWND)lp==GetDlgItem(w,41) && !c->dragging) {
|
||||||
|
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
|
||||||
|
bool pencil=c->selected>=0 && c->marks[c->selected].freehand;
|
||||||
|
if(pencil && c->marks[c->selected].smoothing!=amount) {
|
||||||
|
if(!c->smoothingDrag) c->remember(c->marks);
|
||||||
|
setScreenshotSmoothing(c->marks[c->selected],amount);
|
||||||
|
resolveScreenshotLinks(c->marks);
|
||||||
|
c->smoothingDrag=LOWORD(wp)==TB_THUMBTRACK;
|
||||||
|
} else if(!pencil) c->smoothing=amount;
|
||||||
|
if(LOWORD(wp)!=TB_THUMBTRACK) c->smoothingDrag=false;
|
||||||
|
syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
if(LOWORD(wp)==40) {
|
if(LOWORD(wp)==40) {
|
||||||
if(c->inlineText && HIWORD(wp)==EN_CHANGE) { readInlineText(*c); positionInlineText(*c); }
|
if(c->inlineText && HIWORD(wp)==EN_CHANGE) { readInlineText(*c); positionInlineText(*c); }
|
||||||
@@ -1136,6 +1183,17 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (c->dragging) return 0;
|
if (c->dragging) return 0;
|
||||||
|
if(LOWORD(wp)==44) {
|
||||||
|
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
|
||||||
|
c->smoothing=amount;
|
||||||
|
if(std::any_of(c->marks.begin(),c->marks.end(),[&](const Mark &m){return m.freehand && m.smoothing!=amount;})) {
|
||||||
|
c->remember(c->marks);
|
||||||
|
for(auto &mark:c->marks) if(mark.freehand) setScreenshotSmoothing(mark,amount);
|
||||||
|
resolveScreenshotLinks(c->marks);
|
||||||
|
}
|
||||||
|
c->smoothingDrag=false; syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if(LOWORD(wp)==35) {
|
if(LOWORD(wp)==35) {
|
||||||
finishInlineText(w,*c,true);
|
finishInlineText(w,*c,true);
|
||||||
if(c->selected>=0 && c->marks[c->selected].isLine()) beginMarkText(w,*c,c->selected);
|
if(c->selected>=0 && c->marks[c->selected].isLine()) beginMarkText(w,*c,c->selected);
|
||||||
@@ -1214,13 +1272,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||||
c->syncHistory();
|
c->syncHistory();
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || LOWORD(wp)==36 || LOWORD(wp)==37 || LOWORD(wp)==38 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||||
{
|
{
|
||||||
c->pointer=LOWORD(wp)==27;
|
c->pointer=LOWORD(wp)==27;
|
||||||
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
c->arrow = LOWORD(wp)==11 || LOWORD(wp)==36 || LOWORD(wp)==37 || LOWORD(wp)==38 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||||
c->straight=LOWORD(wp)==22;
|
c->straight=LOWORD(wp)==22;
|
||||||
c->textTool=LOWORD(wp)==26;
|
c->textTool=LOWORD(wp)==26;
|
||||||
c->shape=LOWORD(wp)==37?ScreenshotShape::Trapezoid:LOWORD(wp)==36?ScreenshotShape::Triangle:LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
c->shape=LOWORD(wp)==38?ScreenshotShape::Arc:LOWORD(wp)==37?ScreenshotShape::Trapezoid:LOWORD(wp)==36?ScreenshotShape::Triangle:LOWORD(wp)>=23 && LOWORD(wp)<=25?ScreenshotShape(LOWORD(wp)-22):ScreenshotShape::None;
|
||||||
c->selected=-1; c->selectedLabel=false;
|
c->selected=-1; c->selectedLabel=false;
|
||||||
c->highlight=LOWORD(wp)==19;
|
c->highlight=LOWORD(wp)==19;
|
||||||
c->freehand=LOWORD(wp)==21;
|
c->freehand=LOWORD(wp)==21;
|
||||||
@@ -1233,6 +1291,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,36),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,37),nullptr,FALSE);
|
||||||
|
InvalidateRect(GetDlgItem(w,38),nullptr,FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
@@ -1293,6 +1352,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
|
||||||
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
if(wp=='G') SendMessageW(w,WM_COMMAND,36,0);
|
||||||
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
if(wp=='B') SendMessageW(w,WM_COMMAND,37,0);
|
||||||
|
if(wp=='U') SendMessageW(w,WM_COMMAND,38,0);
|
||||||
}
|
}
|
||||||
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
if (wp=='N' || wp=='A') SendMessageW(w,WM_COMMAND,wp=='N'?10:11,0);
|
||||||
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
if(wp=='H') SendMessageW(w,WM_COMMAND,19,0);
|
||||||
@@ -1530,6 +1590,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->remember(c->marks); c->marks.push_back(std::move(c->stroke));
|
c->remember(c->marks); c->marks.push_back(std::move(c->stroke));
|
||||||
if(c->freehand) {
|
if(c->freehand) {
|
||||||
auto &mark=c->marks.back();
|
auto &mark=c->marks.back();
|
||||||
|
mark.rawPoints=mark.points;
|
||||||
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
||||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||||
resolveScreenshotLinks(c->marks);
|
resolveScreenshotLinks(c->marks);
|
||||||
@@ -1540,10 +1601,18 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
ScreenshotShape recognized{};
|
ScreenshotShape recognized{};
|
||||||
bool straightLine=false;
|
bool straightLine=false;
|
||||||
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized,&straightLine);
|
auto improved=improveScreenshotStroke(c->marks.back().points,&recognized,&straightLine);
|
||||||
|
if(recognized==ScreenshotShape::None && !straightLine) {
|
||||||
|
improved=smoothScreenshotStroke(c->marks.back().rawPoints,c->smoothing);
|
||||||
|
const auto &raw=c->marks.back().rawPoints;
|
||||||
|
if(improved.size()==raw.size() && std::equal(improved.begin(),improved.end(),raw.begin(),
|
||||||
|
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;})) improved.clear();
|
||||||
|
}
|
||||||
if(!improved.empty()) {
|
if(!improved.empty()) {
|
||||||
c->remember(c->marks);
|
c->remember(c->marks);
|
||||||
auto &mark=c->marks.back();
|
auto &mark=c->marks.back();
|
||||||
mark.points=std::move(improved);
|
if(recognized==ScreenshotShape::None && !straightLine) {
|
||||||
|
setScreenshotSmoothing(mark,c->smoothing);
|
||||||
|
} else { mark.points=std::move(improved); mark.rawPoints.clear(); }
|
||||||
mark.from=mark.points.front(); mark.to=mark.points.back();
|
mark.from=mark.points.front(); mark.to=mark.points.back();
|
||||||
if(straightLine) { mark.points.clear(); mark.freehand=false; }
|
if(straightLine) { mark.points.clear(); mark.freehand=false; }
|
||||||
if(recognized!=ScreenshotShape::None) {
|
if(recognized!=ScreenshotShape::None) {
|
||||||
@@ -1563,7 +1632,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
c->marks.back().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
c->marks.back().style=c->lineStyle; c->marks.back().ends=c->straight || c->shape!=ScreenshotShape::None?LineEnds::None:c->arrowEnds;
|
||||||
c->marks.back().shape=c->shape;
|
c->marks.back().shape=c->shape;
|
||||||
auto &mark=c->marks.back();
|
auto &mark=c->marks.back();
|
||||||
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid) mark.points=shapeOutline(mark);
|
if(mark.shape==ScreenshotShape::Triangle || mark.shape==ScreenshotShape::Trapezoid || mark.shape==ScreenshotShape::Arc) mark.points=shapeOutline(mark);
|
||||||
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
|
||||||
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
|
||||||
resolveScreenshotLinks(c->marks);
|
resolveScreenshotLinks(c->marks);
|
||||||
@@ -1586,7 +1655,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (c->editing)
|
if (c->editing)
|
||||||
{
|
{
|
||||||
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
auto limits = reinterpret_cast<MINMAXINFO *>(lp);
|
||||||
limits->ptMinTrackSize = {MulDiv(730,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
limits->ptMinTrackSize = {MulDiv(766,GetDpiForWindow(w),96),MulDiv(240,GetDpiForWindow(w),96)};
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1901,7 +1970,14 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
|||||||
POINT start=from?from->from:m.from, end=to?to->from:m.to;
|
POINT start=from?from->from:m.from, end=to?to->from:m.to;
|
||||||
if(from) m.from=rim(*from,end,true);
|
if(from) m.from=rim(*from,end,true);
|
||||||
if(to) m.to=rim(*to,start,false);
|
if(to) m.to=rim(*to,start,false);
|
||||||
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
if(m.freehand && !m.points.empty()) {
|
||||||
|
m.points.front()=m.from; m.points.back()=m.to;
|
||||||
|
if(!m.rawPoints.empty() && (m.rawPoints.front().x!=m.from.x || m.rawPoints.front().y!=m.from.y ||
|
||||||
|
m.rawPoints.back().x!=m.to.x || m.rawPoints.back().y!=m.to.y)) {
|
||||||
|
m.rawPoints.front()=m.from; m.rawPoints.back()=m.to;
|
||||||
|
setScreenshotSmoothing(m,m.smoothing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state[index]=2;
|
state[index]=2;
|
||||||
};
|
};
|
||||||
@@ -2055,10 +2131,15 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
return low<=high?std::clamp(delta,low,high):0L;
|
return low<=high?std::clamp(delta,low,high):0L;
|
||||||
};
|
};
|
||||||
auto bounds=markBounds(m);
|
auto bounds=markBounds(m);
|
||||||
|
for(auto p:m.rawPoints) {
|
||||||
|
bounds.left=std::min(bounds.left,p.x); bounds.right=std::max(bounds.right,p.x);
|
||||||
|
bounds.top=std::min(bounds.top,p.y); bounds.bottom=std::max(bounds.bottom,p.y);
|
||||||
|
}
|
||||||
LONG dx=shift(position.x-origin.x,bounds.left,bounds.right,width);
|
LONG dx=shift(position.x-origin.x,bounds.left,bounds.right,width);
|
||||||
LONG dy=shift(position.y-origin.y,bounds.top,bounds.bottom,height);
|
LONG dy=shift(position.y-origin.y,bounds.top,bounds.bottom,height);
|
||||||
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
m.from.x+=dx; m.to.x+=dx; m.from.y+=dy; m.to.y+=dy;
|
||||||
for(auto &point:m.points) { point.x+=dx; point.y+=dy; }
|
for(auto &point:m.points) { point.x+=dx; point.y+=dy; }
|
||||||
|
for(auto &point:m.rawPoints) { point.x+=dx; point.y+=dy; }
|
||||||
}
|
}
|
||||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||||
{
|
{
|
||||||
@@ -2083,6 +2164,10 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(m.freehand && !m.points.empty()) (part==MarkPart::Start?m.points.front():m.points.back())=endpoint;
|
if(m.freehand && !m.points.empty()) (part==MarkPart::Start?m.points.front():m.points.back())=endpoint;
|
||||||
|
if(m.freehand && !m.rawPoints.empty()) {
|
||||||
|
(part==MarkPart::Start?m.rawPoints.front():m.rawPoints.back())=endpoint;
|
||||||
|
setScreenshotSmoothing(m,m.smoothing);
|
||||||
|
}
|
||||||
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
|
||||||
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
|
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
|
||||||
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
|
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,7 @@ void showScreenshot(ScreenshotHost &);
|
|||||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||||
enum class LineEnds { End, Start, Both, None };
|
enum class LineEnds { End, Start, Both, None };
|
||||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid };
|
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid, Arc };
|
||||||
struct ScreenshotOffset { double x, y; };
|
struct ScreenshotOffset { double x, y; };
|
||||||
struct ScreenshotLink {
|
struct ScreenshotLink {
|
||||||
unsigned long long target{};
|
unsigned long long target{};
|
||||||
@@ -39,6 +39,8 @@ struct ScreenshotMark {
|
|||||||
bool labelRotationManual=false;
|
bool labelRotationManual=false;
|
||||||
unsigned long long id{};
|
unsigned long long id{};
|
||||||
ScreenshotLink fromLink, toLink;
|
ScreenshotLink fromLink, toLink;
|
||||||
|
std::vector<POINT> rawPoints; // Original pencil path for reversible smoothing.
|
||||||
|
int smoothing=0; // 0..100; applies only to freehand strokes.
|
||||||
bool isText() const { return !text.empty(); }
|
bool isText() const { return !text.empty(); }
|
||||||
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
|
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
|
||||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-6
@@ -473,6 +473,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 +645,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 +754,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 +770,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 +822,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;
|
||||||
|
|||||||
@@ -38,6 +38,42 @@ inline double cross(Point a, Point b, Point c) { return (b.x-a.x)*(c.y-a.y)-(b.y
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
|
// Filter by distance along the path, not mouse-event count. Always start from
|
||||||
|
// the original path so lowering the amount restores detail without drift.
|
||||||
|
inline std::vector<POINT> smoothScreenshotStroke(const std::vector<POINT> &input, int amount) {
|
||||||
|
amount=std::clamp(amount,0,100);
|
||||||
|
if(!amount || input.size()<5) return input;
|
||||||
|
using namespace stroke_detail;
|
||||||
|
std::vector<Point> raw;
|
||||||
|
for(auto p:input) if(raw.empty() || distance(raw.back(),{double(p.x),double(p.y)})>0)
|
||||||
|
raw.push_back({double(p.x),double(p.y)});
|
||||||
|
double total=length(raw);
|
||||||
|
if(raw.size()<5 || total<16) return input;
|
||||||
|
auto points=sample(raw,size_t(std::clamp(std::ceil(total/2)+1,3.0,4096.0)));
|
||||||
|
double step=total/(points.size()-1),radius=0.5+32*std::pow(amount/100.0,2);
|
||||||
|
int reach=std::max(1,int(std::ceil(radius/step)));
|
||||||
|
std::vector<POINT> result;
|
||||||
|
for(size_t i=0;i<points.size();++i) {
|
||||||
|
Point sum{}; double weights=0;
|
||||||
|
for(int j=std::max(0,int(i)-reach);j<=std::min(int(points.size())-1,int(i)+reach);++j) {
|
||||||
|
double d=(j-double(i))*step,weight=std::exp(-2*d*d/(radius*radius));
|
||||||
|
sum.x+=points[j].x*weight; sum.y+=points[j].y*weight; weights+=weight;
|
||||||
|
}
|
||||||
|
double blend=std::min(1.0,std::min(i,points.size()-1-i)*step/radius);
|
||||||
|
POINT p{LONG(std::lround(points[i].x+blend*(sum.x/weights-points[i].x))),
|
||||||
|
LONG(std::lround(points[i].y+blend*(sum.y/weights-points[i].y)))};
|
||||||
|
if(result.empty() || p.x!=result.back().x || p.y!=result.back().y) result.push_back(p);
|
||||||
|
}
|
||||||
|
result.front()=input.front(); result.back()=input.back();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
inline void setScreenshotSmoothing(ScreenshotMark &mark,int amount) {
|
||||||
|
if(!mark.freehand || mark.shape!=ScreenshotShape::None || mark.points.empty()) return;
|
||||||
|
if(mark.rawPoints.empty()) mark.rawPoints=mark.points;
|
||||||
|
mark.smoothing=std::clamp(amount,0,100);
|
||||||
|
mark.points=smoothScreenshotStroke(mark.rawPoints,mark.smoothing);
|
||||||
|
mark.from=mark.points.front(); mark.to=mark.points.back();
|
||||||
|
}
|
||||||
// Returns a replacement path; an empty result means no visible improvement.
|
// Returns a replacement path; an empty result means no visible improvement.
|
||||||
// Report recognized geometry separately from a smoothed freehand path.
|
// Report recognized geometry separately from a smoothed freehand path.
|
||||||
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr,
|
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr,
|
||||||
|
|||||||
+7
-3
@@ -219,8 +219,7 @@ 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",
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
||||||
"chatCycles"}) j.erase(key); // Chat names and matching rules stay on this PC.
|
|
||||||
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)
|
||||||
@@ -230,6 +229,9 @@ Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const s
|
|||||||
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||||
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||||
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||||
|
// Profiles uploaded before v0.6.53 do not participate in chat synchronization.
|
||||||
|
// An explicit empty object from a newer client does mean remove all profiles.
|
||||||
|
if (!j.contains("chatCycles")) j["chatCycles"] = original.at("chatCycles");
|
||||||
auto allowed = parse(sharedSyncSettings(local));
|
auto allowed = parse(sharedSyncSettings(local));
|
||||||
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||||
throw std::runtime_error("Unsupported synchronized settings format.");
|
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||||
@@ -295,7 +297,9 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l
|
|||||||
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
|
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal)));
|
auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal);
|
||||||
|
if (!profile.contains("chatCycles")) profile["chatCycles"] = parse(local).value("chatCycles", Json::object());
|
||||||
|
auto selected = bytes(normalizeProfile(profile));
|
||||||
merged = {selected, selected, selected, {}};
|
merged = {selected, selected, selected, {}};
|
||||||
}
|
}
|
||||||
if (validate) { validate(merged.local); validate(merged.remote); }
|
if (validate) { validate(merged.local); validate(merged.remote); }
|
||||||
|
|||||||
+17
-1
@@ -8,6 +8,7 @@ Json normalizeProfile(Json j)
|
|||||||
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||||
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||||
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
|
if (!j.contains("chatCycles")) j["chatCycles"] = Json::object();
|
||||||
for (auto &sn : j.at("snippets"))
|
for (auto &sn : j.at("snippets"))
|
||||||
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
||||||
return j;
|
return j;
|
||||||
@@ -53,6 +54,10 @@ Json flatProfile(Json j)
|
|||||||
for (auto it = replacements.begin(); it != replacements.end();)
|
for (auto it = replacements.begin(); it != replacements.end();)
|
||||||
if (it.value().is_null()) it = replacements.erase(it);
|
if (it.value().is_null()) it = replacements.erase(it);
|
||||||
else ++it;
|
else ++it;
|
||||||
|
auto &chats = j["chatCycles"];
|
||||||
|
for (auto it = chats.begin(); it != chats.end();)
|
||||||
|
if (it.value().is_null()) it = chats.erase(it);
|
||||||
|
else ++it;
|
||||||
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())
|
||||||
{
|
{
|
||||||
@@ -94,6 +99,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"},
|
||||||
{"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"},
|
||||||
@@ -141,6 +147,11 @@ MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::stri
|
|||||||
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
|
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
|
||||||
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
|
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
|
||||||
}
|
}
|
||||||
|
if (path == "/chatCycles") {
|
||||||
|
auto name = key;
|
||||||
|
if (auto separator = name.find('\n'); separator != name.npos) name.replace(separator, 1, " / ");
|
||||||
|
childLabel = L"Chat languages: " + wide(name);
|
||||||
|
}
|
||||||
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
|
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
|
||||||
// Keep nulls in the ancestor so deletion conflicts survive restarts.
|
// Keep nulls in the ancestor so deletion conflicts survive restarts.
|
||||||
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
|
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
|
||||||
@@ -181,7 +192,12 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
|
|||||||
}
|
}
|
||||||
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
||||||
{
|
{
|
||||||
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
|
auto localProfile = parse(local), remoteProfile = parse(remote);
|
||||||
|
// Absence in an older client is not a deletion. Preserve the participating
|
||||||
|
// client's chat profiles until both copies understand this field.
|
||||||
|
if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object());
|
||||||
|
if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles");
|
||||||
|
auto l = treeProfile(localProfile), r = treeProfile(remoteProfile);
|
||||||
// With no common history, union independent entries; differing scalar values
|
// With no common history, union independent entries; differing scalar values
|
||||||
// remain on their respective devices until the user resolves the conflict.
|
// remain on their respective devices until the user resolves the conflict.
|
||||||
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
|
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
|
||||||
|
|||||||
+134
-11
@@ -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"
|
||||||
@@ -520,11 +521,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 +537,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 +735,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 +817,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 +1045,9 @@ 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{};
|
||||||
};
|
};
|
||||||
|
void updateSettingsState(HWND w, SettingsView *v);
|
||||||
void selectSettingsPage(HWND w, SettingsView *v)
|
void selectSettingsPage(HWND w, SettingsView *v)
|
||||||
{
|
{
|
||||||
int selected = TabCtrl_GetCurSel(GetDlgItem(w, 260));
|
int selected = TabCtrl_GetCurSel(GetDlgItem(w, 260));
|
||||||
@@ -1169,6 +1248,7 @@ void fillSettings(HWND w, SettingsView *v)
|
|||||||
v->selection = 0;
|
v->selection = 0;
|
||||||
fillLayouts(w, v);
|
fillLayouts(w, v);
|
||||||
v->filling = false;
|
v->filling = false;
|
||||||
|
updateSettingsState(w, v);
|
||||||
}
|
}
|
||||||
int integer(HWND w, int id)
|
int integer(HWND w, int id)
|
||||||
{
|
{
|
||||||
@@ -1298,7 +1378,10 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto words = parseCommaList(text);
|
auto words = parseCommaList(text);
|
||||||
std::sort(words.begin(), words.end());
|
std::sort(words.begin(), words.end());
|
||||||
words.erase(std::unique(words.begin(), words.end()), words.end());
|
words.erase(std::unique(words.begin(), words.end()), words.end());
|
||||||
draft.autoWords[language] = std::move(words);
|
// Opening and accepting an untouched empty dictionary must not
|
||||||
|
// introduce a new entry and mark Settings as changed.
|
||||||
|
if (!words.empty() || draft.autoWords.contains(language))
|
||||||
|
draft.autoWords[language] = std::move(words);
|
||||||
}
|
}
|
||||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||||
draft.autoPrefixChars = value(w, 408);
|
draft.autoPrefixChars = value(w, 408);
|
||||||
@@ -1522,6 +1605,7 @@ void showOcrOptions(HWND parent, SettingsView *v)
|
|||||||
showRequestedWindow(w); SetForegroundWindow(w);
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
}
|
}
|
||||||
#include "ui_sync.inl"
|
#include "ui_sync.inl"
|
||||||
|
#include "ui_lan.inl"
|
||||||
bool settingsChanged(HWND w, SettingsView *v)
|
bool settingsChanged(HWND w, SettingsView *v)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1535,6 +1619,27 @@ bool settingsChanged(HWND w, SettingsView *v)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void updateSettingsState(HWND w, SettingsView *v)
|
||||||
|
{
|
||||||
|
if (!v || !v->ready || v->filling || v->refreshing) return;
|
||||||
|
v->refreshing = true;
|
||||||
|
bool changed = settingsChanged(w, v);
|
||||||
|
EnableWindow(GetDlgItem(w, IDOK), changed);
|
||||||
|
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." : L"No unsaved changes.";
|
||||||
|
if (value(w, 261) != text) value(w, 261, text);
|
||||||
|
v->refreshing = false;
|
||||||
|
}
|
||||||
|
// Refresh after commands have updated controls/drafts, including early returns.
|
||||||
|
// Saving can destroy Settings, so retrieve its state only if it still exists.
|
||||||
|
struct SettingsStateRefresh
|
||||||
|
{
|
||||||
|
HWND window;
|
||||||
|
~SettingsStateRefresh()
|
||||||
|
{
|
||||||
|
if (IsWindow(window))
|
||||||
|
updateSettingsState(window, reinterpret_cast<SettingsView *>(GetWindowLongPtrW(window, GWLP_USERDATA)));
|
||||||
|
}
|
||||||
|
};
|
||||||
void closeSettings(HWND w, SettingsView *v)
|
void closeSettings(HWND w, SettingsView *v)
|
||||||
{
|
{
|
||||||
if (settingsChanged(w, v))
|
if (settingsChanged(w, v))
|
||||||
@@ -1583,6 +1688,12 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
switch (msg)
|
switch (msg)
|
||||||
{
|
{
|
||||||
|
case WM_ENABLE:
|
||||||
|
if (wp) updateSettingsState(w, v);
|
||||||
|
break;
|
||||||
|
case WM_ACTIVATE:
|
||||||
|
if (LOWORD(wp) != WA_INACTIVE) updateSettingsState(w, v);
|
||||||
|
break;
|
||||||
case WM_NOTIFY:
|
case WM_NOTIFY:
|
||||||
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
|
if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE)
|
||||||
{
|
{
|
||||||
@@ -1592,6 +1703,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case WM_COMMAND:
|
case WM_COMMAND:
|
||||||
{
|
{
|
||||||
|
SettingsStateRefresh refresh{w};
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
if (id == 220 && HIWORD(wp) == LBN_SELCHANGE && !v->filling)
|
if (id == 220 && HIWORD(wp) == LBN_SELCHANGE && !v->filling)
|
||||||
{
|
{
|
||||||
@@ -1606,6 +1718,7 @@ 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 == 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)
|
||||||
{
|
{
|
||||||
@@ -1627,6 +1740,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
if (id == IDOK)
|
if (id == IDOK)
|
||||||
{
|
{
|
||||||
|
if (!settingsChanged(w, v)) return 0;
|
||||||
if (v->app->busy)
|
if (v->app->busy)
|
||||||
throw std::runtime_error("Wait for the current text operation to finish");
|
throw std::runtime_error("Wait for the current text operation to finish");
|
||||||
v->app->applySettings(collectSettings(w, v));
|
v->app->applySettings(collectSettings(w, v));
|
||||||
@@ -1846,6 +1960,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
auto *parent = (SettingsView *)GetWindowLongPtrW(v->app->settingsWindow, GWLP_USERDATA);
|
auto *parent = (SettingsView *)GetWindowLongPtrW(v->app->settingsWindow, GWLP_USERDATA);
|
||||||
parent->draft.snippets = s.snippets;
|
parent->draft.snippets = s.snippets;
|
||||||
|
updateSettingsState(v->app->settingsWindow, parent);
|
||||||
}
|
}
|
||||||
DestroyWindow(w);
|
DestroyWindow(w);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1930,8 +2045,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,
|
||||||
@@ -2060,20 +2178,25 @@ 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);
|
||||||
page();
|
page();
|
||||||
|
|
||||||
|
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
|
||||||
button(w, L"Impo&rt...", 250, 18, 466, 125);
|
button(w, L"Impo&rt...", 250, 18, 466, 125);
|
||||||
button(w, L"&Export...", 251, 155, 466, 125);
|
button(w, L"&Export...", 251, 155, 466, 125);
|
||||||
button(w, L"&Save", IDOK, 490, 466, 100);
|
button(w, L"&Save", IDOK, 490, 466, 100);
|
||||||
button(w, L"&Cancel", IDCANCEL, 602, 466, 100);
|
button(w, L"&Cancel", IDCANCEL, 602, 466, 100);
|
||||||
fillSettings(w, v);
|
fillSettings(w, v);
|
||||||
|
v->ready = true;
|
||||||
|
updateSettingsState(w, v);
|
||||||
TabCtrl_SetCurSel(tabs, 0);
|
TabCtrl_SetCurSel(tabs, 0);
|
||||||
selectSettingsPage(w, v);
|
selectSettingsPage(w, v);
|
||||||
if (visible)
|
if (visible)
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
// Uses the resident service; the configurator never opens a discovery socket.
|
||||||
|
struct LanView
|
||||||
|
{
|
||||||
|
HWND parent{};
|
||||||
|
App *app{};
|
||||||
|
nlohmann::json snapshot=nlohmann::json::object();
|
||||||
|
std::vector<std::string> ids;
|
||||||
|
std::wstring error;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
auto text=request.dump(); auto reply=app.lanCommand(Bytes(text.begin(),text.end()));
|
||||||
|
return nlohmann::json::parse(reply);
|
||||||
|
}
|
||||||
|
std::string selectedLan(HWND w,LanView &v)
|
||||||
|
{
|
||||||
|
auto index=SendDlgItemMessageW(w,702,LB_GETCURSEL,0,0);
|
||||||
|
return index>=0 && size_t(index)<v.ids.size() ? v.ids[size_t(index)] : "";
|
||||||
|
}
|
||||||
|
void refreshLan(HWND w,LanView &v)
|
||||||
|
{
|
||||||
|
auto selected=selectedLan(w,v);
|
||||||
|
auto next=lanRequest(*v.app,{{"action","status"}});
|
||||||
|
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);
|
||||||
|
auto state=peer.at("paired").get<bool>() ? L"Paired" : L"Not paired";
|
||||||
|
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,LB_RESETCONTENT,0,0); v.ids=std::move(ids);v.rows=std::move(rows);
|
||||||
|
for(size_t i=0;i<v.ids.size();++i)
|
||||||
|
{
|
||||||
|
auto row=SendDlgItemMessageW(w,702,LB_ADDSTRING,0,(LPARAM)v.rows[i].c_str());
|
||||||
|
if(v.ids[i]==selected) SendDlgItemMessageW(w,702,LB_SETCURSEL,row,0);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
v.snapshot=std::move(next);
|
||||||
|
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
|
||||||
|
for(const auto &peer:v.snapshot["peers"]) if(peer.at("id")==selected) {online=peer.at("online");paired=peer.at("paired");}
|
||||||
|
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
|
||||||
|
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
|
||||||
|
value(w,708,v.error.empty() ? wide(v.snapshot.at("status").get<std::string>()) : v.error);
|
||||||
|
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
|
||||||
|
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
|
||||||
|
EnableWindow(GetDlgItem(w,706),pending);
|
||||||
|
EnableWindow(GetDlgItem(w,707),paired);
|
||||||
|
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending&&!paired);
|
||||||
|
auto positions=v.snapshot.value("positions",nlohmann::json::object());
|
||||||
|
EnableWindow(GetDlgItem(w,715),enabled&&!positions.empty()&&!v.snapshot.value("shareInput",false));
|
||||||
|
EnableWindow(GetDlgItem(w,716),v.snapshot.value("shareInput",false));
|
||||||
|
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(gridState!=v.gridState){v.gridState=std::move(gridState);InvalidateRect(GetDlgItem(w,721),nullptr,FALSE);}
|
||||||
|
std::wstring detail=L"Drag paired monitors to empty cells. This PC stays fixed. Arrow keys also work.";
|
||||||
|
detail=L"Drag monitors to neighboring cells. Mouse crosses shared edges; empty cells block passage.";
|
||||||
|
value(w,719,v.error.empty()?detail:v.error);
|
||||||
|
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);
|
||||||
|
if(mode==2)inputStatus=L"Mouse and keyboard control the OTHER PC. Ctrl+Alt+Esc returns here.";
|
||||||
|
else if(mode==3)inputStatus=L"This PC is controlled remotely. Local physical input takes control back.";
|
||||||
|
value(w,718,inputStatus);
|
||||||
|
}
|
||||||
|
#include "ui_lan_layout.inl"
|
||||||
|
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||||
|
{
|
||||||
|
if(msg==WM_DPICHANGED) {resizeForDpi(w,wp,lp);return 0;}
|
||||||
|
auto v=(LanView*)GetWindowLongPtrW(w,GWLP_USERDATA);
|
||||||
|
if(!v) return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
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;}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(msg==WM_TIMER) {refreshLan(w,*v);return 0;}
|
||||||
|
if(msg==WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id=LOWORD(wp); auto peer=selectedLan(w,*v);
|
||||||
|
if(id==702&&HIWORD(wp)==LBN_SELCHANGE) {refreshLan(w,*v);return 0;}
|
||||||
|
if(HIWORD(wp)!=BN_CLICKED) return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
if(!IsWindowEnabled(GetDlgItem(w,id)))return 0;
|
||||||
|
v->error.clear();
|
||||||
|
if(id==701) lanRequest(*v->app,{{"action","enable"},{"enabled",!v->snapshot.value("enabled",false)}});
|
||||||
|
if(id==704)
|
||||||
|
{
|
||||||
|
auto code=lanRequest(*v->app,{{"action","code"}}).at("code").get<std::string>();
|
||||||
|
std::string grouped; for(size_t i=0;i<code.size();++i) {if(i&&i%4==0) grouped+='-';grouped+=code[i];}
|
||||||
|
value(w,703,wide(grouped)); SetFocus(GetDlgItem(w,703)); SendDlgItemMessageW(w,703,EM_SETSEL,0,-1);
|
||||||
|
}
|
||||||
|
if(id==705) {lanRequest(*v->app,{{"action","pair"},{"peer",peer},{"code",utf8(value(w,703))}});value(w,703,L"");}
|
||||||
|
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
|
||||||
|
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
|
||||||
|
if(id==715)
|
||||||
|
{
|
||||||
|
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
|
||||||
|
}
|
||||||
|
if(id==716)lanRequest(*v->app,{{"action","input"},{"enabled",false}});
|
||||||
|
refreshLan(w,*v); return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(const std::exception &e) {v->error=wide(e.what());value(w,708,v->error);}
|
||||||
|
return DefWindowProcW(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
void showLan(HWND parent,App &app)
|
||||||
|
{
|
||||||
|
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,836,0,false);
|
||||||
|
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
|
||||||
|
auto v=new LanView{parent,&app};SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)v);
|
||||||
|
label(w,L"Find running KSwitcher instances on your local network and pair them.",18,16,700);
|
||||||
|
control(w,L"STATIC",L"",0,709,18,45,700,24);
|
||||||
|
button(w,L"&Enable discovery",701,18,78,210);
|
||||||
|
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
|
||||||
|
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,224,700,40);
|
||||||
|
label(w,L"Same pairing c&ode on both computers",18,273,700);
|
||||||
|
edit(w,703,18,301,470);SendDlgItemMessageW(w,703,EM_SETLIMITTEXT,80,0);
|
||||||
|
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,338,700);
|
||||||
|
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,412,700,40);
|
||||||
|
label(w,L"Arrange your computers",18,458,700);
|
||||||
|
WNDCLASSW gridClass{};gridClass.lpfnWndProc=lanGridProc;gridClass.hInstance=app.instance;gridClass.hCursor=LoadCursorW(nullptr,IDC_ARROW);gridClass.lpszClassName=L"KSwitcher.LAN.Layout";RegisterClassW(&gridClass);
|
||||||
|
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,
|
||||||
|
scale(w,18),scale(w,486),scale(w,700),scale(w,180),w,reinterpret_cast<HMENU>(721),app.instance,v);
|
||||||
|
windowLayoutState(w)->controls.push_back({grid,{18,486,718,666}});
|
||||||
|
control(w,L"STATIC",L"",0,719,18,674,700,24);
|
||||||
|
button(w,L"Enable &input sharing",715,18,706,330);
|
||||||
|
button(w,L"&Stop input sharing",716,370,706,240);
|
||||||
|
control(w,L"STATIC",L"",0,718,18,744,700,28);
|
||||||
|
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,776,580,40);
|
||||||
|
button(w,L"&Close",IDCANCEL,618,792,100);
|
||||||
|
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
|
||||||
|
SetTimer(w,1,1000,nullptr);
|
||||||
|
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.51";
|
inline constexpr wchar_t AppVersion[] = L"0.6.57";
|
||||||
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;
|
||||||
|
|||||||
+145
-4
@@ -1,6 +1,7 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
|
#include "lan_pairing.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ void contextChecks()
|
|||||||
ks::validateSettings(scoped);
|
ks::validateSettings(scoped);
|
||||||
require(ks::snippetAppPriority(scoped.snippets[1], L"ms-teams") > ks::snippetAppPriority(scoped.snippets[0], L"ms-teams"), "app snippet precedes global regardless of list order");
|
require(ks::snippetAppPriority(scoped.snippets[1], L"ms-teams") > ks::snippetAppPriority(scoped.snippets[0], L"ms-teams"), "app snippet precedes global regardless of list order");
|
||||||
require(ks::snippetAppPriority(scoped.snippets[1], L"Telegram.exe") == 0, "restricted snippet cannot run in another app");
|
require(ks::snippetAppPriority(scoped.snippets[1], L"Telegram.exe") == 0, "restricted snippet cannot run in another app");
|
||||||
|
require(ks::snippetAppPriority(scoped.snippets[1], L"Outlook.EXE") == 2, "restricted snippet also runs in its second application");
|
||||||
require(ks::snippetAppPriority(scoped.snippets[0], L"") == 1, "legacy/global snippets remain available");
|
require(ks::snippetAppPriority(scoped.snippets[0], L"") == 1, "legacy/global snippets remain available");
|
||||||
auto scopedRoundtrip = ks::decodeSettings(ks::encodeSettings(scoped), {});
|
auto scopedRoundtrip = ks::decodeSettings(ks::encodeSettings(scoped), {});
|
||||||
require(scopedRoundtrip.snippets[1].apps == std::vector<std::wstring>{L"ms-teams", L"outlook"}, "scopes normalize and persist");
|
require(scopedRoundtrip.snippets[1].apps == std::vector<std::wstring>{L"ms-teams", L"outlook"}, "scopes normalize and persist");
|
||||||
@@ -73,9 +75,9 @@ void contextChecks()
|
|||||||
require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles");
|
require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles");
|
||||||
auto withoutProfiles = settings;
|
auto withoutProfiles = settings;
|
||||||
withoutProfiles.chatCycles.clear();
|
withoutProfiles.chatCycles.clear();
|
||||||
require(ks::sharedSyncSettings(settings) == ks::sharedSyncSettings(withoutProfiles), "profiles excluded from sync");
|
require(ks::sharedSyncSettings(settings) != ks::sharedSyncSettings(withoutProfiles), "profiles included in sync");
|
||||||
auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), settings, installed, false);
|
auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), withoutProfiles, installed, false);
|
||||||
require(restored.chatCycles == settings.chatCycles, "sync preserves local profiles");
|
require(restored.chatCycles == settings.chatCycles, "sync transfers chat profiles to another PC");
|
||||||
settings.chatCycles[key] = {settings.cycle[1]};
|
settings.chatCycles[key] = {settings.cycle[1]};
|
||||||
require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile");
|
require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile");
|
||||||
settings.chatCycles[key] = {L"win:04070407"};
|
settings.chatCycles[key] = {L"win:04070407"};
|
||||||
@@ -121,6 +123,52 @@ int main()
|
|||||||
app.settings.chatCycles[key] = {app.layouts[0].id};
|
app.settings.chatCycles[key] = {app.layouts[0].id};
|
||||||
ks::showSettings(app);
|
ks::showSettings(app);
|
||||||
HWND parent = app.settingsWindow;
|
HWND parent = app.settingsWindow;
|
||||||
|
auto saveEnabled=[&] { return IsWindowEnabled(GetDlgItem(parent,IDOK))!=FALSE; };
|
||||||
|
auto status=[&] { wchar_t text[256]{}; GetDlgItemTextW(parent,261,text,256); return std::wstring(text); };
|
||||||
|
require(!saveEnabled() && status()==L"No unsaved changes.","new Settings shows clean state with Save disabled");
|
||||||
|
SendMessageW(parent,WM_COMMAND,IDOK,0);
|
||||||
|
require(IsWindow(parent),"Save command with no changes does not apply or close Settings");
|
||||||
|
bool originalLog=IsDlgButtonChecked(parent,208)==BST_CHECKED;
|
||||||
|
CheckDlgButton(parent,208,originalLog?BST_UNCHECKED:BST_CHECKED);
|
||||||
|
SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0);
|
||||||
|
require(saveEnabled() && status().find(L"Unsaved changes") == 0,"checkbox edit immediately marks Settings dirty");
|
||||||
|
CheckDlgButton(parent,208,originalLog?BST_CHECKED:BST_UNCHECKED);
|
||||||
|
SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0);
|
||||||
|
require(!saveEnabled(),"reverting checkbox disables Save again");
|
||||||
|
wchar_t originalLimit[32]{}; GetDlgItemTextW(parent,240,originalLimit,32);
|
||||||
|
SetDlgItemTextW(parent,240,L"invalid");
|
||||||
|
require(saveEnabled(),"invalid text remains an unsaved edit");
|
||||||
|
SetDlgItemTextW(parent,240,originalLimit);
|
||||||
|
require(!saveEnabled(),"restoring valid original text clears dirty state");
|
||||||
|
SendMessageW(parent,WM_COMMAND,257,0);
|
||||||
|
HWND ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||||
|
require(ocr!=nullptr,"OCR preferences open");
|
||||||
|
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
|
||||||
|
SetDlgItemTextW(ocr,503,L"eng");
|
||||||
|
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(!saveEnabled(),"cancelled child changes leave Settings clean");
|
||||||
|
SendMessageW(parent,WM_COMMAND,257,0);
|
||||||
|
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||||
|
SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng");
|
||||||
|
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||||
|
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
|
||||||
|
SendMessageW(parent,WM_COMMAND,257,0);
|
||||||
|
ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr);
|
||||||
|
SetDlgItemTextW(ocr,503,languages);
|
||||||
|
SendMessageW(ocr,WM_COMMAND,IDOK,0);
|
||||||
|
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
|
||||||
|
SendMessageW(parent,WM_COMMAND,255,0);
|
||||||
|
HWND automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
|
require(automatic!=nullptr,"automatic correction preferences open");
|
||||||
|
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
|
||||||
|
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
|
||||||
|
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||||
|
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
|
||||||
|
SendMessageW(parent,WM_COMMAND,255,0);
|
||||||
|
automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
|
CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED);
|
||||||
|
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||||
|
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) == 5, "five settings categories");
|
||||||
for (int page = 0; page < 5; ++page)
|
for (int page = 0; page < 5; ++page)
|
||||||
@@ -151,6 +199,7 @@ int main()
|
|||||||
TabCtrl_SetCurSel(tabs, 1);
|
TabCtrl_SetCurSel(tabs, 1);
|
||||||
NMHDR notification{tabs, 260, TCN_SELCHANGE};
|
NMHDR notification{tabs, 260, TCN_SELCHANGE};
|
||||||
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification);
|
||||||
|
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 = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
||||||
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
||||||
@@ -169,6 +218,7 @@ int main()
|
|||||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
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");
|
||||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||||
dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr);
|
dialog = FindWindowW(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");
|
||||||
@@ -178,20 +228,30 @@ int main()
|
|||||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
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");
|
||||||
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
require(!app.settingsWindow && app.settings.chatCycles.at(key).size() == app.layouts.size(), "settings saves selected languages");
|
require(!app.settingsWindow && app.settings.chatCycles.at(key).size() == app.layouts.size(), "settings saves selected languages");
|
||||||
require(ks::loadSettings(root / L"settings.json", app.layouts).chatCycles == app.settings.chatCycles, "profile saved to disk");
|
require(ks::loadSettings(root / L"settings.json", app.layouts).chatCycles == app.settings.chatCycles, "profile saved to disk");
|
||||||
ks::showSettings(app);
|
ks::showSettings(app);
|
||||||
parent = app.settingsWindow;
|
parent = app.settingsWindow;
|
||||||
|
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 = FindWindowW(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(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
|
||||||
|
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
|
GetClientRect(parent,&rect); dc=GetDC(parent);
|
||||||
|
clip=ks::screenshotClip(dc,rect.right,rect.bottom,{}); ReleaseDC(parent,dc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"settings-unsaved.png",
|
||||||
|
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");
|
||||||
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;
|
||||||
|
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
||||||
ks::showSnippets(app);
|
ks::showSnippets(app);
|
||||||
HWND snippets = app.snippetsWindow;
|
HWND snippets = app.snippetsWindow;
|
||||||
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||||
@@ -199,9 +259,18 @@ int main()
|
|||||||
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
|
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
|
||||||
CheckDlgButton(snippets,314,BST_CHECKED);
|
CheckDlgButton(snippets,314,BST_CHECKED);
|
||||||
SendMessageW(snippets,WM_COMMAND,314,0);
|
SendMessageW(snippets,WM_COMMAND,314,0);
|
||||||
SetDlgItemTextW(snippets,315,L"Notepad.exe, MS-Teams.EXE");
|
SetDlgItemTextW(snippets,315,L"Notepad.exe");
|
||||||
|
auto secondApp = SendDlgItemMessageW(snippets,316,CB_ADDSTRING,0,(LPARAM)L"MS-Teams.EXE");
|
||||||
|
SendDlgItemMessageW(snippets,316,CB_SETCURSEL,secondApp,0);
|
||||||
|
SendMessageW(snippets,WM_COMMAND,317,0);
|
||||||
|
SendMessageW(snippets,WM_COMMAND,317,0);
|
||||||
|
wchar_t appList[128]{};
|
||||||
|
GetDlgItemTextW(snippets,315,appList,128);
|
||||||
|
require(std::wstring(appList)==L"Notepad.exe, MS-Teams.EXE", "Add app appends a second application without duplicates");
|
||||||
SendMessageW(snippets,WM_COMMAND,IDOK,0);
|
SendMessageW(snippets,WM_COMMAND,IDOK,0);
|
||||||
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
|
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
|
||||||
|
require(!saveEnabled() && status()==L"No unsaved changes.","separately saved snippet edit leaves Settings clean");
|
||||||
|
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||||
ks::showSnippets(app);snippets=app.snippetsWindow;
|
ks::showSnippets(app);snippets=app.snippetsWindow;
|
||||||
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
|
||||||
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
|
||||||
@@ -215,6 +284,78 @@ int main()
|
|||||||
SetDlgItemTextW(snippets,315,L"chrome.exe");
|
SetDlgItemTextW(snippets,315,L"chrome.exe");
|
||||||
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
|
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
|
||||||
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
|
||||||
|
ks::showSettings(app);parent=app.settingsWindow;
|
||||||
|
SendMessageW(parent,WM_COMMAND,262,0);
|
||||||
|
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
||||||
|
require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(lan,707)),"cannot forget nonexistent pair");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(lan,715)),"input sharing requires a paired computer");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(lan,716)),"stop input disabled when sharing is off");
|
||||||
|
require(GetDlgItem(lan,721)!=nullptr,"monitor arrangement grid is present");
|
||||||
|
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(!std::filesystem::exists(root/L"lan-pairing.bin"),"opening LAN settings does not enable or save discovery");
|
||||||
|
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
|
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
|
||||||
|
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
|
||||||
|
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
|
||||||
|
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
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')}}}}}};
|
||||||
|
auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end())));
|
||||||
|
ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp);
|
||||||
|
SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0);
|
||||||
|
HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr);
|
||||||
|
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls");
|
||||||
|
SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0);
|
||||||
|
SendMessageW(lan,WM_TIMER,1,0);
|
||||||
|
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");
|
||||||
|
// Let the native radio-button theme animation finish before visual capture.
|
||||||
|
auto repaintUntil=GetTickCount64()+200;
|
||||||
|
while(GetTickCount64()<repaintUntil)
|
||||||
|
{
|
||||||
|
MSG message{};
|
||||||
|
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)){TranslateMessage(&message);DispatchMessageW(&message);}
|
||||||
|
Sleep(10);
|
||||||
|
}
|
||||||
|
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
|
RECT r{};GetClientRect(lan,&r);HDC 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-screen-layout.png",ks::encodePng(ks::decodeImage(screenshot)));
|
||||||
|
SendMessageW(lan,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=FindWindowW(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";
|
||||||
|
|||||||
@@ -676,6 +676,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;
|
||||||
|
|||||||
+24
-3
@@ -22,6 +22,7 @@ namespace
|
|||||||
ks::App *app{};
|
ks::App *app{};
|
||||||
HWND host{}, field{};
|
HWND host{}, field{};
|
||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
|
bool snippetRecoveryOnly = false;
|
||||||
bool captured = false, failed = false;
|
bool captured = false, failed = false;
|
||||||
bool conversionOnly = false;
|
bool conversionOnly = false;
|
||||||
bool typosOnly = false;
|
bool typosOnly = false;
|
||||||
@@ -588,10 +589,29 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
|
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
case 62:
|
case 64:
|
||||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
captured = true;
|
captured = true;
|
||||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
|
app->settings.snippets = {{L";repo-changes-jira", L"expanded{cursor}"}};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"prefix ;repo-changes-jira", false);
|
||||||
|
record(L";repo-changes-jira");
|
||||||
|
droppedSelectionKeys = 10;
|
||||||
|
enterPresses = 0;
|
||||||
|
app->transform(0, 0);
|
||||||
|
break;
|
||||||
|
case 65:
|
||||||
|
require(text() == L"prefix expanded", "long snippet recovers partial selection and preserves prefix");
|
||||||
|
require(droppedSelectionKeys == 0 && enterPresses == 0, "snippet recovery consumes missing arrows without Enter");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "snippet recovery restores clipboard");
|
||||||
|
if (snippetRecoveryOnly) { finish(); break; }
|
||||||
|
stage = 62;
|
||||||
|
break;
|
||||||
|
case 62:
|
||||||
|
if (!captured) saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
|
captured = true;
|
||||||
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
app->settings.snippets = {{L";r", L"unexpected{enter}"}};
|
app->settings.snippets = {{L";r", L"unexpected{enter}"}};
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
prepare(L"prefix ", false);
|
prepare(L"prefix ", false);
|
||||||
@@ -1038,8 +1058,9 @@ int main(int argc, char **argv)
|
|||||||
SetWindowSubclass(field, observeEnter, 1, 0);
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||||
stage = 20;
|
stage = 20;
|
||||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
snippetRecoveryOnly = argc > 1 && std::string(argv[1]) == "--snippet-recovery-only";
|
||||||
stage = 62;
|
if (snippetRecoveryOnly || (argc > 1 && std::string(argv[1]) == "--snippets-only"))
|
||||||
|
stage = 64;
|
||||||
SetTimer(host, 1, 100, nullptr);
|
SetTimer(host, 1, 100, nullptr);
|
||||||
auto forwarding = SetWindowsHookExW(WH_KEYBOARD_LL,forwardingHook,GetModuleHandleW(nullptr),0);
|
auto forwarding = SetWindowsHookExW(WH_KEYBOARD_LL,forwardingHook,GetModuleHandleW(nullptr),0);
|
||||||
require(forwarding!=nullptr,"downstream forwarding fixture installed before application hook");
|
require(forwarding!=nullptr,"downstream forwarding fixture installed before application hook");
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
#include "lan.h"
|
||||||
|
#include "lan_pairing.h"
|
||||||
|
#include <iostream>
|
||||||
|
#include <tlhelp32.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int passed{};
|
||||||
|
void check(bool ok,const char *name) {if(!ok) throw std::runtime_error(name);++passed;}
|
||||||
|
template<class F> void rejects(F f,const char *name) {bool failed=false;try{f();}catch(...){failed=true;}check(failed,name);}
|
||||||
|
unsigned threads()
|
||||||
|
{
|
||||||
|
HANDLE snapshot=CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD,0);
|
||||||
|
if(snapshot==INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot inspect test threads");
|
||||||
|
THREADENTRY32 entry{sizeof(entry)};unsigned count=0;
|
||||||
|
if(Thread32First(snapshot,&entry)) do {if(entry.th32OwnerProcessID==GetCurrentProcessId()) ++count;} while(Thread32Next(snapshot,&entry));
|
||||||
|
CloseHandle(snapshot);return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main(int argc,char **argv)
|
||||||
|
{
|
||||||
|
using namespace ks;using namespace ks::lan;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const std::string a(32,'a'),b(32,'b'),c(32,'c'),code="0123456789abcdef0123456789abcdef";
|
||||||
|
check(normalizeCode("0123-4567-89AB-CDEF-0123-4567-89ab-cdef")==code,"code accepts groups and case");
|
||||||
|
rejects([]{normalizeCode("123456");},"short guessable codes rejected");
|
||||||
|
rejects([]{normalizeCode(std::string(32,'x'));},"invalid code rejected");
|
||||||
|
check(identifier(randomHex(16)),"cryptographic instance ID");
|
||||||
|
check(mac("key","The quick brown fox jumps over the lazy dog")=="f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", "HMAC SHA256 known vector");
|
||||||
|
check(equal("abc","abc")&&!equal("abc","abd")&&!equal("abc","ab"),"proof comparison");
|
||||||
|
Pairing left(a,b,code,100),right(b,a,code,100),wrong(b,a,std::string(32,'f'),100);
|
||||||
|
check(wrong.receive(left.offer(),101).empty(),"different code cannot acknowledge");
|
||||||
|
auto forged=left.offer();forged["id"]=c;
|
||||||
|
check(right.receive(forged,101).empty(),"identity substitution rejected");
|
||||||
|
forged=left.offer();forged["version"]=2;
|
||||||
|
check(right.receive(forged,101).empty(),"unsupported protocol rejected");
|
||||||
|
forged=left.offer();forged["to"]=c;
|
||||||
|
check(right.receive(forged,101).empty(),"wrong destination rejected");
|
||||||
|
auto ackR=right.receive(left.offer(),101),ackL=left.receive(right.offer(),101);
|
||||||
|
check(ackR.size()==1&&ackL.size()==1,"mutual offers acknowledged");
|
||||||
|
check(!left.confirmed&&!right.confirmed,"discovery and offers are not trust");
|
||||||
|
auto confirmL=left.receive(ackR[0],102),confirmR=right.receive(ackL[0],102);
|
||||||
|
check(confirmL.size()==1&&confirmR.size()==1,"authenticated challenges confirmed");
|
||||||
|
left.receive(confirmR[0],103);right.receive(confirmL[0],103);
|
||||||
|
check(left.confirmed&&right.confirmed&&left.derived==right.derived,"both endpoints derive same pair key");
|
||||||
|
check(left.derived!=code&&left.derived.size()==64,"pair key separate from setup code");
|
||||||
|
Pairing retry(a,b,code,200);
|
||||||
|
check(retry.receive(ackR[0],201).empty()&&!retry.confirmed,"old ACK cannot pair new attempt");
|
||||||
|
check(retry.receive(confirmR[0],201).empty()&&!retry.confirmed,"old confirmation cannot pair new attempt");
|
||||||
|
Pairing expired(b,a,code,100);
|
||||||
|
check(expired.receive(left.offer(),100+PairLifetime).empty(),"pairing window expires");
|
||||||
|
check(left.receive(right.offer(),104).size()==1,"duplicate UDP offer is safely acknowledged");
|
||||||
|
rejects([&]{Pairing self(a,a,code,0);},"self-pair rejected");
|
||||||
|
auto root=std::filesystem::temp_directory_path()/wide("ks-lan-test-"+randomHex(16));
|
||||||
|
std::filesystem::create_directory(root);
|
||||||
|
struct Cleanup{std::filesystem::path p;~Cleanup(){std::error_code ec;std::filesystem::remove_all(p,ec);}} cleanup{root};
|
||||||
|
auto request=[](LanService &service,Json j){auto s=j.dump();return Json::parse(service.command(Bytes(s.begin(),s.end())));};
|
||||||
|
std::string identity;
|
||||||
|
{
|
||||||
|
auto idleThreads=threads();
|
||||||
|
LanService service(root);
|
||||||
|
check(threads()==idleThreads,"disabled LAN creates no worker thread");
|
||||||
|
auto state=request(service,{{"action","status"}});
|
||||||
|
check(!state.at("enabled").get<bool>()&&state.at("peers").empty(),"disabled by default");
|
||||||
|
identity=state.at("id");
|
||||||
|
check(!std::filesystem::exists(root/L"lan-pairing.bin"),"viewing disabled status does not create config");
|
||||||
|
auto generated=request(service,{{"action","code"}}).at("code").get<std::string>();
|
||||||
|
check(identifier(generated),"generated pairing code has 128 bits");
|
||||||
|
rejects([&]{request(service,{{"action","pair"},{"peer",b},{"code",code}});},"cannot pair undiscovered peer while disabled");
|
||||||
|
request(service,{{"action","enable"},{"enabled",false}});
|
||||||
|
auto sealed=readFile(root/L"lan-pairing.bin",256*1024);
|
||||||
|
check(std::string(sealed.begin(),sealed.end()).find(identity)==std::string::npos,"local configuration protected by DPAPI");
|
||||||
|
if(argc>1&&std::string(argv[1])=="--network")
|
||||||
|
{
|
||||||
|
request(service,{{"action","enable"},{"enabled",true}});
|
||||||
|
Sleep(400);
|
||||||
|
auto active=request(service,{{"action","status"}});
|
||||||
|
check(active.at("enabled").get<bool>() && active.at("status").get<std::string>().find("LAN stopped:")==std::string::npos,"opt-in discovery starts successfully");
|
||||||
|
check(threads()>idleThreads,"enabled discovery has a worker");
|
||||||
|
request(service,{{"action","enable"},{"enabled",false}});
|
||||||
|
check(threads()==idleThreads,"disabling discovery joins its worker");
|
||||||
|
// Reopening immediately exercises socket cleanup and exclusive bind.
|
||||||
|
request(service,{{"action","enable"},{"enabled",true}});Sleep(200);
|
||||||
|
check(request(service,{{"action","status"}}).at("status").get<std::string>().find("LAN stopped:")==std::string::npos,"discovery port released and reusable");
|
||||||
|
request(service,{{"action","enable"},{"enabled",false}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
LanService restored(root);auto state=request(restored,{{"action","status"}});
|
||||||
|
check(state.at("id")==identity&&!state.at("enabled").get<bool>(),"stable local instance ID and disabled state restored");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto saved=Json::parse(unprotect(readFile(root/L"lan-pairing.bin",256*1024)));
|
||||||
|
saved["trusted"][b]={{"name","Other KSwitcher"},{"key",left.derived}};
|
||||||
|
auto json=saved.dump();atomicWrite(root/L"lan-pairing.bin",protect(Bytes(json.begin(),json.end())));
|
||||||
|
LanService restored(root);auto state=request(restored,{{"action","status"}});
|
||||||
|
check(state["peers"].size()==1 && state["peers"][0].at("paired").get<bool>() && !state["peers"][0].at("online").get<bool>(),"saved pair remains visible offline");
|
||||||
|
check(state.dump().find(left.derived)==std::string::npos,"status never exposes stored pair keys");
|
||||||
|
rejects([&]{request(restored,{{"action","input"},{"enabled",true},{"peer",b},{"edge",1}});},"input cannot be enabled without discovery");
|
||||||
|
rejects([&]{request(restored,{{"action","input"},{"enabled",false},{"edge",4}});},"invalid screen edge rejected");
|
||||||
|
auto position=request(restored,{{"action","input"},{"enabled",false},{"peer",b},{"edge",2}});
|
||||||
|
check(position.at("inputEdge")==2&&!restored.inputEnabled(),"screen position saved without enabling input");
|
||||||
|
if(argc>1&&std::string(argv[1])=="--network")
|
||||||
|
{
|
||||||
|
request(restored,{{"action","enable"},{"enabled",true}});
|
||||||
|
rejects([&]{request(restored,{{"action","input"},{"enabled",true},{"peer",c},{"edge",1}});},"discovered but unpaired peer cannot receive input");
|
||||||
|
request(restored,{{"action","input"},{"enabled",true},{"peer",b},{"edge",2}});
|
||||||
|
check(restored.inputEnabled(),"paired input enabled with explicit screen position");
|
||||||
|
request(restored,{{"action","enable"},{"enabled",false}});
|
||||||
|
check(!restored.inputEnabled(),"disabling discovery also stops input runtime");
|
||||||
|
}
|
||||||
|
request(restored,{{"action","forget"},{"peer",b}});
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
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;}}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
#include <winsock2.h>
|
||||||
|
#include "lan_input.h"
|
||||||
|
#include "lan_mesh.h"
|
||||||
|
#include <iostream>
|
||||||
|
#include <deque>
|
||||||
|
|
||||||
|
using namespace ks;using namespace ks::lan;
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int passed{};
|
||||||
|
void check(bool value,const char *text){if(!value)throw std::runtime_error(text);++passed;}
|
||||||
|
template<class F>void rejects(F f,const char *text){bool failed=false;try{f();}catch(...){failed=true;}check(failed,text);}
|
||||||
|
struct Desktop
|
||||||
|
{
|
||||||
|
RECT rect{-1920,-200,0,880};POINT point{-1000,300};bool available=true,physicalHeld=false,allow=true;
|
||||||
|
std::vector<INPUT> events;int dpi=96;bool delayed=false;InputRouter *router{};
|
||||||
|
InputEnvironment environment()
|
||||||
|
{
|
||||||
|
return {[this]{return rect;},[this]{return point;},[this](const INPUT &i){
|
||||||
|
if(!allow)return false;
|
||||||
|
events.push_back(i);
|
||||||
|
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)};
|
||||||
|
if(router){MSLLHOOKSTRUCT m{};m.pt=point;m.flags=LLMHF_INJECTED;m.dwExtraInfo=i.mi.dwExtraInfo;router->mouse(WM_MOUSEMOVE,m,1000);}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},[this]{return available;},[this]{return physicalHeld;},{},{},[this](POINT){return dpi;}};
|
||||||
|
}
|
||||||
|
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;}));}
|
||||||
|
};
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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)source;while(!out.empty()){auto f=out.front();out.pop_front();target.receive(f,now);}}
|
||||||
|
struct Pair
|
||||||
|
{
|
||||||
|
Desktop a,b;std::deque<InputFrame> ab,ba;
|
||||||
|
InputRouter left,right;
|
||||||
|
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)
|
||||||
|
{a.router=&left;b.router=&right;b.rect={0,0,1600,900};b.point={600,400};left.tick(true,1000);right.tick(true,1000);}
|
||||||
|
~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)
|
||||||
|
{
|
||||||
|
motion(left,a,edgePoint(a.rect,edge,30000,0),1000);
|
||||||
|
check(left.mode()==ControlMode::Requesting,"crossing edge requests ownership, not immediate suppression");
|
||||||
|
deliver();check(left.mode()==ControlMode::Sending&&right.mode()==ControlMode::Receiving,"mouse and keyboard ownership acknowledged together");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Pair p(side);auto middle=edgePoint(p.a.rect,side,32768,0);
|
||||||
|
check(atEdge(middle,p.a.rect,side),"all four virtual-screen edges supported");
|
||||||
|
check(std::abs(edgePosition(middle,p.a.rect,side)-32768)<100,"negative monitor coordinates normalize");
|
||||||
|
p.enter(side);
|
||||||
|
auto old=p.b.point;
|
||||||
|
for(int step=0;step<20;++step)
|
||||||
|
{
|
||||||
|
auto point=p.a.point;if(side==0)--point.x;else if(side==1)++point.x;else if(side==2)--point.y;else ++point.y;
|
||||||
|
motion(p.left,p.a,point,1110+step);p.deliver(1110+step);
|
||||||
|
}
|
||||||
|
check(side<2 ? std::abs(p.b.point.x-old.x)==20 : std::abs(p.b.point.y-old.y)==20,"one-pixel mouse movements have no accumulating warp bias");
|
||||||
|
check(p.left.keyboard(key('A',0x1e),true,1200),"source key down suppressed");p.deliver(1200);
|
||||||
|
check(p.a.keyEvents(true)==0&&p.b.keyEvents(true)==1,"key only injected on computer with cursor");
|
||||||
|
check(p.b.events.back().ki.dwFlags&KEYEVENTF_SCANCODE,"physical scan code preserved across layouts");
|
||||||
|
check(p.left.keyboard(key('A',0x1e),false,1210),"source key up suppressed");p.deliver(1210);
|
||||||
|
check(p.b.keyEvents(false)==1,"remote key released");
|
||||||
|
auto injected=key('A',0x1e);injected.flags=LLKHF_INJECTED;injected.dwExtraInfo=remoteInputMarker();
|
||||||
|
check(!p.right.keyboard(injected,true,1220),"received input continues to destination hotkeys/snippets, no routing loop");
|
||||||
|
MSLLHOOKSTRUCT click{};check(p.left.mouse(WM_LBUTTONDOWN,click,1230),"source click suppressed");p.deliver(1230);
|
||||||
|
check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_LEFTDOWN,"remote left button pressed");
|
||||||
|
p.left.mouse(WM_LBUTTONUP,click,1240);p.deliver(1240);
|
||||||
|
check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_LEFTUP,"remote left button released");
|
||||||
|
click.mouseData=DWORD(120)<<16;p.left.mouse(WM_MOUSEWHEEL,click,1250);p.deliver(1250);
|
||||||
|
check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_WHEEL&&p.b.events.back().mi.mouseData==120,"wheel delta forwarded");
|
||||||
|
auto centre=p.a.point;POINT moved=centre;
|
||||||
|
if(side==0)moved.x-=30;else if(side==1)moved.x+=30;else if(side==2)moved.y-=30;else moved.y+=30;
|
||||||
|
check(motion(p.left,p.a,moved,1300),"remote mouse move suppressed locally");p.deliver(1300);
|
||||||
|
check(p.b.point.x!=old.x||p.b.point.y!=old.y,"cursor moves on target");
|
||||||
|
// Repeated source movement towards the return edge.
|
||||||
|
for(int n=0;n<5&&p.left.mode()==ControlMode::Sending;++n)
|
||||||
|
{
|
||||||
|
moved=p.a.point;if(side==0)moved.x+=400;else if(side==1)moved.x-=400;else if(side==2)moved.y+=400;else moved.y-=400;
|
||||||
|
motion(p.left,p.a,moved,1400+n);p.deliver(1400+n);
|
||||||
|
}
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"crossing target return edge restores local ownership");
|
||||||
|
check(!atEdge(p.a.point,p.a.rect,side),"return cursor inset avoids immediate bounce");
|
||||||
|
check(!p.left.keyboard(key('B'),true,1500),"normal local typing resumes");p.left.keyboard(key('B'),false,1501);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.left.keyboard(key(VK_LCONTROL),true,1200);p.left.keyboard(key(VK_LMENU),true,1201);p.deliver();
|
||||||
|
check(p.left.keyboard(key(VK_ESCAPE),true,1202),"emergency return chord consumed");p.deliver();
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"emergency chord returns both sides");
|
||||||
|
check(p.b.keyEvents(false)==2,"emergency return releases remote modifiers");
|
||||||
|
check(p.left.keyboard(key(VK_LCONTROL),false,1203)&&p.left.keyboard(key(VK_LMENU),false,1204),"stray source modifier releases consumed after return");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.left.keyboard(key('A'),true,1200);p.deliver();
|
||||||
|
p.left.tick(false,1300);p.right.tick(false,1300);
|
||||||
|
check(p.b.keyEvents(false)==1&&p.left.mode()==ControlMode::Local,"disconnect releases held remote key");
|
||||||
|
check(p.left.keyboard(key('A'),false,1400),"source does not leak orphaned key-up after disconnect");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.left.keyboard(key('A'),true,1200);p.deliver();p.b.available=false;p.right.tick(true,1300);
|
||||||
|
check(p.right.mode()==ControlMode::Local&&p.b.keyEvents(false)==0,"secure desktop stops ownership without injecting there");
|
||||||
|
p.b.available=true;p.right.tick(true,1400);check(p.b.keyEvents(false)==1,"blocked release retried when normal desktop returns");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.a.physicalHeld=true;motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);
|
||||||
|
check(p.left.mode()==ControlMode::Local,"no cross-screen handoff with physically held key/button");
|
||||||
|
p.a.physicalHeld=false;motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1100);
|
||||||
|
p.left.keyboard(key('X'),true,1101);p.deliver(1200);
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"typing during handoff cancels stale accept");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);motion(p.right,p.b,edgePoint(p.b.rect,0,30000,0),1000);
|
||||||
|
p.deliver();p.deliver();check(p.left.mode()==ControlMode::Sending&&p.right.mode()==ControlMode::Receiving,"simultaneous crossing has deterministic ownership");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);p.left.keyboard(key('A'),true,1200);p.deliver();
|
||||||
|
check(!p.right.keyboard(key('B'),true,1300),"local physical keyboard takes over target");p.deliver();
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.b.keyEvents(false)==1,"local takeover returns source and releases remote keys");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.b.allow=false;motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);p.deliver();
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"blocked SendInput fails closed without capturing source");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);p.enter(1);MSLLHOOKSTRUCT m{};m.mouseData=DWORD(XBUTTON2)<<16;
|
||||||
|
p.left.mouse(WM_XBUTTONDOWN,m,1200);p.deliver();p.right.tick(false,1300);
|
||||||
|
check(p.b.events.back().mi.dwFlags==MOUSEEVENTF_XUP&&p.b.events.back().mi.mouseData==XBUTTON2,"disconnect releases held extra mouse button");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Pair p(1);motion(p.left,p.a,edgePoint(p.a.rect,1,30000,0),1000);p.left.tick(true,2101);
|
||||||
|
check(p.left.mode()==ControlMode::Local,"unanswered ownership request times out locally");p.deliver(2200);p.deliver(2200);
|
||||||
|
check(p.left.mode()==ControlMode::Local&&p.right.mode()==ControlMode::Local,"late handoff acknowledgement cannot steal control");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Desktop d;bool accept=true;std::deque<InputFrame> frames;
|
||||||
|
InputRouter router(d.environment(),[&](auto f){frames.push_back(f);return accept;},1,true);
|
||||||
|
router.tick(true,1000);motion(router,d,edgePoint(d.rect,1,30000,0),1000);
|
||||||
|
auto token=frames.front().token;router.receive({InputType::Accept,token},1100);
|
||||||
|
accept=false;check(router.keyboard(key('Q'),true,1200),"failed queue still consumes the key rather than typing locally");
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
SOCKET s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(s==INVALID_SOCKET)throw std::runtime_error("test socket");
|
||||||
|
sockaddr_in a{};a.sin_family=AF_INET;a.sin_addr.s_addr=htonl(INADDR_LOOPBACK);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
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 channelChecks()
|
||||||
|
{
|
||||||
|
std::string a(32,'a'),b(32,'b'),secret(64,'f');auto na=randomHex(16),nb=randomHex(16);
|
||||||
|
auto k=inputSessionKey(secret,a,b,na,nb);check(k!=inputSessionKey(secret,b,a,nb,na),"directional session keys differ");
|
||||||
|
check(inputSessionKey(secret,a,b,na,nb,0)!=inputSessionKey(secret,a,b,na,nb,1),"screen geometry bound into authenticated session");
|
||||||
|
InputCipher tx(k),rx(k);InputFrame f{InputType::Key,37,'A',0x1e,1};auto packet=tx.encrypt(f);
|
||||||
|
check(packet.size()==36&&rx.decrypt(packet)==f,"AES-GCM authenticated input roundtrip");
|
||||||
|
rejects([&]{rx.decrypt(packet);},"encrypted frame replay rejected");
|
||||||
|
auto next=tx.encrypt({InputType::Move,37,-12,400});auto tampered=next;tampered[4]^=1;
|
||||||
|
rejects([&]{rx.decrypt(tampered);},"ciphertext modification rejected");
|
||||||
|
check(rx.decrypt(next)==InputFrame{InputType::Move,37,-12,400},"failed authentication does not expose or advance data");
|
||||||
|
InputCipher wrong(inputSessionKey(secret,b,a,nb,na));rejects([&]{wrong.decrypt(packet);},"reflection across directions 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");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,0,port);
|
||||||
|
auto deadline=GetTickCount64()+5000;std::vector<InputFrame> received;
|
||||||
|
while((!client.ready()||!server.ready())&&GetTickCount64()<deadline)
|
||||||
|
{
|
||||||
|
server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);
|
||||||
|
}
|
||||||
|
check(client.ready()&&server.ready(),"real TCP peers mutually authenticate with paired key");
|
||||||
|
check(received.empty(),"handshake produces no input events");
|
||||||
|
client.poll(htonl(INADDR_LOOPBACK),{f},received,GetTickCount64());
|
||||||
|
for(int n=0;n<100&&received.empty();++n){server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
|
||||||
|
check(received.size()==1&&received[0]==f,"real TCP channel delivers one ordered authenticated key");received.clear();
|
||||||
|
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);}
|
||||||
|
check(received.size()==1&&received[0]==reverse,"real input channel is bidirectional");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,std::string(64,'e'),0,port);
|
||||||
|
std::vector<InputFrame> received;auto start=GetTickCount64();
|
||||||
|
while(GetTickCount64()-start<400){server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
|
||||||
|
check(!client.ready()&&!server.ready()&&received.empty(),"wrong saved pair key never authorizes input");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto port=freePort();InputChannel client(a,b,secret,1,port),server(b,a,secret,1,port);
|
||||||
|
std::vector<InputFrame> received;auto start=GetTickCount64();
|
||||||
|
while(GetTickCount64()-start<400){server.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());client.poll(htonl(INADDR_LOOPBACK),{},received,GetTickCount64());Sleep(1);}
|
||||||
|
check(!client.ready()&&!server.ready()&&received.empty(),"conflicting screen arrangement rejected before input");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
try{channelChecks();hubChecks();routerChecks();meshChecks();std::cout<<"Passed "<<passed<<" LAN input checks\n";}
|
||||||
|
catch(const std::exception &e){std::cerr<<"FAIL: "<<e.what()<<'\n';return 1;}
|
||||||
|
}
|
||||||
@@ -83,6 +83,53 @@ int main(int argc, char **argv)
|
|||||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||||
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||||
"new screenshot editor starts with the pencil tool");
|
"new screenshot editor starts with the pencil tool");
|
||||||
|
{
|
||||||
|
require(GetDlgItem(window,41) && GetDlgItem(window,44),"smoothing slider and Apply to all exist");
|
||||||
|
auto adjust=[&](int value,int code=TB_THUMBPOSITION) {
|
||||||
|
SendDlgItemMessageW(window,41,TBM_SETPOS,TRUE,value);
|
||||||
|
SendMessageW(window,WM_HSCROLL,MAKEWPARAM(code,0),(LPARAM)GetDlgItem(window,41));
|
||||||
|
};
|
||||||
|
adjust(70); require(c.smoothing==70 && c.marks.empty(),"no selection changes default smoothing");
|
||||||
|
ks::Mark pencil{{40,80},{240,80},0}; pencil.freehand=true;
|
||||||
|
for(int i=0;i<=100;++i) pencil.points.push_back({40+i*2,80+(i%2?5:0)});
|
||||||
|
ks::Mark rectangle{{40,130},{240,210},0}; rectangle.shape=ks::ScreenshotShape::Rectangle;
|
||||||
|
c.marks={pencil,pencil,rectangle}; c.selected=0;
|
||||||
|
auto rawImage=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks)).bgra;
|
||||||
|
adjust(40,TB_THUMBTRACK); adjust(90,TB_THUMBTRACK); adjust(90,TB_ENDTRACK);
|
||||||
|
require(c.marks[0].smoothing==90 && c.marks[1].smoothing==0 && c.smoothing==70,
|
||||||
|
"selected stroke smoothing leaves other objects and default alone");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks[0].smoothing==0,"one undo reverses an entire slider drag");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(c.marks[0].smoothing==90,"redo restores smoothing amount and path");
|
||||||
|
c.selected=0; ks::syncLineOptions(window,c);
|
||||||
|
SendMessageW(window,WM_COMMAND,44,0);
|
||||||
|
require(c.marks[0].smoothing==90 && c.marks[1].smoothing==90 && c.smoothing==90 &&
|
||||||
|
c.marks[2].shape==ks::ScreenshotShape::Rectangle && c.marks[2].rawPoints.empty(),
|
||||||
|
"Apply to all updates pencil strokes and default without changing shapes");
|
||||||
|
require(ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks)).bgra!=rawImage,
|
||||||
|
"PNG export uses the smoothed geometry");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks[0].smoothing==90 && c.marks[1].smoothing==0,"bulk smoothing is one undo step");
|
||||||
|
c.selected=0; adjust(0);
|
||||||
|
require(c.marks[0].points.size()==pencil.points.size() && c.marks[0].points[1].y==85,
|
||||||
|
"selected zero smoothing restores exact original path");
|
||||||
|
require(ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks)).bgra==rawImage,
|
||||||
|
"restoring zero smoothing restores the exact original exported pixels");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
adjust(75);
|
||||||
|
auto mouse=[&](UINT message,POINT p) {
|
||||||
|
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,90});
|
||||||
|
for(int i=1;i<=100;++i) mouse(WM_MOUSEMOVE,{40+i*2,90+LONG(std::lround(35*std::sin(i*.04)+3*std::sin(i*1.7)))});
|
||||||
|
mouse(WM_LBUTTONUP,{240,64});
|
||||||
|
require(c.marks.size()==1 && c.marks[0].freehand && c.marks[0].smoothing==75 && !c.marks[0].rawPoints.empty(),
|
||||||
|
"new curved pencil stroke uses toolbar default and retains original samples");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{}; c.smoothing=25;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
|
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
|
||||||
SendMessageW(window,WM_KEYDOWN,'G',0);
|
SendMessageW(window,WM_KEYDOWN,'G',0);
|
||||||
@@ -141,6 +188,38 @@ int main(int argc, char **argv)
|
|||||||
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
||||||
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
||||||
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
require(GetDlgItem(window,38)!=nullptr,"arc has a dedicated toolbar button");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'U',0);
|
||||||
|
require(c.shape==ks::ScreenshotShape::Arc && c.arrow && !c.freehand,"U selects arc tool");
|
||||||
|
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
|
||||||
|
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Arc && !c.marks[0].isHighlight(),
|
||||||
|
"arc tool creates an editable shape");
|
||||||
|
auto arc=c.marks[0];
|
||||||
|
require(ks::hitScreenshotMark(arc,{150,70},3)==ks::MarkPart::Body &&
|
||||||
|
ks::hitScreenshotMark(arc,{150,180},3)==ks::MarkPart::None,
|
||||||
|
"arc selects its curve but has no closing chord");
|
||||||
|
ks::Surface rendered(320,240); PatBlt(rendered.dc,0,0,320,240,WHITENESS);
|
||||||
|
ks::drawMarks(rendered.dc,{arc});
|
||||||
|
require(GetPixel(rendered.dc,150,70)==arc.color && GetPixel(rendered.dc,150,180)==RGB(255,255,255),
|
||||||
|
"arc renders an open curve without a closing chord");
|
||||||
|
auto exported=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,{arc}));
|
||||||
|
auto channel=[&](int x,int y,int component){return exported.bgra[(y*exported.width+x)*4+component];};
|
||||||
|
require(channel(150,70,2)==GetRValue(arc.color) && channel(150,70,1)==GetGValue(arc.color) &&
|
||||||
|
channel(150,180,0)==255 && channel(150,180,1)==255 && channel(150,180,2)==255,
|
||||||
|
"PNG export preserves the arc and its open chord");
|
||||||
|
mouse(WM_LBUTTONDOWN,arc.to); mouse(WM_LBUTTONUP,{260,200});
|
||||||
|
require(c.marks[0].to.x==260 && c.marks[0].points.back().y==200,"arc resizes with its bounds");
|
||||||
|
auto arcGrip=ks::screenshotShapeRotationHandle(arc);
|
||||||
|
auto turned=ks::dragScreenshotMark(arc,ks::MarkPart::Rotation,arcGrip,{280,135},320,240);
|
||||||
|
require(turned.rotation!=0 && ks::hitScreenshotMark(turned,ks::rotateShapePoint(turned,{150,70}),3)==ks::MarkPart::Body,
|
||||||
|
"rotated arc remains selectable");
|
||||||
|
std::vector<ks::Mark> linked{arc}; ks::Mark connector{{10,10},{150,70},0};
|
||||||
|
ks::linkScreenshotEndpoint(connector,false,{150,70},linked,4);
|
||||||
|
require(connector.toLink.target!=0,"connector can attach to the arc curve");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo arc resize");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo arc creation");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Arc,"redo arc creation");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
SendMessageW(window,WM_COMMAND,21,0);
|
SendMessageW(window,WM_COMMAND,21,0);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#include "settings_merge.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");
|
||||||
|
std::cout<<"Passed 5 settings-save merge checks\n";
|
||||||
|
}catch(const std::exception &e){std::cerr<<e.what()<<'\n';return 1;}}
|
||||||
@@ -122,4 +122,31 @@ inline void testStrokeImprovement() {
|
|||||||
double deviation=0; for(auto p:smooth) deviation+=std::abs(p.y-100);
|
double deviation=0; for(auto p:smooth) deviation+=std::abs(p.y-100);
|
||||||
require(deviation/smooth.size()<1.5 && smooth.front().y==jitter.front().y && smooth.back().y==jitter.back().y,
|
require(deviation/smooth.size()<1.5 && smooth.front().y==jitter.front().y && smooth.back().y==jitter.back().y,
|
||||||
"smoothing reduces jitter and preserves endpoints");
|
"smoothing reduces jitter and preserves endpoints");
|
||||||
|
auto equal=[](const auto &a,const auto &b){return a.size()==b.size() && std::equal(a.begin(),a.end(),b.begin(),
|
||||||
|
[](POINT x,POINT y){return x.x==y.x && x.y==y.y;});};
|
||||||
|
require(equal(ks::smoothScreenshotStroke(jitter,0),jitter),"zero smoothing preserves exact mouse samples");
|
||||||
|
std::vector<POINT> waves;
|
||||||
|
for(int i=0;i<=200;++i) waves.push_back({30+i,100+LONG(std::lround(6*std::sin(i*.5)))});
|
||||||
|
auto roughness=[](const auto &p){double total=0; for(size_t i=1;i<p.size();++i) total+=std::abs(p[i].y-p[i-1].y); return total;};
|
||||||
|
auto low=ks::smoothScreenshotStroke(waves,25),high=ks::smoothScreenshotStroke(waves,100);
|
||||||
|
require(roughness(high)<roughness(low) && roughness(low)<roughness(waves),"higher smoothing removes more jitter");
|
||||||
|
require(high.front().x==waves.front().x && high.front().y==waves.front().y &&
|
||||||
|
high.back().x==waves.back().x && high.back().y==waves.back().y,"smoothing preserves both endpoints");
|
||||||
|
ks::ScreenshotMark mark{waves.front(),waves.back(),0}; mark.freehand=true; mark.points=waves;
|
||||||
|
ks::setScreenshotSmoothing(mark,100); ks::setScreenshotSmoothing(mark,25);
|
||||||
|
require(equal(mark.points,low),"lowering smoothing recomputes from original instead of accumulating filters");
|
||||||
|
ks::setScreenshotSmoothing(mark,0);
|
||||||
|
require(equal(mark.points,waves),"zero restores original after multiple adjustments");
|
||||||
|
ks::setScreenshotSmoothing(mark,100);
|
||||||
|
auto moved=ks::dragScreenshotMark(mark,ks::MarkPart::Body,{0,0},{10,15},500,500);
|
||||||
|
ks::setScreenshotSmoothing(moved,0);
|
||||||
|
require(moved.points[40].x==waves[40].x+10 && moved.points[40].y==waves[40].y+15,
|
||||||
|
"moving preserves the original path in moved coordinates");
|
||||||
|
auto resized=ks::dragScreenshotMark(mark,ks::MarkPart::End,mark.to,{260,130},500,500);
|
||||||
|
ks::setScreenshotSmoothing(resized,0);
|
||||||
|
require(resized.points.back().x==260 && resized.points.back().y==130,"endpoint editing survives smoothing changes");
|
||||||
|
auto duplicate=ks::cloneScreenshotMark(mark,{mark},500,500);
|
||||||
|
ks::setScreenshotSmoothing(duplicate,0);
|
||||||
|
require(duplicate.points[40].x==waves[40].x+16 && duplicate.points[40].y==waves[40].y+16,
|
||||||
|
"cloning retains independent original geometry");
|
||||||
}
|
}
|
||||||
+75
-2
@@ -70,11 +70,84 @@ int main()
|
|||||||
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
||||||
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
||||||
auto j = parse(base);
|
auto j = parse(base);
|
||||||
|
{
|
||||||
|
const std::wstring chat=L"telegram\nРобота / ~test";
|
||||||
|
const auto chatKey=utf8(chat);
|
||||||
|
Settings chats=local;
|
||||||
|
chats.chatCycles[chat]={L"win:00000422",L"win:00000409"};
|
||||||
|
chats.chatCycles[L"telegram-web\nРобота / ~test"]={L"win:00000409"};
|
||||||
|
chats.chatCycles[L"ms-teams\nTeam"]={L"win:00000422"};
|
||||||
|
chats.chatCycles[L"viber\nSaved inactive"]={L"win:00000409"};
|
||||||
|
auto shared=sharedSyncSettings(chats);
|
||||||
|
require(restoreSyncSettings(shared,local,{},true).chatCycles==chats.chatCycles,
|
||||||
|
"sync transfers all chat applications, Unicode names and ordered layout IDs even when not installed");
|
||||||
|
auto legacy=j; legacy.erase("chatCycles");
|
||||||
|
require(restoreSyncSettings(bytes(legacy),chats,{},true).chatCycles==chats.chatCycles,
|
||||||
|
"legacy profile without chat field preserves existing profiles");
|
||||||
|
require(restoreSyncSettings(base,chats,{},true).chatCycles.empty(),
|
||||||
|
"explicit empty chat map clears synchronized profiles");
|
||||||
|
auto l=j,r=j;
|
||||||
|
l["chatCycles"][chatKey]=Json::array({"win:00000422","win:00000409"});
|
||||||
|
r["chatCycles"]["ms-teams\nOther"]=Json::array({"win:00000409"});
|
||||||
|
auto first=mergeSyncProfile({},bytes(l),bytes(r));
|
||||||
|
require(first.conflicts.empty() && parse(first.local)["chatCycles"].size()==2 && first.local==first.remote,
|
||||||
|
"first sync unions profiles for different chats");
|
||||||
|
auto ancestor=first.local;
|
||||||
|
l=r=parse(ancestor);
|
||||||
|
l["chatCycles"][chatKey]=Json::array({"win:00000409","win:00000422"});
|
||||||
|
r["chatCycles"]["ms-teams\nOther"]=Json::array({"win:00000422"});
|
||||||
|
auto independent=mergeSyncProfile(ancestor,bytes(l),bytes(r));
|
||||||
|
require(independent.conflicts.empty() && parse(independent.local)["chatCycles"]==
|
||||||
|
Json({{chatKey,l["chatCycles"][chatKey]},{"ms-teams\nOther",r["chatCycles"]["ms-teams\nOther"]}}),
|
||||||
|
"independent chat edits merge without losing language order");
|
||||||
|
r["chatCycles"][chatKey]=Json::array({"win:00000422"});
|
||||||
|
auto conflict=mergeSyncProfile(ancestor,bytes(l),bytes(r));
|
||||||
|
require(conflict.conflicts.size()==1 && conflict.conflicts[0].key.find("~1")!=std::string::npos &&
|
||||||
|
conflict.conflicts[0].label.find(L"Chat languages: telegram / ")==0,
|
||||||
|
"same-chat edit conflict has readable name and escaped pointer");
|
||||||
|
auto again=mergeSyncProfile(conflict.base,conflict.local,conflict.remote);
|
||||||
|
require(again.conflicts.size()==1,"chat conflict survives persisted merge ancestor");
|
||||||
|
auto resolved=mergeSyncProfile(again.base,again.local,again.remote,{{again.conflicts[0],false}});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.local)["chatCycles"][chatKey]==r["chatCycles"][chatKey],
|
||||||
|
"choose remote languages for one chat");
|
||||||
|
l=r=parse(ancestor); l["chatCycles"].erase(chatKey);
|
||||||
|
auto removed=mergeSyncProfile(ancestor,bytes(l),bytes(r));
|
||||||
|
require(removed.conflicts.empty() && !restoreSyncSettings(removed.local,local,{},true).chatCycles.contains(chat),
|
||||||
|
"chat deletion propagates and merged profile remains loadable");
|
||||||
|
r["chatCycles"][chatKey]=Json::array({"win:00000409"});
|
||||||
|
auto deletion=mergeSyncProfile(ancestor,bytes(l),bytes(r));
|
||||||
|
require(deletion.conflicts.size()==1,"chat deletion versus edit requires a choice");
|
||||||
|
auto erase=mergeSyncProfile(deletion.base,deletion.local,deletion.remote,{{deletion.conflicts[0],true}});
|
||||||
|
require(erase.conflicts.empty() && !parse(erase.remote)["chatCycles"].contains(chatKey),
|
||||||
|
"choosing deletion removes chat without leaving null entries");
|
||||||
|
auto stale=mergeSyncProfile(ancestor,bytes(l),bytes(legacy));
|
||||||
|
require(stale.conflicts.empty() && parse(stale.local)["chatCycles"]==l["chatCycles"],
|
||||||
|
"older remote missing chat field cannot delete or resurrect local chat profiles");
|
||||||
|
auto validate=[&](const Bytes &value){restoreSyncSettings(value,chats,{},true);};
|
||||||
|
FakeProvider provider; provider.remote={1,bytes(legacy)};
|
||||||
|
auto download=synchronize(provider,{},shared,SyncAction::Download,validate,{},true);
|
||||||
|
require(provider.writes==0 && restoreSyncSettings(download.settings,local,{},true).chatCycles==chats.chatCycles,
|
||||||
|
"download from older server profile preserves chat settings");
|
||||||
|
provider.remote={1,base};
|
||||||
|
download=synchronize(provider,{},shared,SyncAction::Download,validate,{},true);
|
||||||
|
require(restoreSyncSettings(download.settings,local,{},true).chatCycles.empty(),
|
||||||
|
"download explicit empty chat map removes local profiles");
|
||||||
|
provider.remote={1,bytes(legacy)};
|
||||||
|
auto upload=synchronize(provider,{},shared,SyncAction::Upload,validate,{},true);
|
||||||
|
require(provider.writes==1 && restoreSyncSettings(provider.remote.settings,local,{},true).chatCycles==chats.chatCycles,
|
||||||
|
"upload publishes chat profiles while keeping PC layout settings separate");
|
||||||
|
auto invalid=j; invalid["chatCycles"][chatKey]=Json::array();
|
||||||
|
rejects([&]{restoreSyncSettings(bytes(invalid),local,{},true);},"reject empty synchronized chat cycle");
|
||||||
|
invalid["chatCycles"][chatKey]=Json::array({"not-a-layout"});
|
||||||
|
rejects([&]{restoreSyncSettings(bytes(invalid),local,{},true);},"reject invalid synchronized layout ID");
|
||||||
|
invalid["chatCycles"]=Json::object({{"unknown\nChat",Json::array({"win:00000409"})}});
|
||||||
|
rejects([&]{restoreSyncSettings(bytes(invalid),local,{},true);},"reject unknown synchronized chat application");
|
||||||
|
}
|
||||||
{
|
{
|
||||||
Settings scoped = local;
|
Settings scoped = local;
|
||||||
scoped.snippets.push_back({L"test", L"Only Teams", L"teams-scope", {L"ms-teams"}});
|
scoped.snippets.push_back({L"test", L"Work apps", L"teams-scope", {L"ms-teams", L"outlook"}});
|
||||||
auto wire=sharedSyncSettings(scoped);
|
auto wire=sharedSyncSettings(scoped);
|
||||||
require(restoreSyncSettings(wire, local, {}).snippets[1].apps==std::vector<std::wstring>{L"ms-teams"}, "sync preserves app scope");
|
require(restoreSyncSettings(wire, local, {}).snippets[1].apps==std::vector<std::wstring>{L"ms-teams", L"outlook"}, "sync preserves multiple app scopes");
|
||||||
auto leftScope=j,rightScope=j;
|
auto leftScope=j,rightScope=j;
|
||||||
leftScope["snippets"].push_back({{"id","left-scope"},{"trigger","same"},{"text","left"},{"apps",Json::array({"notepad"})}});
|
leftScope["snippets"].push_back({{"id","left-scope"},{"trigger","same"},{"text","left"},{"apps",Json::array({"notepad"})}});
|
||||||
rightScope["snippets"].push_back({{"id","right-scope"},{"trigger","same"},{"text","right"},{"apps",Json::array({"ms-teams"})}});
|
rightScope["snippets"].push_back({{"id","right-scope"},{"trigger","same"},{"text","right"},{"apps",Json::array({"ms-teams"})}});
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -895,6 +896,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