Compare commits

..
Author SHA1 Message Date
agrynco 00cd1262b4 chore(release): package kswitcher v0.6.80 2026-11-14 16:10:08 +02:00
agrynco 7bc734a9a8 fix(files): distinguish lan peer lookup failures 2026-11-14 16:07:54 +02:00
agrynco a19ccaf035 Merge pull request 'Synchronize Files with PowerShell and fix the LAN drive picker' (#21) from codex/files-polish-release into main 2026-11-14 16:01:54 +02:00
agrynco 60625103f8 chore(release): package kswitcher v0.6.79 2026-11-14 15:53:27 +02:00
agrynco aa02f2cacb fix(files): synchronize terminal navigation and polish lan picker 2026-11-14 15:52:14 +02:00
agrynco 6a1e39efa9 Merge pull request 'Synchronize KSwitcher Files settings across open windows' (#20) from codex/files-live-settings into main 2026-11-14 15:18:15 +02:00
agrynco 7242c3f775 test(settings): keep picker fixture within the monitor 2026-11-14 15:17:54 +02:00
agrynco a43f6441b4 chore(release): refresh verified windows package 2026-11-14 15:15:20 +02:00
agrynco 2f5ae373a1 fix(settings): preserve unset files shortcut in options 2026-11-14 15:14:16 +02:00
agrynco b978361180 chore(release): package kswitcher v0.6.78 with files 2026-11-14 15:11:26 +02:00
agrynco 93b8055085 fix(files): synchronize settings across windows
Reload shared preferences in open windows and apply only fields changed in Options.
2026-11-14 15:09:17 +02:00
agrynco 70ee338fc3 Merge pull request 'Add searchable command history to KSwitcher Files' (#19) from codex/files-command-history into main 2026-11-14 14:43:10 +02:00
agrynco 46b4b51cea feat(files): add searchable command history
Persist command recall and align keyboard, mouse, and dialog behavior.

Refs #18
2026-11-14 14:42:48 +02:00
agrynco 721c3f0f38 docs: record KSwitcher issue workflow 2026-11-14 12:56:47 +02:00
agrynco 47851d75ef Merge pull request 'Add folder shortcuts to KSwitcher Files' (#17) from codex/issue-15-folder-shortcuts into main
Fixes #15
2026-11-14 12:20:53 +02:00
agrynco ad01951956 Add folder shortcuts to KSwitcher Files
Share Far dialog fields, palette, frame layout, and configurable padding. Document shortcut behavior and the shared-code rule.

Refs #15
2026-11-14 12:19:13 +02:00
agrynco bf092d2764 Merge pull request 'Customize file type styles and preview Files options' (#16) from codex/files-type-styles into main
Fixes #14
2026-11-14 11:26:01 +02:00
agrynco d5137887b9 Customize file type styles and preview Files options
Refs #14
2026-11-14 11:25:31 +02:00
agrynco c75cc989f4 Merge pull request 'Add Files shortcuts and unified Options' (#13) from codex/issue-12-files-options into main
Fixes #12
2026-11-14 10:47:30 +02:00
agrynco f580892ca9 Add Files shortcuts and unified Options
Refs #12
2026-11-14 10:44:54 +02:00
agrynco 372c4cb8bd Merge pull request 'Browse and manage files on paired KSwitcher PCs' (#11) from codex/issue-10-lan-files into main
Fixes #10
2026-11-14 09:49:36 +02:00
agrynco 37158c1ff0 Browse and manage files on paired KSwitcher PCs
Refs #10
2026-11-14 09:47:06 +02:00
agrynco 02f81327fd Improve Change drive and list KSwitcher LAN peers
Refs #9
2026-11-14 03:07:55 +02:00
agrynco 2834510680 Merge KSwitcher Files operations and embedded PowerShell 2026-11-14 02:39:34 +02:00
agrynco 2c53e99efc Embed PowerShell terminal and polish file panels
Refs #8
2026-11-14 02:38:47 +02:00
agrynco fb98731ec2 Add F6 rename and move to KSwitcher Files
Refs #7
2026-11-14 01:25:45 +02:00
agrynco 531efec2eb Add file deletion and Recycle Bin support
Refs #6
2026-11-14 01:00:40 +02:00
agrynco 27c397e375 Highlight the cursor only in the active file panel 2026-11-14 00:21:45 +02:00
agrynco 186621eb3c Integrate panel paths into the file manager frames 2026-11-14 00:15:55 +02:00
agrynco d3a0b83692 Merge pull request 'Activate focused dialog buttons with Enter' (#5) from codex/kswitcher-files into main 2026-11-14 00:06:19 +02:00
agrynco f38886d1ea Activate focused dialog buttons with Enter
Refs #4
2026-11-14 00:05:51 +02:00
agrynco e2c6734817 Merge pull request 'Add KSwitcher Files local manager and copy workflow' (#3) from codex/kswitcher-files into main 2026-11-14 00:01:06 +02:00
agrynco 10d5a42f2a Add KSwitcher Files local manager and copy workflow 2026-11-13 23:59:44 +02:00
agrynco 34d89db95b Release KSwitcher v0.6.77 with local pencil smoothing 2026-11-13 14:58:55 +02:00
agrynco c10b141483 Release KSwitcher v0.6.76 with incoming file notifications 2026-11-13 14:24:47 +02:00
agrynco 0c77d5e46d Release KSwitcher v0.6.75 with clearer diagnostics and custom replacements 2026-11-13 13:01:07 +02:00
agrynco 70a53834e3 Release KSwitcher v0.6.74 with LAN file transfer and diagnostics MCP 2026-11-13 12:22:52 +02:00
agrynco 73448058d7 Release KSwitcher v0.6.73 with autostart path repair 2026-11-13 10:28:41 +02:00
agrynco 12fdd7c549 Release KSwitcher v0.6.72 with dedicated settings tabs 2026-11-12 21:17:15 +02:00
agrynco 3cb2eee637 Release KSwitcher v0.6.71 with Enter snippet confirmation 2026-11-12 19:05:38 +02:00
agrynco 41a1426a16 Ignore whole-line caret bounds for snippet suggestions 2026-11-12 17:46:44 +02:00
agrynco 601a7288c4 Release KSwitcher v0.6.70 with text-caret snippet placement 2026-11-12 17:20:47 +02:00
agrynco 7eca94ea59 Release KSwitcher v0.6.69 with snippet autocomplete 2026-11-12 16:51:08 +02:00
agrynco 299e61a5aa Release KSwitcher v0.6.68 with elevated restart 2026-11-12 11:52:23 +02:00
agrynco 2bafdbf863 Release KSwitcher v0.6.67 2026-11-12 10:58:07 +02:00
agrynco 1df2e2e90b Apply mouse bindings to forwarded input 2026-11-12 10:54:47 +02:00
agrynco c0defcbfb2 Release KSwitcher v0.6.66 with tray icon retry 2026-11-12 09:16:38 +02:00
agrynco 8a65e7f7fb Release KSwitcher v0.6.65 2026-11-12 01:18:21 +02:00
agrynco 14f33012a3 feat: release kswitcher 0.6.64
Add mouse button actions and application profiles, with volume controls and per-monitor settings layout fixes.
2026-11-12 00:39:12 +02:00
agrynco 5a2106899d Release v0.6.63: automatic LAN transport fallback and connection help 2026-11-10 19:30:50 +02:00
agrynco 3761b790ae Release v0.6.62: identify applications by window 2026-11-10 00:46:07 +02:00
agrynco a0e1de40ef Pick an application by dragging onto its highlighted window 2026-11-10 00:43:02 +02:00
agrynco 5d96abb83e Release v0.6.61: configure languages per application 2026-11-09 23:24:42 +02:00
agrynco 011664cc53 Release v0.6.60: allow apostrophes in custom corrections 2026-11-09 11:59:49 +02:00
agrynco 4d8d8d47b9 Release v0.6.59: clarify sync results and refresh downloaded settings 2026-11-09 11:22:27 +02:00
agrynco d0387904a0 Release v0.6.58: preserve LAN connections across missed discovery 2026-11-06 22:08:47 +02:00
agrynco 5de010ee44 Release v0.6.57: multi-PC input, shared clipboard and resilient Settings Save 2026-11-06 21:33:37 +02:00
agrynco 82292c8a7e Release v0.6.56: LAN mouse and keyboard sharing 2026-11-06 16:39:04 +02:00
agrynco 4fbeb56421 Wait for native layout selection repaint in UI capture 2026-11-06 16:30:48 +02:00
agrynco e0bfdd9a90 Add encrypted LAN mouse and keyboard sharing with four-edge handoff 2026-11-06 16:28:43 +02:00
agrynco 9b565dd55e Add opt-in KSwitcher LAN discovery and mutual code pairing preview 2026-11-06 15:30:06 +02:00
agrynco 346af2256f Release v0.6.55: reliable snippet selection in desktop chats 2026-11-06 15:04:00 +02:00
agrynco 99db99e603 Fix partial snippet selection in desktop chat editors 2026-11-06 14:58:24 +02:00
agrynco 63b7b879a3 Release v0.6.54: adjustable pencil smoothing and unsaved settings state 2026-11-06 14:46:53 +02:00
agrynco 727dc96908 Release v0.6.53: synchronize chat language profiles 2026-11-06 12:39:00 +02:00
agrynco ede4ce47c0 Release v0.6.52: add Arc tool to screenshot editor 2026-11-06 12:32:27 +02:00
agrynco 3b8b8f26fe Release v0.6.51: Telegram Web and application-scoped snippets 2026-11-06 10:47:31 +02:00
agrynco 19f1e4531c Center Add app button against the application selector 2026-11-06 10:41:21 +02:00
agrynco a124e030ab Add application-scoped snippets with global fallback and scope-aware sync 2026-11-06 10:37:01 +02:00
agrynco c18148a574 Add event-driven Telegram Web K chat profiles for Chrome 2026-11-06 10:26:42 +02:00
agrynco 15197da548 Release v0.6.50: disable Viber chat OCR 2026-11-06 00:21:39 +02:00
agrynco 66e24b5efc Disable Viber chat OCR while preserving saved profiles 2026-11-06 00:18:04 +02:00
agrynco 178573233b Release v0.6.49: Viber chat language profiles with local OCR 2026-11-05 23:52:50 +02:00
agrynco 4e7a683c47 Release v0.6.48: chat language profiles and tabbed settings 2026-11-05 23:25:33 +02:00
agrynco e33387c2d5 Release v0.6.47: allow multi-word auto-correction replacements 2026-11-05 22:51:57 +02:00
agrynco 5de5b791bf Release v0.6.46: bundle StayHere activity utility 2026-11-05 12:50:37 +02:00
agrynco c109408a98 Release v0.6.45: fix Far snippets and ship portable Utilities folder 2026-11-05 12:35:44 +02:00
agrynco 90b2e0841f Release v0.6.44: select newest item when opening clipboard history 2026-11-05 00:15:54 +02:00
agrynco 6621a49022 Release v0.6.43: fix forwarded input and verify snippet pastes 2026-11-04 21:18:39 +02:00
agrynco a2b88f247c Release v0.6.42: use compact pinned checkbox beside history search 2026-11-04 20:38:36 +02:00
agrynco 684ca1b3cc Release v0.6.41: filter and remember pinned clipboard history 2026-11-04 19:58:15 +02:00
agrynco 524a836cc0 Release v0.6.40: align tagged source and Windows version metadata 2026-11-04 19:45:52 +02:00
agrynco 00c9cb0ee4 Release v0.6.39: isolate history UI and image clipboard memory 2026-11-04 19:40:54 +02:00
agrynco fa2f6d2ce4 Release v0.6.38: avoid unnecessary RichEdit and cancel hidden history previews 2026-11-04 17:24:57 +02:00
agrynco 30e70192dc Release v0.6.37: isolate image capture and OCR, fingerprint history duplicates 2026-11-04 16:58:34 +02:00
agrynco 94cab4c112 Release v0.6.36: offload notification registration and mitigate Viber paste timing 2026-11-04 15:40:50 +02:00
agrynco d13e9849a7 Release v0.6.35: reduce resident memory and compact shared dictionaries 2026-11-04 14:08:05 +02:00
agrynco 004fee4976 Release v0.6.34: use four-direction cursors for movable screenshot handles 2026-11-04 01:43:49 +02:00
agrynco b08c5ff8ff Release v0.6.33: warn about unsaved settings and expose menu access keys 2026-11-04 01:11:15 +02:00
agrynco b6e7bda220 Release v0.6.32: make screenshot pan test independent of viewport DPI 2026-11-03 15:34:23 +02:00
agrynco 89e506faae Release v0.6.31: verify trailing spaces omitted by editor copy 2026-11-03 14:45:35 +02:00
agrynco c78b51d341 Record completed v0.6.30 release validation 2026-11-02 11:57:19 +02:00
agrynco 0184b837f4 Release v0.6.30: guard paste retries and clear failed conversion state 2026-11-02 11:54:58 +02:00
agrynco ca553a5e7d Release v0.6.29: clear clipboard history from its window 2026-11-02 11:34:45 +02:00
agrynco 55993c0f90 Release v0.6.28: typo correction, partial sync and selection diagnostics 2026-11-02 11:19:13 +02:00
agrynco 738c81dce2 Release v0.6.27: menu and Alt access keys 2026-11-02 01:23:33 +02:00
agrynco 22edece7cf Release v0.6.26: OpenBao settings synchronization with per-setting conflicts 2026-11-02 00:37:57 +02:00
124 changed files with 24994 additions and 602 deletions

No files matched your search

+35
View File
@@ -1,5 +1,11 @@
# Project instructions
## Reuse shared code
Reuse existing shared code wherever possible. When the same behavior,
component, or logic is needed in two or more places, extract it into a
shared component or function and use it at every applicable call site.
## Mandatory Wiki updates for every release
A release is not complete until the published project Wiki reflects that release.
@@ -33,3 +39,32 @@ Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
Updating only the release notes or changing only the Wiki version number does
not satisfy this rule; the current functionality must also be reviewed.
## Gitea issue authentication
For Gitea issue API requests from this checkout, use the project token stored
with Windows DPAPI in `kswitcher-gitea-token.dpapi` under the directory returned
by `git rev-parse --absolute-git-dir`. Read it with PowerShell
`Get-Content -LiteralPath $tokenFile | ConvertTo-SecureString` and use it only
for the Gitea authorization header. Keep the token out of tracked files and
command output.
## KSwitcher Files UI
Before changing the file manager UI, read [FILES-UI.md](FILES-UI.md).
Reuse the shared dialog rendering and layout helpers. Update the document
when an agreed UI requirement changes.
## Link changes to Gitea issues
For commits that implement a Gitea issue, include `Refs #<number>` in the
commit message body. In a pull request, use `Fixes #<number>` when merging
fully resolves the issue, or `Refs #<number>` when the work is partial.
Use the existing issue for follow-up commits on the same change.
Assign each new issue to the [KSwitcher project board](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/projects/8)
using its default column, and verify that the issue appears on the board.
Use Markdown headings, lists, and bold emphasis to make issue descriptions
easy to scan. Embed relevant screenshots as Markdown images using their
Gitea attachment URLs, and verify that the images render in the description.
+151 -4
View File
@@ -1,14 +1,45 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.25 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.80 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_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)
add_executable(KSwitcherFiles WIN32 src/files_main.cpp resources/files.rc)
set_target_properties(KSwitcherFiles PROPERTIES OUTPUT_NAME "KSwitcher.Files")
target_compile_definitions(KSwitcherFiles PRIVATE UNICODE _UNICODE NOMINMAX _WIN32_WINNT=0x0A00)
find_program(KSWITCHER_POWERSHELL NAMES pwsh powershell REQUIRED)
add_custom_target(KSwitcherTerminalAssets
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -File "${CMAKE_CURRENT_SOURCE_DIR}/tools/stage-files-terminal.ps1"
-Destination "$<TARGET_FILE_DIR:KSwitcherFiles>"
VERBATIM)
add_dependencies(KSwitcherFiles KSwitcherTerminalAssets)
target_include_directories(KSwitcherFiles PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/build/terminal-deps/sdk/build/native/include")
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32 uuid)
if(MSVC)
target_compile_options(KSwitcherFiles PRIVATE /utf-8)
else()
target_link_options(KSwitcherFiles PRIVATE -municode -static)
endif()
add_executable(StayHere WIN32 utilities/StayHere/main.cpp)
add_executable(StayHereTest WIN32 utilities/StayHere/main.cpp)
target_compile_definitions(StayHereTest PRIVATE STAYHERE_TEST_INSTANCE)
set_target_properties(StayHere PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tools")
foreach(utilityTarget StayHere StayHereTest)
target_compile_definitions(${utilityTarget} PRIVATE UNICODE _UNICODE _WIN32_WINNT=0x0A00)
target_link_libraries(${utilityTarget} PRIVATE user32 shell32 gdi32 wtsapi32)
if(MSVC)
target_compile_options(${utilityTarget} PRIVATE /utf-8)
set_property(TARGET ${utilityTarget} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
endif()
endforeach()
add_library(kscore src/lan_files.cpp 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_link_libraries(KSwitcherFiles PRIVATE kscore)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC 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)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
@@ -17,11 +48,28 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp)
add_executable(KSwitcherSyncTests tests/sync.cpp)
target_include_directories(KSwitcherSyncTests PRIVATE vendor)
target_link_libraries(KSwitcherSyncTests PRIVATE kscore)
if(MSVC)
set_property(TARGET KSwitcherSyncTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherSyncTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherScrollTests tests/scroll.cpp)
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.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(KSwitcherIntegration PRIVATE kscore)
# Opt-in interactive fixture: requires an installed Far Manager.
add_executable(KSwitcherFarSnippetTests WIN32 tests/far_snippets.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(KSwitcherFarSnippetTests PRIVATE kscore)
if(MSVC)
target_compile_options(KSwitcherFarSnippetTests PRIVATE /utf-8)
set_property(TARGET KSwitcherFarSnippetTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherFarSnippetTests PRIVATE -municode -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherInputTests tests/input.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(KSwitcherInputTests PRIVATE kscore)
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
@@ -40,6 +88,7 @@ else()
endif()
add_executable(KSwitcherOcrTests tests/ocr.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(KSwitcherOcrTests PRIVATE kscore)
add_dependencies(KSwitcherOcrTests KSwitcherScreenshot)
if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8)
@@ -57,15 +106,113 @@ else()
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
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(KSwitcherLanFilesTests tests/lan_files.cpp)
target_link_libraries(KSwitcherLanFilesTests PRIVATE kscore)
add_test(NAME lan-files-regressions COMMAND KSwitcherLanFilesTests)
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)
target_link_libraries(KSwitcherChatProfileTests PRIVATE kscore)
if(MSVC)
target_compile_options(KSwitcherChatProfileTests PRIVATE /utf-8)
set_property(TARGET KSwitcherChatProfileTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherChatProfileTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_test(NAME chat-profile-regressions COMMAND KSwitcherChatProfileTests)
add_executable(KSwitcherHistoryHostTests tests/history_host.cpp)
target_link_libraries(KSwitcherHistoryHostTests PRIVATE kscore psapi)
set_target_properties(KSwitcherHistoryHostTests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/history-host-fixture-$<CONFIG>")
add_dependencies(KSwitcherHistoryHostTests KSwitcher KSwitcherScreenshot)
add_custom_command(TARGET KSwitcherHistoryHostTests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:KSwitcher>" "$<TARGET_FILE_DIR:KSwitcherHistoryHostTests>/KSwitcher.exe"
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:KSwitcherScreenshot>" "$<TARGET_FILE_DIR:KSwitcherHistoryHostTests>/KSwitcher.Screenshot.exe"
COMMAND ${CMAKE_COMMAND} -E touch "$<TARGET_FILE_DIR:KSwitcherHistoryHostTests>/.local-dev")
if(MSVC)
target_compile_options(KSwitcherHistoryHostTests PRIVATE /utf-8)
set_property(TARGET KSwitcherHistoryHostTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherHistoryHostTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherClipboardSnapshotTests tests/clipboard_snapshot.cpp)
target_link_libraries(KSwitcherClipboardSnapshotTests PRIVATE kscore psapi)
add_dependencies(KSwitcherClipboardSnapshotTests KSwitcherScreenshot)
if(MSVC)
target_compile_options(KSwitcherClipboardSnapshotTests PRIVATE /utf-8)
set_property(TARGET KSwitcherClipboardSnapshotTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherClipboardSnapshotTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherImageCaptureTests tests/image_capture.cpp)
target_link_libraries(KSwitcherImageCaptureTests PRIVATE kscore)
add_dependencies(KSwitcherImageCaptureTests KSwitcherScreenshot)
if(MSVC)
target_compile_options(KSwitcherImageCaptureTests PRIVATE /utf-8)
set_property(TARGET KSwitcherImageCaptureTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherImageCaptureTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
# Explicit interactive test: stop the resident first so its clipboard listener
# does not capture the test fixtures. Like KSwitcherIntegration, not part of ctest.
add_executable(KSwitcherDictionaryTests tests/dictionary.cpp)
target_link_libraries(KSwitcherDictionaryTests PRIVATE kscore psapi)
if(MSVC)
set_property(TARGET KSwitcherDictionaryTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherDictionaryTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_test(NAME dictionary-regressions COMMAND KSwitcherDictionaryTests)
set_tests_properties(dictionary-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
add_test(NAME native-regressions COMMAND KSwitcherTests)
add_executable(KSwitcherUtilitiesTests tests/utilities.cpp)
if(MSVC)
set_property(TARGET KSwitcherUtilitiesTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests)
add_test(NAME stayhere-lifecycle COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/stayhere.ps1" -Executable "$<TARGET_FILE:StayHereTest>")
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherFiles RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
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)
+157
View File
@@ -0,0 +1,157 @@
# Оформлення KSwitcher Files
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
## Скорочення
- У командному рядку Ctrl+V і Shift+Insert вставляють текст із буфера обміну
в позицію курсора або замінюють виділення. Якщо фокус на файловій панелі,
ці клавіші переводять його до командного рядка й вставляють текст там.
Enter запускає команду. Escape очищає поле й залишає фокус у командному рядку.
Промпт і текст команди вирівняні по спільній базовій лінії.
- Ctrl+E підставляє останню команду та переводить фокус із файлової панелі
до командного рядка. Повторне натискання
підставляє старішу, доки не досягне початку історії. Alt+F8 відкриває історію
з групуванням за датами. Enter або подвійний клік у списку підставляє вибрану
команду в поле. Для запуску натисни Enter у самому полі.
- Історія зберігає 1000 останніх непорожніх команд, надісланих із поля KSwitcher
Files, включно з повторами. Вона зберігається локально в профілі Windows між
запусками. Options → Command line → Command history змінює обидві комбінації,
перевіряє конфлікти та дозволяє відновити типові значення.
- Ширина історії підлаштовується під найдовшу команду в межах робочої області
монітора. Рядок, який не вміщується, закінчується трьома крапками. Вибір
підставляє до командного рядка повний текст.
- Пошук в історії фільтрує команди за всіма введеними словами, незалежно від
регістру. Збіги за текстом або датою йдуть перед тематичними. Тематичні
ключові слова визначаються локально й використовуються лише для пошуку,
зокрема в раніше збережених записах. Кожна команда відображається один
раз, за датою останнього запуску.
- Історія використовує оформлення діалогів Delete і Copy та спільне поле Far.
Підпис, поле й список вирівнюються за відступами Dialog frame. Рядки команд
і роздільники дат мають власний внутрішній відступ.
Вікно історії бірюзове, поле пошуку сіро-бірюзове. Поки історія відкрита,
дії в панелях, меню й командному рядку заблоковано. Заголовок і рамка
головного вікна доступні для переміщення, зміни розміру та керування вікном.
Escape або клік поза діалогом закриває історію. Заголовок і рамка головного
вікна залишають історію відкритою під час керування вікном.
Історія центрується відносно головного вікна й рухається разом із ним.
Tab циклічно переводить фокус між пошуком і списком.
Стрілка вгору або вниз із пошуку одразу пересуває вибір у списку.
Висота списку залежить від кількості результатів. Коли місця бракує,
прокручується список.
- Короткий Alt перемикає головне меню Files при відпусканні. F9 також перемикає меню. Після закриття фокус повертається до активної панелі. Alt з літерою верхнього рівня спрацьовує одразу при натисканні літери. Alt+O відкриває Options, а відпускання Alt після комбінації не перемикає меню.
- Коли головне меню відкрите, L, F, C, O та R відкривають Left, Files, Commands, Options та Right. У підменю підсвічена літера викликає відповідну команду. Літери унікальні в межах поточного рівня. Усталені мнемоніки мають пріоритет, зокрема X для Exit.
- Глобальне скорочення **Open KSwitcher Files** можна змінити або очистити в налаштуваннях. Для профілю без цього поля пропонується Ctrl+Alt+F, якщо Windows реєструє комбінацію і вона вільна серед прив’язок KSwitcher.
- Глобальне скорочення повертає до відкритого вікна Files із тієї ж збірки або запускає Files, якщо такого вікна немає. Пункт **Files** у треї відкриває нове вікно.
- Ctrl+Shift+0–9 відкриває діалог Title і Path із поточною папкою. OK додає запис без дублювання, Escape скасовує. Title необов’язковий, порожнє значення показує шлях. Записи LAN зберігають ID ПК, а редагування Path залишає запис на тому самому ПК.
- Ctrl+0–9 одразу відкриває єдину папку. Для кількох папок відкривається компактний попап Far з одним заголовком Folder shortcuts у подвійній рамці та коротким рядом клавіш у нижній рамці. Ширина залежить від найдовшого запису й обмежується розміром вікна, а висота — кількістю видимих рядків. Нижня рамка показує Ins Del F4 Ctrl+Up Ctrl+Down. Insert додає поточну папку через Title і Path, Delete відв’язує, F4 редагує, Ctrl+Up і Ctrl+Down змінюють і зберігають порядок. Enter відкриває папку. Escape або клік поза попапом закриває його й повертає фокус до панелі. Commands → Folder shortcuts дає вибрати зайняту цифру й відкрити той самий попап навіть для єдиного запису. Brief і Wide доступні в меню Left/Right та Options.
## Налаштування Files
- Options у головному меню одразу відкриває стандартний діалог Windows. Ліворуч є пошук за назвою категорії чи параметра й дерево результатів. Вибір дочірнього вузла відкриває лише його редактор праворуч. Пошук за назвою окремої властивості відкриває вузол, що її містить. Перемикання вузлів зберігає незастосовані зміни.
- File panel modes має вузли Panel layout, Wide panel columns і Status line columns. У Colors кожен колір має власний вузол. В Appearance шрифт редагується у вузлі Font. Діалог змінює розмір від розміру, який вміщує всі параметри. Ширина лівої навігації стала, дерево росте по висоті, а права область використовує додатковий простір. OK, Cancel та Apply лишаються внизу праворуч.
- Reset selected to defaults скидає вибраний рівень дерева. Корінь All settings скидає всі параметри, навіть коли пошук приховує частину дерева. У редакторах із кількома параметрами Reset property скидає лише вибрану властивість. Скидання змінює чернетку до Apply або OK. Стандартний Panel background — `#000080`.
- Apply доступна, коли чернетка відрізняється від застосованих налаштувань. Після Apply або повернення всіх значень до застосованих вона вимикається. Apply одразу зберігає зміни й перемальовує панелі за відкритим діалогом. OK робить те саме й закриває діалог. Cancel і Escape відкидають зміни після останнього Apply та повертають фокус до панелі.
- Colors → File type styles налаштовує текст, фон, підкреслення й закреслення для правил за розширенням і групи System and hidden. Розширення можна вибрати зі списку Windows або додати вручну з крапкою чи без неї. Вони зберігаються в KSwitcher, незалежно від асоціацій Windows. Курсор, позначки й результат передачі зберігають свої сигнальні кольори. Кольори правила можна перевизначити окремо для звичайного рядка, позначки Insert, курсора у фокусі та курсора без фокуса. Позначка має пріоритет над курсором, а підсвічування передачі має пріоритет над кольорами курсора. Без перевизначення стан зберігає звичайний вигляд панелі. Мініпанель унизу редактора одразу показує всі чотири стани на синтетичних рядках до Apply.
- Нижня область показує живе прев’ю для кольорів, шрифту, режимів панелей, широких колонок і рядка стану. Для Popup background, Menu selection і Command bar зразок показує відповідний елемент. Прев’ю читає чернетку, зокрема після Reset, і не потребує реальних файлів. Воно заповнює простір від останнього видимого елемента редактора до нижніх кнопок зі сталими відступами. Зі зміною розміру росте область зразка, а рядки зберігають масштаб шрифту.
- Підписи File type, Windows extension or custom extension і Row state стоять над відповідними полями зі спільним лівим краєм. Ширина списків враховує найдовші значення, шрифт, DPI й доступне місце. Add і Remove стоять одразу після своїх полів.
- Початковий набір правил охоплює EXE, Markdown, системні й приховані елементи, архіви, зображення, аудіо й відео, офісні документи й PDF, код і скрипти. Збережений набір правил завантажується як є, включно з видаленими правилами. Reset у File type styles відновлює весь початковий набір у чернетці.
## Панелі
- У Change drive повідомлення No paired computers з’являється після
успішної відповіді з порожнім списком. Збої локального запиту до
KSwitcher відображаються окремо.
- Якщо команда у вбудованому PowerShell змінює поточну папку,
панель, з якої надіслано команду, переходить до цієї папки.
- Коли список LAN-дисків змінює розмір меню Change drive, рамка й список
змінюють позицію разом. Рамка повністю перемальовується після зміни
розміру, а список має однаковий внутрішній відступ зліва і справа.
- Тайм-аут запиту дисків відображається як Connection timed out. Якщо
ввімкнено Write log, Files записує помилки запитів пар і дисків в окремий
журнал `data/kswitcher-files.log`. Системні помилки містять код Windows.
- Застосовані налаштування Files одразу підхоплюють інші відкриті вікна.
Apply зберігає поля, змінені в поточній чернетці. Якщо два вікна одночасно
редагували набір стилів файлів, відкрий Options заново, щоб побачити свіжі правила.
- Типовий колір тексту для ZIP-архівів — світлий коралово-рожевий.
- Меню Change drive показує літеру, тип, загальний обсяг і вільне місце. Відкриття меню запускає нове LAN-виявлення. Секція KSwitcher LAN показує збережені пари, зокрема офлайн. Після кожного відкриття меню асинхронно запитує диски й показує їх під назвою відповідного ПК. Поки триває запит, видно стан завантаження. Якщо запит не вдався, видно помилку. Повторне відкриття запускає новий запит. Кожна панель має власний диск і шлях, а в заголовку віддаленої панелі перед шляхом показано назву ПК.
- Список дисків і файлові операції використовують права облікового запису Windows, під яким працює віддалений KSwitcher. Достатньо спарювання й доступного TCP 45885, спільні папки налаштовувати не потрібно.
- F5, F6, F8 і Shift+Delete у віддалених панелях використовують спільні підтвердження, попередження й прогрес. F5/F6 працюють із файлами та деревами папок між локальними й віддаленими панелями, зокрема між двома різними ПК. Між шляхами одного віддаленого ПК вміст копіюється на ньому. F8 використовує кошик того ПК, на якому лежить файл.
- Після розриву з’єднання повтори F5 або F6 до того самого призначення. Віддалене копіювання відновлюється з підтвердженої позиції, якщо збережені байти відповідають початку вихідного файла. Підтверджене скасування видаляє частковий запис поточного файла. Завершені файли лишаються на місці. Наявний файл призначення замінюється після повного запису, а при переміщенні джерело видаляється після підтвердження запису призначення.
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
- Бірюзовий курсор поточного рядка видно лише в активній панелі, зокрема коли клавіатурний фокус переходить у командний рядок. Кожна панель зберігає свою позицію курсора. Коли панелі видимі, F5 і F6 з командного рядка діють на активну панель.
- Клік у панелі нижче списку ставить курсор на останній рядок, а клік вище списку — на перший.
- Поточний шлях відображається по центру верхньої подвійної лінії панелі. Лінію видно з обох боків напису. Бірюзове тло активної панелі охоплює лише напис шляху з невеликими відступами. Довгий шлях скорочується в межах панелі.
## Спільні елементи діалогів
- Власні діалоги Files блокують дії панелей і командного рядка, залишаючи
системний заголовок і рамку головного вікна доступними. Клік поза діалогом
діє як Cancel. Для прогресу копіювання, переміщення й видалення це відкриває
підтвердження скасування.
- Enter активує кнопку у фокусі. Для кнопок із власним малюванням використовуй спільний обробник `dialogButtonEnter`. У підтвердженні скасування початковий фокус стоїть на No.
- При перенесенні вікна між моніторами з різним DPI оновлюй шрифт, висоту рядків панелей і компонування відкритого прогресу копіювання. Перевір перенесення під час активного копіювання.
- Для власних діалогів і списків Far використовуй спільні метрики та малювання подвійної рамки. Еталон Delete задає 8 px від краю тла до першої лінії та 14 px від внутрішньої лінії до бічної межі вмісту при 96 DPI. Обидва відступи масштабуються за DPI. Вертикальний відступ враховує висоту заголовка та нижніх підказок. Діалоги й списки зберігають свою палітру та поведінку.
- Appearance → Dialog frame налаштовує зовнішній і внутрішній відступи від 0 до 24 px при 96 DPI. Row text padding додає від 0 до 4 ширин символу з кожного боку тексту в рядках Folder shortcuts. Inner padding задає відстань від рамки до краю списку. Ширина попапа враховує обидва текстові відступи, а довгі шляхи скорочуються в межах доступної ширини. Живий зразок показує чернетку. Apply зберігає значення, Cancel відкидає незастосовані зміни, Reset selected to defaults повертає 8 px, 14 px і 0 символів. Профілі без цих параметрів використовують ті самі значення.
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` центрує текст, підсвічує фокус і додає фігурні дужки для дії за замовчуванням та квадратні для решти кнопок.
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
- Для текстових полів Far використовуй ресурс `FAR_EDIT` і спільну підготовку та палітру діалогів. Поля призначення Copy/Rename і Title/Path використовують нативний Win32 Edit без рамки, моноширинний шрифт і вертикальне центрування. Кольори Text і Popup background діють на всі ці поля, зокрема на поле Copy лише для читання. У відповідних розділах Options → Colors зміни видно на зразку поля. Existing files у Copy є списком без редагування. Поля та редагований список Windows Options мають системне оформлення.
- Для позиціонування роздільника використовуй невидимий маркер, якщо межу не задає інший елемент секції. Спільний обробник малює лінію.
- Випадаючі списки Far використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
## Діалог Copy
- Вирівнюй підпис Existing files по вертикальному центру списку. Ширше поле списку починається ближче до підпису, а його правий край збігається з правим краєм поля імені.
- Зберігай однакові проміжки від верхнього роздільника до поля імені й до списку. Підпис, поля та прапорець мають читатися повністю.
- Відступ після прапорця до нижнього роздільника дорівнює відступу від верхнього роздільника до списку.
- Кнопки Copy та Cancel стоять по центру компактної нижньої секції. Висота кнопок має вміщати весь текст поточного шрифту.
## Вікно прогресу копіювання
- F6 використовує спільне оформлення діалогів Copy і прогресу. Заголовок підтвердження — Rename/Move, кнопка за замовчуванням — Rename, заголовок прогресу — Move.
- Поле призначення F6 початково містить шлях іншої панелі. Для одного елемента просте ім’я задає перейменування в поточній папці. Для кількох елементів діалог показує їх кількість і приймає один шлях до папки.
- Секції поточного файла й загального прогресу мають однаковий вертикальний відступ від вмісту до верхньої та нижньої ліній. Cancel стоїть по центру спільної нижньої секції.
- Обидві шкали займають усю ширину вмісту. Відсотки показуються по центру шкали, а її висота враховує поточний шрифт.
## Попередження про конфлікт
- Для однойменних файлів Warning показує точний розмір у байтах із розділенням тисяч і місцевий
час останньої зміни окремо для вхідного файла та файла в папці призначення.
- Після розрахунку остаточної висоти центр Warning збігається з центром вікна прогресу.
- Remember choice стоїть по вертикальному центру власної секції. Кнопки Overwrite, Skip і Cancel стоять по центру нижньої секції.
- Tab проходить Remember choice, Overwrite, Skip і Cancel. Стрілки вліво та вправо циклічно перемикають три кнопки в порядку їх розташування.
- Стрілки вгору й вниз переходять між сусідніми секціями з інтерактивними елементами. У Warning стрілка вгору з ряду кнопок переводить фокус на Remember choice, а вниз із прапорця — на Overwrite.
## Видалення
- F8 показує сіре підтвердження з дією Move. Shift+Delete і підтвердження непорожньої папки мають темно-червоне тло. Вікно прогресу видалення має сіре тло, як у Copy.
- Escape у початковому підтвердженні повертає фокус до панелі, з якої викликано видалення. Поточний рядок зберігається, а стрілки одразу працюють.
- F8 переміщує підтверджені елементи в кошик Windows. Shift+Delete видаляє підтверджене дерево від вкладених елементів до батьківської папки. Прогрес і підсумок окремо показують успішні, пропущені й невдалі дії.
- Cancel і Esc у вікні прогресу відкривають спільне червоне підтвердження скасування. Yes зупиняє операцію, а No або Esc у підтвердженні повертає до прогресу. Початковий фокус стоїть на No.
## Перевірка змін
Відкрий Copy і Warning в одній збірці та порівняй висоту нижніх секцій, центрування, повноту підписів і видимість фокуса. Перевір Tab, стрілки й Esc. Після узгодження нового правила онови відповідний розділ цього документа.
+168
View File
@@ -0,0 +1,168 @@
# LAN mouse, keyboard and clipboard — v0.6.58 (experimental)
KSwitcher discovers other enabled KSwitcher instances, pairs them with the same
code on both computers, and shares mouse and keyboard through a screen edge.
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.
## Files and diagnostic logs
In **Settings → Computers → Manage computers…**, select an online paired
computer and choose **Send file…**. Both computers need a build with file transfer.
Files up to 16 MiB travel over encrypted LAN. On the destination, open
**Incoming files…** to save or discard each file. The save dialog suggests the
original filename. KSwitcher shows a Windows notification when a file is ready
to save, and its tray tooltip shows the pending count. Pending files are held
in memory until saved, discarded, or KSwitcher closes.
To retrieve the current or rotated diagnostic log, enable **Allow paired
computers to download diagnostic logs** on the computer that owns the logs.
This setting defaults to off. On the requesting PC, select that computer and
choose **Download log…** or **Previous log…**. Diagnostic logs may contain
text copied from applications.
### MCP
In **Settings → MCP**, choose **Copy configuration** and paste the block into
Codex's `~/.codex/config.toml`. It starts the MCP server over stdio with the
absolute path to `KSwitcher.Config.exe` and the `--mcp` argument.
`list_log_computers` returns paired computer IDs and availability.
`read_diagnostic_log` reads the latest 100 lines by default (1–500 with
`lines`). Use `offset` to skip newer lines and page backward. Set `previous` to read the rotated log. Omit `peer` for the
local log or pass a paired computer ID for a remote log. Remote reads need
the local resident process running and log downloads enabled on the remote PC.
## Set up multiple computers
1. Install the v0.6.58 or newer on every participating PC. Input protocol v2 requires all participants to update; earlier one-pair builds cannot connect. Preserve local data; do not copy pairing identities between PCs.
2. Open **Settings → Computers → Manage computers…** and enable discovery on both PCs.
Only running LAN-enabled KSwitcher instances appear, not generic network devices.
3. Match the computer name and instance-ID prefix displayed on the other PC.
Select each other, generate one code, enter that same code on both, then click
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.
Input first tries TCP in the usual direction, then reverse TCP after 1.5 seconds.
If TCP has not authenticated after 5 seconds, the lower-ID peer negotiates an
encrypted UDP input session on the existing discovery socket (UDP 45883).
UDP uses fresh session challenges, authenticated encryption, ordered event
numbers, acknowledgements and bounded retries; duplicated packets do not repeat
key presses. Two seconds without authenticated traffic or acknowledgement
progress closes the session and returns control locally. A working UDP session
stays on UDP until disconnected; it does not oscillate between transports.
Both PCs must run an updated build to use all fallback methods. Older peers can
still use the original TCP protocol. No extra ports or external relay are used.
After 15 seconds without a connection, the UI points to **Connection help**.
The help window can copy PowerShell commands for the actual resident EXE path,
including a path containing apostrophes. Run each PC's own command in an
administrator PowerShell on that PC. These commands permit inbound TCP 45884
and UDP 45883 only from the local subnet; the program does not execute them.
Moving the EXE may require a new application firewall rule. A timeout alone
does not establish that the firewall is the cause: check that input sharing is
enabled on both PCs and that both are running the intended copy.
The instance ID belongs to a data directory, not a hostname/IP address. Discovered
names and addresses are untrusted. Pairing uses a generated 128-bit code and
mutual HMAC-SHA256 challenge proofs bound to both instance IDs. The code is never
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.
## v0.6.58 connection fixes
Saved trust keeps TCP listeners available even when discovery announcements are missed. Working authenticated input sessions no longer depend on discovery presence. Pair saved is distinct from input connected; the list refreshes every second. A changed instance ID requires a new pairing. Keep each PC's own data folder during upgrades. Connection state changes and listener Windows errors are written to the diagnostic log.
+548 -10
View File
@@ -6,16 +6,135 @@ The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data r
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
From v0.6.72, use **Settings → Snippets** to edit snippets in place and
**Save snippets** to save them independently. **Clipboard** contains OCR
options. **Sync**, **Computers**, and **Diagnostics** have their own tabs.
**Українською.** З v0.6.72 сніпети редагуються у **Settings → Snippets**
і зберігаються окремою кнопкою **Save snippets**. OCR розміщено у
**Clipboard**, а для синхронізації, ПК та журналу є окремі вкладки
**Sync**, **Computers** і **Diagnostics**.
[![Support KSwitcher development — buy the developer a coffee](resources/support/donate-en.png)](https://send.monobank.ua/jar/88h2TddKfS)
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
## KSwitcher computers — v0.6.58 (experimental)
TCP input listeners and authenticated connections survive missed discovery announcements.
The list distinguishes saved pairing from connected input and refreshes every second.
Keep each PC’s own data folder during updates; a different instance ID requires new pairing.
Arrange multiple paired PCs on a monitor grid. Mouse and keyboard follow shared
edges, including A → B → C and back. New text and images go to every online paired
PC's history and active clipboard. The history Computers menu filters by multiple
sources using checkboxes. See [LAN-PREVIEW.md](LAN-PREVIEW.md).
**Update every participating PC to v0.6.58:** input protocol v2 is incompatible
with older one-pair builds. Keep each PC's own data directory. Pair every input
source with all destinations, arrange the same physical layout on each PC and
enable input sharing. Empty cells and offline neighbors block passage.
**Українською.** Розставте кілька спарованих ПК по сітці: миша й клавіатура
переходять через спільний край, зокрема A → B → C і назад. Нові текст та зображення
передаються всім доступним спарованим ПК в історію й активний буфер. Фільтр
Computers в історії має чекбокси. Оновіть усі ПК до v0.6.58, залишивши кожному
власну папку data. Спаруйте джерело вводу з усіма потрібними ПК й задайте
однакове фізичне розташування на кожному. Передача файлів не реалізована.
Saving Settings now merges independent resident/editor changes. If the same field
changed on both sides, choose whether to retain your draft without closing the
window. Declining preserves all draft edits. No export/import workaround is needed.
## Mouse buttons for applications
In **Settings → Mouse**, put the pointer over **Test mouse buttons here** and
press the buttons you want to test. The page shows the last detected button and
counts presses separately for ordinary window events and Windows Raw Input.
Use **+** beside **Mouse events** to capture and name a wheel direction.
If Windows reports an existing button or an already added direction, the
dialog selects that event in the list. Extra device controls need to emit a
supported Windows mouse signal before KSwitcher can bind them.
When you move the pointer to another PC using KSwitcher Computers or Mouse Without
Borders, the receiving PC uses its own mouse profiles. Configure the actions in
**Settings → Mouse** on that PC.
Choose **Global** to set up fallback actions. Use **+** beside **Profiles** to create
a named profile and set its applications. Double-click a profile or choose
**Edit…** in its context menu to change its name or applications. The
**Applications** column shows the scope of each profile.
Use the checkbox in each profile row to enable or disable its actions.
Double-click a mouse event, or right-click it and choose
**Edit…**, then pick its action and click **Apply** and **Save**. **Reset to default**
restores the global action for an application or standard behavior for Global.
Drag the crosshair in the profile editor onto an open window to add its
application. The folder icon accepts an executable or a Windows shortcut
to one. You can also type an executable name and click **+**.
An empty application list makes the profile's actions fallbacks. A scoped
profile's actions take precedence when one of its applications is active.
For example, set the side buttons to volume control in **Global** and to browser
navigation in a named profile with Chrome in its application list.
Volume actions show the current system level at the lower right of the
monitor under the pointer. The indicator stays visible during a hold and hides
one second after release.
For **Volume up** and **Volume down**, set **Step (%)** next to **Action**.
New values range from 1 to 50 percentage points.
The default is four percentage points. Hold an assigned button to repeat the action
at increasing speed. If the button also has a double-click action, a single
click waits for the double-click interval before adjusting the volume.
Enabled profiles cannot assign the same mouse event to the same application,
or both assign an unrestricted fallback to the same event.
An application can inherit a global action,
override it, or use standard mouse behavior. Available actions include
browser navigation, system volume, launching an executable, and sending a
key combination. For **Launch application**, enter a name Windows can find
(such as `calc.exe`) or select an executable with **Browse**.
The pointer selects the application profile and the target for browser
navigation. **Send key combination** works when the window under the pointer
is active. To record a combination, click the field and press the keys.
For example, press `Ctrl+W` to bind closing the active browser tab or
`Ctrl+V` to bind pasting in File Explorer. An inactive window receives
the standard mouse action.
Left and right
bindings apply only over the active window, so you can click another window
to switch applications. When both clicks have custom actions, a single click
waits for the Windows double-click interval. If only the double-click has an
action, the first click keeps its standard behavior.
Bindings apply to all mice on this PC. Windows exposes five
standard buttons. Hardware controls such as a wheel-mode or device switch
require a driver or device integration if they do not emit a standard click.
## Run
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
Double-click the tray icon to open Settings, the bold default menu item.
Closing Settings with unsaved changes (including accepted auto-correction or OCR options)
asks whether to save, discard changes, or keep editing. Unchanged settings close without a prompt.
If saving fails, the window stays open with your edits intact.
Settings displays **No unsaved settings** when the draft matches the saved settings. If **Start with Windows** is enabled, **Save** remains available to recheck the autostart path.
The Computers window applies and saves network changes immediately, separately from Settings.
Pairing shows the target computer and remaining wait time; enter the same code and click **Pair** on both PCs.
The code stays visible for copying or retry until the pair is saved. A saved pair still requires input sharing to be enabled on both PCs.
Input automatically tries direct TCP, reverse TCP (after 1.5 seconds), and encrypted UDP over discovery (after 5 seconds).
Both PCs need an updated build for all fallback methods. The connection status identifies the selected transport.
If attempts remain unsuccessful for 15 seconds, **Connection help** explains checks and provides copyable administrator PowerShell commands for the running executable on that PC. Run each PC's own command locally; rules allow only the local subnet. Commands are never executed automatically.
**Unsaved changes — click Save to apply** appears for pending edits, including accepted
chat-language, auto-correction and OCR changes. Cancelling a child dialog or restoring
the original values clears its pending changes. Snippets saved in their own window
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,
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
still follows Windows preferences; press Alt in a dialog to reveal them.
From v0.6.33, tray and history menus show access letters in the right-hand column.
Press the menu letter without Alt: Alt dismisses an open menu. Shortcut-capture fields still record Alt combinations;
press Tab to leave a shortcut field before using form access keys.
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
@@ -43,8 +162,14 @@ In Settings, enable **Start with Windows** and **Start with Windows as administr
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
the task runs in the signed-in user's interactive session, including on battery power.
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
future automatic launches. Saving unrelated settings does not recreate startup entries.
With both options enabled, opening the regular `KSwitcher.exe` after exiting a development
build requests administrator approval if needed. Development builds keep their current launch privileges.
Disabling the administrator option restores ordinary per-user startup. Disabling startup stops
future automatic launches. On launch and after clicking **Save** in Settings, KSwitcher checks whether the
enabled autostart entry still points to its current location. If the previous file is missing and
KSwitcher has the required privileges, it updates the path automatically. If the previous path may
still be in use or the repair requires administrator approval, KSwitcher shows both paths and asks
before updating the entry.
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
Exit [Typlo](https://github.com/bitcodepro/Typlo) when using overlapping shortcuts to avoid competing keyboard hooks.
@@ -65,6 +190,9 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input
path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets.
On the sending computer, KSwitcher ignores keyboard events consumed by another keyboard hook
(including Mouse Without Borders forwarding). The receiving computer still processes delivered
input normally, so both computers can run KSwitcher without starting duplicate snippet operations.
KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask
keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
@@ -165,7 +293,8 @@ for shapes.
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
to pan an enlarged image. Panning is limited to axes where the image exceeds the viewport;
an image that fits stays centered. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
The wheel without Ctrl still adjusts the object under the cursor.
@@ -181,10 +310,29 @@ Every shape has a round rotation handle above its outline. Drag it to rotate aro
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
undo and redo it. The square handle still controls outline thickness.
From v0.6.34, endpoint, shape-corner and number-radius handles show the four-direction move cursor.
Thickness handles retain directional resize cursors; rotation retains the hand cursor. Dragging,
snapping and shape constraints are unchanged.
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
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
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
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.
@@ -253,6 +401,106 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
## Implemented
### Chat language profiles — v0.6.48
> From v0.6.50: Viber chat OCR is temporarily disabled. Saved Viber profiles are retained but inactive. Telegram and Teams profiles are unchanged; clipboard/image OCR remains available.
>
> З v0.6.50: OCR чатів Viber тимчасово вимкнений. Збережені профілі Viber залишаються, але не діють. Профілі Telegram і Teams та OCR зображень/буфера працюють як раніше.
Settings separates General, Languages, Shortcuts, Clipboard and Tools into native tabs.
Switching tabs preserves edits; Save, Cancel, Import and Export stay available below every page.
Open the desired chat in Telegram Desktop or Microsoft Teams, then open Settings → Languages →
**Languages for a chat…**. Select the chat and click the languages to include. Choose
**Use languages**, then **Save** in Settings. The subset order is saved from the main
layout list when editing the profile; one-language profiles are supported. **Remove profile** restores default behavior.
For a matching chat, **Alt+Shift**, **Win+Space** (and reverse **Win+Shift+Space**) cycle
through that subset. KSwitcher's **Next layout** conversion uses the same subset.
Explicit KSwitcher hotkey bindings take precedence. Without a matching profile,
Windows shortcuts keep their normal behavior and Next layout uses the global cycle.
Unavailable layouts are skipped; if none remain installed, the global cycle is used.
Direct layout shortcuts are unchanged. Profiles do not change Windows' installed languages.
Matching uses the application and visible chat name, not a unique account/contact ID.
Identically named chats in one application share a profile; renamed chats need a new
profile. Telegram unread counters are ignored. Teams currently recognizes the desktop
`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.
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.
**Українською.** Налаштування розділено на вкладки General, Languages, Shortcuts, Clipboard
і Tools. Перехід між ними зберігає незбережені зміни; Save, Cancel, Import і Export доступні
на кожній вкладці. Відкрий потрібний чат у Telegram або Teams, потім **Settings → Languages → Languages
for a chat…**. Вибери чат і потрібні мови, натисни **Use languages**, а потім **Save**.
Набір діє для **Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** KSwitcher.
Можна вибрати й одну мову; порядок зберігається із загального списку під час редагування профілю. **Remove profile**
повертає звичайну поведінку. Без профілю системні комбінації працюють як раніше,
а Next layout використовує загальний набір. Видалені з Windows розкладки пропускаються;
якщо доступних мов профілю не залишилося, використовується загальний набір.
Прямі комбінації мов не змінюються; явно задані комбінації KSwitcher мають пріоритет.
Зіставлення виконується за програмою та назвою чату: однакові назви в одній програмі
мають спільний профіль, після перейменування потрібен новий. Лічильник непрочитаних
Telegram ігнорується. Teams підтримує зазначений вище формат заголовка.
Експериментальні профілі Viber із v0.6.49 вимкнено у v0.6.50 через затримки від повторного OCR. Фонове розпізнавання, OCR при перемиканні мов та відкритті профілів більше не запускаються. Профілі Viber збережені й позначені як неактивні; системне перемикання та Next layout у Viber використовують звичайну поведінку й загальний набір. OCR буфера та зображень не змінено.
Профілі локальні, входять до ручного експорту/імпорту, але не до
хмарної синхронізації. Доступно з v0.6.48.
### Telegram Web in Chrome — v0.6.51
Open a chat in Telegram Web K (`https://web.telegram.org/k/`) in Chrome, then use **Settings → Languages → Languages for a chat...** and select **Telegram Web: name**. Choose languages, then **Use languages → Save**. The same subset applies to Alt+Shift, Win+Space, Win+Shift+Space and Next layout. Web profiles are separate from desktop Telegram profiles. Same-name chats across browser profiles/accounts share a web profile.
Recognition uses Chrome accessibility: the address bar and active document must both identify a Telegram chat, then the visible header supplies the name. The app watches foreground/focus and address/header-change events; it does not use OCR, a polling timer, an extension, or message-history extraction. The address bar, home page and unsupported URLs do not match. Web A and other browsers have not been validated and are not advertised as supported. Page or accessibility changes can make a chat unavailable instead of guessing.
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
### Snippet autocomplete — v0.6.69
Starting with v0.6.71, **Enter** accepts a suggestion after you navigate
the list with **↑ / ↓**. Without arrow navigation, **Enter** goes to the
editor. З v0.6.71 **Enter** після вибору стрілками вставляє сніпет. Без
переміщення списком **Enter** надходить до редактора.
Starting with v0.6.70, suggestions open at the text caret. If the editor
provides no caret position, you can still type the complete trigger to
expand the snippet.
Type `;` at the start of a trigger to open suggestions near the caret, or
type the first three characters of another trigger. The list filters as you
type and fits the widest matching trigger. Use **↑ / ↓** and **Tab** or click
an entry. **Esc** dismisses the list. Full triggers still expand
automatically. See the [snippet guide](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Snippets).
**Українською.** Введіть `;` на початку тригера або три перші символи іншого
тригера, щоб відкрити підказки біля курсора. Список фільтрується під час набору
й підлаштовує ширину під найдовший збіг. Для вибору натисніть **↑ / ↓** і
**Tab** або клацніть рядок. **Esc** закриває список. Повні тригери й далі
спрацьовують автоматично. Дивіться [посібник зі сніпетів](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Snippets-uk).
З v0.6.70 підказки відкриваються біля текстового курсора. Якщо редактор не
надає його позицію, введіть повний тригер для розгортання сніпета.
### Desktop chat snippet selection — v0.6.55
ChatGPT and Codex desktop editors use paced selection for snippet triggers. If only a verified suffix is selected, KSwitcher extends the selection and verifies the full trigger before replacement. Unrelated text or changed focus cancels the operation. Triggers remain recognized across keyboard layouts. One snippet can target multiple applications: add each with **Add app**, or enter comma-separated process names. These existing application scopes also synchronize.
**Українською.** У настільних ChatGPT і Codex тригер виділяється з паузами між натисканнями. Якщо виділилася лише перевірена кінцева частина, KSwitcher доповнює виділення й перевіряє весь тригер перед заміною. Інший текст або зміна фокусу скасовують операцію. Тригери розпізнаються в різних розкладках. Один сніпет можна прив’язати до кількох програм через **Add app** або назви процесів через кому; ці прив’язки вже підтримують синхронізацію.
### Application-specific snippets — v0.6.51
In **Settings → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Use **Save snippets** to apply the changes.
A matching application-specific snippet takes precedence over a global snippet with the same trigger. The same trigger can be used for different applications; overlapping scopes for the same trigger are rejected. Existing snippets without a scope remain global. Matching is case-insensitive by process name, with or without `.exe`, and is checked before insertion. No OCR or timer is involved. Chrome scope means the entire browser, not one website.
Scopes are saved with snippets and included in manual import/export and synchronization; merge conflicts consider the application scopes. Use v0.6.51 or newer on every synchronizing PC before sharing scoped snippets: older versions do not understand the restriction and may treat the snippet as global.
**Українською.** У **Settings → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. Натисни **Save snippets**, щоб застосувати зміни.
Сніпет конкретної програми має пріоритет над глобальним із тим самим тригером. Для різних програм однакові тригери дозволені, для перетинних областей — ні. Старі сніпети залишаються глобальними. Зіставлення за назвою процесу не залежить від регістру та суфікса `.exe`; OCR і таймерів немає. Для Chrome це весь браузер, а не окремий сайт. Обмеження входять до експорту/імпорту та синхронізації. Перед синхронізацією онови всі ПК до v0.6.51 або новішої: старі версії не розуміють обмеження та можуть застосувати сніпет глобально.
### Existing features
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
that changes it. If none changes the text, the operation keeps the text and advances one layout.
@@ -260,13 +508,31 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
Clipboard synchronizers such as Mouse Without Borders may republish a copy only when
KSwitcher first observed it owned by the target application and the selection text and
editor selection metadata remain identical. Unobserved or altered foreign copies are rejected.
Snippets normally require accessibility verification of their selected trigger and pasted text,
even with logging disabled. If verification is unavailable or the paste differs, expansion
stops without running cursor or Enter actions. Clipboard changes during paste also cancel actions.
Far Manager uses its native Alt+Shift+Arrow selection, Ctrl+Insert copy and Shift+Insert
paste commands instead: both the trigger and inserted text must match verified clipboard
copies from Far. Cancellation restores the full temporary selection, not one cursor step.
Far supports nonempty single-line replacements up to 4096 caret steps, including
`{cursor}` and verified `{enter}` actions. Empty or multiline replacements are rejected
before selection; pasting a newline could execute a command before it can be verified.
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
If the editor's word boundary differs from the recorded typing, it falls back to paced character
selection: Teams can coalesce a burst of Shift+Left events into just one character.
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
A snapshot interrupted by a concurrent clipboard publication is retried up to three total
attempts before selection starts, provided the foreground and input context remain unchanged.
A silently dropped copy is retried up to three total attempts while the clipboard and input
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
history and cloud synchronization. Paste is not blindly retried.
history and cloud synchronization. Paste is not blindly retried: with manual paste readback
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
original and fresh native queries confirm that exactly the original range is still selected.
Other controls and unknown/changed selections are not retried. A verified failed paste clears
repeat-conversion state and does not switch the Windows layout.
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
@@ -316,6 +582,10 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
**Clear history… / Alt+H** in the history window clears all saved entries after confirmation,
including pinned entries and entries hidden by search. No is the default choice. The current
Windows clipboard and future recording are unchanged. Settings also retains its Clear history action.
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
in the context menu opens the original image in the built-in screenshot editor.
Annotate it there, then copy the result. Opening the editor leaves the
@@ -331,10 +601,17 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
Diagnostic stages stay in a memory buffer of at most 128 events for 30 seconds.
Selection diagnostics include the expected text, each copied candidate before and after suffix
recovery, paced Shift+Left progress or batch delivery counts, and clipboard sequence changes,
retry counts and elapsed times. Focus, caret geometry and keyboard layout are recorded when
Windows exposes them; selection offsets are available for native EDIT controls and explicitly
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
or layout-refresh events during an operation. Failed selection verification includes the expected
selection and actual copied text in the operation details, without implying that a paste occurred.
Problem operations write a structured summary to `kswitcher.log`, with their preceding
context. Successful operations stay in that bounded memory buffer and are included only when
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
a subsequent problem flushes it. Verified pastes that take at least two seconds also write a summary without the copied text. Flushed context is discarded. Ordinary application
errors are also logged when logging is enabled. This works in normal Release builds.
Incident context contains tracked physical input, the expected word, copied text, conversion result,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
@@ -383,6 +660,146 @@ This is a working native implementation, not a claim of complete UI/release pari
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
## OpenBao synchronization (experimental, opt-in)
The **Sync...** button is available in Settings for everyone. Synchronization remains disabled
until you configure your own OpenBao and enable it. Connection configuration is saved separately
in `sync.json`. The portable data directory is normally `data/` beside the executable; installations
without a writable application directory use the existing local-app-data fallback.
No server address or account is included in the application.
The first provider is **OpenBao KV v2**. The dialog accepts an HTTPS server origin, username,
password, KV mount (default `kswitcher`), profile path (default `personal/settings`) and userpass
authentication mount (default `userpass`). Use the same profile on each of your PCs. This is
administrator-provisioned private access, not public account registration. Non-root namespaces,
reverse-proxy URL prefixes, MFA login flows and other providers are not implemented yet.
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
When synchronization is disabled, connection fields and sync actions are inactive. Check **Enable
synchronization** to make them available. To disable an existing connection, uncheck it and click
**Save**; this preserves the saved connection and password without starting a sync request.
**Disconnect / forget password** remains available while synchronization is disabled.
A blank password keeps the existing Windows credential for that server, login mount and username.
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
kept in memory for one sync attempt, with best-effort self-revocation afterwards. Configure bounded
token lifetimes on the server as well (for example a 5-minute default and 10-minute maximum).
**Disconnect / forget password** disables synchronization and removes the current connection's
saved credential. A request already sent to the server may still complete; its downloaded settings
will not be applied after the connection changes. Old credentials for other connection identities
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
On the first connection, **Save / Sign in / Sync** merges the existing profiles without
requiring a whole-profile replacement. Independent entries (such as snippets, exclusions and
replacement pairs) are combined. Different values for the same setting remain on their respective
sides and appear in **Resolve individual conflicts...**; no value is guessed without a common history.
Nonconflicting changes synchronize immediately, and unresolved conflicts survive subsequent syncs.
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
- **Download remote** replaces this PC's shared settings with the server profile.
- If the server has no profile yet, choose **Upload local** to create it.
- Unsaved edits in Settings are not synchronized. After a successful **Download remote**, the open Settings window refreshes automatically and keeps the selected tab. The download confirmation warns that unsaved edits will be discarded.
- The status distinguishes downloading, downloaded settings, no local changes and failure. HTTP 400 errors identify authentication, download or upload; field errors remain visible until the next action.
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
continues offline. Independent changes to different settings fields are merged against the last
successful baseline. **Resolve individual conflicts...** shows the name and local/server values
for each conflict. Choose either value for selected settings and apply your choices; unselected
conflicts stay pending. Other, nonconflicting changes continue to synchronize. Choices are checked
against freshly read values before writing, so a stale dialog cannot overwrite a newer edit.
Conflicting shortcuts are separate settings, including bindings for individual keyboard layouts.
Exclusions and personal dictionary words merge by membership. Ordered layout/dictionary lists
remain single settings because their ordering affects behavior.
Snippets have persistent IDs that survive renaming. Different snippets merge independently;
trigger and text edits within a snippet can merge too. Deleting a snippet retains a deletion
marker, so an unchanged offline copy cannot bring it back. Deletion versus a concurrent edit
is an explicit conflict, offering the deleted or edited version. Duplicate triggers and shortcut
collisions created by independent edits are also presented for resolution. Deletion markers are
retained without automatic expiry; recreating a snippet in the editor gives it a new ID.
Existing profiles migrate automatically: legacy snippet IDs derive deterministically from the
original trigger, then are stored permanently. Sync writes use envelope version 2; older clients
refuse this version instead of dropping IDs/deletions. Update all participating PCs before using
the new merge behavior. Existing version-1 profiles and local baselines remain readable.
Download/Upload are still available for deliberate whole-profile recovery/initialization, not
required for ordinary conflicts. Every server write uses the version just read as a CAS
condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
Deleted server versions are not silently recreated.
The server profile includes all settings from KSwitcher Settings, including mouse events and
bindings, application language profiles, startup, keep-awake, logging, history limits and OCR.
Update the clients that use the same server profile before syncing the expanded format.
Older server backups preserve the local values for fields they never stored. Restored executable
paths refer to the same locations and may need updating on a fresh installation.
Connection details and credentials stay local so you can sign in again after reinstalling.
Clipboard history, screenshots and dictionary files are never uploaded. Full synchronization identifies
missing keyboard layouts by language (when Windows can identify it) and exact ID.
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
local layout cycle and its direct shortcuts unchanged while synchronizing the other shared settings.
The server's layout cycle and direct shortcuts are preserved on automatic sync and explicit Upload;
Download also keeps the local layout configuration. Later syncs cannot erase missing server layouts
just because this PC does not have them installed. Uncheck the option to resume full layout sync.
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
change (`settings-before-sync.bin`) are protected with Windows DPAPI and belong to this Windows
account/machine. Reinstallation recovery uses the remote profile and a fresh login; copying these
local DPAPI files to a new machine does not recover them. For an older server snapshot, use OpenBao
KV version history to restore that snapshot as the latest version, then choose Download remote.
The app does not yet provide a server-history browser. Keep ordinary server backups too.
OpenBao administrator setup:
1. Enable a KV **version 2** engine at `kswitcher`; enable **Require Check-and-Set** and set
maximum versions to a suitable value, such as 30.
2. Create a dedicated userpass account, with a policy restricted to the chosen profile. Do not
use the administrator account. For the defaults, the application needs only:
```hcl
path "kswitcher/data/personal/settings" {
capabilities = ["create", "read", "update"]
}
path "auth/token/revoke-self" {
capabilities = ["update"]
}
```
3. Set bounded token lifetimes on that account/auth mount and enter its credentials in KSwitcher.
Keep the server's address and TLS certificate reachable/trusted on each participating PC.
`SyncProvider` separates versioned read/conditional-write operations from merging, local state
and UI integration, so another service can be added without replacing the synchronization logic.
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
## Portable utilities
The tray menu's **Tools** submenu lists `.exe` files in a `tools` folder beside
`KSwitcher.exe`, sorted by filename. For example, place `StayHere.exe` at
`tools/StayHere.exe` and choose **Tools → StayHere** to launch it manually.
The folder is rescanned whenever the menu opens; no configuration or restart is needed.
Only files directly in `tools` are listed (not subfolders, shortcuts, or KSwitcher's
own executables beside the main program). Utilities start with `tools` as their working
directory and are never started automatically. Their lifetime is independent of KSwitcher.
An absent or empty folder displays a disabled placeholder; launch errors are reported.
From v0.6.45, the release includes `tools/README.txt`, so extracting the ZIP or
installing an update creates the folder. Add your own trusted portable executables;
StayHere is bundled from v0.6.46, and settings synchronization does not transfer
utility files. Updates preserve personal utilities and refresh bundled StayHere and
documentation. Repository development scripts
are not included in the portable tools folder.
Choose **Tools → StayHere** to start the bundled activity utility. It starts
enabled; left-click its tray icon to pause/resume or right-click for Exit. While
enabled it prevents automatic sleep and, after three minutes without input, sends
F15 on its 15-second check. It does not move the mouse, bypass locking, start with
KSwitcher automatically, or directly control Teams status. Locked/disconnected
sessions do not receive simulated activity. See `tools/StayHere-README.md`.
## Build
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
@@ -402,13 +819,46 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
verified local build uses GCC/w64devkit.
## Automatic layout correction (experimental)
## Automatic correction (experimental)
Enable **Automatically correct layout (experimental)** in Settings, then use **Auto-correction
Enable **Automatic correction (experimental)** in Settings, then use **Auto-correction
options…** to select dictionary languages and edit excluded applications or words to never correct.
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
Target layouts must also be enabled in the layout cycle.
### Custom replacements and spelling typos
Two optional modes in **Auto-correction options…** extend layout correction. Both are off by
default and require the main **Automatic correction (experimental)** checkbox to be enabled.
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
are rejected. The typo must be one word of 1–32 characters with at least one letter. It can contain digits and internal apostrophes. The correction may contain several words
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
spaces. Corrections also allow apostrophes (`'`, `’`, `ʼ`) between letters, for example
`ревю=рев'ю`. Both sides must use the same supported language, without other punctuation; leading,
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
already valid words, and do not require dictionary membership or satisfy the frequency limit.
Update every synchronizing PC to v0.6.75 before sharing pairs with digits or apostrophes in the typo.
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
after no layout correction is found. It accepts exactly one dictionary candidate reachable
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
and target must have at least four letters. Multiple candidates leave the input unchanged,
even if a frequency limit would exclude one of them. Personal dictionary words participate.
This is word-level spelling correction, not grammar or sentence-context correction.
Both modes run after Space, preserve surrounding punctuation and an initial capital, and keep
the current layout. The selected dictionary languages, enabled layout cycle, minimum input
length, excluded applications, never-correct list, protected fields and Undo behavior apply.
ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped. A replacement
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
are included in settings export/import and synchronization. Undo restores the original word
and suppresses its correction until restart.
When synchronizing phrase replacements, update all participating PCs to v0.6.47 or newer;
older clients reject spaces in replacement pairs.
### Layout correction and shared options
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
@@ -458,7 +908,12 @@ support automatic correction. Dictionary recognition is heuristic, not an unders
subtitle vocabulary is incomplete, and uncommon names or jargon can need personal exceptions.
No network service or language model is used. The three dictionary files occupy about 2.6 MB on disk;
the English/Ukrainian lookup data uses less than 3 MiB of RAM and is loaded on first automatic check.
the bundled EN/UK/RU lookup payload uses about 0.90 MiB of RAM. When automatic correction is enabled,
selected dictionaries are loaded before typing and shared by the input predictor and worker.
Words are stored in compact indexed blocks; all words and frequency ranks are retained, and editable
UTF-8 dictionary files keep the same format. Custom dictionaries can use more memory.
`build.ps1 -Test` checks every bundled word/rank and measures lookup speed; CMake exposes the same
check as `dictionary-regressions`. The payload figure excludes the rest of the process and allocator overhead.
Keep `dictionaries/` beside the executable when copying the app. These word lists derive from
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) and are separately licensed under
CC BY-SA 4.0; attribution, pinned source revision and license are in `dictionaries/`.
@@ -474,6 +929,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
.\build.ps1 -Far # Explicit integration test using an isolated Far Manager profile
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
```
@@ -529,3 +985,85 @@ Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
legacy version tag: clients that have not installed the bridge would select it.
Keep the compatibility bridge in the first 100 releases returned by Gitea while
supporting old clients; their immutable update code only reads that page.
### Editors that omit copied trailing spaces
From v0.6.31, KSwitcher recovers trailing ordinary spaces omitted by Ctrl+C only when Windows accessibility proves the complete selection. The same focused control and range are rechecked before paste, and the result is read back even without Write log. Recent-text conversion, automatic correction, repeat conversion and Undo retain exact verification. Missing letters, tabs, line breaks and unavailable or ambiguous selections are not reconstructed. No setting or shortcut has changed.
### Notification registration and Viber — v0.6.36
Notification registration runs in KSwitcher.Screenshot.exe; keep all three executables together. Startup registration is asynchronous. Viber pastes allow 75 ms for clipboard notifications before Ctrl+V, retaining focus, ownership and selection checks. This mitigates an intermittent clipboard timing issue; it does not guarantee all Viber failures are resolved. UI Automation is unchanged.
### Image history, OCR and duplicate fingerprints — v0.6.37
Image clipboard capture and automatic/uncached manual history OCR run in short-lived KSwitcher.Screenshot.exe processes. Keep all three executables together. Original clipboard formats, image limits, privacy exclusions, Windows OCR, optional Tesseract and searchable encrypted OCR results are preserved. Background OCR is cancelled on shutdown or an OCR-profile change; late results do not recreate deleted entries.
Consecutive duplicates use SHA-256 fingerprints inside the encrypted history index, including pixel fingerprints for equivalent image encodings. Older entries acquire fingerprints on first comparison without a bulk migration. Pins and OCR caches survive duplicate detection; non-consecutive copies still create separate entries. New text no longer loads the preceding image to rule out a duplicate. No shortcut, setting or default changed.
Moving OCR isolates its temporary memory rather than reducing the combined processing peak. A controlled three-run comparison retained about 0.8 MiB less private RAM in the caller after recognition; combined peak RAM was about 55.6 MiB before and 56.6 MiB with the helper. Results depend on the image, OCR engine and system.
### Pinned history checkbox — v0.6.42
Check **★ Pinned only** beside the history search box to show only pinned entries; uncheck it to show all entries. The checkbox sits on the left, immediately followed by the search box; its right edge stays aligned with the history list when resizing. The text search applies within that filter. Unpinning an item removes it from the pinned view immediately. The checkbox state and search are remembered after closing and reopening history. **Clear history…** still deletes the entire history after confirmation, including entries hidden by the filter.
### History process and clipboard memory — v0.6.40
History opens in a separate, short-lived instance of **KSwitcher.exe**. **Esc** closes it even when focus is on the window or an action button, releasing Windows UI allocations with the process. The resident keeps keyboard hooks, clipboard capture and history storage. Search and the pinned-only filter are preserved in encrypted view state. Each fresh opening selects and reveals the newest matching entry; background refreshes preserve the selected record while browsing. If a full-text item viewer is still open, the helper stays alive until the last viewer closes. Pin/delete, formatted/plain paste and OCR remain available; settings and default shortcuts are unchanged.
Image thumbnails are decoded in **KSwitcher.Screenshot.exe**, returning only bounded preview pixels. Image clipboard backup/restore also uses that helper and encrypted temporary files, with clipboard-sequence checks before restoring. Selection reads avoid loading image formats. Keep all three executables together; no fourth executable is required. Paste allows a brief foreground handoff after history closes while preserving cancellation checks. UI Automation remains in the resident.
A live development-build test measured **4.37 MiB before history and 4.35 MiB after two open/Esc cycles**, compared with an earlier roughly 8–9 MiB plateau. A same-image clipboard-backup benchmark retained **12.78 MiB before versus 0.05 MiB after**. These measure private working set, not a guaranteed total footprint. Include all helpers when measuring active use: combined memory can be higher while history is open. No forced working-set trimming is used. Existing screenshots and shortcuts remain applicable. The v0.6.38 approach of retaining a hidden history window/RichEdit control is superseded by process termination.
### Languages for applications
Open **Settings → Languages → Languages for applications…**. Select an application
on the left and click its allowed languages on the right. Every application has
an independent selection, even when several applications use the same languages.
Existing assignments from the earlier shared-set editor retain their language
choices and become independent profiles when this dialog is applied.
The list includes running applications and saved profiles. Add a closed application
by its executable name, such as `Codex.exe`. Matching ignores case and `.exe` and
covers all windows of that executable, including all tabs of a browser.
To identify an open application, drag **Drag to window** onto its window. An outline
shows which window will be selected. Release to select its executable or press **Esc** to cancel.
Applications without a custom profile use the default languages; merely viewing
an application does not create a profile. **Use defaults** clears only the selected
application's custom choice. Switching applications preserves pending edits.
Choose **Use languages** once, then **Save** in Settings; **Cancel** discards the
dialog's changes. A matching contact profile takes priority over an application
profile, which takes priority over the default cycle.
**Alt+Shift**, **Win+Space**, reverse **Win+Shift+Space** and **Next layout** use the
resolved subset. In an application with custom languages, a layout outside the
subset is returned to the first available allowed language by a periodic check
(250 ms, deferred while KSwitcher is busy). This also handles entering the app
with an excluded language active or selecting one via Windows or a direct shortcut.
Languages remain installed and visible in Windows' language picker. Unavailable
layouts are skipped; if the entire selection is unavailable, the default cycle is
used. Application language profiles are included in server synchronization and
manual settings export/import.
**Українською.** Відкрий **Settings → Languages → Languages for applications…**.
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
незалежний, навіть якщо в кількох програмах він однаковий. Старі прив'язки спільних
наборів зберігають вибрані мови та стають незалежними профілями після застосування
цього вікна. У списку є відкриті програми та збережені профілі; закриту програму
можна додати за назвою файла, наприклад `Codex.exe`. Регістр і `.exe` не мають
значення. Правило охоплює всі вікна програми, зокрема всі вкладки браузера.
Щоб визначити відкриту програму, перетягни приціл **Drag to window** на її вікно.
Рамка показує, яке вікно буде вибрано. Відпусти приціл, щоб вибрати програму,
або натисни **Esc**, щоб скасувати.
Без окремого профілю діють загальні мови; простий перегляд програми не створює
профіль. **Use defaults** повертає лише вибрану програму до загальних мов.
Перехід між програмами зберігає чернетки. **Use languages**, потім **Save**
застосовує всі зміни, **Cancel** скасовує зміни вікна. Пріоритет: профіль контакту
→ профіль програми → загальний цикл.
**Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** використовують
відповідний вибір. Мову поза профілем програми перевірка кожні 250 мс замінює
першою доступною дозволеною мовою (відкладається, коли KSwitcher зайнятий).
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі входять до серверної синхронізації
та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+220 -18
View File
@@ -1,28 +1,230 @@
# Latest Windows release
## v0.6.25 — 2026-11-01
## v0.6.80 — 2026-11-14
## Changes
- Pencil (F) automatically recognizes nearly straight strokes on mouse release and turns them into editable straight lines at the drawn angle.
- Preserve endpoints, color, thickness, line style and arrowheads. Ctrl+Z restores the exact original stroke; a second undo removes it, and Ctrl+Y reapplies recognition.
- Reject short gestures, noticeable curves, spikes and substantial backtracking. Existing shape recognition, smoothing, linked strokes and Shift constraints retain their behavior.
Includes the Files updates from v0.6.79. Change drive now explains why the local peer lookup failed and shows No paired computers only when the response is empty.
## Validation
423 native regressions, 473 input/UI checks, line/shape recognition and screenshot editor regressions, PNG save regressions, shared wheel handling, notification registration, 48 updater checks and updater progress checks. The user also confirmed the feature in the development editor.
**Українською.** Випуск включає зміни Files із v0.6.79. Change drive показує конкретну причину збою запиту пар. No paired computers з’являється лише після успішної порожньої відповіді.
## Limitations
Recognition is heuristic and conservative. Endpoints must be at least 24 source-image pixels apart; uncertain strokes may remain freehand. Use Straight line (L) when an exact line is needed. Linked strokes and strokes drawn partly with Shift are not automatically straightened. Existing browser-selection and clipboard/accessibility limitations remain unchanged.
## v0.6.79 — 2026-11-14
**Upgrade:** 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 screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
In KSwitcher Files, changing directory in PowerShell updates the panel
that sent the command. The global Files shortcut brings an open window to
the foreground. Change drive redraws its frame when LAN drives arrive and
shows a short timeout message. Files writes LAN lookup errors to its own log.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
**Українською.** Зміна каталогу в PowerShell оновлює панель, з якої
надіслано команду. Глобальна клавіша Files повертає до відкритого вікна.
Change drive перемальовує рамку після появи LAN-дисків і показує коротке
повідомлення про тайм-аут. Files записує помилки LAN у власний журнал.
Build and package:
## v0.6.78 — 2026-11-14
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
The Windows package includes KSwitcher Files, a two-panel file manager with
local and paired LAN drives, folder shortcuts, an embedded PowerShell terminal,
and searchable command history. Applied Files settings update other open
instances immediately.
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
**Українською.** До пакета для Windows входить KSwitcher Files, двопанельний
файловий менеджер із локальними дисками й дисками спарених ПК, ярликами папок,
вбудованим PowerShell та пошуком в історії команд. Застосовані налаштування
Files одразу підхоплюють інші відкриті вікна.
## v0.6.77 — 2026-11-13
The screenshot editor remembers the local pencil smoothing default across
restarts. Adjust the slider with no pencil stroke selected, or use **Apply to
all**, to save the value for new strokes. Adjusting a selected stroke changes
only that stroke.
**Українською.** Редактор скріншотів зберігає типове згладжування олівця на
цьому ПК та відновлює його після перезапуску. Змініть повзунок без виділеного
штриха олівця або натисніть **Apply to all**, щоб запам’ятати значення для
нових штрихів. Зміна виділеного штриха впливає лише на нього.
## v0.6.76 — 2026-11-13
Incoming LAN files suggest their original filename when saved. KSwitcher shows
a Windows tray notification when a file is ready to save, and the tray tooltip
shows the pending count.
**Українською.** Під час збереження вхідного LAN-файлу пропонується його
оригінальна назва. KSwitcher показує сповіщення в треї, коли файл готовий
до збереження. Підказка іконки показує кількість файлів у черзі.
## v0.6.75 — 2026-11-13
Conversion cancels earlier when input changes and skips restoring an untouched
clipboard. Diagnostic records identify the active conversion behind `busy`,
separate clipboard and paste verification timings, and retain a text-free
summary of verified pastes that take at least two seconds. MCP log reads
support `offset` to page backward.
**Українською.** Конвертація раніше скасовується після зміни вводу й пропускає
відновлення буфера, якщо його не змінювала. Діагностика показує активну спробу
для `busy`, окремий час роботи з буфером і перевірки вставки, а також зберігає
підсумок перевірених вставок від двох секунд без скопійованого тексту. У MCP
параметр `offset` дає змогу читати старіші рядки.
Explicit replacements now accept digits and internal apostrophes in the source word, for example нав1язав=навʼязав. Invalid entries identify the offending pair.
**Українською.** Явні заміни підтримують цифри й внутрішні апострофи у вихідному слові, наприклад нав1язав=навʼязав. Повідомлення про помилку показує проблемну пару.
## v0.6.74 — 2026-11-13
- Send files up to 16 MiB to a selected paired PC over encrypted LAN. Save or discard them from **Incoming files**.
- Download current and previous diagnostic logs from paired PCs that explicitly allow remote log downloads.
- Copy the Codex configuration from **Settings → MCP**. The MCP server lists paired PCs and reads local or remote diagnostic logs.
- Search contacts in the **Languages for a chat** table. **Copy selected** in Diagnostics now uses normal clipboard synchronization.
**Українською.** Передавайте файли до 16 МіБ вибраному спареному ПК зашифрованою локальною мережею. У **Incoming files** можна зберегти або відхилити файл. Поточний і попередній журнали доступні для завантаження після ввімкнення відповідного дозволу на віддаленому ПК. У **Settings → MCP** можна скопіювати конфігурацію Codex для читання локальних і віддалених журналів. У таблиці **Languages for a chat** додано пошук контактів, а **Copy selected** у діагностиці тепер проходить через звичайну синхронізацію буфера.
## v0.6.73 — 2026-11-13
After moving KSwitcher, launch it from its current folder. If Windows autostart
still points to the old location, KSwitcher updates the path automatically when
the previous executable is missing and the process has sufficient rights. If
the previous copy may still be in use or the repair needs administrator approval,
choose **Update autostart path** and confirm UAC if prompted. To check again,
click **Save** in Settings without changing the autostart options.
**Українською.** Після перенесення KSwitcher запустіть його з нової папки.
Якщо автозапуск і далі вказує на старий шлях, KSwitcher виправить його
автоматично, коли старий файл відсутній і прав достатньо. Якщо стара копія
ще може використовуватися або потрібні права адміністратора, натисніть
**Update autostart path** і підтвердьте UAC, якщо з’явиться запит. Для повторної
перевірки натисніть **Save** у налаштуваннях без зміни параметрів автозапуску.
## v0.6.72 — 2026-11-12
Edit snippets directly in **Settings → Snippets**. **Save snippets** applies
snippet edits independently of the settings draft. Find OCR options in
**Clipboard** and open backup and synchronization, paired computers, and
logging from **Sync**, **Computers**, and **Diagnostics**, respectively.
**Українською.** Редагуйте сніпети безпосередньо у **Settings → Snippets**.
Кнопка **Save snippets** зберігає їх окремо від чернетки налаштувань.
Параметри OCR тепер у **Clipboard**. Для синхронізації, спарених ПК та
журналу є вкладки **Sync**, **Computers** і **Diagnostics**.
## v0.6.71 — 2026-11-12
After navigating snippet suggestions with **↑ / ↓**, press **Enter** to
insert the selected snippet and close the list. The editor does not receive
that keypress. If you have not navigated the list, **Enter** still goes to
the editor. **Tab** and mouse selection also work.
KSwitcher skips suggestions when VS Code's EditContext supplies the bounds
of the whole line in place of the text caret. See the [FAQ](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/FAQ)
for the VS Code workaround.
**Українською.** Після вибору підказки клавішами **↑ / ↓** натисніть
**Enter**, щоб вставити сніпет і закрити список. Редактор не отримує це
натискання. Якщо ви не переміщалися списком, **Enter** і далі надходить
до редактора. Також можна вибрати сніпет через **Tab** або мишею.
KSwitcher приховує підказки, коли EditContext у VS Code повертає координати
всього рядка замість текстового курсора. Обхідний шлях описано у [FAQ українською](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/FAQ-uk).
## v0.6.70 — 2026-11-12
Snippet suggestions are anchored to the text caret. KSwitcher uses the
editor's caret location or text accessibility information, including for
thin carets and wrapped text. If the editor provides no caret position,
the list stays hidden. Full triggers continue to expand automatically.
**Українською.** Підказки сніпетів прив’язані до текстового курсора.
KSwitcher використовує координати курсора або дані доступності редактора,
зокрема для тонкого курсора й перенесених рядків. Якщо редактор не надає
позицію текстового курсора, список не відкривається. Повні тригери й далі
спрацьовують автоматично.
## v0.6.69 — 2026-11-12
Snippet autocomplete opens beside the text caret as soon as you type `;` at
the start of a trigger. Other triggers offer suggestions after three
characters. The list stays open while you type, resizes to fit the matching
triggers, and respects application restrictions. Use **↑ / ↓** and **Tab** or
click a suggestion to insert it. Press **Esc** to dismiss the list. Full
triggers still expand automatically.
**Українською.** Автодоповнення сніпетів відкривається біля текстового курсора,
щойно ви вводите `;` на початку тригера. Для інших тригерів підказки
з’являються після трьох символів. Список залишається відкритим під час набору,
підлаштовує ширину під відповідні тригери й враховує обмеження за програмами.
Виберіть сніпет клавішами **↑ / ↓** і **Tab** або мишею. **Esc** закриває
список. Повні тригери й далі спрацьовують автоматично.
## v0.6.68 — 2026-11-12
With **Start with Windows** and **Start with Windows as administrator** enabled,
opening the regular KSwitcher after exiting a development build requests
administrator approval when needed. UAC prompts on the secure desktop still
require local input.
**Українською.** Якщо ввімкнено **Start with Windows** і **Start with Windows as administrator**,
звичайний KSwitcher після закриття dev-збірки за потреби запитує запуск від
адміністратора. Запити UAC на захищеному робочому столі підтверджуйте
безпосередньо на цьому ПК.
## v0.6.67 — 2026-11-12
Mouse buttons and wheel actions now use the profiles on the PC receiving
input through KSwitcher Computers or Mouse Without Borders. Update the
receiving PC and configure its actions in **Settings → Mouse**.
**Українською.** Кнопки й коліщатко тепер працюють за профілями ПК,
який отримує ввід через KSwitcher Computers або Mouse Without Borders.
Оновіть цей ПК і налаштуйте дії в **Settings → Mouse**.
## v0.6.66 — 2026-11-12
If Windows rejects registration of the notification-area icon at sign-in or
after Explorer restarts, KSwitcher retries about once a second until the
icon is registered. Settings become accessible from the icon without
restarting KSwitcher.
**Українською.** Якщо Windows не приймає значок KSwitcher в області
сповіщень під час входу або після перезапуску Провідника, KSwitcher
повторює спробу приблизно щосекунди. Коли значок з’явиться, з нього
можна відкрити налаштування без перезапуску KSwitcher.
## v0.6.65 — 2026-11-12
Use **Settings → Mouse → +** beside **Mouse events** to capture and name a
vertical or horizontal wheel direction. Capturing a known button or an
already registered direction selects its existing row. A new signal is
accepted only when Windows delivers it through both the test and runtime
input paths. A configured wheel action replaces scrolling for that direction
where the profile applies.
OpenBao now backs up all KSwitcher Settings, including mouse events, bindings,
application profiles, startup, clipboard history limits, and OCR preferences.
Update all clients that share a server profile before syncing. Downloading an
older backup preserves local settings for fields it never included. After
reinstalling Windows, reconnect to OpenBao and choose **Download remote**.
Executable paths and optional OCR software must still be available on the
restored PC. Clipboard contents and Windows credentials stay on the device.
**Українською.** У **Settings → Mouse** натисни **+** біля **Mouse events**,
щоб додати напрямок прокручування. Відомий сигнал виділить наявний рядок.
OpenBao тепер зберігає всі налаштування KSwitcher, зокрема профілі миші,
OCR й автозапуск. Перед синхронізацією онови всі клієнти спільного профілю.
Після перевстановлення Windows підключись до OpenBao й натисни
**Download remote**.
## v0.6.64 — 2026-11-12
The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut.
Actions include keyboard combinations, browser navigation, launching applications, and system volume. Volume actions have a configurable step and an on-screen indicator. When Windows reports separate press and release events, holding the button repeats the volume change at increasing speed.
Use **Test mouse buttons here** to check which events Windows delivers. Mouse profiles are stored on the current PC. The configurator also keeps fixed-size settings dialogs fitted when moving them between displays with different scaling.
## Upgrade
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 a manual upgrade, exit KSwitcher and its configurator and screenshot editors, then extract the entire **KSwitcher-win-x64.zip** into its installation folder. Verify it with **SHA256SUMS.txt**. Keep personal data, customized dictionaries and tools. Keep the three KSwitcher executables together.
See the [English Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home) or [Українська Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk).
+1 -1
View File
@@ -1 +1 @@
69efef3571e2836875fc36dbd42a62d642471e7cd275343959dfb575e5848bd5 KSwitcher-win-x64.zip
f6067958677c4045fc5243459fc37522489a7f081969dac42befd07101814e61 KSwitcher-win-x64.zip
+131
View File
@@ -0,0 +1,131 @@
{
"schema": 1,
"version": "0.6.80",
"files": [
{
"sha256": "654544aa5a7c944f607edf5324cff68d1f447b9219fb9b3933e90b0eb8a3ba13",
"path": "KSwitcher.exe",
"size": 2341376
},
{
"sha256": "a546c2dfd811ee8a3cf4301b5b21d1d6a72dd7280c3eac272eebd7963bc48b32",
"path": "KSwitcher.Config.exe",
"size": 2341888
},
{
"sha256": "a5cc8809b12698b266857440eece154a26bba64570bc65be1cb77959f7b0ebef",
"path": "KSwitcher.Screenshot.exe",
"size": 3319808
},
{
"sha256": "d827cf29385db23dbdc5729d4397d768c32dd5ed7581ef10cb9f8c81370ec8a0",
"path": "KSwitcher.Files.exe",
"size": 1826304
},
{
"sha256": "d3fce90bdeecb08596e377dea6a316354dd8949e32ff09b89d51be00c14e21b6",
"path": "WebView2Loader.dll",
"size": 194912
},
{
"sha256": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f",
"path": "terminal/addon-fit-LICENSE.txt",
"size": 1103
},
{
"sha256": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527",
"path": "terminal/addon-fit.js",
"size": 1521
},
{
"sha256": "084d97a727b778fb10d31adcd557ee7840a38377ed60e0ef0f3ece93b349b1ef",
"path": "terminal/index.html",
"size": 527
},
{
"sha256": "f677e61d7d90d3023839ee131ddabed7a5c3510b18d47cc9a3c19353913ca5cc",
"path": "terminal/terminal.js",
"size": 3481
},
{
"sha256": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd",
"path": "terminal/xterm-LICENSE.txt",
"size": 1261
},
{
"sha256": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6",
"path": "terminal/xterm.css",
"size": 7112
},
{
"sha256": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972",
"path": "terminal/xterm.js",
"size": 488663
},
{
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"path": "LICENSE",
"size": 4401
},
{
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
"path": "json-LICENSE.txt",
"size": 1075
},
{
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b",
"path": "README.md",
"size": 93885
},
{
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada",
"path": "LAN-PREVIEW.md",
"size": 11418
},
{
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
"path": "dictionaries/en.txt",
"size": 682867
},
{
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20138
},
{
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
"path": "dictionaries/README.md",
"size": 2265
},
{
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
"path": "dictionaries/ru.txt",
"size": 1060364
},
{
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
"path": "dictionaries/sources.json",
"size": 1280
},
{
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
"path": "dictionaries/uk.txt",
"size": 940799
},
{
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"path": "tools/README.txt",
"size": 993
},
{
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
"path": "tools/StayHere.exe",
"size": 134656
},
{
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md",
"size": 2796
}
]
}
+94 -13
View File
@@ -1,4 +1,4 @@
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage)
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage, [switch]$Far)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$compiler = Join-Path $root '.tools\w64devkit\bin\g++.exe'
@@ -15,8 +15,14 @@ Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -
Push-Location $root
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')
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
$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'
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
if ($LASTEXITCODE) { throw 'StayHere build failed' }
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
foreach ($name in @('lan_files','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"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
@@ -29,31 +35,79 @@ try {
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
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/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_files.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' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$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/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_files.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' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.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_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
$filesPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Files.exe'
$terminalIncludes = & "$root/tools/stage-files-terminal.ps1" -Destination (Split-Path $filesPath)
& "$root/tools/stage-files-terminal.ps1" -Destination build/release | Out-Null
& windres resources/files.rc -O coff -o build/files-res.o
if ($LASTEXITCODE) { throw 'File manager resource compilation failed' }
& $compiler @common "-I$terminalIncludes" -mwindows -municode -static -s src/files_main.cpp build/files-res.o build/lan_files.o build/lan_channel.o build/lan_pairing.o build/platform.o build/core.o '-Wl,--gc-sections' @libs -o $filesPath
if ($LASTEXITCODE) { throw 'File manager build failed' }
Copy-Item -LiteralPath $filesPath -Destination build/release/KSwitcher.Files.exe -Force
if ($Test) {
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
& .\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_files.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 tests/lan_files.cpp build/lan_files.o build/lan_channel.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanFilesTests.exe
if ($LASTEXITCODE) { throw 'LAN file test build failed' }
& .\build\KSwitcherLanFilesTests.exe
if ($LASTEXITCODE) { throw 'LAN file tests failed' }
& $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' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
if ($LASTEXITCODE) { throw 'StayHere lifecycle tests failed' }
& $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
& .\build\KSwitcherUtilitiesTests.exe
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
& $compiler @common tests/dictionary.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& .\build\KSwitcherDictionaryTests.exe
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
& $compiler @common tests/sync.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& .\build\KSwitcherSyncTests.exe
if ($LASTEXITCODE) { throw 'Sync tests failed' }
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
& .\build\KSwitcherScrollTests.exe
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/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_files.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' }
& .\build\KSwitcherTests.exe
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/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_files.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' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input 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/chat_profiles.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& .\build\KSwitcherChatProfileTests.exe
if ($LASTEXITCODE) { throw 'Chat profile tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
@@ -61,7 +115,7 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
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_files.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' }
& $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
@@ -72,16 +126,43 @@ try {
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
}
if ($Far) {
& $compiler @common tests/far_snippets.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
if ($fixture.ExitCode) { throw 'Far snippet tests failed' }
}
if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.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
$historyFixture = Join-Path (Resolve-Path build) ('history-host-fixture-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory $historyFixture | Out-Null
Copy-Item $executablePath,(Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe') -Destination $historyFixture
Set-Content (Join-Path $historyFixture '.local-dev') 'Isolated history integration test'
$historyTest = Join-Path $historyFixture 'KSwitcherHistoryHostTests.exe'
& $compiler @common tests/history_host.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& $historyTest
if ($LASTEXITCODE) { throw 'History host tests failed' }
$snapshotTest = Join-Path (Split-Path $executablePath) 'KSwitcherClipboardSnapshotTests.exe'
& $compiler @common tests/clipboard_snapshot.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& $snapshotTest
if ($LASTEXITCODE) { throw 'Clipboard snapshot tests failed' }
$captureTest = Join-Path (Split-Path $executablePath) 'KSwitcherImageCaptureTests.exe'
& $compiler @common tests/image_capture.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& $captureTest
if ($LASTEXITCODE) { throw 'Image capture tests failed' }
& $compiler @common tests/integration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
}
if ($Ocr) {
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.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/KSwitcherOcrTests.exe
$ocrTest = Join-Path (Split-Path $executablePath) 'KSwitcherOcrTests.exe'
& $compiler @common tests/ocr.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.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' }
& .\build\KSwitcherOcrTests.exe
& $ocrTest
if ($LASTEXITCODE) { throw 'OCR tests failed' }
}
foreach ($folder in @('build/dictionaries','build/release/dictionaries','build/staged/dictionaries')) {
+19 -2
View File
@@ -6,12 +6,20 @@ New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Files.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\WebView2Loader.dll') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\terminal') -Destination $destination -Recurse -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LAN-PREVIEW.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
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 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','WebView2Loader.dll','terminal','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
@@ -22,7 +30,16 @@ $manifest=Join-Path $destination 'release-files.json'
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
$files += $manifest
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
# Archive only manifest-listed files, preserving relative paths. Never sweep up
# personal utilities or leftover developer files from the staging directory.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$stream = [IO.File]::Open($archive,[IO.FileMode]::Create)
$zip = [IO.Compression.ZipArchive]::new($stream,[IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($name in @($entries | ForEach-Object { $_.path }) + @('release-files.json')) {
[IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,(Join-Path $destination $name),$name,[IO.Compression.CompressionLevel]::Optimal) | Out-Null
}
} finally { $zip.Dispose(); $stream.Dispose() }
Get-FileHash -LiteralPath $archive -Algorithm SHA256
if ($PublishRelease) {
$release = Join-Path $root 'Release'
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,25,0
PRODUCTVERSION 0,6,25,0
FILEVERSION 0,6,80,0
PRODUCTVERSION 0,6,80,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.25"
VALUE "FileVersion", "0.6.80"
END
END
BLOCK "VarFileInfo"
+186
View File
@@ -0,0 +1,186 @@
#include <windows.h>
#include <commctrl.h>
#define FAR_EDIT(id, mode, x, y, width, height) \
CONTROL "", id, "Edit", ES_AUTOHSCROLL | WS_TABSTOP | mode, x, y, width, height
1 ICON "kswitcher-files.ico"
1 RT_MANIFEST "app.manifest"
101 DIALOGEX 0, 0, 397, 310
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_CLIPCHILDREN
CAPTION "KSwitcher Files Options"
FONT 9, "Segoe UI"
BEGIN
LTEXT "&Search settings", -1, 8, 8, 113, 10
EDITTEXT 270, 8, 21, 113, 14, ES_AUTOHSCROLL
CONTROL "", 271, "SysTreeView32", WS_TABSTOP | WS_BORDER | TVS_HASLINES | TVS_LINESATROOT | TVS_HASBUTTONS | TVS_SHOWSELALWAYS, 8, 40, 113, 240
LTEXT "", 272, 131, 8, 253, 12
AUTOCHECKBOX "Left panel: &Wide", 273, 132, 27, 115, 12
AUTOCHECKBOX "Right panel: W&ide", 274, 251, 27, 125, 12
GROUPBOX "Wide panel columns", 275, 129, 27, 259, 59
LTEXT "Size column &width (6–16 characters)", 276, 138, 44, 175, 10
EDITTEXT 201, 335, 41, 43, 14, ES_NUMBER | ES_AUTOHSCROLL
AUTOCHECKBOX "&Align file extensions", 202, 138, 64, 108, 12
AUTOCHECKBOX "Align &folder extensions", 204, 252, 64, 125, 12
GROUPBOX "Status line columns", 277, 129, 27, 259, 46
AUTOCHECKBOX "Show &details", 203, 138, 40, 100, 12
AUTOCHECKBOX "Name", 205, 138, 55, 47, 12
AUTOCHECKBOX "Size", 206, 196, 55, 42, 12
AUTOCHECKBOX "Date", 207, 254, 55, 45, 12
AUTOCHECKBOX "Time", 208, 312, 55, 45, 12
PUSHBUTTON "Change &color…", 279, 131, 48, 82, 15
LTEXT "", 280, 222, 50, 155, 12
LTEXT "", 281, 131, 30, 254, 14
PUSHBUTTON "Change &font…", 282, 131, 52, 82, 15
LTEXT "&Outer padding (px at 96 DPI)", 302, 131, 31, 173, 12
COMBOBOX 303, 311, 28, 56, 125, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "&Inner padding (px at 96 DPI)", 304, 131, 54, 173, 12
COMBOBOX 305, 311, 51, 56, 125, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "&Row text padding (characters)", 306, 131, 77, 173, 12
COMBOBOX 307, 311, 74, 56, 76, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "Previous command", 320, 131, 30, 120, 12
CONTROL "", 321, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 27, 110, 15
LTEXT "Open command history", 322, 131, 53, 120, 12
CONTROL "", 323, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 50, 110, 15
PUSHBUTTON "Reset selected to defaults", 284, 131, 181, 125, 15
COMBOBOX 285, 131, 183, 139, 95, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "Reset property", 286, 278, 183, 108, 15
LTEXT "File type", 287, 131, 26, 75, 10
COMBOBOX 288, 131, 38, 151, 100, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "&Remove", 289, 290, 38, 77, 14
LTEXT "Windows extension or custom extension", 290, 131, 63, 246, 10
COMBOBOX 291, 131, 75, 151, 108, CBS_DROPDOWN | CBS_AUTOHSCROLL | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "A&dd", 292, 290, 75, 77, 14
LTEXT "Row state", 300, 131, 100, 72, 10
COMBOBOX 301, 131, 112, 174, 80, CBS_DROPDOWNLIST | WS_TABSTOP
PUSHBUTTON "&Foreground…", 293, 131, 135, 78, 14
PUSHBUTTON "&Background…", 294, 216, 135, 78, 14
PUSHBUTTON "&Inherit colors", 295, 301, 135, 83, 14
LTEXT "", 296, 131, 152, 253, 13
AUTOCHECKBOX "&Underline", 297, 131, 166, 80, 12
AUTOCHECKBOX "Stri&kethrough", 298, 218, 166, 100, 12
CONTROL "Settings preview", 299, "Static", SS_OWNERDRAW | WS_BORDER, 131, 202, 253, 81
DEFPUSHBUTTON "OK", IDOK, 222, 289, 50, 14
PUSHBUTTON "Cancel", IDCANCEL, 280, 289, 50, 14
PUSHBUTTON "&Apply", 283, 338, 289, 50, 14
END
102 DIALOGEX 0, 0, 350, 145
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copy"
FONT 10, "Consolas"
BEGIN
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
FAR_EDIT(211, ES_MULTILINE, 14, 29, 322, 18)
LTEXT "", 328, 14, 49, 322, 35, SS_NOPREFIX
LTEXT "Existing files:", 212, 14, 92, 122, 14, SS_CENTERIMAGE
COMBOBOX 213, 140, 92, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
CONTROL "Also ask on read-only files", 214, "Button", BS_OWNERDRAW | WS_TABSTOP, 14, 112, 322, 14
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 127, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 127, 86, 18
END
103 DIALOGEX 0, 0, 460, 126
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copying"
FONT 10, "Consolas"
BEGIN
LTEXT "Counting files...", 220, 12, 16, 436, 12, SS_OWNERDRAW
LTEXT "", 221, 12, 31, 436, 12, SS_OWNERDRAW
CONTROL "", 222, "msctls_progress32", PBS_SMOOTH, 12, 46, 436, 14
LTEXT "Files:", 224, 12, 63, 436, 12
LTEXT "Bytes:", 225, 12, 77, 436, 12
CONTROL "", 226, "msctls_progress32", PBS_SMOOTH, 12, 91, 436, 14
LTEXT "", 228, 12, 60, 1, 1
LTEXT "", 229, 12, 104, 1, 1
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 106, 86, 18
END
104 DIALOGEX 0, 0, 360, 139
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Warning"
FONT 10, "Consolas"
BEGIN
LTEXT "", 230, 12, 16, 336, 12
LTEXT "", 231, 12, 33, 336, 12, SS_PATHELLIPSIS
LTEXT "", 233, 12, 55, 1, 1
LTEXT "New", 329, 12, 58, 65, 13
RTEXT "", 330, 78, 58, 270, 13
LTEXT "Existing", 331, 12, 72, 65, 13
RTEXT "", 332, 78, 72, 270, 13
LTEXT "", 333, 12, 88, 1, 1
LTEXT "", 234, 12, 114, 1, 1
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 90, 336, 18
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 122, 110, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 122, 68, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 122, 86, 18
END
105 DIALOGEX 0, 0, 310, 65
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Cancel copy"
FONT 10, "Consolas"
BEGIN
CTEXT "Do you really want to cancel copying?", 235, 12, 17, 286, 16, SS_CENTERIMAGE
LTEXT "", 236, 12, 38, 1, 1
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 83, 42, 68, 18
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 159, 42, 68, 18
END
106 DIALOGEX 0, 0, 400, 190
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Delete"
FONT 10, "Consolas"
BEGIN
LTEXT "", 240, 14, 14, 372, 16
LTEXT "", 241, 14, 32, 372, 122, SS_NOPREFIX
LTEXT "", 243, 14, 177, 1, 1
CONTROL "&Move", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 37, 178, 86, 18
CONTROL "&All", 244, "Button", BS_OWNERDRAW | WS_TABSTOP, 129, 178, 63, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 197, 178, 63, 18
CONTROL "Skip a&ll", 249, "Button", BS_OWNERDRAW | WS_TABSTOP, 197, 178, 94, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 265, 178, 79, 18
END
107 DIALOGEX 0, 0, 400, 82
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Deleting"
FONT 10, "Consolas"
BEGIN
LTEXT "Scanning...", 245, 14, 15, 372, 14, SS_PATHELLIPSIS | SS_NOPREFIX
LTEXT "", 246, 14, 31, 372, 14
CONTROL "", 247, "msctls_progress32", PBS_SMOOTH, 14, 48, 372, 18
LTEXT "", 248, 14, 73, 1, 1
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 157, 74, 86, 18
END
109 DIALOGEX 0, 0, 250, 85
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
CAPTION "Folder shortcuts"
FONT 10, "Consolas"
BEGIN
CONTROL "", 310, "ListBox", LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP, 8, 14, 234, 50
END
110 DIALOGEX 0, 0, 370, 116
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Folder shortcuts"
FONT 10, "Consolas"
BEGIN
LTEXT "&Title", 316, 16, 18, 338, 12
FAR_EDIT(314, ES_LEFT, 16, 31, 338, 15)
LTEXT "&Path", 317, 16, 49, 338, 12
FAR_EDIT(211, ES_LEFT, 16, 62, 338, 15)
LTEXT "", 315, 10, 82, 1, 1, NOT WS_VISIBLE
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 97, 86, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 86, 86, 18
END
111 DIALOGEX 0, 0, 385, 220
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
FONT 9, "Consolas"
BEGIN
LTEXT "&Search commands or topics", 326, 12, 14, 361, 12
FAR_EDIT(325, ES_MULTILINE, 12, 28, 361, 16)
LISTBOX 324, 12, 49, 361, 155, LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP
END
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+18
View File
@@ -0,0 +1,18 @@
KSwitcher portable utilities
Put your trusted portable .exe utilities directly in this tools folder,
beside this README.txt. Right-click the KSwitcher tray icon and open Tools.
For example: tools/StayHere.exe appears as Tools > StayHere.
The list refreshes each time the tray menu opens. Subfolders and shortcuts
are not listed. Utilities start only when you choose them, with this folder
as their working directory. Closing KSwitcher does not close a utility.
StayHere.exe is bundled. Choose Tools > StayHere to start it; it starts
enabled, prevents automatic sleep, and sends F15 after 3 minutes of inactivity.
Left-click its tray icon to pause/resume; right-click for Exit. See StayHere-README.md.
It does not start automatically with KSwitcher or bypass Windows locking.
Add other utilities separately. Updates preserve your own files in this folder;
bundled StayHere.exe and documentation may be refreshed. Utilities are not
transferred by settings synchronization.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>PowerShell terminal</title>
<link rel="stylesheet" href="xterm.css">
<style>
html, body, #terminal { width:100%; height:100%; margin:0; overflow:hidden; background:#181818; }
</style>
<div id="terminal"></div>
<script src="xterm.js"></script>
<script src="addon-fit.js"></script>
<script src="terminal.js"></script>
</html>
+99
View File
@@ -0,0 +1,99 @@
const bridge = window.chrome.webview;
const terminal = new Terminal({
fontFamily: 'Consolas, monospace',
fontSize: 16,
cursorBlink: false,
stdinDisabled: true,
theme: { background: '#181818', foreground: '#e8e8e8' },
});
const fit = new FitAddon.FitAddon();
terminal.parser.registerOscHandler(633, data => {
const prefix = 'ks-directory:';
if (!data.startsWith(prefix)) return false;
try {
bridge.postMessage('directory:' + decodeURIComponent(data.slice(prefix.length)));
} catch {
bridge.postMessage('directory:');
}
return true;
});
terminal.loadAddon(fit);
const host = document.getElementById('terminal');
terminal.open(host);
host.addEventListener('pointerup', () => bridge.postMessage('focus-input'), true);
const screen = host.querySelector('.xterm-screen');
let traceEnabled = false;
let traceBudget = 4096;
let currentOffset = 0;
function trace(phase) {
if (!traceEnabled || traceBudget <= 0) return;
traceBudget--;
const buffer = terminal.buffer.active;
bridge.postMessage(`trace:${phase},${host.clientHeight},${terminal.rows},${buffer.viewportY},${buffer.baseY},${Math.round(currentOffset)}`);
}
function anchorOutput() {
const buffer = terminal.buffer.active;
let offset = 0;
if (buffer.type === 'normal' && buffer.viewportY === buffer.baseY) {
let last = 0;
for (let row = terminal.rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true)) {
last = row;
break;
}
}
offset = Math.max(0, terminal.rows - 1 - last) * screen.offsetHeight / terminal.rows;
}
screen.style.transform = `translateY(${offset}px)`;
currentOffset = offset;
trace(2);
}
terminal.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown' || !event.ctrlKey || event.altKey || event.shiftKey) return true;
if (event.code === 'KeyO') bridge.postMessage('toggle');
else if (event.code === 'ArrowUp') bridge.postMessage('grow');
else if (event.code === 'ArrowDown') bridge.postMessage('shrink');
else return true;
event.preventDefault();
return false;
});
const reportSize = () => bridge.postMessage(`size:${terminal.cols},${terminal.rows}`);
terminal.onResize(reportSize);
terminal.onScroll(() => {
trace(3);
anchorOutput();
});
bridge.addEventListener('message', ({ data }) => {
if (data === 'trace:on') {
traceEnabled = true;
trace(2);
return;
}
const font = /^font:(\d+):(\d+):([01]):(.*)$/u.exec(data);
if (font) {
const [, points, weight, italic, family] = font;
const quotedFamily = family.replace(/["\\]/g, '\\$&');
terminal.options.fontFamily = `"${quotedFamily}", monospace`;
terminal.options.fontSize = Number(points) * 96 / 720;
terminal.options.fontWeight = Number(weight);
host.style.fontStyle = italic === '1' ? 'italic' : 'normal';
fit.fit();
anchorOutput();
return;
}
const bytes = new Uint8Array(data.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(data.slice(2*i, 2*i+2), 16);
terminal.write(bytes, anchorOutput);
});
new ResizeObserver(() => {
const buffer = terminal.buffer.active;
const wasAtBottom = buffer.viewportY === buffer.baseY;
trace(0);
fit.fit();
trace(1);
if (wasAtBottom) terminal.scrollToBottom();
requestAnimationFrame(anchorOutput);
}).observe(host);
bridge.postMessage('ready');
fit.fit();
reportSize();
+1752 -123
View File
File diff suppressed because it is too large. Load diff
+96 -3
View File
@@ -2,6 +2,11 @@
#include "autocorrect.h"
#include "storage.h"
#include "update.h"
#include "sync.h"
#include "chat_context.h"
#include "lan.h"
#include "mouse_buttons.h"
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -32,6 +37,12 @@ class App
{
public:
HWND window{}, historyWindow{}, settingsWindow{}, snippetsWindow{};
HWND historyService{}; // Non-null only in the short-lived history UI process.
bool isolateHistory{};
unsigned historyEntryWindows{};
std::wstring historySelection, historyTop, historyQuery;
bool historyPinnedOnly{};
std::vector<std::string> historyComputers;
HINSTANCE instance{};
std::filesystem::path data;
std::vector<Layout> layouts;
@@ -40,7 +51,7 @@ class App
AutoCorrector autoCorrector;
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
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<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
@@ -54,14 +65,16 @@ class App
bool smoke{};
int startCommand{};
std::wstring updateStartupEvent;
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {}, HWND historyService = nullptr);
~App();
int execute();
void dispatch(int);
void transform(int, int snippet = -1);
void transform(int, int snippet = -1, size_t snippetLength = 0, HWND snippetEditor = nullptr);
void pasteEntry(std::wstring, bool plain);
void recognizeEntry(std::wstring);
void captureHistory();
void publishClipboard(Clip, const std::wstring &);
void receiveClipboard();
void enqueue(std::function<void()>);
void enqueueHistorySearch(std::function<void()>);
void report(const std::wstring &);
@@ -71,22 +84,58 @@ class App
void applySettings(const Settings &);
void reloadLayouts();
void clearHistory();
void openHistoryProcess();
void refreshHistoryProcess();
void remoteHistory(const char *action, const std::wstring &id = {}, bool plain = false);
LRESULT historyRequest(HWND, const COPYDATASTRUCT &);
void saveHistoryView(const std::wstring &, const std::wstring &, const std::wstring &, bool);
void clearLog(bool previous);
void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
void resetInput();
void resetLanInput();
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
Bytes syncConflictData();
Bytes lanCommand(const Bytes &);
void pollSync();
static App *current;
private:
friend struct InputRegressionAccess;
void releaseIdleMemory(ULONGLONG now);
void applySettings(const Settings &, bool validateOcrInstallation);
void checkAutostartPath();
bool checkingAutostartPath{};
HHOOK keyboardHook{}, mouseHook{};
struct MousePress
{
HWND target{};
POINT point{};
ULONGLONG started{};
MouseAction action;
bool held{}, waitForSecond{};
};
std::array<MousePress, std::size(MouseButtonNames)> mousePresses{};
std::array<MousePress, std::size(MouseButtonNames)> pendingMouseClicks{};
std::deque<MousePress> mouseActions;
HWND mouseWheelTarget{};
int mouseWheelSignal{-1}, mouseWheelRemainder{};
void queueMouseAction(MousePress);
void flushMouseClicks();
void beginMouseVolume(int button);
void repeatMouseVolume(int button);
bool volumeButtonHeld() const;
void dispatchMouseActions();
std::unique_ptr<LanService> lanService;
HANDLE historyProcess{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
HANDLE notificationRegistration{};
std::thread historySearchWorker;
std::condition_variable historySearchReady;
std::function<void()> pendingHistorySearch;
@@ -96,26 +145,70 @@ class App
Settings historyOcrSettings;
void scheduleHistoryOcr(const Settings &);
std::thread updateWorker;
std::thread syncWorker;
std::atomic<bool> syncDone{false};
bool syncRunning{};
ULONGLONG nextSync{}, syncRetryAfter{};
SyncResult syncResult;
SyncConfig syncConnection;
SyncConfig conflictConnection;
std::vector<SyncConflict> pendingSyncConflicts;
Bytes syncSnapshot;
std::wstring syncStatus = L"Not connected.";
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate, notifiedUpdate;
bool (*updateNotifier)(const std::wstring &){};
bool stop{};
UINT taskbarCreated{};
bool trayRetryPending{};
uint64_t transformAttempt{}, previousAttemptVersion{};
size_t incomingLanFileCount{};
bool incomingFileBalloon{};
std::atomic<uint64_t> activeTransformAttempt{};
ULONGLONG previousAttemptAt{};
HWND previousAttemptWindow{};
int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings;
Hotkey registeredFilesHotkey{};
int filesHotkeyId{};
HotkeyHook hotkeyHook;
ChatSwitchHook chatSwitchHook;
HWND chatWindow{};
std::wstring activeChat, activeChatTitle;
void refreshChatContext();
void switchChatLayout(bool reverse);
HWND typingTarget{};
bool autoPending{};
int snippetPending = -1;
size_t snippetPendingLength{};
HWND snippetPendingEditor{};
bool snippetTyping{};
HWND snippetPopup{}, snippetTarget{}, snippetFocus{};
uint64_t snippetPopupVersion{};
size_t snippetPrefixLength{}, snippetSelection{};
bool snippetNavigated{};
int snippetRowHeight{};
UINT snippetDpi{96};
RECT snippetPopupAnchor{};
bool snippetCaretPending{};
std::optional<RECT> snippetCaret;
std::vector<size_t> snippetMatches;
std::array<bool, 256> snippetKeys{};
void matchSnippets(HWND, bool expandExact);
bool queueSnippet(HWND, size_t index, size_t length, HWND editor = nullptr);
void showSnippetPopup();
void placeSnippetPopup(RECT);
void hideSnippetPopup();
bool snippetPopupCurrent() const;
bool handleSnippetKey(DWORD, UINT);
void selectSnippet(size_t);
static LRESULT CALLBACK snippetPopupProc(HWND, UINT, WPARAM, LPARAM);
std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
void replayTyping();
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT keyboardAfterHooks(int, WPARAM, LPARAM, LRESULT);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &, bool visible = true);
+100
View File
@@ -0,0 +1,100 @@
// History UI has no clipboard listener or hooks. The resident remains the only writer.
namespace {
constexpr ULONG_PTR HistoryRequest = 0x4b530200;
constexpr UINT HistoryHostShow = WM_APP + 71, HistoryHostRefresh = WM_APP + 72;
constexpr wchar_t HistoryHostClass[] = L"KSwitcher.HistoryHost";
using HistoryJson = nlohmann::json;
HWND historyController(HANDLE process)
{
if (!process || WaitForSingleObject(process, 0) == WAIT_OBJECT_0) return nullptr;
auto window = FindWindowW(HistoryHostClass, nullptr);
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
return pid == GetProcessId(process) ? window : nullptr;
}
}
void App::openHistoryProcess()
{
auto foreground = GetForegroundWindow();
if (foreground != window && foreground != historyWindow) pasteTarget = foreground;
if (historyProcess && WaitForSingleObject(historyProcess, 0) == WAIT_OBJECT_0) {
CloseHandle(historyProcess); historyProcess = nullptr; historyWindow = nullptr;
}
if (historyProcess) {
if (auto host = historyController(historyProcess)) {
AllowSetForegroundWindow(GetProcessId(historyProcess));
PostMessageW(host, HistoryHostShow, 0, 0);
}
return;
}
auto path = executable();
auto command = L"\"" + path + L"\" --history-host=" + std::to_wstring(reinterpret_cast<uintptr_t>(window));
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION child{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, std::filesystem::path(path).parent_path().c_str(), &startup, &child))
throw std::runtime_error("Cannot start clipboard history window");
CloseHandle(child.hThread);
historyProcess = child.hProcess;
AllowSetForegroundWindow(child.dwProcessId);
}
void App::refreshHistoryProcess()
{
if (auto host = historyController(historyProcess)) PostMessageW(host, HistoryHostRefresh, 0, 0);
}
void App::remoteHistory(const char *action, const std::wstring &id, bool plain)
{
if (!historyService || !configurationPeer(historyService, L"KSwitcher.exe"))
throw std::runtime_error("KSwitcher has stopped. Reopen clipboard history.");
auto text = HistoryJson{{"action", action}, {"id", utf8(id)}, {"plain", plain}}.dump();
if (std::string_view(action) == "paste") {
DWORD residentPid{}; GetWindowThreadProcessId(historyService, &residentPid);
AllowSetForegroundWindow(residentPid);
}
COPYDATASTRUCT message{HistoryRequest, DWORD(text.size()), text.data()};
DWORD_PTR result{};
if (!SendMessageTimeoutW(historyService, WM_COPYDATA, reinterpret_cast<WPARAM>(window),
reinterpret_cast<LPARAM>(&message), SMTO_ABORTIFHUNG | SMTO_ERRORONEXIT,
15000, &result) || result != 1)
throw std::runtime_error("KSwitcher could not complete the history request. Reopen history and retry.");
}
LRESULT App::historyRequest(HWND sender, const COPYDATASTRUCT &message)
{
DWORD pid{}; GetWindowThreadProcessId(sender, &pid);
if (!historyProcess || pid != GetProcessId(historyProcess) ||
!configurationPeer(sender, L"KSwitcher.exe") || message.cbData > 4096 ||
!message.cbData || !message.lpData || installingUpdate) return FALSE;
try {
const auto *first = static_cast<const char *>(message.lpData);
auto job = HistoryJson::parse(first, first + message.cbData);
auto action = job.at("action").get<std::string>();
auto id = wide(job.at("id").get<std::string>());
if (id.size() > 128) return FALSE;
if (action == "ready") {
auto ui = reinterpret_cast<HWND>(uintptr_t(std::stoull(id)));
DWORD uiPid{}; GetWindowThreadProcessId(ui, &uiPid);
wchar_t name[64]{}; GetClassNameW(ui, name, 64);
if (uiPid != pid || std::wstring(name) != L"KSwitcher.History") return FALSE;
historyWindow = ui;
} else if (action == "paste") {
if (busy || !history) return FALSE;
pasteEntry(id, job.at("plain").get<bool>());
} else if (action == "ocr") {
if (busy || !history) return FALSE;
recognizeEntry(id);
} else if (action == "clear") clearHistory();
else if (action == "pin" || action == "remove") {
if (busy || !history) return FALSE;
if (action == "pin") history->pin(id); else history->remove(id);
} else return FALSE;
return TRUE;
} catch (...) { return FALSE; }
}
void App::saveHistoryView(const std::wstring &selection, const std::wstring &top, const std::wstring &query, bool pinnedOnly)
{
if (!historyService) return;
try {
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())));
} catch (...) {} // Closing the UI must still release its process if state cannot be saved.
}
+116
View File
@@ -0,0 +1,116 @@
// Included by app.cpp; all App mutations happen on its window thread.
Bytes App::syncConflictData()
{
if (loadSyncConfig(data) != conflictConnection) return encodeSyncConflicts({});
return encodeSyncConflicts(pendingSyncConflicts);
}
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
{
if (action == 0) return syncStatus;
if (syncRunning) return L"Synchronization is already running.";
auto c = loadSyncConfig(data);
if (!c.enabled) return syncStatus = L"Synchronization is disabled.";
validateSyncConfig(c);
if (action == 4 && (c != conflictConnection || choices.empty()))
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
if (busy) return syncStatus = L"Sync did not start. Wait for the current text operation to finish, then try again.";
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
if (syncWorker.joinable()) syncWorker.join();
syncConnection = c;
syncSnapshot = encodeSettings(settings);
auto snapshot = settings;
auto availableLayouts = layouts;
syncRunning = true;
syncDone = false;
syncStatus = action == 2 ? L"Downloading remote settings from OpenBao..." :
action == 3 ? L"Uploading local settings to OpenBao..." : L"Connecting to OpenBao...";
nextSync = GetTickCount64() + SyncIntervalMs;
try
{
syncWorker = std::thread([this, c, snapshot, availableLayouts, action, choices] {
SyncResult result;
try
{
auto provider = makeSyncProvider(c);
// An explicit choice also repairs a missing/corrupt local baseline.
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts, c.keepLocalLayouts); }, choices,
c.keepLocalLayouts);
if (result.success && c.keepLocalLayouts)
result.status += L" Partial sync: this PC's layouts and their shortcuts were kept; server layouts were preserved.";
}
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
catch (const std::exception &e) { result.status = wide(e.what()); }
if (!result.success)
result.status = std::wstring(action == 2 ? L"Download failed. Local settings were not changed. " :
action == 3 ? L"Upload failed. " : L"Synchronization failed. ") + result.status;
syncResult = std::move(result);
syncDone.store(true);
});
}
catch (...) { syncRunning = false; Bytes().swap(syncSnapshot); syncResult = {}; throw; }
return syncStatus;
}
void App::pollSync()
{
try
{
if (syncRunning && syncDone.load())
{
if (busy) return;
syncWorker.join();
// Own completed payloads locally so every return/exception releases them.
auto result = std::exchange(syncResult, SyncResult{});
auto snapshot = std::exchange(syncSnapshot, Bytes{});
syncRunning = false;
syncDone = false;
syncStatus = result.status;
if (result.success)
{
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != snapshot)
{
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
nextSync = GetTickCount64() + 2000;
return;
}
auto next = restoreSyncSettings(result.settings, settings, layouts, syncConnection.keepLocalLayouts);
if (encodeSettings(next) != snapshot)
{
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
// A restored OCR configuration can precede the optional local installation.
applySettings(next, false);
syncStatus += L" Changes applied on this PC; local backup saved.";
}
else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." :
L" No local settings were changed; conflicts still need your choice.";
saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base);
if (result.downloaded)
{
if (settingsWindow) SendMessageW(settingsWindow, ConfigSettingsDownloaded, 0, 0);
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
config && configurationPeer(config, L"KSwitcher.Config.exe"))
PostMessageW(config, ConfigSettingsDownloaded, 0, 0);
}
pendingSyncConflicts = std::move(result.conflicts);
conflictConnection = syncConnection;
SYSTEMTIME time{}; GetLocalTime(&time);
wchar_t stamp[32]{};
swprintf(stamp, std::size(stamp), L" (%02u:%02u:%02u)", time.wHour, time.wMinute, time.wSecond);
syncStatus += stamp;
}
// Back off after errors (including bad credentials) instead of repeatedly
// hitting userpass lockout. The explicit Sync button remains available.
syncRetryAfter = result.success ? 0 : GetTickCount64() + 15 * 60000;
nextSync = result.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
}
if (!syncRunning && GetTickCount64() >= nextSync)
{
nextSync = GetTickCount64() + SyncIntervalMs;
if (loadSyncConfig(data).enabled) syncCommand(1);
}
}
catch (const nlohmann::json::exception &) { syncStatus = L"Invalid local sync configuration or cached profile."; nextSync = GetTickCount64() + SyncIntervalMs; }
catch (const std::exception &e) { syncStatus = wide(e.what()); nextSync = GetTickCount64() + SyncIntervalMs; }
}
+189 -44
View File
@@ -42,82 +42,160 @@ bool safeAutoWord(const std::wstring &word, int minimumLength)
auto small = lower(word);
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
}
std::string_view AutoCorrector::Dictionary::word(size_t index) const
namespace {
constexpr size_t DictionaryBlockWords = 32;
}
std::string_view AutoCorrector::Dictionary::word(size_t block) const
{
auto start = offsets[index];
auto end = bytes.find('\t', start);
return std::string_view(bytes).substr(start, end - start);
size_t start = offsets[block];
// Every block starts with a complete word (zero shared prefix).
size_t length = static_cast<unsigned char>(bytes[start++]) & 15;
if (length == 15) length = static_cast<unsigned char>(bytes[start++]);
return std::string_view(bytes).substr(start, length);
}
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
{
auto raw = readFile(path, 2 * 1024 * 1024);
bytes.assign(raw.begin(), raw.end());
for (size_t start = 0; start < bytes.size();)
const auto raw = readFile(path, 2 * 1024 * 1024);
const std::string_view source(reinterpret_cast<const char *>(raw.data()), raw.size());
std::vector<uint32_t> lines;
lines.reserve(std::count(source.begin(), source.end(), '\n') + 1);
for (size_t start = 0; start < source.size();)
{
auto end = bytes.find('\n', start);
if (end == bytes.npos)
end = bytes.size();
auto tab = bytes.find('\t', start);
if (tab == bytes.npos || tab >= end || tab == start || tab - start > 160)
auto end = source.find('\n', start);
if (end == source.npos) end = source.size();
auto tab = source.find('\t', start);
if (tab == source.npos || tab >= end || tab == start || tab - start > 160)
throw std::runtime_error("Invalid auto-correction dictionary");
int rank{};
auto rankEnd = end > tab + 1 && bytes[end - 1] == '\r' ? end - 1 : end;
auto parse = std::from_chars(bytes.data() + tab + 1, bytes.data() + rankEnd, rank);
if (parse.ec != std::errc() || parse.ptr != bytes.data() + rankEnd || rank <= 0)
auto rankEnd = end > tab + 1 && source[end - 1] == '\r' ? end - 1 : end;
auto parse = std::from_chars(source.data() + tab + 1, source.data() + rankEnd, rank);
if (parse.ec != std::errc() || parse.ptr != source.data() + rankEnd || rank <= 0)
throw std::runtime_error("Invalid dictionary rank");
offsets.push_back((uint32_t)start);
lines.push_back(static_cast<uint32_t>(start));
start = end + 1;
}
if (offsets.empty())
throw std::runtime_error("Empty auto-correction dictionary");
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
auto frequency = [&](uint32_t offset)
{
if (lines.empty()) throw std::runtime_error("Empty auto-correction dictionary");
auto entry = [&](uint32_t offset) { return source.substr(offset, source.find('\t', offset) - offset); };
auto frequency = [&](uint32_t offset) {
int rank{};
auto start = offset + entry(offset).size() + 1;
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
std::from_chars(source.data() + start, source.data() + source.size(), rank);
return rank;
};
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
{ return entry(a) == entry(b); }), offsets.end());
offsets.shrink_to_fit();
bytes.shrink_to_fit();
std::sort(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b);
});
lines.erase(std::unique(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
return entry(a) == entry(b);
}), lines.end());
offsets.reserve((lines.size() + DictionaryBlockWords - 1) / DictionaryBlockWords);
std::string_view previous;
// Allocate the packed payload exactly once. Geometric growth and a final
// shrink otherwise leave discarded buffers in the resident process heap.
size_t packedSize = 0;
for (size_t i = 0; i < lines.size(); ++i)
{
auto current = entry(lines[i]);
size_t prefix = 0;
if (i % DictionaryBlockWords != 0)
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
auto suffix = current.size() - prefix;
packedSize += 1 + (prefix >= 15) + (suffix >= 15) + suffix + 2;
auto rank = static_cast<uint32_t>(frequency(lines[i]));
if (rank >= 65535)
do { ++packedSize; rank >>= 7; } while (rank);
previous = current;
}
bytes.reserve(packedSize);
previous = {};
for (size_t i = 0; i < lines.size(); ++i)
{
auto current = entry(lines[i]);
size_t prefix = 0;
if (i % DictionaryBlockWords == 0)
offsets.push_back(static_cast<uint32_t>(bytes.size()));
else
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
size_t suffix = current.size() - prefix;
bytes.push_back(static_cast<char>((std::min(prefix, size_t(15)) << 4) | std::min(suffix, size_t(15))));
if (prefix >= 15) bytes.push_back(static_cast<char>(prefix));
if (suffix >= 15) bytes.push_back(static_cast<char>(suffix));
bytes.append(current.substr(prefix));
auto rank = static_cast<uint32_t>(frequency(lines[i]));
auto shortRank = std::min(rank, uint32_t(65535));
bytes.push_back(static_cast<char>(shortRank & 255));
bytes.push_back(static_cast<char>(shortRank >> 8));
if (rank >= 65535)
do {
auto next = rank & 127;
rank >>= 7;
bytes.push_back(static_cast<char>(next | (rank ? 128 : 0)));
} while (rank);
previous = current;
}
}
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
{
auto key = utf8(lower(text));
size_t begin = 0, end = offsets.size();
// Find the last block whose first word is <= the search key.
while (begin < end)
{
auto mid = begin + (end - begin) / 2;
if (word(mid) < key)
begin = mid + 1;
else
end = mid;
if (word(mid) <= key) begin = mid + 1;
else end = mid;
}
if (begin == offsets.size() || word(begin) != key)
return 0;
auto start = offsets[begin] + word(begin).size() + 1;
int rank{};
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
return rank;
if (!begin) return 0;
size_t block = begin - 1;
size_t at = offsets[block];
const size_t limit = begin < offsets.size() ? offsets[begin] : bytes.size();
char decoded[160]; // The loader validates lengths; lookup never allocates a decoded word.
while (at < limit)
{
auto lengths = static_cast<unsigned char>(bytes[at++]);
size_t prefix = lengths >> 4, suffix = lengths & 15;
if (prefix == 15) prefix = static_cast<unsigned char>(bytes[at++]);
if (suffix == 15) suffix = static_cast<unsigned char>(bytes[at++]);
memcpy(decoded + prefix, bytes.data() + at, suffix);
at += suffix;
uint32_t rank = static_cast<unsigned char>(bytes[at]) |
(uint32_t(static_cast<unsigned char>(bytes[at + 1])) << 8);
at += 2;
if (rank == 65535) {
rank = 0;
uint32_t shift = 0;
unsigned char part;
do {
part = static_cast<unsigned char>(bytes[at++]);
rank |= uint32_t(part & 127) << shift;
shift += 7;
} while (part & 128);
}
auto compared = std::string_view(decoded, size_t(prefix) + suffix).compare(key);
if (compared == 0) return static_cast<int>(rank);
if (compared > 0) return 0;
}
return 0;
}
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
{
if (languages == wanted)
return;
std::map<std::wstring, Dictionary> next;
std::map<std::wstring, std::shared_ptr<const Dictionary>> next;
for (auto &lang : wanted)
{
if (lang != L"en" && lang != L"uk" && lang != L"ru")
throw std::runtime_error("Unsupported dictionary language");
next.emplace(lang, Dictionary(folder / (lang + L".txt")));
next.emplace(lang, std::make_shared<const Dictionary>(folder / (lang + L".txt")));
}
dictionaries = std::move(next);
languages = wanted;
}
void AutoCorrector::shareDictionaries(const AutoCorrector &source)
{
dictionaries = source.dictionaries;
languages = source.languages;
}
void AutoCorrector::reject(std::wstring word, const Settings &settings)
{
while (!word.empty() && openingDelimiter(word.front(), settings))
@@ -132,7 +210,7 @@ size_t AutoCorrector::memoryBytes() const
{
size_t bytes = 0;
for (auto &[language, d] : dictionaries)
bytes += d.bytes.capacity() + d.offsets.capacity() * sizeof(uint32_t);
bytes += d->bytes.capacity() + d->offsets.capacity() * sizeof(uint32_t);
return bytes;
}
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
@@ -164,6 +242,26 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage))
return {};
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
auto small = lower(word);
bool inCycle = std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
small.find_first_not_of(alphabet) == small.npos && inCycle;
auto sameLayout = [&](std::wstring text)
{
if (word != small) text[0] = upper(text.substr(0, 1))[0];
return AutoSuggestion{source, source, prefix + text + suffix};
};
if (settings.autoReplace && inCycle)
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
(word == small || (word.substr(1) == small.substr(1) && word != upper(word))) &&
validAutoReplacementSource(small, alphabet) &&
validAutoCorrection(replacement->second, alphabet))
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
{
auto found = settings.autoWords.find(language);
@@ -173,7 +271,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
};
// Valid words from either selected language are never changed.
for (auto &[language, d] : dictionaries)
if (d.rank(word) || personal(language, word))
if (d->rank(word) || personal(language, word))
return {};
std::optional<AutoSuggestion> candidate;
std::optional<std::wstring> matchedText;
@@ -193,7 +291,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto converted = convert(word, layouts[source], layouts[i], true);
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
continue;
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second->rank(converted);
if (!rank)
continue;
if (matchedText && *matchedText != converted)
@@ -203,7 +301,54 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
(!candidate || priority(i) < priority(candidate->target)))
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
}
return candidate;
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
return candidate;
// Generate only single edits. Stop at a second distinct dictionary hit,
// including rare hits: frequency limits must never hide ambiguity.
std::wstring found;
int foundRank = 0;
std::set<std::wstring> personalTargets;
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
for (const auto &text : it->second) personalTargets.insert(lower(text));
auto consider = [&](const std::wstring &text)
{
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage)->rank(text);
if (!rank) return true;
if (!found.empty()) return false;
found = text;
foundRank = rank;
return true;
};
for (size_t i = 0; i < small.size(); ++i)
{
auto text = small;
text.erase(i, 1);
if (!consider(text)) return {};
if (i + 1 < small.size())
{
text = small;
std::swap(text[i], text[i + 1]);
if (!consider(text)) return {};
}
for (wchar_t c : alphabet)
{
text = small;
text[i] = c;
if (!consider(text)) return {};
}
}
if (small.size() < 32)
for (size_t i = 0; i <= small.size(); ++i)
for (wchar_t c : alphabet)
{
auto text = small;
text.insert(i, 1, c);
if (!consider(text)) return {};
}
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
return sameLayout(found);
return {};
}
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
const Layout &physical, const Settings &settings)
+9 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "platform.h"
#include <map>
#include <memory>
#include <set>
#include <string_view>
namespace ks
@@ -18,23 +19,30 @@ int matchAutoInput(const Run &, const std::wstring &, const std::vector<Layout>
bool autoCorrectionAllowed(HWND, const Settings &);
class AutoCorrector
{
friend struct AutoCorrectorRegressionAccess;
struct Dictionary
{
// Front-coded UTF-8 words in independently searchable blocks of 32.
// Prefix/suffix lengths share a byte (15 escapes to a full byte length).
// Ranks use two bytes; 65535 escapes to a varint for larger custom ranks.
std::string bytes;
std::vector<uint32_t> offsets;
std::string_view word(size_t) const;
explicit Dictionary(const std::filesystem::path &);
int rank(const std::wstring &) const;
};
std::map<std::wstring, Dictionary> dictionaries;
std::map<std::wstring, std::shared_ptr<const Dictionary>> dictionaries;
std::vector<std::wstring> languages;
std::set<std::wstring> rejected;
public:
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
// Share immutable dictionary storage, keeping each consumer's undo rejections private.
void shareDictionaries(const AutoCorrector &);
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
const Settings &) const;
void reject(std::wstring word, const Settings & = {});
// Referenced dictionary payload, including shared storage (do not sum across consumers).
size_t memoryBytes() const;
};
} // namespace ks
+124 -14
View File
@@ -27,6 +27,7 @@ template <class T> struct Com
struct Str
{
BSTR value;
Str() : value(nullptr) {}
Str(const wchar_t *s) : value(SysAllocString(s))
{
if (!value)
@@ -51,10 +52,108 @@ bool missing(HRESULT hr)
{
return hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || hr == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND);
}
constexpr wchar_t RunKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
HRESULT openTaskFolder(Com<ITaskService> &service, Com<ITaskFolder> &folder)
{
HRESULT hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out());
VARIANT empty;
VariantInit(&empty);
if (SUCCEEDED(hr))
hr = service->Connect(empty, empty, empty, empty);
if (SUCCEEDED(hr))
hr = service->GetFolder(Str(L"\\"), folder.out());
return hr;
}
bool sameAccount(const wchar_t *account)
{
auto sid = currentUserSid();
if (sid == account)
return true;
DWORD sidSize{}, domainSize{};
SID_NAME_USE use{};
LookupAccountNameW(nullptr, account, nullptr, &sidSize, nullptr, &domainSize, &use);
if (GetLastError() != ERROR_INSUFFICIENT_BUFFER)
return false;
Bytes accountSid(sidSize);
std::wstring domain(domainSize, L'\0');
if (!LookupAccountNameW(nullptr, account, accountSid.data(), &sidSize, domain.data(), &domainSize, &use))
return false;
LPWSTR accountSidText{};
if (!ConvertSidToStringSidW(accountSid.data(), &accountSidText))
return false;
bool same = sid == accountSidText;
LocalFree(accountSidText);
return same;
}
std::wstring registeredTaskPath()
{
Com<ITaskService> service;
Com<ITaskFolder> folder;
check(openTaskFolder(service, folder));
Com<IRegisteredTask> task;
auto hr = folder->GetTask(Str(L"KSwitcher"), task.out());
if (missing(hr))
return {};
check(hr);
VARIANT_BOOL enabled{};
check(task->get_Enabled(&enabled));
if (!enabled)
return {};
Com<ITaskDefinition> definition;
Com<IPrincipal> principal;
Str user;
check(task->get_Definition(definition.out()));
check(definition->get_Principal(principal.out()));
check(principal->get_UserId(&user.value));
if (!user.value || !sameAccount(user.value))
return {};
Com<IActionCollection> actions;
LONG count{};
check(definition->get_Actions(actions.out()));
check(actions->get_Count(&count));
if (count != 1)
return {};
Com<IAction> action;
check(actions->get_Item(1, action.out()));
TASK_ACTION_TYPE type{};
check(action->get_Type(&type));
if (type != TASK_ACTION_EXEC)
return {};
Com<IExecAction> exec;
Str path, arguments;
check(action->QueryInterface(IID_IExecAction, (void **)exec.out()));
check(exec->get_Arguments(&arguments.value));
check(exec->get_Path(&path.value));
return path.value && (!arguments.value || !*arguments.value) ? path.value : L"";
}
std::wstring registeredRunPath()
{
DWORD size{};
auto status = RegGetValueW(HKEY_CURRENT_USER, RunKey, L"KSwitcher", RRF_RT_REG_SZ, nullptr, nullptr, &size);
if (status == ERROR_FILE_NOT_FOUND || status == ERROR_UNSUPPORTED_TYPE)
return {};
check(HRESULT_FROM_WIN32(status));
std::wstring command(size / sizeof(wchar_t), L'\0');
check(HRESULT_FROM_WIN32(RegGetValueW(HKEY_CURRENT_USER, RunKey, L"KSwitcher", RRF_RT_REG_SZ, nullptr,
command.data(), &size)));
command.resize(wcslen(command.c_str()));
// KSwitcher writes a quoted executable path with no arguments.
if (command.size() >= 2 && command.front() == L'"' && command.find(L'"', 1) == command.size() - 1)
return command.substr(1, command.size() - 2);
return {};
}
void runEntry(bool enabled)
{
HKEY key{};
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr,
if (RegCreateKeyExW(HKEY_CURRENT_USER, RunKey, 0, nullptr,
0, KEY_SET_VALUE, nullptr, &key, nullptr) != ERROR_SUCCESS)
throw std::runtime_error("Cannot access autostart settings");
auto command = L"\"" + executable() + L"\"";
@@ -66,6 +165,27 @@ void runEntry(bool enabled)
throw std::runtime_error("Cannot save autostart settings");
}
} // namespace
std::wstring staleAutostartPath(bool elevated)
{
auto registered = elevated ? registeredTaskPath() : registeredRunPath();
auto path = std::filesystem::path(registered);
if (!path.is_absolute() || _wcsicmp(path.filename().c_str(), L"KSwitcher.exe") != 0)
return {};
auto current = executable();
std::error_code error;
if (_wcsicmp(registered.c_str(), current.c_str()) == 0 || std::filesystem::equivalent(path, current, error))
return {};
return registered;
}
void repairAutostartPath(bool elevated)
{
if (elevated)
setAutostart(true, true);
else
runEntry(true);
}
std::wstring currentUserSid()
{
HANDLE token{};
@@ -88,13 +208,10 @@ std::wstring currentUserSid()
void configureAutostart(int mode)
{
Com<ITaskService> service;
check(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out()));
Com<ITaskFolder> folder;
check(openTaskFolder(service, folder));
VARIANT empty;
VariantInit(&empty);
check(service->Connect(empty, empty, empty, empty));
Com<ITaskFolder> folder;
check(service->GetFolder(Str(L"\\"), folder.out()));
Com<IRegisteredTask> existing;
HRESULT found = folder->GetTask(Str(L"KSwitcher"), existing.out());
if (FAILED(found) && !missing(found))
@@ -158,16 +275,9 @@ void setAutostart(bool enabled, bool elevated)
if (mode != 2)
{
Com<ITaskService> service;
HRESULT hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out());
VARIANT empty;
VariantInit(&empty);
Com<ITaskFolder> folder;
HRESULT hr = openTaskFolder(service, folder);
Com<IRegisteredTask> task;
if (SUCCEEDED(hr))
hr = service->Connect(empty, empty, empty, empty);
if (SUCCEEDED(hr))
hr = service->GetFolder(Str(L"\\"), folder.out());
if (SUCCEEDED(hr))
hr = folder->GetTask(Str(L"KSwitcher"), task.out());
if (missing(hr))
+143
View File
@@ -0,0 +1,143 @@
// Chrome Telegram Web integration: native accessibility, no OCR or polling timer.
#include "chat_context.h"
#include <uiautomation.h>
#include <atomic>
#include <thread>
namespace ks {
namespace {
template<class T> struct BrowserCom {
T *p{}; ~BrowserCom(){if(p)p->Release();}
T** out(){return &p;} T* operator->(){return p;}
};
std::wstring browserString(IUIAutomationElement* e, PROPERTYID id) {
VARIANT v;VariantInit(&v);std::wstring s;
if(SUCCEEDED(e->GetCurrentPropertyValue(id,&v)) && v.vt==VT_BSTR && v.bstrVal) s.assign(v.bstrVal,SysStringLen(v.bstrVal));
VariantClear(&v);return s;
}
struct ChromeElements { BrowserCom<IUIAutomationElement> address,document,header; int addresses{},documents{}; };
void findChromeElements(IUIAutomationTreeWalker* walker,IUIAutomationElement* e,ChromeElements& found,int depth,int& budget) {
if(depth>12 || --budget<0)return;
CONTROLTYPEID type{};BOOL offscreen=TRUE;e->get_CurrentControlType(&type);e->get_CurrentIsOffscreen(&offscreen);
if(type==UIA_DocumentControlTypeId){
if(!offscreen && browserString(e,UIA_AutomationIdPropertyId)==L"RootWebArea"){
if(++found.documents==1){e->AddRef();found.document.p=e;}}
return; // Never traverse message content.
}
if(type==UIA_EditControlTypeId && !offscreen && browserString(e,UIA_ClassNamePropertyId)==L"OmniboxViewViews"){
if(++found.addresses==1){e->AddRef();found.address.p=e;}}
BrowserCom<IUIAutomationElement> child;walker->GetFirstChildElement(e,child.out());
while(child.p && budget>0){findChromeElements(walker,child.p,found,depth+1,budget);BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(child.p,next.out());child.p->Release();child.p=next.p;next.p=nullptr;}
}
IUIAutomationElement* chromeHeader(IUIAutomationTreeWalker* walker,IUIAutomationElement* document) {
// Web K's center pane is separate from its chat list and message history.
std::function<IUIAutomationElement*(IUIAutomationElement*,int)> center;
center=[&](IUIAutomationElement* e,int depth)->IUIAutomationElement* {
if(depth>3)return nullptr;
if(browserString(e,UIA_AutomationIdPropertyId)==L"column-center"){e->AddRef();return e;}
auto id=browserString(e,UIA_AutomationIdPropertyId);if(id==L"column-left"||id==L"column-right")return nullptr;
BrowserCom<IUIAutomationElement> c;walker->GetFirstChildElement(e,c.out());int limit=12;
while(c.p && --limit>0){if(auto result=center(c.p,depth+1))return result;BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}return nullptr;
};
BrowserCom<IUIAutomationElement> pane;pane.p=center(document,0);if(!pane.p)return nullptr;
BrowserCom<IUIAutomationElement> header;walker->GetFirstChildElement(pane.p,header.out());if(!header.p)return nullptr;
BrowserCom<IUIAutomationElement> c;walker->GetFirstChildElement(header.p,c.out());int limit=16,buttons=0;IUIAutomationElement* title{};
while(c.p && --limit>0){CONTROLTYPEID type{};c->get_CurrentControlType(&type);if(type==UIA_ButtonControlTypeId)++buttons;
if(type==UIA_TextControlTypeId && !title){title=c.p;title->AddRef();}
BrowserCom<IUIAutomationElement> next;walker->GetNextSiblingElement(c.p,next.out());c.p->Release();c.p=next.p;next.p=nullptr;}
if(buttons<2 && title){title->Release();title=nullptr;}return title;
}
std::wstring chromeTitleKey(IUIAutomationElement* header){
if(!header)return {};
auto name=browserString(header,UIA_NamePropertyId);
if(name.empty()||name.size()>512||name.find_first_of(L"\r\n")!=name.npos)return {};
return L"telegram-web\n"+name;
}
std::wstring chromeSnapshot(IUIAutomation* uia,HWND window,ChromeElements& found) {
if(!IsWindow(window)||IsIconic(window)||lower(processName(window))!=L"chrome")return {};
BrowserCom<IUIAutomationElement> root;BrowserCom<IUIAutomationTreeWalker> walker;
if(FAILED(uia->ElementFromHandle(window,root.out()))||!root.p||FAILED(uia->get_ControlViewWalker(walker.out())))return {};
int budget=250;findChromeElements(walker.p,root.p,found,0,budget);
if(found.addresses!=1||found.documents!=1)return {};
auto address=telegramWebKey(browserString(found.address.p,UIA_ValueValuePropertyId));
auto document=telegramWebKey(browserString(found.document.p,UIA_ValueValuePropertyId));
BOOL editing=FALSE;found.address->get_CurrentHasKeyboardFocus(&editing);
if(editing || address.empty() || address!=document)return {};
found.header.p=chromeHeader(walker.p,found.document.p);
return chromeTitleKey(found.header.p);
}
std::mutex browserMutex;
HWND browserWindow{};std::wstring browserKey;
std::atomic<DWORD> browserThreadId{};
std::atomic<bool> browserPending{}, browserStopping{};
std::thread browserThread;
void invalidateBrowser(){std::lock_guard lock(browserMutex);browserKey.clear();}
void requestBrowser(){invalidateBrowser();auto id=browserThreadId.load();if(id && !browserPending.exchange(true))PostThreadMessageW(id,WM_APP+61,0,0);}
void CALLBACK browserForeground(HWINEVENTHOOK,DWORD,HWND,LONG,LONG,DWORD,DWORD){requestBrowser();}
class BrowserChanged final: public IUIAutomationPropertyChangedEventHandler {
std::atomic<ULONG> refs{1};
public:
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void** out) override {if(!out)return E_POINTER;*out=nullptr;if(iid==IID_IUnknown||iid==IID_IUIAutomationPropertyChangedEventHandler){*out=static_cast<IUIAutomationPropertyChangedEventHandler*>(this);AddRef();return S_OK;}return E_NOINTERFACE;}
ULONG STDMETHODCALLTYPE AddRef() override{return ++refs;}
ULONG STDMETHODCALLTYPE Release() override{auto n=--refs;if(!n)delete this;return n;}
HRESULT STDMETHODCALLTYPE HandlePropertyChangedEvent(IUIAutomationElement*,PROPERTYID,VARIANT) override {requestBrowser();return S_OK;}
};
}
std::wstring readChromeChat(HWND window) {
auto hr=CoInitializeEx(nullptr,COINIT_MULTITHREADED);
struct Apartment{bool own;~Apartment(){if(own)CoUninitialize();}} apartment{SUCCEEDED(hr)};
BrowserCom<IUIAutomation2> uia;
if(FAILED(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out())))return {};
uia->put_ConnectionTimeout(150);uia->put_TransactionTimeout(150);
ChromeElements elements;return chromeSnapshot(uia.p,window,elements);
}
std::wstring cachedChromeChat(HWND window){std::lock_guard lock(browserMutex);return window==browserWindow?browserKey:L"";}
void startBrowserChatMonitor(){
if(browserThread.joinable())return;
browserStopping=false;
browserThread=std::thread([] {
CoInitializeEx(nullptr,COINIT_MULTITHREADED);MSG message{};PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
browserThreadId=GetCurrentThreadId();
auto foreground=SetWinEventHook(EVENT_SYSTEM_FOREGROUND,EVENT_SYSTEM_FOREGROUND,nullptr,browserForeground,0,0,WINEVENT_OUTOFCONTEXT);
// Focus changes cover tab selection and entering/leaving the address bar.
auto focus=SetWinEventHook(EVENT_OBJECT_FOCUS,EVENT_OBJECT_FOCUS,nullptr,browserForeground,0,0,WINEVENT_OUTOFCONTEXT);
BrowserCom<IUIAutomation2> uia;CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)uia.out());
BrowserCom<IUIAutomationElement> address,document,header;BrowserCom<BrowserChanged> handler;handler.p=new BrowserChanged;
if(uia.p){uia->put_ConnectionTimeout(150);uia->put_TransactionTimeout(150);}
requestBrowser();
if(browserStopping)PostThreadMessageW(GetCurrentThreadId(),WM_QUIT,0,0);
while(GetMessageW(&message,nullptr,0,0)>0){
if(message.message!=WM_APP+61){TranslateMessage(&message);DispatchMessageW(&message);continue;}
browserPending=false;
if(!uia.p)continue;
if(address.p){uia->RemovePropertyChangedEventHandler(address.p,handler.p);address.p->Release();address.p=nullptr;}
if(document.p){uia->RemovePropertyChangedEventHandler(document.p,handler.p);document.p->Release();document.p=nullptr;}
if(header.p){uia->RemovePropertyChangedEventHandler(header.p,handler.p);header.p->Release();header.p=nullptr;}
auto window=GetForegroundWindow();ChromeElements elements;auto key=chromeSnapshot(uia.p,window,elements);
PROPERTYID properties[]={UIA_ValueValuePropertyId};
bool subscribed=elements.address.p && elements.document.p;
if(subscribed){address.p=elements.address.p;elements.address.p=nullptr;document.p=elements.document.p;elements.document.p=nullptr;
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(address.p,TreeScope_Element,nullptr,handler.p,properties,1));
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(document.p,TreeScope_Element,nullptr,handler.p,properties,1)) && subscribed;
if(elements.header.p){header.p=elements.header.p;elements.header.p=nullptr;PROPERTYID nameProperty=UIA_NamePropertyId;
subscribed=SUCCEEDED(uia->AddPropertyChangedEventHandlerNativeArray(header.p,TreeScope_Element,nullptr,handler.p,&nameProperty,1)) && subscribed;}
// Close the read/subscribe race without scanning the content tree.
key=telegramWebKey(browserString(address.p,UIA_ValueValuePropertyId));
if(key.empty() || key!=telegramWebKey(browserString(document.p,UIA_ValueValuePropertyId)))key.clear();
else key=chromeTitleKey(header.p);
BOOL editing=FALSE;address->get_CurrentHasKeyboardFocus(&editing);if(editing)key.clear();
}
std::lock_guard lock(browserMutex);browserWindow=window;browserKey=subscribed && !browserPending && window==GetForegroundWindow()?key:L"";
}
browserThreadId=0;
if(foreground)UnhookWinEvent(foreground);
if(focus)UnhookWinEvent(focus);
if(uia.p){if(header.p)uia->RemovePropertyChangedEventHandler(header.p,handler.p);if(address.p)uia->RemovePropertyChangedEventHandler(address.p,handler.p);if(document.p)uia->RemovePropertyChangedEventHandler(document.p,handler.p);}
invalidateBrowser();
// COM objects release before uninitializing the apartment below.
if(header.p){header.p->Release();header.p=nullptr;}
if(address.p){address.p->Release();address.p=nullptr;}if(document.p){document.p->Release();document.p=nullptr;}
if(uia.p){uia.p->Release();uia.p=nullptr;}CoUninitialize();
});
}
void stopBrowserChatMonitor(){browserStopping=true;auto id=browserThreadId.load();if(id)PostThreadMessageW(id,WM_QUIT,0,0);if(browserThread.joinable())browserThread.join();browserPending=false;}
}
+149
View File
@@ -0,0 +1,149 @@
#pragma once
#include "platform.h"
#include <algorithm>
namespace ks
{
inline std::wstring telegramWebKey(std::wstring url)
{
if (url.starts_with(L"https://")) url.erase(0, 8);
const std::wstring host = L"web.telegram.org/";
if (!url.starts_with(host)) return {};
url.erase(0, host.size());
if (!(url.starts_with(L"k/#") || url.starts_with(L"a/#"))) return {};
auto peer = url.substr(3);
if (peer.empty() || peer.size() > 128) return {};
if (peer.front() == L'@')
{
peer = lower(peer);
if (peer.size() < 2 || peer.find_first_not_of(L"abcdefghijklmnopqrstuvwxyz0123456789_", 1) != peer.npos) return {};
}
else
{
auto first = peer.front() == L'-' ? 1u : 0u;
if (first == peer.size() || peer.find_first_not_of(L"0123456789", first) != peer.npos) return {};
}
return L"telegram-web\n" + peer;
}
std::wstring readChromeChat(HWND);
std::wstring cachedChromeChat(HWND);
void startBrowserChatMonitor();
void stopBrowserChatMonitor();
struct ChatSwitchHook
{
bool pressed[256]{};
static bool candidate(UINT key, UINT mods)
{
bool shift = key == VK_SHIFT || key == VK_LSHIFT || key == VK_RSHIFT;
bool alt = key == VK_MENU || key == VK_LMENU || key == VK_RMENU;
return (key == VK_SPACE && (mods == MOD_WIN || mods == (MOD_WIN | MOD_SHIFT))) ||
(shift && mods == MOD_ALT) || (alt && mods == MOD_SHIFT);
}
// 1 forward, 2 backward, -1 consumed repeat/release, 0 ordinary input.
int update(UINT key, bool down, UINT mods, bool enabled)
{
if (key >= 256) return 0;
if (pressed[key]) { if (!down) pressed[key] = false; return -1; }
if (!down || !enabled) return 0;
if (!candidate(key, mods)) return 0;
pressed[key] = true;
return key == VK_SPACE && (mods & MOD_SHIFT) ? 2 : 1;
}
};
inline std::wstring chatTitle(HWND window)
{
wchar_t title[1024]{};
if (!window) return {};
int length = GetWindowTextW(window, title, std::size(title));
if (!length || length >= (int)std::size(title) - 1) return {};
return title;
}
inline std::wstring chatKey(std::wstring process, std::wstring title)
{
process = lower(process);
if (process.ends_with(L".exe")) process.resize(process.size() - 4);
while (!title.empty() && (title.front() == 0x200e || title.front() == 0x200f)) title.erase(0, 1);
if (process == L"telegram")
{
// Telegram's unread badge is not part of the contact name.
auto badge = title.rfind(L" \u2013 (");
if (badge != title.npos && title.back() == L')')
{
auto digits = title.substr(badge + 4, title.size() - badge - 5);
if (!digits.empty() && digits.find_first_not_of(L"0123456789") == digits.npos)
title.resize(badge);
}
if (title.empty() || title == L"Telegram" || title.starts_with(L"Telegram (")) return {};
}
else if (process == L"ms-teams")
{
// Only the observed chat title format; meetings/search are not chats.
const std::wstring prefix = L"Chat | ", suffix = L" | Microsoft Teams";
if (!title.starts_with(prefix) || !title.ends_with(suffix) ||
title.size() <= prefix.size() + suffix.size()) return {};
title = title.substr(prefix.size(), title.size() - prefix.size() - suffix.size());
if (title.empty()) return {};
}
else return {};
return process + L"\n" + title;
}
inline std::wstring windowChatKey(HWND window)
{
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
return window ? chatKey(processName(window), chatTitle(window)) : L"";
}
inline std::wstring applicationName(std::wstring process)
{
process = lower(process);
if (process.ends_with(L".exe")) process.resize(process.size() - 4);
return process;
}
inline const AppLanguageSet *applicationLanguageSet(const Settings &settings, const std::wstring &process)
{
auto name = applicationName(process);
if (name.empty()) return nullptr;
for (const auto &[label, set] : settings.appLanguageSets)
for (const auto &app : set.applications)
if (applicationName(app) == name) return &set;
return nullptr;
}
inline std::wstring languageProfileKey(const Settings &settings, HWND window, const std::wstring &chat)
{
if (!chat.empty() && settings.chatCycles.contains(chat)) return chat;
if (settings.appLanguageSets.empty()) return {};
auto app = applicationName(processName(window));
return applicationLanguageSet(settings, app) ? L"app\n" + app : L"";
}
inline std::wstring windowLanguageProfileKey(const Settings &settings, HWND window)
{
return window ? languageProfileKey(settings, window, settings.chatCycles.empty() ? L"" : windowChatKey(window)) : L"";
}
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
const std::vector<Layout> &layouts)
{
auto found = settings.chatCycles.find(key);
const std::vector<std::wstring> *cycle = found == settings.chatCycles.end() ? nullptr : &found->second;
if (key.starts_with(L"app\n"))
if (auto set = applicationLanguageSet(settings, key.substr(4))) cycle = &set->languages;
if (!cycle) return settings.cycle;
std::vector<std::wstring> result;
for (const auto &id : *cycle)
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
result.push_back(id);
return result.empty() ? settings.cycle : result;
}
inline HKL nextChatLayout(const std::vector<std::wstring> &cycle, const std::vector<Layout> &layouts,
HKL current, bool reverse)
{
std::vector<HKL> handles;
for (const auto &id : cycle)
for (const auto &layout : layouts)
if (layout.id == id) handles.push_back(layout.handle);
if (handles.empty()) return nullptr;
auto found = std::find(handles.begin(), handles.end(), current);
if (found == handles.end()) return handles.front();
auto next = (size_t(found - handles.begin()) + (reverse ? handles.size() - 1 : 1)) % handles.size();
return handles[next];
}
}
+62 -3
View File
@@ -1,6 +1,7 @@
#include "configuration.h"
#include <commctrl.h>
#include <cstring>
#include "mcp_logs.h"
namespace ks
{
@@ -52,6 +53,19 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
}
return FALSE;
}
if (message == ConfigSettingsDownloaded)
{
try
{
app->settings = decodeSettings(request(*app, ConfigRead), app->layouts);
if (app->settingsWindow) SendMessageW(app->settingsWindow, ConfigSettingsDownloaded, 0, 0);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"Unable to refresh downloaded settings", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_APP + 20)
{
try
@@ -79,9 +93,10 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
}
// The configurator owns no history store, dictionaries, worker or input hooks.
// All mutations are validated and committed by the resident process.
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
App::App(HINSTANCE h, bool, const std::filesystem::path &, HWND) : instance(h)
{
current = this;
data = dataDirectory();
layouts = readLayouts();
}
App::~App()
@@ -101,7 +116,16 @@ void App::applySettings(const Settings &next)
memcpy(payload.data(), &length, sizeof(length));
payload.insert(payload.end(), before.begin(), before.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()
{
@@ -110,6 +134,14 @@ void App::reloadLayouts()
settings = decodeSettings(bytes, layouts);
}
void App::clearHistory() { request(*this, ConfigClearHistory); }
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)
{
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
request(*this, ConfigSync, Bytes{static_cast<unsigned char>(action)});
return wide(std::string(result.begin(), result.end()));
}
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
@@ -125,6 +157,26 @@ void App::setHotkeyCapture(HWND field)
}
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
int runMcpConfigurator(HINSTANCE instance)
{
App app(instance);
WNDCLASSW cls{};
cls.lpfnWndProc = controller;
cls.hInstance = instance;
cls.lpszClassName = L"KSwitcher.Config.MCP";
RegisterClassW(&cls);
app.window = CreateWindowExW(0, cls.lpszClassName, L"", 0, 0, 0, 0, 0,
HWND_MESSAGE, nullptr, instance, nullptr);
auto result = runLogMcp([&](const Bytes &payload)
{
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (!app.window || !service) throw std::runtime_error("Start KSwitcher before reading remote logs.");
return request(app, ConfigLan, payload);
});
if (app.window) DestroyWindow(app.window);
return result;
}
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
{
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
@@ -180,6 +232,12 @@ int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
{
HWND popup = GetLastActivePopup(app.settingsWindow);
while (!IsWindowEnabled(popup))
{
HWND next = GetLastActivePopup(popup);
if (next == popup) break;
popup = next;
}
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
}
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
@@ -199,8 +257,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
bool mcp = count > 1 && std::wstring(args[1]) == L"--mcp";
LocalFree(args);
result = ks::runConfigurator(instance, snippets, logs);
result = mcp ? ks::runMcpConfigurator(instance) : ks::runConfigurator(instance, snippets, logs);
}
catch (const std::exception &e)
{
+52 -5
View File
@@ -1,4 +1,6 @@
#include "configuration.h"
#include "settings_merge.h"
#include "../vendor/json.hpp"
#include <cstring>
namespace ks
{
@@ -32,7 +34,8 @@ void launchConfiguration(App &app, bool snippets, bool logs)
}
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
{
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
const bool filesPeers = message.dwData == FilesLanPeers;
if (!configurationPeer(sender, filesPeers ? L"KSwitcher.Files.exe" : L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
(message.cbData && !message.lpData)) return FALSE;
Bytes reply;
bool ok = false;
@@ -40,7 +43,51 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
{
switch (message.dwData)
{
case FilesLanPeers:
{
if(message.cbData) return FALSE;
const std::string request=R"({"action":"filesPeers"})";
const auto snapshot=nlohmann::json::parse(app.lanCommand(Bytes(request.begin(),request.end())));
auto paired=nlohmann::json::array();
for(const auto &peer:snapshot.at("peers"))
{
if(!peer.value("paired",false)) continue;
const auto name=wide(peer.at("name").get<std::string>());
if(name.empty() || name.find_first_of(std::wstring_view(L"\0\r\n\t",4))!=name.npos) continue;
paired.push_back({{"id",peer.at("id")},{"name",peer.at("name")},{"address",peer.at("address")},{"filesBrowse",peer.value("filesBrowse",false)}});
}
const auto encoded=paired.dump();
reply.assign(encoded.begin(),encoded.end());
break;
}
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:
reply = app.syncConflictData();
break;
case ConfigSyncResolve:
{
if (!message.cbData) throw std::runtime_error("Empty conflict choices.");
auto first = static_cast<const unsigned char *>(message.lpData);
auto status = utf8(app.syncCommand(4, decodeSyncChoices(Bytes(first, first + message.cbData))));
reply.assign(status.begin(), status.end());
break;
}
case ConfigSync:
{
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 3)
throw std::runtime_error("Invalid sync command.");
auto status = utf8(app.syncCommand(*static_cast<const unsigned char *>(message.lpData)));
reply.assign(status.begin(), status.end());
break;
}
case ConfigSave:
case ConfigSaveEdited:
{
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.");
@@ -48,11 +95,10 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
memcpy(&length, message.lpData, sizeof(length));
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
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);
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);
break;
}
@@ -94,7 +140,8 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
std::string text = error.what();
reply.assign(text.begin(), text.end());
}
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
if(filesPeers && !ok) return FALSE;
COPYDATASTRUCT response{filesPeers?FilesLanPeersReply:ConfigReply, (DWORD)reply.size(), reply.data()};
DWORD_PTR received{};
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
+5 -1
View File
@@ -1,11 +1,15 @@
#pragma once
#include "app.h"
#include "files_lan_ipc.h"
namespace ks
{
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = FilesLanPeers + 1;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
+34
View File
@@ -6,6 +6,40 @@
namespace ks
{
std::wstring snippetAppName(std::wstring name)
{
auto first = name.find_first_not_of(L" \t"), last = name.find_last_not_of(L" \t");
if (first == name.npos) return {};
name = lower(name.substr(first, last - first + 1));
if (name.ends_with(L".exe")) name.resize(name.size() - 4);
return name;
}
int snippetAppPriority(const Snippet &snippet, const std::wstring &process)
{
if (snippet.apps.empty()) return 1;
auto name = snippetAppName(process);
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
return 0;
}
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
{
if (text.empty() || text.size() > 32) return false;
auto isLetter = [&](wchar_t c) { return alphabet.find(c) != alphabet.npos; };
for (size_t i = 0; i < text.size(); ++i)
{
if (isLetter(text[i])) continue;
if (text[i] != L' ' && text[i] != L'\'' && text[i] != L'\u2019' && text[i] != L'\u02bc')
return false;
if (i == 0 || i + 1 == text.size() || !isLetter(text[i - 1]) || !isLetter(text[i + 1]))
return false;
}
return true;
}
bool validAutoReplacementSource(const std::wstring &text, const std::wstring &alphabet)
{
return text.find(L' ') == text.npos && text.find_first_of(alphabet) != text.npos &&
validAutoCorrection(text, alphabet + L"0123456789");
}
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
+36
View File
@@ -3,6 +3,7 @@
#include <functional>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <windows.h>
@@ -19,6 +20,8 @@ struct Layout
bool hasLetter(wchar_t ch) const;
};
bool letter(wchar_t ch);
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet);
bool validAutoReplacementSource(const std::wstring &text, const std::wstring &alphabet);
std::wstring lower(std::wstring text);
std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
@@ -31,7 +34,13 @@ std::wstring transliterate(const std::wstring &);
struct Snippet
{
std::wstring trigger, text;
std::wstring id;
std::vector<std::wstring> apps; // Empty means everywhere; executable names without .exe.
};
std::wstring snippetAppName(std::wstring);
int snippetAppPriority(const Snippet &, const std::wstring &process);
std::wstring snippetId(const Snippet &);
std::wstring newSnippetId();
struct Template
{
std::wstring text, suffix;
@@ -46,11 +55,29 @@ struct Hotkey
static std::optional<Hotkey> parse(std::wstring);
std::wstring format() const;
};
struct AppLanguageSet
{
std::vector<std::wstring> languages, applications;
bool operator==(const AppLanguageSet &) const = default;
};
enum class MouseActionType { Standard, Shortcut, Back, Forward, VolumeUp, VolumeDown, Mute, Launch };
inline constexpr int DefaultMouseVolumeStep = 4;
struct MouseAction
{
MouseActionType type = MouseActionType::Standard;
Hotkey shortcut;
std::wstring executable;
std::set<std::wstring> applications{};
int volumeStep = DefaultMouseVolumeStep;
bool operator==(const MouseAction &) const = default;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
bool autoCorrect = false;
bool autoReplace = false, autoTypos = false;
std::map<std::wstring, std::wstring> autoReplacements;
bool writeLog = true;
bool screenshotAutoOpenEditor = false;
bool useTesseract = false;
@@ -70,9 +97,18 @@ struct Settings
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
screenshot = L"Win+Shift+S";
std::optional<std::wstring> filesHotkey;
std::vector<std::wstring> cycle, direct, directIds;
// Exact application + visible chat name; an absent entry uses the global cycle.
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
std::map<std::wstring, AppLanguageSet> appLanguageSets;
std::map<std::wstring, std::map<std::wstring, MouseAction>> mouseBindings;
std::map<std::wstring, std::wstring> customMouseEvents;
std::set<std::wstring> disabledMouseProfiles;
std::map<std::wstring, std::set<std::wstring>> mouseProfileApplications;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets;
std::vector<std::wstring> snippetTombstones;
};
// Consume repeats and key-up for intercepted chords, even if settings change.
struct HotkeyHook
+2 -1
View File
@@ -37,7 +37,8 @@ inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &conten
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
field("original") + L"\r\n\r\nExpected selection:\r\n" + field("selectionExpected") +
L"\r\n\r\nExpected result / verification target:\r\n" + field("expected") +
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
+107
View File
@@ -0,0 +1,107 @@
constexpr std::array<WORD,2> defaultHistoryKeys{MAKEWORD('E',HOTKEYF_CONTROL),MAKEWORD(VK_F8,HOTKEYF_ALT)};
auto commandHistoryKeys=defaultHistoryKeys;
constexpr size_t commandHistoryLimit=1000;
bool validHistoryKeys(const std::array<WORD,2> &keys)
{
if(keys[0]==keys[1]) return false;
return std::all_of(keys.begin(),keys.end(),[](WORD binding)
{
const auto key=LOBYTE(binding), modifiers=HIBYTE(binding);
const bool letter=key>='A' && key<='Z';
const bool function=key>=VK_F1 && key<=VK_F12;
if(!letter && !function) return false;
if(modifiers==HOTKEYF_CONTROL) return (letter && key!='C' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
if(modifiers==HOTKEYF_ALT) return function && key!=VK_F1 && key!=VK_F2 && key!=VK_F4 && key!=VK_F10;
return false;
});
}
std::array<WORD,2> optionHistoryKeys(HWND dialog)
{
return {static_cast<WORD>(SendDlgItemMessageW(dialog,321,HKM_GETHOTKEY,0,0)),
static_cast<WORD>(SendDlgItemMessageW(dialog,323,HKM_GETHOTKEY,0,0))};
}
void setOptionHistoryKeys(HWND dialog,const std::array<WORD,2> &keys)
{
SendDlgItemMessageW(dialog,321,HKM_SETHOTKEY,keys[0],0);
SendDlgItemMessageW(dialog,323,HKM_SETHOTKEY,keys[1],0);
}
void loadCommandHistoryKeys()
{
const std::array<WORD,2> keys{static_cast<WORD>(preference(L"PreviousCommandKey",defaultHistoryKeys[0])),
static_cast<WORD>(preference(L"CommandHistoryKey",defaultHistoryKeys[1]))};
commandHistoryKeys=validHistoryKeys(keys)?keys:defaultHistoryKeys;
}
void loadCommandHistory()
{
DWORD bytes{};
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)
!=ERROR_SUCCESS || bytes>16*1024*1024 || bytes%sizeof(wchar_t)) return;
std::wstring data(bytes/sizeof(wchar_t),L'\0');
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)
!=ERROR_SUCCESS) return;
size_t start{};
while(start<data.size())
{
const auto dateEnd=data.find(L'\0',start);
if(dateEnd==std::wstring::npos) break;
const auto commandEnd=data.find(L'\0',dateEnd+1);
if(commandEnd==std::wstring::npos) break;
terminalHistory.push_back({data.substr(start,dateEnd-start),data.substr(dateEnd+1,commandEnd-dateEnd-1)});
start=commandEnd+1;
}
terminalHistoryIndex=terminalHistory.size();
}
void rememberCommand(const std::wstring &command)
{
SYSTEMTIME now{};
GetLocalTime(&now);
wchar_t date[24]{};
GetDateFormatEx(LOCALE_NAME_USER_DEFAULT,0,&now,L"dd.MM.yyyy",date,std::size(date),nullptr);
terminalHistory.push_back({date,command});
if(terminalHistory.size()>commandHistoryLimit) terminalHistory.erase(terminalHistory.begin());
std::wstring data;
for(const auto &entry:terminalHistory)
{
data.append(entry.date).push_back(L'\0');
data.append(entry.command).push_back(L'\0');
}
if(RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",REG_BINARY,data.data(),
static_cast<DWORD>(data.size()*sizeof(wchar_t)))!=ERROR_SUCCESS)
setStatus(L"Could not save command history.");
refreshCommandHistory();
}
void recallCommand(size_t index)
{
terminalHistoryIndex=index;
SetWindowTextW(terminalInput,index<terminalHistory.size()?terminalHistory[index].command.c_str():L"");
SetFocus(terminalInput);
SendMessageW(terminalInput,EM_SETSEL,-2,-1);
}
void showCommandHistory(HWND owner);
bool handleCommandHistoryKey(HWND window,const MSG &message)
{
if(message.message!=WM_KEYDOWN && message.message!=WM_SYSKEYDOWN) return false;
if((GetKeyState(VK_LWIN)&0x8000) || (GetKeyState(VK_RWIN)&0x8000)) return false;
const BYTE modifiers=((GetKeyState(VK_CONTROL)&0x8000)?HOTKEYF_CONTROL:0) |
((GetKeyState(VK_MENU)&0x8000)?HOTKEYF_ALT:0) | ((GetKeyState(VK_SHIFT)&0x8000)?HOTKEYF_SHIFT:0);
const auto binding=MAKEWORD(message.wParam,modifiers);
if(binding==commandHistoryKeys[0] && (message.hwnd==terminalInput ||
message.hwnd==panels[0].list || message.hwnd==panels[1].list))
{
if(terminalHistoryIndex) recallCommand(terminalHistoryIndex-1);
return true;
}
if(binding!=commandHistoryKeys[1]) return false;
if(!(message.lParam&(1LL<<30))) showCommandHistory(window);
return true;
}
+365
View File
@@ -0,0 +1,365 @@
constexpr int historyList=324;
constexpr int historySearch=325;
constexpr COLORREF historySearchSurface=RGB(132,177,177);
constexpr int historySearchLabel=326;
HWND commandHistoryWindow{};
HHOOK historyMouseHook{};
LRESULT CALLBACK dismissHistoryOnOutsideClick(int code,WPARAM message,LPARAM data)
{
const auto dialog=commandHistoryWindow;
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) &&
(message==WM_LBUTTONDOWN || message==WM_RBUTTONDOWN ||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN))
{
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
const auto clicked=WindowFromPoint(point);
const auto owner=GetWindow(dialog,GW_OWNER);
if(clicked!=dialog && !IsChild(dialog,clicked))
{
POINT local=point;
RECT client{};
GetClientRect(owner,&client);
ScreenToClient(owner,&local);
const bool ownerClient=clicked==owner || IsChild(owner,clicked);
if(!ownerClient || PtInRect(&client,local))
{
const auto focusPanel=clicked==panels[0].list?panels[0].list:
clicked==panels[1].list?panels[1].list:panels[active].list;
if(PostMessageW(dialog,WM_CLOSE,reinterpret_cast<WPARAM>(ownerClient?focusPanel:nullptr),0)
&& ownerClient) return 1;
}
}
}
return CallNextHookEx(nullptr,code,message,data);
}
std::wstring historyLower(std::wstring value)
{
if(!value.empty()) CharLowerBuffW(value.data(),static_cast<DWORD>(value.size()));
return value;
}
std::wstring_view commandTopicKeywords(const std::wstring &command)
{
const auto start=command.find_first_not_of(L" \t");
if(start==command.npos) return L"";
const auto end=command.find_first_of(L" \t",start);
auto tool=command.substr(start,end-start);
if(tool.ends_with(L".exe")) tool.resize(tool.size()-4);
const auto args=end==command.npos?L"":command.substr(end);
if(tool==L"ipconfig" && args.find(L"/flushdns")!=args.npos)
return L"network tools мережа dns flush clear cache очистити кеш";
if(tool==L"nslookup" || tool==L"resolve-dnsname")
return L"network tools мережа dns lookup запит";
if(tool==L"ping" || tool==L"tracert" || tool==L"ipconfig" || tool==L"test-netconnection" ||
tool==L"netstat" || tool==L"curl" || tool==L"invoke-webrequest")
return L"network tools мережа з’єднання connectivity address адреса";
if(tool==L"cls" || tool==L"clear" || tool==L"clear-host")
return L"terminal clear screen очистити екран консоль";
if(tool==L"git")
{
const auto first=args.find_first_not_of(L" \t");
const auto action=first==args.npos?L"":args.substr(first,args.find_first_of(L" \t",first)-first);
if(action==L"pull" || action==L"fetch" || action==L"update")
return L"git version control repository update fetch pull оновити репозиторій синхронізація";
return L"git version control repository репозиторій контроль версій";
}
return L"";
}
void populateCommandHistory(HWND dialog)
{
const auto search=GetDlgItem(dialog,historySearch);
std::wstring query(GetWindowTextLengthW(search)+1,L'\0');
query.resize(GetWindowTextW(search,query.data(),static_cast<int>(query.size())));
query=historyLower(std::move(query));
std::wistringstream stream(query);
std::vector<std::wstring> words;
for(std::wstring word;stream>>word;) words.push_back(word);
const auto list=GetDlgItem(dialog,historyList);
SendMessageW(list,WM_SETREDRAW,FALSE,0);
SendMessageW(list,LB_RESETCONTENT,0,0);
const auto add=[&](const std::wstring &label,LPARAM index)
{
const auto row=SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
if(row>=0) SendMessageW(list,LB_SETITEMDATA,row,index);
return row;
};
std::array<std::vector<size_t>,2> matches;
std::set<std::wstring_view> seen;
for(size_t index=terminalHistory.size();index-- > 0;)
{
const auto &entry=terminalHistory[index];
if(!seen.insert(entry.command).second) continue;
const auto command=historyLower(entry.command);
const auto containsAll=[&](const std::wstring &text)
{
return std::all_of(words.begin(),words.end(),[&](const auto &word){return text.find(word)!=text.npos;});
};
if(containsAll(command+L" "+entry.date))
{
matches[0].push_back(index);
continue;
}
const auto keywords=commandTopicKeywords(command);
if(!keywords.empty() && containsAll(command+L" "+entry.date+L" "+std::wstring(keywords)))
matches[1].push_back(index);
}
LRESULT selected=LB_ERR;
for(const auto &group:matches)
{
std::wstring lastDate;
bool first=true;
for(auto match=group.rbegin();match!=group.rend();++match)
{
const auto index=*match;
const auto &entry=terminalHistory[index];
const auto &date=entry.date;
if(first || date!=lastDate) add(date,-1);
first=false;
lastDate=date;
const auto row=add(entry.command,index);
if(row>=0 && (words.empty() || selected==LB_ERR)) selected=row;
}
}
if(selected==LB_ERR) add(words.empty()?L"No commands yet":L"No matching commands",-2);
else SendMessageW(list,LB_SETCURSEL,selected,0);
SendMessageW(list,WM_SETREDRAW,TRUE,0);
InvalidateRect(list,nullptr,TRUE);
}
LRESULT CALLBACK historySearchKeys(HWND search,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
{
if(message==WM_GETDLGCODE)
{
const auto code=DefSubclassProc(search,message,key,flags);
if(key==VK_RETURN || key==VK_TAB || key==VK_ESCAPE) return code&~DLGC_WANTALLKEYS;
return code|DLGC_WANTARROWS;
}
if(message==WM_KEYDOWN && (key==VK_UP || key==VK_DOWN))
{
const auto list=GetDlgItem(GetParent(search),historyList);
SetFocus(list);
SendMessageW(list,message,key,flags);
return 0;
}
return DefSubclassProc(search,message,key,flags);
}
int historyRowInset(UINT dpi)
{
return std::max(MulDiv(4,dpi,96),dialogFrameMetrics(dpi).inner)+dialogRowTextInset(cellWidth);
}
LRESULT CALLBACK historyListScroll(HWND list,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(message==LB_GETTOPINDEX || message==WM_NCDESTROY)
return DefSubclassProc(list,message,wp,lp);
const auto top=SendMessageW(list,LB_GETTOPINDEX,0,0);
const auto result=scrollList(list,message,wp,lp);
if(top!=SendMessageW(list,LB_GETTOPINDEX,0,0))
InvalidateRect(GetParent(list),nullptr,FALSE);
return result;
}
void sizeCommandHistory(HWND dialog,HWND list,bool movingOwner=false)
{
const auto owner=GetWindow(dialog,GW_OWNER);
RECT ownerBounds{};
if(!GetWindowRect(owner,&ownerBounds) || IsIconic(owner)) return;
MONITORINFO monitor{sizeof(monitor)};
if(!GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor)) return;
RECT area{};
if(!IntersectRect(&area,&ownerBounds,&monitor.rcWork)) area=monitor.rcWork;
const auto dc=GetDC(list);
if(!dc) return;
const auto previous=SelectObject(dc,mono);
LONG longest{};
for(const auto &entry:terminalHistory)
{
SIZE size{};
if(GetTextExtentPoint32W(dc,entry.command.c_str(),static_cast<int>(entry.command.size()),&size))
longest=std::max(longest,size.cx);
}
SelectObject(dc,previous);
ReleaseDC(list,dc);
const auto dpi=GetDpiForWindow(dialog);
const auto frame=dialogFrameMetrics(dpi);
const int inset=frame.content();
const int top=dialogListInset(frame,textHeight);
const int gap=MulDiv(4,dpi,96);
const int listTop=top+2*textHeight+2*gap;
const LONG desired=std::max<LONG>(MulDiv(320,dpi,96),
longest+2*cellWidth+2*historyRowInset(dpi)+GetSystemMetricsForDpi(SM_CXVSCROLL,dpi))+2*inset;
const auto target=std::min(desired,area.right-area.left);
const auto rows=std::max<LRESULT>(1,SendMessageW(list,LB_GETCOUNT,0,0));
RECT listWindow{},listClient{};
GetWindowRect(list,&listWindow);
GetClientRect(list,&listClient);
const auto listBorder=listWindow.bottom-listWindow.top-listClient.bottom;
const auto height=std::min<LONG>(listTop+rows*textHeight+listBorder+inset,
area.bottom-area.top);
const auto left=std::clamp((ownerBounds.left+ownerBounds.right-target)/2,area.left,area.right-target);
const auto y=std::clamp((ownerBounds.top+ownerBounds.bottom-height)/2,area.top,area.bottom-height);
RECT current{};
if(movingOwner && GetWindowRect(dialog,&current) &&
current.right-current.left==target && current.bottom-current.top==height)
{
if(current.left!=left || current.top!=y)
SetWindowPos(dialog,nullptr,left,y,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
return;
}
SetWindowPos(dialog,nullptr,left,y,target,height,SWP_NOZORDER|SWP_NOACTIVATE);
const auto place=[&](HWND control,int rowTop,int rowHeight)
{
SetWindowPos(control,nullptr,inset,rowTop,std::max(1L,target-2*inset),rowHeight,
SWP_NOZORDER|SWP_NOACTIVATE);
};
place(GetDlgItem(dialog,historySearchLabel),top,textHeight);
place(GetDlgItem(dialog,historySearch),top+textHeight+gap,textHeight);
SetWindowPos(list,nullptr,inset,listTop,std::max(1L,target-2*inset-5),
std::max(1L,height-listTop-inset),SWP_NOZORDER|SWP_NOACTIVATE);
GetClientRect(list,&listClient);
if(rows*textHeight<=listClient.bottom) SendMessageW(list,LB_SETTOPINDEX,0,0);
InvalidateRect(dialog,nullptr,FALSE);
}
void layoutOpenCommandHistory(bool movingOwner=false)
{
if(commandHistoryWindow)
sizeCommandHistory(commandHistoryWindow,GetDlgItem(commandHistoryWindow,historyList),movingOwner);
}
INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
prepareCopyDialog(dialog);
const auto list=GetDlgItem(dialog,historyList);
SetWindowSubclass(list,historyListScroll,1,0);
SendMessageW(list,WM_SETFONT,reinterpret_cast<WPARAM>(mono),TRUE);
SendMessageW(list,LB_SETITEMHEIGHT,0,std::max(1,textHeight));
const auto search=GetDlgItem(dialog,historySearch);
SendDlgItemMessageW(dialog,historySearchLabel,WM_SETFONT,reinterpret_cast<WPARAM>(mono),TRUE);
SetWindowSubclass(search,historySearchKeys,1,0);
populateCommandHistory(dialog);
sizeCommandHistory(dialog,list);
SetFocus(search);
return FALSE;
}
if(message==WM_COMMAND && LOWORD(wp)==historySearch && HIWORD(wp)==EN_CHANGE)
{
populateCommandHistory(dialog);
sizeCommandHistory(dialog,GetDlgItem(dialog,historyList));
return TRUE;
}
if(message==WM_NCDESTROY && commandHistoryWindow==dialog)
{
if(historyMouseHook) UnhookWindowsHookEx(historyMouseHook);
historyMouseHook=nullptr;
commandHistoryWindow=nullptr;
}
if(message==WM_CTLCOLORLISTBOX || message==WM_CTLCOLOREDIT)
{
const auto dc=reinterpret_cast<HDC>(wp);
const bool search=message==WM_CTLCOLOREDIT;
SetTextColor(dc,search?RGB(0,0,0):colors[Text]);
const auto background=search?historySearchSurface:colors[Dialog];
SetBkColor(dc,background);
if(search) SetDCBrushColor(dc,background);
return reinterpret_cast<INT_PTR>(search?GetStockObject(DC_BRUSH):teal);
}
if(message!=WM_DRAWITEM || wp!=historyList)
if(const auto painted=paintCopyDialog(dialog,message,wp,lp,L" History ",false,{},true,
GetDlgItem(dialog,historyList)))
return painted;
if(message==WM_DRAWITEM && wp==historyList)
{
const auto &item=*reinterpret_cast<DRAWITEMSTRUCT*>(lp);
if(item.itemID==UINT(-1)) return TRUE;
const auto count=SendMessageW(item.hwndItem,LB_GETTEXTLEN,item.itemID,0);
if(count<0) return TRUE;
std::wstring label(static_cast<size_t>(count)+1,L'\0');
SendMessageW(item.hwndItem,LB_GETTEXT,item.itemID,reinterpret_cast<LPARAM>(label.data()));
label.resize(count);
const bool selected=(item.itemState&ODS_SELECTED)!=0 && item.itemData<terminalHistory.size();
const auto background=colors[selected?Selection:Dialog];
const auto ink=colors[Text];
if(item.itemData==static_cast<ULONG_PTR>(-1))
{
auto bounds=item.rcItem;
InflateRect(&bounds,-historyRowInset(GetDpiForWindow(dialog)),0);
const auto saved=SaveDC(item.hDC);
SelectObject(item.hDC,mono);
SetBkColor(item.hDC,background);
SetTextColor(item.hDC,ink);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&bounds,nullptr,0,nullptr);
const auto middle=(bounds.top+bounds.bottom)/2;
RECT rule{bounds.left,middle,bounds.right,middle+1};
SetBkColor(item.hDC,ink);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&rule,nullptr,0,nullptr);
SIZE size{};
GetTextExtentPoint32W(item.hDC,label.c_str(),static_cast<int>(label.size()),&size);
const auto left=(bounds.left+bounds.right-size.cx)/2;
RECT caption{left-cellWidth,bounds.top,left+size.cx+cellWidth,bounds.bottom};
SetBkColor(item.hDC,background);
ExtTextOutW(item.hDC,left,middle-size.cy/2,ETO_OPAQUE,&caption,label.c_str(),
static_cast<UINT>(label.size()),nullptr);
RestoreDC(item.hDC,saved);
}
else paintDialogListRow(item.hDC,item.rcItem,label,mono,ink,background,
historyRowInset(GetDpiForWindow(dialog)),DT_END_ELLIPSIS);
if(selected && (item.itemState&ODS_FOCUS)) DrawFocusRect(item.hDC,&item.rcItem);
return TRUE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
const auto owner=GetWindow(dialog,GW_OWNER);
const auto focusPanel=message==WM_CLOSE?reinterpret_cast<HWND>(wp):panels[active].list;
DestroyWindow(dialog);
if(focusPanel && IsWindow(owner) && !IsIconic(owner))
{
SetActiveWindow(owner);
SetFocus(focusPanel);
}
return TRUE;
}
if(message==WM_COMMAND && (LOWORD(wp)==IDOK || (LOWORD(wp)==historyList && HIWORD(wp)==LBN_DBLCLK)))
{
const auto row=SendDlgItemMessageW(dialog,historyList,LB_GETCURSEL,0,0);
const auto index=SendDlgItemMessageW(dialog,historyList,LB_GETITEMDATA,row,0);
if(index>=0 && static_cast<size_t>(index)<terminalHistory.size())
{
const auto owner=GetWindow(dialog,GW_OWNER);
DestroyWindow(dialog);
SetActiveWindow(owner);
recallCommand(static_cast<size_t>(index));
}
return TRUE;
}
return FALSE;
}
void refreshCommandHistory()
{
if(commandHistoryWindow) populateCommandHistory(commandHistoryWindow);
layoutOpenCommandHistory();
}
void showCommandHistory(HWND owner)
{
if(!commandHistoryWindow)
commandHistoryWindow=CreateDialogParamW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(111),owner,commandHistoryDialog,0);
if(!commandHistoryWindow) return;
if(!historyMouseHook)
historyMouseHook=SetWindowsHookExW(WH_MOUSE_LL,dismissHistoryOnOutsideClick,GetModuleHandleW(nullptr),0);
ShowWindow(commandHistoryWindow,SW_SHOW);
SetActiveWindow(commandHistoryWindow);
SetFocus(GetDlgItem(commandHistoryWindow,historySearch));
}
+128
View File
@@ -0,0 +1,128 @@
HWND activeFilesDialog{};
HHOOK filesDialogMouseHook{};
bool filesMouseDown(UINT message)
{
return message==WM_LBUTTONDOWN || message==WM_RBUTTONDOWN ||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN;
}
LRESULT CALLBACK filesDialogMouse(int code,WPARAM message,LPARAM data)
{
const auto dialog=activeFilesDialog;
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) && filesMouseDown(static_cast<UINT>(message)))
{
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
const auto clicked=WindowFromPoint(point);
const auto root=GetAncestor(dialog,GA_ROOTOWNER);
if(clicked!=dialog && !IsChild(dialog,clicked))
{
RECT client{};
POINT local=point;
GetClientRect(root,&client);
ScreenToClient(root,&local);
if(clicked!=root || PtInRect(&client,local))
{
const bool internal=clicked==root || IsChild(root,clicked) ||
(clicked && GetAncestor(clicked,GA_ROOTOWNER)==root);
if(PostMessageW(dialog,WM_COMMAND,IDCANCEL,0) && internal) return 1;
}
}
}
return CallNextHookEx(nullptr,code,message,data);
}
struct FilesDialogInput
{
HWND previous=activeFilesDialog;
explicit FilesDialogInput(HWND dialog)
{
activeFilesDialog=dialog;
if(!filesDialogMouseHook)
filesDialogMouseHook=SetWindowsHookExW(WH_MOUSE_LL,filesDialogMouse,GetModuleHandleW(nullptr),0);
}
~FilesDialogInput()
{
activeFilesDialog=previous;
if(!previous && filesDialogMouseHook)
{
UnhookWindowsHookEx(filesDialogMouseHook);
filesDialogMouseHook=nullptr;
}
}
};
bool filesDialogHandlesMessage(HWND dialog,MSG &message)
{
if(message.hwnd==dialog || IsChild(dialog,message.hwnd)) return IsDialogMessageW(dialog,&message);
if(!message.hwnd || GetAncestor(message.hwnd,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER)) return false;
const bool input=(message.message>=WM_KEYFIRST && message.message<=WM_KEYLAST) ||
(message.message>=WM_MOUSEFIRST && message.message<=WM_MOUSELAST) ||
message.message==WM_CONTEXTMENU;
if(input && message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)
PostMessageW(dialog,WM_COMMAND,IDCANCEL,0);
return input;
}
struct FilesDialogResult
{
DLGPROC procedure;
LPARAM argument;
INT_PTR result=IDCANCEL;
bool finished=false;
};
constexpr wchar_t filesDialogResultProperty[]=L"KSwitcher.Files.DialogResult";
INT_PTR CALLBACK filesDialogProcedure(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
auto *result=reinterpret_cast<FilesDialogResult*>(GetPropW(dialog,filesDialogResultProperty));
if(message==WM_INITDIALOG)
{
result=reinterpret_cast<FilesDialogResult*>(lp);
if(!SetPropW(dialog,filesDialogResultProperty,result)) return FALSE;
lp=result->argument;
}
if(!result) return FALSE;
const auto handled=result->procedure(dialog,message,wp,lp);
if(message==WM_NCDESTROY) RemovePropW(dialog,filesDialogResultProperty);
return handled;
}
void finishFilesDialog(HWND dialog,INT_PTR answer)
{
auto *result=reinterpret_cast<FilesDialogResult*>(GetPropW(dialog,filesDialogResultProperty));
if(!result) return;
result->result=answer;
result->finished=true;
ShowWindow(dialog,SW_HIDE);
}
INT_PTR runFilesDialog(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
{
FilesDialogResult result{procedure,argument};
const auto dialog=CreateDialogParamW(instance,resource,owner,filesDialogProcedure,
reinterpret_cast<LPARAM>(&result));
if(!dialog) return -1;
GetWindowRect(GetAncestor(owner,GA_ROOTOWNER),&previousOwnerBounds);
FilesDialogInput input(dialog);
ShowWindow(dialog,SW_SHOW);
SetActiveWindow(dialog);
MSG message{};
while(!result.finished && IsWindow(dialog))
{
const auto status=GetMessageW(&message,nullptr,0,0);
if(status<=0)
{
if(status==0) PostQuitMessage(static_cast<int>(message.wParam));
break;
}
if(filesDialogHandlesMessage(dialog,message)) continue;
TranslateMessage(&message);
DispatchMessageW(&message);
}
if(IsWindow(dialog)) DestroyWindow(dialog);
if(IsWindow(owner) && !IsIconic(GetAncestor(owner,GA_ROOTOWNER))) SetActiveWindow(owner);
return result.result;
}
+427
View File
@@ -0,0 +1,427 @@
struct FolderShortcut
{
fs::path path;
std::string peerId;
std::wstring peerName,title;
};
using FolderShortcuts=std::array<std::vector<FolderShortcut>,10>;
FolderShortcuts folderShortcuts;
constexpr wchar_t folderShortcutsKey[]=L"FolderShortcuts";
bool sameFolderShortcut(const FolderShortcut &left,const FolderShortcut &right)
{
return left.peerId==right.peerId &&
CompareStringOrdinal(left.path.c_str(),-1,right.path.c_str(),-1,TRUE)==CSTR_EQUAL;
}
bool saveFolderShortcuts(const FolderShortcuts &shortcuts)
{
try
{
auto groups=Json::array();
for(const auto &group:shortcuts)
{
auto rows=Json::array();
for(const auto &entry:group)
rows.push_back({{"path",ks::utf8(entry.path.native())},{"peer",entry.peerId},
{"name",ks::utf8(entry.peerName)},{"title",ks::utf8(entry.title)}});
groups.push_back(std::move(rows));
}
const auto data=groups.dump();
return data.size()<=1024*1024 &&
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,REG_BINARY,data.data(),
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
}
catch(const std::exception &){return false;}
}
void loadFolderShortcuts()
{
DWORD bytes{};
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
!bytes || bytes>1024*1024) return;
std::string data(bytes,'\0');
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
return;
try
{
const auto groups=Json::parse(data.begin(),data.begin()+bytes);
if(!groups.is_array() || groups.size()!=folderShortcuts.size()) return;
FolderShortcuts loaded;
for(size_t digit=0;digit<loaded.size();++digit)
{
if(!groups[digit].is_array()) return;
for(const auto &row:groups[digit])
{
FolderShortcut entry{filePath(row.at("path")),row.at("peer"),ks::wide(row.at("name")),ks::wide(row.value("title",""))};
if(!entry.path.is_absolute() || entry.path.native().find(L'\0')!=std::wstring::npos) continue;
loaded[digit].push_back(std::move(entry));
}
}
folderShortcuts=std::move(loaded);
}
catch(const std::exception &) {}
}
constexpr int shortcutTitle=314,shortcutPath=211;
struct FolderShortcutEdit
{
FolderShortcut entry;
int digit;
std::optional<size_t> index;
};
std::wstring shortcutField(HWND dialog,int id)
{
const auto field=GetDlgItem(dialog,id);
const auto length=GetWindowTextLengthW(field);
std::wstring result(static_cast<size_t>(length)+1,L'\0');
result.resize(GetWindowTextW(field,result.data(),length+1));
return result;
}
INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(const auto painted=paintCopyDialog(dialog,message,wp,lp,L" Folder shortcuts ",false,{315}))
return painted;
if(message==WM_INITDIALOG)
{
auto &edit=*reinterpret_cast<FolderShortcutEdit*>(lp);
SetWindowLongPtrW(dialog,DWLP_USER,lp);
prepareCopyDialog(dialog);
layoutDialogContent(dialog,316);
for(const auto id:{316,317,IDOK,IDCANCEL})
SendDlgItemMessageW(dialog,id,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
SendDlgItemMessageW(dialog,shortcutTitle,EM_SETLIMITTEXT,255,0);
SendDlgItemMessageW(dialog,shortcutPath,EM_SETLIMITTEXT,32767,0);
SetDlgItemTextW(dialog,shortcutTitle,edit.entry.title.c_str());
SetDlgItemTextW(dialog,shortcutPath,edit.entry.path.c_str());
if(!edit.entry.peerId.empty())
SetDlgItemTextW(dialog,317,(L"Path on "+edit.entry.peerName).c_str());
layoutDialogButtons(dialog,315,{IDOK,IDCANCEL});
return TRUE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
finishFilesDialog(dialog,IDCANCEL);
return TRUE;
}
if(message!=WM_COMMAND || LOWORD(wp)!=IDOK) return FALSE;
auto &edit=*reinterpret_cast<FolderShortcutEdit*>(GetWindowLongPtrW(dialog,DWLP_USER));
auto path=fs::path(shortcutField(dialog,shortcutPath)).lexically_normal();
if(path.has_relative_path() && !path.has_filename()) path=path.parent_path();
const auto root=path.root_name().native();
const bool lanDrive=root.size()==2 && root[1]==L':' &&
((root[0]>=L'A' && root[0]<=L'Z') || (root[0]>=L'a' && root[0]<=L'z'));
if(!path.is_absolute() || (!edit.entry.peerId.empty() && !lanDrive))
{
MessageBoxW(dialog,L"Enter an absolute folder path on the original computer.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
SetFocus(GetDlgItem(dialog,shortcutPath));
return TRUE;
}
auto entry=edit.entry;
entry.path=std::move(path);
entry.title=shortcutField(dialog,shortcutTitle);
const auto &group=folderShortcuts[edit.digit];
for(size_t i=0;i<group.size();++i)
if((!edit.index || i!=*edit.index) && sameFolderShortcut(group[i],entry))
{
MessageBoxW(dialog,L"This folder is already assigned to this digit.",L"Folder shortcuts",MB_OK|MB_ICONINFORMATION);
return TRUE;
}
auto updated=folderShortcuts;
if(edit.index) updated[edit.digit][*edit.index]=std::move(entry);
else updated[edit.digit].push_back(std::move(entry));
if(!saveFolderShortcuts(updated))
{
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
return TRUE;
}
folderShortcuts=std::move(updated);
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
finishFilesDialog(dialog,IDOK);
return TRUE;
}
bool editFolderShortcut(HWND owner,FolderShortcutEdit &edit)
{
return runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(110),owner,
folderShortcutEditDialog,reinterpret_cast<LPARAM>(&edit))==IDOK;
}
void addFolderShortcut(const Panel &panel,int digit)
{
if(panel.directory.empty()) return;
FolderShortcutEdit edit{{panel.directory,panel.peer?panel.peer->id:"",
panel.peer?panel.peer->name:L""},digit,{}};
if(editFolderShortcut(GetParent(panel.list),edit))
setStatus(L"Folder added to Ctrl+"+std::to_wstring(digit)+L".");
SetFocus(panel.list);
}
void openFolderShortcut(Panel &panel,const FolderShortcut &entry)
{
if(entry.peerId.empty())
{
openOn(panel,entry.path,true,{});
return;
}
queryDrivePeers();
const auto peer=std::find_if(drivePeers.begin(),drivePeers.end(),
[&](const auto &candidate){return candidate.id==entry.peerId && !candidate.address.empty();});
if(peer==drivePeers.end())
{
setStatus(L"Paired computer unavailable: "+entry.peerName);
return;
}
openOn(panel,entry.path,false,*peer);
}
constexpr int shortcutList=310,quickRemove=313,quickEdit=318,quickInsert=319,quickMoveUp=320,quickMoveDown=321;
constexpr wchar_t quickFolderHints[]=L"Ins Del F4 Ctrl+Up Ctrl+Down";
struct FolderShortcutView
{
int digit;
std::optional<FolderShortcut> chosen;
std::vector<int> digits;
int rowHeight{};
Panel *panel{};
};
void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
{
const auto list=GetDlgItem(dialog,shortcutList);
SendMessageW(list,LB_RESETCONTENT,0,0);
view.digits.clear();
const auto owner=GetWindow(dialog,GW_OWNER);
const auto dc=GetDC(dialog);
const auto font=SelectObject(dc,mono);
const auto dpi=GetDpiForWindow(dialog);
const auto px=[&](int value){return MulDiv(value,dpi,96);};
TEXTMETRICW metrics{};
GetTextMetricsW(dc,&metrics);
view.rowHeight=std::max(1L,metrics.tmHeight);
const auto frame=dialogFrameMetrics(dpi);
const int inset=frame.content();
const int listTop=dialogListInset(frame,view.rowHeight);
const auto measure=[&](std::wstring_view label)
{
SIZE size{};
GetTextExtentPoint32W(dc,label.data(),static_cast<int>(label.size()),&size);
return static_cast<int>(size.cx);
};
int width=std::max(measure(L"Folder shortcuts"),measure(view.digit<0?L"Enter Esc":quickFolderHints))+3*cellWidth;
const int textInset=dialogRowTextInset(cellWidth);
const auto add=[&](const std::wstring &label)
{
width=std::max(width,measure(label)+2*textInset);
SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
};
if(view.digit<0)
{
for(int digit=0;digit<10;++digit)
if(!folderShortcuts[digit].empty())
{
view.digits.push_back(digit);
add(L"Ctrl+"+std::to_wstring(digit));
}
}
else for(const auto &entry:folderShortcuts[view.digit])
add(entry.title.empty()?(entry.peerId.empty()?L"":entry.peerName+L" · ")+entry.path.native():entry.title);
SelectObject(dc,font);
ReleaseDC(dialog,dc);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor);
const auto &work=monitor.rcWork;
RECT parent{};
GetWindowRect(owner,&parent);
const auto windowWidth=std::max(px(260),static_cast<int>(parent.right-parent.left));
const auto windowHeight=std::max(px(140),static_cast<int>(parent.bottom-parent.top));
width=std::min(width,std::max(1,std::min(windowWidth,static_cast<int>(work.right-work.left))-2*inset));
const auto maxRows=std::max(1,(std::min(windowHeight,static_cast<int>(work.bottom-work.top))-2*listTop)/view.rowHeight);
const auto count=static_cast<int>(SendMessageW(list,LB_GETCOUNT,0,0));
const int rows=std::min(count,maxRows);
SendMessageW(list,LB_SETITEMHEIGHT,0,view.rowHeight);
const int listHeight=rows*view.rowHeight;
const int w=width+2*inset,h=listHeight+2*listTop;
const int x=std::clamp(static_cast<int>((parent.left+parent.right-w)/2),
static_cast<int>(work.left),std::max(static_cast<int>(work.left),static_cast<int>(work.right)-w));
const int y=std::clamp(static_cast<int>((parent.top+parent.bottom-h)/2),
static_cast<int>(work.top),std::max(static_cast<int>(work.top),static_cast<int>(work.bottom)-h));
SetWindowPos(dialog,nullptr,x,y,w,h,SWP_NOZORDER|SWP_NOACTIVATE);
SetWindowPos(list,nullptr,inset,listTop,width,listHeight,SWP_NOZORDER|SWP_NOACTIVATE);
SendMessageW(list,LB_SETCURSEL,std::clamp(selection,0,std::max(0,count-1)),0);
InvalidateRect(dialog,nullptr,TRUE);
}
LRESULT CALLBACK quickFolderKeys(HWND list,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
{
if(message==WM_KEYDOWN)
{
int command=0;
if(key==VK_RETURN) command=IDOK;
if(key==VK_DELETE) command=quickRemove;
if(key==VK_F4) command=quickEdit;
if(key==VK_INSERT) command=quickInsert;
if((key==VK_UP || key==VK_DOWN) && (GetKeyState(VK_CONTROL)&0x8000))
command=key==VK_UP?quickMoveUp:quickMoveDown;
if(command)
{
if(!(flags & (1LL<<30))) SendMessageW(GetParent(list),WM_COMMAND,command,0);
return 0;
}
}
return scrollList(list,message,key,flags);
}
INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
auto &view=*reinterpret_cast<FolderShortcutView*>(lp);
SetWindowLongPtrW(dialog,DWLP_USER,lp);
prepareCopyDialog(dialog);
layoutQuickFolders(dialog,view,0);
const auto list=GetDlgItem(dialog,shortcutList);
SetWindowSubclass(list,quickFolderKeys,1,0);
SetFocus(list);
return FALSE;
}
auto *view=reinterpret_cast<FolderShortcutView*>(GetWindowLongPtrW(dialog,DWLP_USER));
if(!view) return FALSE;
if(message==WM_DESTROY)
{
return FALSE;
}
if(message==WM_PAINT)
{
PAINTSTRUCT ps{};
const auto dc=BeginPaint(dialog,&ps);
RECT client{};
GetClientRect(dialog,&client);
const auto frame=CreateSolidBrush(colors[Text]);
const auto bounds=paintDialogFrame(dc,client,dialogFrameMetrics(GetDpiForWindow(dialog)),teal,frame);
DeleteObject(frame);
paintDialogFrameCaptions(dc,client,bounds,L"Folder shortcuts",teal,colors[Text],mono,
view->digit<0?L"Enter Esc":quickFolderHints);
EndPaint(dialog,&ps);
return TRUE;
}
if(message==WM_DRAWITEM && wp==shortcutList)
{
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
if(item.itemID==UINT(-1)) return TRUE;
const auto length=SendMessageW(item.hwndItem,LB_GETTEXTLEN,item.itemID,0);
if(length<0) return TRUE;
std::wstring label(static_cast<size_t>(length)+1,L'\0');
SendMessageW(item.hwndItem,LB_GETTEXT,item.itemID,reinterpret_cast<LPARAM>(label.data()));
const auto background=colors[item.itemState&ODS_SELECTED?Selection:Dialog];
paintDialogListRow(item.hDC,item.rcItem,std::wstring_view(label.data(),static_cast<size_t>(length)),
mono,colors[Text],background,dialogRowTextInset(cellWidth));
return TRUE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
finishFilesDialog(dialog,IDCANCEL);
return TRUE;
}
if(message!=WM_COMMAND) return FALSE;
const auto action=LOWORD(wp);
if(action!=quickRemove && action!=quickEdit && action!=quickInsert && action!=quickMoveUp &&
action!=quickMoveDown && action!=IDOK && !(action==shortcutList && HIWORD(wp)==LBN_DBLCLK)) return FALSE;
const auto row=SendDlgItemMessageW(dialog,shortcutList,LB_GETCURSEL,0,0);
if(view->digit<0)
{
if((action==IDOK || action==shortcutList) && row>=0 && static_cast<size_t>(row)<view->digits.size())
{
view->digit=view->digits[row];
layoutQuickFolders(dialog,*view,0);
}
return TRUE;
}
const auto &group=folderShortcuts[view->digit];
if(action==quickInsert)
{
const auto &panel=*view->panel;
FolderShortcutEdit edit{{panel.directory,panel.peer?panel.peer->id:"",
panel.peer?panel.peer->name:L""},view->digit,{}};
if(!panel.directory.empty() && editFolderShortcut(dialog,edit))
layoutQuickFolders(dialog,*view,static_cast<int>(group.size())-1);
SetFocus(GetDlgItem(dialog,shortcutList));
return TRUE;
}
if(row<0 || static_cast<size_t>(row)>=group.size()) return TRUE;
if(action==quickMoveUp || action==quickMoveDown)
{
const auto target=row+(action==quickMoveUp?-1:1);
if(target<0 || static_cast<size_t>(target)>=group.size()) return TRUE;
auto updated=folderShortcuts;
std::swap(updated[view->digit][row],updated[view->digit][target]);
if(!saveFolderShortcuts(updated))
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
else
{
folderShortcuts=std::move(updated);
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
layoutQuickFolders(dialog,*view,static_cast<int>(target));
}
return TRUE;
}
if(action==quickEdit)
{
FolderShortcutEdit edit{group[row],view->digit,static_cast<size_t>(row)};
if(editFolderShortcut(dialog,edit)) layoutQuickFolders(dialog,*view,static_cast<int>(row));
SetFocus(GetDlgItem(dialog,shortcutList));
return TRUE;
}
if(action==quickRemove)
{
auto updated=folderShortcuts;
updated[view->digit].erase(updated[view->digit].begin()+row);
if(!saveFolderShortcuts(updated))
{
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
return TRUE;
}
folderShortcuts=std::move(updated);
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
if(folderShortcuts[view->digit].empty()) finishFilesDialog(dialog,IDCANCEL);
else layoutQuickFolders(dialog,*view,static_cast<int>(row));
return TRUE;
}
view->chosen=group[row];
finishFilesDialog(dialog,IDOK);
return TRUE;
}
void showFolderShortcuts(Panel &panel,int digit)
{
if(digit>=0)
{
const auto &group=folderShortcuts[digit];
if(group.empty())
{
setStatus(L"No folders assigned to Ctrl+"+std::to_wstring(digit)+L".");
return;
}
if(group.size()==1)
{
openFolderShortcut(panel,group.front());
return;
}
}
if(digit<0 && std::all_of(folderShortcuts.begin(),folderShortcuts.end(),[](const auto &group){return group.empty();}))
{
setStatus(L"No folder shortcuts.");
return;
}
FolderShortcutView view{digit};
view.panel=&panel;
const auto result=runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(109),GetParent(panel.list),
quickFolderDialog,reinterpret_cast<LPARAM>(&view));
SetFocus(panel.list);
if(result==IDOK && view.chosen) openFolderShortcut(panel,*view.chosen);
else if(result==-1) setStatus(L"Could not open Folder shortcuts.");
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include <windows.h>
inline constexpr ULONG_PTR FilesLanPeers = 0x4b53010d;
inline constexpr ULONG_PTR FilesLanPeersReply = 0x4b53010e;
inline constexpr DWORD FilesLanPeersLimit = 64 * 1024;
+4163
View File
File diff suppressed because it is too large. Load diff
+660
View File
@@ -0,0 +1,660 @@
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286;
constexpr int allOptions=4;
enum OptionsPage { PanelModes, PanelColors, Appearance, CommandLine };
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance",L"Command line"};
constexpr const wchar_t *colorNames[]{L"Panel background",L"Text",L"Frame",L"Popup background",
L"Menu selection",L"Command bar",L"File cursor"};
enum PanelSection { PanelLayout, WideColumns, StatusColumns };
constexpr const wchar_t *panelSectionNames[]{L"Panel layout",L"Wide panel columns",L"Status line columns"};
constexpr int panelLayoutControls[]{273,274};
constexpr int wideColumnControls[]{275,276,201,202,204};
constexpr int statusColumnControls[]{277,203,205,206,207,208};
constexpr int colorOptionControls[]{279,280};
constexpr int appearanceControls[]{281,282};
enum AppearanceSection { Font, DialogFrame };
constexpr int outerFramePadding=303,innerFramePadding=305,rowTextPadding=307;
constexpr int dialogFrameControls[]{302,outerFramePadding,304,innerFramePadding,306,rowTextPadding};
constexpr struct
{
int id;
DWORD DialogFramePadding::*value;
DWORD maximum;
} frameOptions[]{
{outerFramePadding,&DialogFramePadding::outer,maxDialogFramePadding},
{innerFramePadding,&DialogFramePadding::inner,maxDialogFramePadding},
{rowTextPadding,&DialogFramePadding::rowText,maxDialogRowTextPadding}};
enum class OptionsAnchor { StretchWidth, StretchHeight, BottomRight };
struct OptionsControl
{
int id;
OptionsAnchor anchor;
RECT bounds{};
};
struct PanelProperty
{
int section, control;
const wchar_t *name;
DWORD defaultValue;
};
constexpr PanelProperty panelProperties[]{
{PanelLayout,273,L"Left panel: Wide",defaultWide},{PanelLayout,274,L"Right panel: Wide",defaultWide},
{WideColumns,201,L"Size column width",defaultSizeWidth},
{WideColumns,202,L"Align file extensions",defaultAlignExtensions},
{WideColumns,204,L"Align folder extensions",defaultAlignFolders},
{StatusColumns,203,L"Show details",defaultShowDetails},
{StatusColumns,205,L"Name",defaultStatusFields&1},{StatusColumns,206,L"Size",(defaultStatusFields>>1)&1},
{StatusColumns,207,L"Date",(defaultStatusFields>>2)&1},{StatusColumns,208,L"Time",(defaultStatusFields>>3)&1}};
void resetPanelProperty(HWND dialog,const PanelProperty &property)
{
if(property.control==201) SetDlgItemInt(dialog,property.control,property.defaultValue,FALSE);
else CheckDlgButton(dialog,property.control,property.defaultValue);
}
struct OptionsBaseline
{
LOGFONTW font=displayFont;
int points=fontPoints;
DialogFramePadding padding=dialogFramePadding;
std::array<COLORREF,ColorCount> palette=colors;
std::vector<FileStyle> styles=fileStyles;
std::array<WORD,2> keys=commandHistoryKeys;
DWORD width=static_cast<DWORD>(sizeWidth), fields=statusFields;
bool extensions=alignExtensions, folders=alignFolders, details=showDetails;
std::array<bool,2> wide{panels[0].wide,panels[1].wide};
};
struct OptionsDraft
{
OptionsBaseline baseline;
LOGFONTW font=displayFont;
int points=fontPoints;
DialogFramePadding framePadding=dialogFramePadding;
std::array<COLORREF,ColorCount> palette{};
std::array<COLORREF,16> custom{};
std::vector<FileStyle> styles=fileStyles;
int styleIndex=0;
bool styleSuggestionsLoaded{};
int page=PanelModes, section=PanelLayout;
HTREEITEM wideColumnsNode{};
RECT initialClient{};
UINT initialDpi=96;
OptionsControl controls[8]{
{optionsTree,OptionsAnchor::StretchHeight},
{optionsTitle,OptionsAnchor::StretchWidth},
{275,OptionsAnchor::StretchWidth},{277,OptionsAnchor::StretchWidth},
{281,OptionsAnchor::StretchWidth},
{IDOK,OptionsAnchor::BottomRight},{IDCANCEL,OptionsAnchor::BottomRight},
{optionsApply,OptionsAnchor::BottomRight}};
};
#include "files_style_options.inl"
#include "files_options_preview.inl"
bool optionsFontChanged(const OptionsDraft &draft)
{
auto pending=draft.font, applied=draft.baseline.font;
// Height follows the selected point size and DPI. applyFont always uses natural width.
pending.lfHeight=applied.lfHeight=0;
pending.lfWidth=applied.lfWidth=0;
return draft.points!=draft.baseline.points ||
memcmp(&pending,&applied,offsetof(LOGFONTW,lfFaceName))!=0 ||
_wcsicmp(pending.lfFaceName,applied.lfFaceName)!=0;
}
struct OptionsWriteLock
{
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Files.OptionsWrite");
DWORD result=mutex?WaitForSingleObject(mutex,2000):WAIT_FAILED;
bool acquired() const { return result==WAIT_OBJECT_0 || result==WAIT_ABANDONED; }
~OptionsWriteLock()
{
if(acquired()) ReleaseMutex(mutex);
if(mutex) CloseHandle(mutex);
}
};
void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
{
BOOL valid{};
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
DWORD fields=0;
for(int i=0;i<4;++i) if(checked(205+i)) fields|=1u<<i;
const auto &base=draft.baseline;
const bool dirty=!valid || width!=base.width ||
checked(273)!=base.wide[0] || checked(274)!=base.wide[1] ||
checked(202)!=base.extensions || checked(204)!=base.folders || checked(203)!=base.details ||
fields!=base.fields || draft.palette!=base.palette || draft.styles!=base.styles ||
draft.framePadding!=base.padding || optionsFontChanged(draft) ||
optionHistoryKeys(dialog)!=base.keys;
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
}
LRESULT CALLBACK optionsGroupBackground(HWND group,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(message==WM_ERASEBKGND)
{
const auto dc=reinterpret_cast<HDC>(wp);
RECT client{};
GetClientRect(group,&client);
FillRect(dc,&client,GetSysColorBrush(COLOR_3DFACE));
return TRUE;
}
return DefSubclassProc(group,message,wp,lp);
}
void layoutOptionsPreview(HWND dialog)
{
const auto preview=GetDlgItem(dialog,fileStylePreview);
if(!(GetWindowLongPtrW(preview,GWL_STYLE)&WS_VISIBLE)) return;
RECT gap{0,0,6,5};
MapDialogRect(dialog,&gap);
const auto title=dialogControlBounds(dialog,optionsTitle);
const auto buttons=dialogControlBounds(dialog,IDOK);
LONG top=title.bottom;
for(auto child=GetWindow(dialog,GW_CHILD);child;child=GetWindow(child,GW_HWNDNEXT))
{
const auto id=GetDlgCtrlID(child);
if(child==preview || id==IDOK || id==IDCANCEL || id==optionsApply ||
!(GetWindowLongPtrW(child,GWL_STYLE)&WS_VISIBLE)) continue;
const auto bounds=dialogControlBounds(dialog,id);
if(id!=-1 && bounds.left>=title.left-4) top=std::max(top,bounds.bottom);
}
top+=gap.bottom;
SetWindowPos(preview,nullptr,title.left,top,title.right-title.left,
std::max(1L,buttons.top-gap.bottom-top),SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
}
void layoutOptions(HWND dialog,const OptionsDraft &draft)
{
const auto dpi=GetDpiForWindow(dialog);
const auto scale=[&](LONG value){return MulDiv(value,dpi,draft.initialDpi);};
RECT client{};
GetClientRect(dialog,&client);
const auto dx=std::max(0L,client.right-scale(draft.initialClient.right));
const auto dy=std::max(0L,client.bottom-scale(draft.initialClient.bottom));
for(const auto &control:draft.controls)
{
const auto &bounds=control.bounds;
auto x=scale(bounds.left),y=scale(bounds.top);
auto width=scale(bounds.right)-x,height=scale(bounds.bottom)-y;
switch(control.anchor)
{
case OptionsAnchor::StretchWidth: width+=dx;break;
case OptionsAnchor::StretchHeight: height+=dy;break;
case OptionsAnchor::BottomRight: x+=dx;y+=dy;break;
}
SetWindowPos(GetDlgItem(dialog,control.id),nullptr,x,y,width,height,
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
}
if(draft.page==PanelColors && draft.section==fileStyleSection) layoutFileStyleFields(dialog,false);
layoutOptionsPreview(dialog);
// Paint the final layout together, including the areas vacated by moved controls.
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
}
void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
{
draft.page=page;
draft.section=section;
const wchar_t *title=L"No matching settings";
if(page==allOptions) title=L"All settings";
else if(page>=0)
{
title=optionsTitles[page];
if(section>=0)
title=page==PanelModes?panelSectionNames[section]:page==PanelColors?
(section==fileStyleSection?L"File type styles":colorNames[section]):
page==CommandLine?L"Command history":section==DialogFrame?L"Dialog frame":L"Font";
}
SetDlgItemTextW(dialog,optionsTitle,title);
const auto show=[&](auto &controls,bool visible)
{
for(const auto id:controls) ShowWindow(GetDlgItem(dialog,id),visible?SW_SHOW:SW_HIDE);
};
EnableWindow(GetDlgItem(dialog,optionsReset),page>=0);
const bool individual=page==PanelModes && section>=0;
ShowWindow(GetDlgItem(dialog,optionsProperty),individual?SW_SHOW:SW_HIDE);
ShowWindow(GetDlgItem(dialog,optionsResetProperty),individual?SW_SHOW:SW_HIDE);
if(individual)
{
SendDlgItemMessageW(dialog,optionsProperty,CB_RESETCONTENT,0,0);
for(size_t i=0;i<std::size(panelProperties);++i)
{
const auto &property=panelProperties[i];
if(property.section!=section) continue;
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_ADDSTRING,0,
reinterpret_cast<LPARAM>(property.name));
if(row>=0) SendDlgItemMessageW(dialog,optionsProperty,CB_SETITEMDATA,row,i);
}
SendDlgItemMessageW(dialog,optionsProperty,CB_SETCURSEL,0,0);
}
show(panelLayoutControls,page==PanelModes && section==PanelLayout);
show(wideColumnControls,page==PanelModes && section==WideColumns);
show(statusColumnControls,page==PanelModes && section==StatusColumns);
show(appearanceControls,page==Appearance && section==Font);
show(dialogFrameControls,page==Appearance && section==DialogFrame);
if(page==Appearance && section==DialogFrame)
{
for(const auto &option:frameOptions)
SendDlgItemMessageW(dialog,option.id,CB_SETCURSEL,draft.framePadding.*option.value,0);
}
constexpr int commandLineControls[]{320,321,322,323};
show(commandLineControls,page==CommandLine && section==0);
const bool fileTypes=page==PanelColors && section==fileStyleSection;
constexpr int fileStyleControls[]{287,288,289,290,291,292,293,294,295,296,297,298,300,fileStyleState};
show(fileStyleControls,fileTypes);
const auto preview=GetDlgItem(dialog,fileStylePreview);
ShowWindow(preview,page>=0 && page<CommandLine && section>=0?SW_SHOW:SW_HIDE);
layoutOptionsPreview(dialog);
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
show(colorOptionControls,page==PanelColors && section>=0 && !fileTypes);
if(fileTypes)
{
if(!draft.styleSuggestionsLoaded)
{
fillFileStyleSuggestions(dialog);
draft.styleSuggestionsLoaded=true;
}
updateFileStyleEditor(dialog,draft,true);
}
if(page==PanelColors && section>=0 && !fileTypes)
{
const auto color=draft.palette[section];
wchar_t hex[8]{};
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(color)),unsigned(GetGValue(color)),unsigned(GetBValue(color)));
SetDlgItemTextW(dialog,280,hex);
}
if(page==Appearance)
{
const auto description=std::wstring(draft.font.lfFaceName)+L" · "+std::to_wstring(draft.points/10)+
(draft.points%10?L"."+std::to_wstring(draft.points%10):L"")+L" pt";
SetDlgItemTextW(dialog,281,description.c_str());
}
}
void filterOptions(HWND dialog,OptionsDraft &draft)
{
wchar_t text[256]{};
GetDlgItemTextW(dialog,optionsSearch,text,std::size(text));
std::wstring query=text;
if(!query.empty()) CharLowerBuffW(query.data(),static_cast<DWORD>(query.size()));
const auto matches=[&](const wchar_t *label)
{
std::wstring lower=label;
CharLowerBuffW(lower.data(),static_cast<DWORD>(lower.size()));
return lower.find(query)!=std::wstring::npos;
};
const HWND tree=GetDlgItem(dialog,optionsTree);
const auto add=[&](const wchar_t *label,int page,int option,HTREEITEM parent)
{
TVINSERTSTRUCTW insert{};
insert.hParent=parent;
insert.hInsertAfter=TVI_LAST;
insert.item.mask=TVIF_TEXT|TVIF_PARAM;
insert.item.pszText=const_cast<wchar_t*>(label);
insert.item.lParam=(page<<8)|(option+1);
return TreeView_InsertItem(tree,&insert);
};
const int previousPage=draft.page,previousSection=draft.section;
TreeView_DeleteAllItems(tree);
draft.wideColumnsNode=nullptr;
const auto root=add(L"All settings",allOptions,-1,TVI_ROOT);
HTREEITEM first{},selected{};
for(int page=PanelModes;page<=CommandLine;++page)
{
HTREEITEM category{};
const bool wholeCategory=matches(optionsTitles[page]) || (!query.empty() && matches(L"All settings"));
const auto addSection=[&](const wchar_t *label,int section,bool aliasMatches=false)
{
if(!wholeCategory && !aliasMatches && !matches(label)) return;
if(!category) category=add(optionsTitles[page],page,-1,root);
const auto node=add(label,page,section,category);
if(!first) first=node;
if(page==previousPage && section==previousSection) selected=node;
if(page==PanelModes && section==WideColumns) draft.wideColumnsNode=node;
};
if(page==PanelModes)
{
for(int section=PanelLayout;section<=StatusColumns;++section)
{
const bool propertyMatches=std::any_of(std::begin(panelProperties),std::end(panelProperties),
[&](const auto &property){return property.section==section && matches(property.name);});
addSection(panelSectionNames[section],section,propertyMatches);
}
}
else if(page==PanelColors)
{
for(int color=0;color<ColorCount;++color) addSection(colorNames[color],color);
const bool extensionMatches=std::any_of(draft.styles.begin(),draft.styles.end(),
[&](const auto &style){return matches(style.extension.c_str());});
addSection(L"File type styles",fileStyleSection,extensionMatches || matches(L"System and hidden") ||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
}
else if(page==CommandLine) addSection(L"Command history",0,matches(L"Previous command"));
else
{
addSection(L"Font",Font,matches(L"Display font"));
addSection(L"Dialog frame",DialogFrame,
matches(L"Outer padding") || matches(L"Inner padding") || matches(L"Row text padding"));
}
if(category) TreeView_Expand(tree,category,TVE_EXPAND);
}
TreeView_Expand(tree,root,TVE_EXPAND);
TreeView_SelectItem(tree,query.empty() && previousPage==allOptions?root:selected?selected:first?first:root);
if(!first) SetDlgItemTextW(dialog,optionsTitle,L"No matching settings");
}
INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
auto &draft=*reinterpret_cast<OptionsDraft*>(lp);
SetWindowLongPtrW(dialog,DWLP_USER,lp);
draft.initialDpi=GetDpiForWindow(dialog);
GetClientRect(dialog,&draft.initialClient);
for(auto &control:draft.controls) control.bounds=dialogControlBounds(dialog,control.id);
// Group boxes are behind their sibling controls. Erase their full client
// area on resize while clipping out the controls inside the frame.
for(const auto id:{275,277})
{
const auto group=GetDlgItem(dialog,id);
SetWindowLongPtrW(group,GWL_STYLE,GetWindowLongPtrW(group,GWL_STYLE)|WS_CLIPSIBLINGS);
SetWindowPos(group,HWND_BOTTOM,0,0,0,0,SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE);
SetWindowSubclass(group,optionsGroupBackground,1,0);
}
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
SendDlgItemMessageW(dialog,201,EM_SETLIMITTEXT,2,0);
SendDlgItemMessageW(dialog,optionsSearch,EM_SETLIMITTEXT,255,0);
for(const auto &option:frameOptions)
for(DWORD value=0;value<=option.maximum;++value)
SendDlgItemMessageW(dialog,option.id,CB_ADDSTRING,0,
reinterpret_cast<LPARAM>(std::to_wstring(value).c_str()));
CheckDlgButton(dialog,202,alignExtensions);
CheckDlgButton(dialog,203,showDetails);
CheckDlgButton(dialog,204,alignFolders);
CheckDlgButton(dialog,273,panels[0].wide);
CheckDlgButton(dialog,274,panels[1].wide);
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
setOptionHistoryKeys(dialog,commandHistoryKeys);
filterOptions(dialog,draft);
updateOptionsApply(dialog,draft);
return TRUE;
}
auto *draft=reinterpret_cast<OptionsDraft*>(GetWindowLongPtrW(dialog,DWLP_USER));
if(!draft) return FALSE;
if(message==WM_DRAWITEM && wp==fileStylePreview)
{
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
if(draft->page==PanelColors && draft->section==fileStyleSection) paintFileStylePreview(item,*draft);
else paintOptionsPreview(dialog,item,*draft);
return TRUE;
}
if(message==WM_GETMINMAXINFO)
{
const auto dpi=GetDpiForWindow(dialog);
RECT minimum{0,0,MulDiv(draft->initialClient.right,dpi,draft->initialDpi),
MulDiv(draft->initialClient.bottom,dpi,draft->initialDpi)};
AdjustWindowRectExForDpi(&minimum,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_STYLE)),
FALSE,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_EXSTYLE)),dpi);
auto &limits=*reinterpret_cast<MINMAXINFO*>(lp);
limits.ptMinTrackSize.x=minimum.right-minimum.left;
limits.ptMinTrackSize.y=minimum.bottom-minimum.top;
return TRUE;
}
if(message==WM_SIZE && wp!=SIZE_MINIMIZED)
{
layoutOptions(dialog,*draft);
return TRUE;
}
if(message==WM_NOTIFY && wp==optionsTree)
{
const auto &change=*reinterpret_cast<const NMTREEVIEWW*>(lp);
if(change.hdr.code==TVN_SELCHANGEDW && change.itemNew.hItem)
showOptionsPage(dialog,*draft,static_cast<int>(change.itemNew.lParam>>8),static_cast<int>(change.itemNew.lParam&255)-1);
return FALSE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
finishFilesDialog(dialog,IDCANCEL);
return TRUE;
}
if(message!=WM_COMMAND) return FALSE;
switch(LOWORD(wp))
{
case optionsSearch:
if(HIWORD(wp)==EN_CHANGE) filterOptions(dialog,*draft);
return TRUE;
case 279:
if(draft->page==PanelColors && draft->section>=0 && draft->section<ColorCount)
{
choosePanelColor(dialog,draft->palette[draft->section],draft->custom.data());
showOptionsPage(dialog,*draft,PanelColors,draft->section);
updateOptionsApply(dialog,*draft);
}
return TRUE;
case 282:
chooseDisplayFont(dialog,draft->font,draft->points);
showOptionsPage(dialog,*draft,Appearance,Font);
updateOptionsApply(dialog,*draft);
return TRUE;
case outerFramePadding: case innerFramePadding: case rowTextPadding:
if(HIWORD(wp)==CBN_SELCHANGE)
{
const auto value=SendMessageW(reinterpret_cast<HWND>(lp),CB_GETCURSEL,0,0);
if(value>=0)
for(const auto &option:frameOptions)
if(option.id==LOWORD(wp)) draft->framePadding.*option.value=static_cast<DWORD>(value);
updateOptionsApply(dialog,*draft);
}
return TRUE;
case fileStyleState:
if(HIWORD(wp)==CBN_SELCHANGE) updateFileStyleEditor(dialog,*draft,false);
return TRUE;
case fileStyleRules:
if(HIWORD(wp)==CBN_SELCHANGE)
{
draft->styleIndex=static_cast<int>(SendDlgItemMessageW(dialog,fileStyleRules,CB_GETCURSEL,0,0));
updateFileStyleEditor(dialog,*draft,false);
}
return TRUE;
case 292:
if(addFileStyle(dialog,*draft)) updateOptionsApply(dialog,*draft);
return TRUE;
case 289: case 293: case 294: case 295: case 297: case 298:
if(draft->styleIndex>=0)
{
auto &style=draft->styles[draft->styleIndex];
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
auto &foreground=state>0?style.states[state-1].foreground:style.foreground;
auto &background=state>0?style.states[state-1].background:style.background;
switch(LOWORD(wp))
{
case 289:
draft->styles.erase(draft->styles.begin()+draft->styleIndex);
break;
case 293: case 294:
{
auto &field=LOWORD(wp)==293?foreground:background;
auto color=field.value_or(draft->palette[LOWORD(wp)==293?Text:PanelBlue]);
if(choosePanelColor(dialog,color,draft->custom.data())) field=color;
break;
}
case 295: foreground.reset();background.reset();break;
case 297: style.underline=IsDlgButtonChecked(dialog,297)==BST_CHECKED;break;
case 298: style.strikeout=IsDlgButtonChecked(dialog,298)==BST_CHECKED;break;
}
updateFileStyleEditor(dialog,*draft,LOWORD(wp)==289);
updateOptionsApply(dialog,*draft);
}
return TRUE;
case optionsReset:
if(draft->page==allOptions || draft->page==CommandLine) setOptionHistoryKeys(dialog,defaultHistoryKeys);
if(draft->page==allOptions || draft->page==PanelModes)
for(const auto &property:panelProperties)
if(draft->section<0 || property.section==draft->section) resetPanelProperty(dialog,property);
if(draft->page==allOptions || (draft->page==PanelColors && draft->section<0))
draft->palette=defaultColors;
else if(draft->page==PanelColors && draft->section< ColorCount)
draft->palette[draft->section]=defaultColors[draft->section];
if(draft->page==allOptions || (draft->page==PanelColors &&
(draft->section<0 || draft->section==fileStyleSection)))
draft->styles=defaultFileStyles;
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=DialogFrame))
{
draft->font=defaultFilesFont();
draft->points=defaultFontPoints;
}
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=Font))
draft->framePadding=defaultDialogFramePadding;
showOptionsPage(dialog,*draft,draft->page,draft->section);
updateOptionsApply(dialog,*draft);
return TRUE;
case optionsResetProperty:
{
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_GETCURSEL,0,0);
if(row>=0)
{
const auto property=SendDlgItemMessageW(dialog,optionsProperty,CB_GETITEMDATA,row,0);
if(property>=0 && static_cast<size_t>(property)<std::size(panelProperties))
resetPanelProperty(dialog,panelProperties[property]);
updateOptionsApply(dialog,*draft);
}
return TRUE;
}
case 201:
case 202: case 203: case 204: case 205: case 206: case 207: case 208:
case 273: case 274:
updateOptionsApply(dialog,*draft);
return TRUE;
case 321: case 323:
updateOptionsApply(dialog,*draft);
return TRUE;
case optionsApply:
case IDOK:
{
BOOL valid{};
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
if(!valid || width<6 || width>16)
{
SetDlgItemTextW(dialog,optionsSearch,L"");
TreeView_SelectItem(GetDlgItem(dialog,optionsTree),draft->wideColumnsNode);
MessageBoxW(dialog,L"Enter a width from 6 to 16 characters.",optionsTitles[PanelModes],MB_OK|MB_ICONWARNING);
SetFocus(GetDlgItem(dialog,201));
SendDlgItemMessageW(dialog,201,EM_SETSEL,0,-1);
return TRUE;
}
const auto owner=GetWindow(dialog,GW_OWNER);
const OptionsWriteLock writeLock;
if(!writeLock.acquired())
{
setStatus(L"Another window is saving Files settings. Try Apply again.");
return TRUE;
}
const auto &base=draft->baseline;
auto keys=optionHistoryKeys(dialog);
loadCommandHistoryKeys();
for(size_t i=0;i<keys.size();++i)
if(keys[i]==base.keys[i]) keys[i]=commandHistoryKeys[i];
if(!validHistoryKeys(keys))
{
MessageBoxW(dialog,L"Choose distinct shortcuts using Ctrl or Alt that are free in Files.",L"Command history",MB_OK|MB_ICONWARNING);
return TRUE;
}
const bool stylesChanged=draft->styles!=base.styles;
loadFileStyles();
if(stylesChanged && fileStyles!=base.styles && fileStyles!=draft->styles)
{
MessageBoxW(dialog,L"File type styles changed in another window. Reopen Options before editing them again.",
L"KSwitcher Files Options",MB_OK|MB_ICONWARNING);
return TRUE;
}
if(stylesChanged && !saveFileStyles(draft->styles))
{
MessageBoxW(dialog,L"Could not save the file type styles.",L"KSwitcher Files Options",MB_OK|MB_ICONWARNING);
return TRUE;
}
const bool fontChanged=optionsFontChanged(*draft);
if(fontChanged && !applyFont(owner,draft->font,draft->points))
{
if(stylesChanged) saveFileStyles(fileStyles);
MessageBoxW(dialog,L"Could not apply the selected font.",optionsTitles[Appearance],MB_OK|MB_ICONWARNING);
return TRUE;
}
const auto saveChanged=[](const wchar_t *name,DWORD value,DWORD initial)
{
if(value!=initial) savePreference(name,value);
};
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
const auto inputKeys=optionHistoryKeys(dialog);
saveChanged(L"PreviousCommandKey",inputKeys[0],base.keys[0]);
saveChanged(L"CommandHistoryKey",inputKeys[1],base.keys[1]);
saveChanged(L"SizeWidth",width,base.width);
saveChanged(L"AlignExtensions",checked(202),base.extensions);
saveChanged(L"AlignFolders",checked(204),base.folders);
saveChanged(L"ShowDetails",checked(203),base.details);
DWORD fields=preference(L"StatusFields",defaultStatusFields)&15;
const auto initialFields=fields;
for(int i=0;i<4;++i)
{
const DWORD bit=1u<<i;
if(checked(205+i)!=((base.fields&bit)!=0)) fields=checked(205+i)?fields|bit:fields&~bit;
}
saveChanged(L"StatusFields",fields,initialFields);
for(int i=0;i<ColorCount;++i)
saveChanged(std::to_wstring(i).c_str(),draft->palette[i],base.palette[i]);
saveChanged(dialogFrameOuterKey,draft->framePadding.outer,base.padding.outer);
saveChanged(dialogFrameInnerKey,draft->framePadding.inner,base.padding.inner);
saveChanged(dialogRowTextKey,draft->framePadding.rowText,base.padding.rowText);
if(fontChanged)
{
savePreference(L"FontPoints",fontPoints);
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"DisplayFont",REG_BINARY,&displayFont,sizeof(displayFont));
}
saveChanged(L"LeftWide",checked(273),base.wide[0]);
saveChanged(L"RightWide",checked(274),base.wide[1]);
loadFilesPreferences(owner);
notifyFilesPreferences(owner);
draft->baseline=OptionsBaseline{};
draft->font=displayFont;
draft->points=fontPoints;
draft->styles=fileStyles;
draft->palette=colors;
draft->framePadding=dialogFramePadding;
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
CheckDlgButton(dialog,202,alignExtensions);
CheckDlgButton(dialog,203,showDetails);
CheckDlgButton(dialog,204,alignFolders);
CheckDlgButton(dialog,273,panels[0].wide);
CheckDlgButton(dialog,274,panels[1].wide);
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
setOptionHistoryKeys(dialog,commandHistoryKeys);
if(draft->page==PanelColors && draft->section==fileStyleSection)
updateFileStyleEditor(dialog,*draft,true);
showOptionsPage(dialog,*draft,draft->page,draft->section);
layout(owner);
showFileDetails(panels[active]);
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
if(LOWORD(wp)==IDOK) finishFilesDialog(dialog,IDOK);
else updateOptionsApply(dialog,*draft);
return TRUE;
}
}
return FALSE;
}
void editOptions(HWND owner)
{
const auto panel=panels[active].list;
OptionsDraft draft;
std::copy(std::begin(colors),std::end(colors),draft.palette.begin());
const auto result=runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
reinterpret_cast<LPARAM>(&draft));
SetFocus(panel);
if(result==-1) setStatus(L"Could not open Files Options.");
}
+169
View File
@@ -0,0 +1,169 @@
void paintOptionsPreview(HWND dialog,const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
{
const auto dc=item.hDC;
const auto saved=SaveDC(dc);
if(!saved) return;
IntersectClipRect(dc,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
const auto dpi=GetDpiForWindow(item.hwndItem);
const auto px=[&](int value){return MulDiv(value,dpi,96);};
auto font=draft.font;
font.lfHeight=-MulDiv(draft.points,dpi,720);
font.lfWidth=0;
const auto previewFont=CreateFontIndirectW(&font);
SelectObject(dc,previewFont?previewFont:mono);
TEXTMETRICW metrics{};
GetTextMetricsW(dc,&metrics);
const auto rowHeight=std::max<LONG>(px(18),metrics.tmHeight+px(4));
const auto &palette=draft.palette;
const auto fill=[&](RECT bounds,COLORREF color)
{
SetBkColor(dc,color);
ExtTextOutW(dc,0,0,ETO_OPAQUE|ETO_CLIPPED,&bounds,nullptr,0,nullptr);
};
const auto row=[&](RECT bounds,std::wstring_view label,COLORREF fg,COLORREF bg,bool right=false)
{
fill(bounds,bg);
bounds.left+=px(5);
bounds.right-=px(5);
SetBkMode(dc,TRANSPARENT);
SetTextColor(dc,fg);
DrawTextW(dc,label.data(),static_cast<int>(label.size()),&bounds,
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS|(right?DT_RIGHT:DT_LEFT));
};
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
auto bounds=item.rcItem;
fill(bounds,palette[PanelBlue]);
const auto frame=[&](RECT rect)
{
SetDCBrushColor(dc,palette[Frame]);
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
InflateRect(&rect,-px(2),-px(2));
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
};
const auto panel=[&](RECT panelBounds,bool wide,bool cursor,std::wstring_view path)
{
frame(panelBounds);
InflateRect(&panelBounds,-px(4),-px(4));
auto line=panelBounds;
line.bottom=line.top+rowHeight;
row(line,path,palette[Frame],palette[PanelBlue]);
OffsetRect(&line,0,rowHeight);
BOOL valid{};
const auto input=GetDlgItemInt(dialog,201,&valid,FALSE);
const auto columns=valid?std::clamp(input,6u,16u):static_cast<UINT>(sizeWidth);
const auto entry=[&](std::wstring_view stem,std::wstring_view ext,bool folder,bool highlight)
{
const auto bg=highlight?palette[PanelCursor]:palette[PanelBlue];
auto name=line;
if(wide)
{
name.right=std::max(name.left+px(20),name.right-static_cast<LONG>(columns+2)*metrics.tmAveCharWidth);
name.right=std::min(name.right,line.right);
auto amount=line;
amount.left=name.right;
row(amount,folder?L"Folder":formatSize(48382,columns),palette[Text],bg,true);
fill({amount.left,amount.top,amount.left+px(1),amount.bottom},palette[Frame]);
}
if(wide && checked(folder?204:202) && name.right-name.left>px(70))
{
auto extension=name;
extension.left=extension.right-std::min<LONG>(px(60),(extension.right-extension.left)/2);
name.right=extension.left;
row(extension,ext,palette[Text],bg,true);
row(name,stem,palette[Text],bg);
}
else row(name,std::wstring(stem)+L"."+std::wstring(ext),palette[Text],bg);
OffsetRect(&line,0,rowHeight);
};
entry(L"report",L"txt",false,cursor);
entry(L"examples",L"old",true,false);
};
if(draft.page==Appearance && draft.section==DialogFrame)
{
InflateRect(&bounds,-px(8),-px(8));
const auto framePadding=dialogFrameMetrics(dpi,draft.framePadding);
const auto surface=CreateSolidBrush(palette[Dialog]);
const auto ink=CreateSolidBrush(palette[Text]);
const auto frame=paintDialogFrame(dc,bounds,framePadding,surface,ink);
const auto titleFont=previewFont?previewFont:mono;
paintDialogFrameCaptions(dc,bounds,frame,L"Folder shortcuts",surface,palette[Text],titleFont);
DeleteObject(ink);
DeleteObject(surface);
auto content=bounds;
InflateRect(&content,-framePadding.content(),-dialogListInset(framePadding,metrics.tmHeight));
content.bottom=std::min(content.bottom,content.top+rowHeight);
if(content.bottom>content.top)
paintDialogListRow(dc,content,LR"(C:\Projects\Example\Documents\Archive)",titleFont,
palette[Text],palette[Selection],dialogRowTextInset(metrics.tmAveCharWidth,draft.framePadding));
}
else if(draft.page==PanelColors && (draft.section==Dialog || draft.section==Selection))
{
InflateRect(&bounds,-px(12),-px(10));
const auto maxWidth=px(340);
bounds.right=std::min(bounds.right,bounds.left+maxWidth);
fill(bounds,palette[Dialog]);
frame(bounds);
InflateRect(&bounds,-px(5),-px(5));
auto line=bounds;
line.bottom=line.top+rowHeight;
row(line,L"Change drive",palette[Text],palette[Dialog]);
OffsetRect(&line,0,rowHeight);
row(line,L"C: fixed",palette[Text],palette[Selection]);
OffsetRect(&line,0,rowHeight);
row(line,L"D: fixed",palette[Text],palette[Dialog]);
}
else if(draft.page==PanelColors && draft.section==Footer)
{
auto bar=bounds;
bar.top=bar.bottom-rowHeight;
row(bar,L"F2 Refresh F3 View F5 Copy F6 Move",palette[Text],palette[Footer]);
bounds.bottom=bar.top;
panel(bounds,false,false,L"C:\\Example");
}
else if(draft.page==PanelModes && draft.section==PanelLayout)
{
auto left=bounds,right=bounds;
left.right=(bounds.left+bounds.right)/2;
right.left=left.right+px(4);
panel(left,checked(273),true,L"C:\\Example");
panel(right,checked(274),false,L"D:\\Example");
}
else if(draft.page==PanelModes && draft.section==StatusColumns)
{
auto statusRow=bounds;
statusRow.top=statusRow.bottom-rowHeight;
bounds.bottom=statusRow.top;
panel(bounds,true,true,L"C:\\Example");
std::wstring label;
if(checked(203))
{
constexpr const wchar_t *values[]{L"report.txt",L"47 KiB",L"2026-11-14",L"10:30"};
for(int i=0;i<4;++i)
if(checked(205+i))
{
if(!label.empty()) label+=L" ";
label+=values[i];
}
}
row(statusRow,label,palette[Text],palette[PanelBlue]);
}
else panel(bounds,draft.page==PanelModes || checked(273),
draft.page==PanelColors && draft.section==PanelCursor,L"C:\\Example");
if(draft.page==PanelColors && (draft.section==Text || draft.section==Dialog))
{
auto field=item.rcItem;
InflateRect(&field,-px(12),-px(10));
field.right=std::min(field.right,field.left+px(340));
field.top+=px(5)+4*rowHeight;
field.bottom=field.top+rowHeight+px(6);
fill(field,RGB(192,192,192));
InflateRect(&field,-px(4),-px(3));
row(field,L"C:\\Example",palette[Text],palette[Dialog]);
}
RestoreDC(dc,saved);
if(previewFont) DeleteObject(previewFont);
}
+209
View File
@@ -0,0 +1,209 @@
constexpr int fileStyleSection=ColorCount;
constexpr int fileStyleRules=288,fileStyleSuggestion=291,fileStylePreview=299,fileStyleState=301;
constexpr const wchar_t *fileStyleStateNames[]{L"Normal",L"Marked (Insert)",L"Focused cursor",L"Unfocused cursor"};
void layoutFileStyleFields(HWND dialog,bool redraw)
{
RECT client{};
GetClientRect(dialog,&client);
RECT spacing{0,0,8,0};
MapDialogRect(dialog,&spacing);
const auto gap=spacing.right;
const auto dpi=GetDpiForWindow(dialog);
const auto arrow=GetSystemMetricsForDpi(SM_CXVSCROLL,dpi);
const auto border=GetSystemMetricsForDpi(SM_CXEDGE,dpi);
const auto flags=SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW;
const auto fit=[&](int id,int buttonId)
{
const auto combo=GetDlgItem(dialog,id);
const auto bounds=dialogControlBounds(dialog,id);
const auto dc=GetDC(combo);
if(!dc) return;
const auto previous=SelectObject(dc,reinterpret_cast<HFONT>(SendMessageW(combo,WM_GETFONT,0,0)));
SIZE extent{};
const std::wstring_view example=L".example";
GetTextExtentPoint32W(dc,example.data(),static_cast<int>(example.size()),&extent);
LONG longest=extent.cx;
const auto count=SendMessageW(combo,CB_GETCOUNT,0,0);
for(LRESULT row=0;row<count;++row)
{
const auto length=SendMessageW(combo,CB_GETLBTEXTLEN,row,0);
if(length<=0) continue;
std::wstring item(static_cast<size_t>(length)+1,L'\0');
if(SendMessageW(combo,CB_GETLBTEXT,row,reinterpret_cast<LPARAM>(item.data()))!=CB_ERR &&
GetTextExtentPoint32W(dc,item.data(),static_cast<int>(length),&extent))
longest=std::max(longest,extent.cx);
}
if(previous) SelectObject(dc,previous);
ReleaseDC(combo,dc);
const auto button=buttonId?dialogControlBounds(dialog,buttonId):RECT{};
const auto buttonWidth=button.right-button.left;
const auto available=client.right-bounds.left-gap-(buttonId?gap+buttonWidth:0);
const auto width=std::max(1L,std::min(available,longest+arrow+2*border+gap));
RECT dropped{};
SendMessageW(combo,CB_GETDROPPEDCONTROLRECT,0,reinterpret_cast<LPARAM>(&dropped));
SetWindowPos(combo,nullptr,bounds.left,bounds.top,width,dropped.bottom-dropped.top,flags);
if(buttonId)
SetWindowPos(GetDlgItem(dialog,buttonId),nullptr,bounds.left+width+gap,button.top,
buttonWidth,button.bottom-button.top,flags);
};
fit(fileStyleRules,289);
fit(fileStyleSuggestion,292);
fit(fileStyleState,0);
if(redraw)
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
}
void fillFileStyleSuggestions(HWND dialog)
{
std::set<std::wstring> extensions;
for(DWORD i=0;;++i)
{
wchar_t name[256]{};
DWORD length=std::size(name);
const auto result=RegEnumKeyExW(HKEY_CLASSES_ROOT,i,name,&length,nullptr,nullptr,nullptr,nullptr);
if(result==ERROR_MORE_DATA) continue;
if(result!=ERROR_SUCCESS) break;
if(name[0]!=L'.') continue;
auto extension=fileStyleExtension(name);
if(!extension.empty()) extensions.insert(std::move(extension));
}
for(const auto &extension:extensions)
SendDlgItemMessageW(dialog,fileStyleSuggestion,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(extension.c_str()));
SendDlgItemMessageW(dialog,fileStyleSuggestion,CB_LIMITTEXT,255,0);
for(const auto label:fileStyleStateNames)
SendDlgItemMessageW(dialog,fileStyleState,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
SendDlgItemMessageW(dialog,fileStyleState,CB_SETCURSEL,0,0);
}
void updateFileStyleEditor(HWND dialog,OptionsDraft &draft,bool rebuild)
{
const auto combo=GetDlgItem(dialog,fileStyleRules);
if(rebuild)
{
SendMessageW(combo,CB_RESETCONTENT,0,0);
for(const auto &style:draft.styles)
{
const auto label=style.extension==L"@system-hidden"?L"System and hidden":style.extension.c_str();
SendMessageW(combo,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
}
draft.styleIndex=draft.styles.empty()?-1:std::clamp(draft.styleIndex,0,static_cast<int>(draft.styles.size())-1);
SendMessageW(combo,CB_SETCURSEL,draft.styleIndex,0);
layoutFileStyleFields(dialog,true);
}
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
const bool selected=draft.styleIndex>=0;
for(const int id:{289,293,294,295,297,298})
EnableWindow(GetDlgItem(dialog,id),selected);
if(!selected)
{
SetDlgItemTextW(dialog,296,L"");
CheckDlgButton(dialog,297,BST_UNCHECKED);
CheckDlgButton(dialog,298,BST_UNCHECKED);
return;
}
const auto &style=draft.styles[draft.styleIndex];
const auto colorLabel=[](std::optional<COLORREF> color)->std::wstring
{
if(!color) return L"Default";
wchar_t hex[8]{};
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(*color)),unsigned(GetGValue(*color)),unsigned(GetBValue(*color)));
return hex;
};
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
const auto fg=state>0?style.states[state-1].foreground:style.foreground;
const auto bg=state>0?style.states[state-1].background:style.background;
const auto label=L"Foreground: "+colorLabel(fg)+L" Background: "+colorLabel(bg);
SetDlgItemTextW(dialog,296,label.c_str());
CheckDlgButton(dialog,297,style.underline);
CheckDlgButton(dialog,298,style.strikeout);
}
bool addFileStyle(HWND dialog,OptionsDraft &draft)
{
wchar_t input[256]{};
GetDlgItemTextW(dialog,fileStyleSuggestion,input,std::size(input));
const auto extension=fileStyleExtension(input);
if(extension.empty())
{
MessageBoxW(dialog,L"Enter a file extension such as .txt.",L"File type styles",MB_OK|MB_ICONWARNING);
SetFocus(GetDlgItem(dialog,fileStyleSuggestion));
return false;
}
const auto existing=std::find_if(draft.styles.begin(),draft.styles.end(),
[&](const auto &style){return sameFileExtension(style.extension,extension);});
if(existing!=draft.styles.end()) draft.styleIndex=static_cast<int>(existing-draft.styles.begin());
else
{
if(draft.styles.size()>=64)
{
MessageBoxW(dialog,L"The profile can contain up to 64 file type rules.",L"File type styles",MB_OK|MB_ICONWARNING);
return false;
}
draft.styles.push_back({extension});
draft.styleIndex=static_cast<int>(draft.styles.size())-1;
}
SetDlgItemTextW(dialog,fileStyleSuggestion,L"");
updateFileStyleEditor(dialog,draft,true);
return true;
}
void paintFileStylePreview(const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
{
const auto saved=SaveDC(item.hDC);
const bool selected=draft.styleIndex>=0 && static_cast<size_t>(draft.styleIndex)<draft.styles.size();
const auto extension=selected?std::wstring_view(draft.styles[draft.styleIndex].extension):std::wstring_view{};
const bool systemHidden=extension==L"@system-hidden";
SetBkColor(item.hDC,draft.palette[PanelBlue]);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&item.rcItem,nullptr,0,nullptr);
if(selected)
{
auto font=draft.font;
font.lfHeight=-MulDiv(draft.points,GetDpiForWindow(item.hwndItem),720);
font.lfWidth=0;
const auto fontHandle=CreateFontIndirectW(&font);
if(fontHandle) SelectObject(item.hDC,fontHandle);
TEXTMETRICW metrics{};
GetTextMetricsW(item.hDC,&metrics);
const int height=metrics.tmHeight+MulDiv(4,GetDpiForWindow(item.hwndItem),96);
if(fontHandle)
{
SelectObject(item.hDC,mono?mono:GetStockObject(SYSTEM_FONT));
DeleteObject(fontHandle);
}
IntersectClipRect(item.hDC,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
for(int i=0;i<4;++i)
{
const auto state=static_cast<FileRowState>(i);
const auto background=draft.palette[state==FileRowState::Focused?PanelCursor:PanelBlue];
const auto style=resolveFileStyle(extension,!systemHidden,systemHidden,
state==FileRowState::Focused || state==FileRowState::Marked,
draft.palette[Text],background,draft.styles,state);
auto decorated=font;
if(style.decorations)
{
decorated.lfUnderline=(style.decorations&1)!=0;
decorated.lfStrikeOut=(style.decorations&2)!=0;
}
const auto previewFont=CreateFontIndirectW(&decorated);
const auto previous=SelectObject(item.hDC,previewFont?previewFont:mono);
auto row=item.rcItem;
row.top+=i*height;
row.bottom=row.top+height;
SetBkColor(item.hDC,style.background);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&row,nullptr,0,nullptr);
constexpr const wchar_t *samples[]{L"normal-sample",L"marked-sample",L"focused-cursor",L"unfocused-cursor"};
const auto label=std::wstring(samples[i])+(systemHidden?L" (hidden)":std::wstring(extension));
row.left+=5;
row.right-=5;
SetBkMode(item.hDC,TRANSPARENT);
SetTextColor(item.hDC,style.foreground);
DrawTextW(item.hDC,label.c_str(),static_cast<int>(label.size()),&row,
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS);
SelectObject(item.hDC,previous);
if(previewFont) DeleteObject(previewFont);
}
}
RestoreDC(item.hDC,saved);
}
+180
View File
@@ -0,0 +1,180 @@
enum class FileRowState { Normal, Marked, Focused, Unfocused };
struct FileStyleColors
{
std::optional<COLORREF> foreground,background;
bool operator==(const FileStyleColors &) const = default;
};
struct FileStyle
{
std::wstring extension;
std::optional<COLORREF> foreground,background;
bool underline{},strikeout{};
std::array<FileStyleColors,3> states{};
bool operator==(const FileStyle &) const = default;
};
const std::vector<FileStyle> defaultFileStyles{
{L"@system-hidden"},{L".exe",RGB(85,255,85)},{L".md",RGB(231,192,247)},
{L".7z",RGB(255,210,128)},{L".zip",RGB(255,155,170)},{L".rar",RGB(255,210,128)},
{L".tar",RGB(255,210,128)},{L".gz",RGB(255,210,128)},
{L".png",RGB(165,230,255)},{L".jpg",RGB(165,230,255)},{L".jpeg",RGB(165,230,255)},
{L".gif",RGB(165,230,255)},{L".webp",RGB(165,230,255)},{L".svg",RGB(165,230,255)},
{L".mp3",RGB(255,185,216)},{L".wav",RGB(255,185,216)},{L".flac",RGB(255,185,216)},
{L".mp4",RGB(255,185,216)},{L".mkv",RGB(255,185,216)},{L".mov",RGB(255,185,216)},
{L".pdf",RGB(255,229,168)},{L".docx",RGB(255,229,168)},{L".xlsx",RGB(255,229,168)},
{L".pptx",RGB(255,229,168)},{L".odt",RGB(255,229,168)},
{L".cpp",RGB(168,247,190)},{L".h",RGB(168,247,190)},{L".cs",RGB(168,247,190)},
{L".py",RGB(168,247,190)},{L".js",RGB(168,247,190)},{L".ts",RGB(168,247,190)},
{L".ps1",RGB(168,247,190)},{L".bat",RGB(168,247,190)},{L".cmd",RGB(168,247,190)}};
std::vector<FileStyle> fileStyles=defaultFileStyles;
constexpr wchar_t fileStyleKey[]=L"FileStyles";
std::wstring fileStyleExtension(std::wstring_view text)
{
const auto first=text.find_first_not_of(L" \t");
if(first==text.npos) return {};
auto extension=std::wstring(text.substr(first,text.find_last_not_of(L" \t")-first+1));
if(extension.front()!=L'.') extension.insert(0,1,L'.');
if(extension.size()<2 || extension.size()>255 ||
extension.find_first_of(L". *?\\/:<>|\";\r\n\t",1)!=extension.npos) return {};
CharLowerBuffW(extension.data(),static_cast<DWORD>(extension.size()));
return extension;
}
bool sameFileExtension(std::wstring_view left,std::wstring_view right)
{
return !left.empty() && left.size()==right.size() &&
CompareStringOrdinal(left.data(),static_cast<int>(left.size()),
right.data(),static_cast<int>(right.size()),TRUE)==CSTR_EQUAL;
}
void loadFileStyles()
{
DWORD bytes{};
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
!bytes || bytes>256*1024) return;
std::string data(bytes,'\0');
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
return;
try
{
const auto rows=Json::parse(data.begin(),data.begin()+bytes);
if(!rows.is_array() || rows.size()>64) return;
std::vector<FileStyle> parsed;
for(const auto &row:rows)
{
FileStyle style{ks::wide(row.at("extension").get<std::string>())};
if(style.extension!=L"@system-hidden" && fileStyleExtension(style.extension)!=style.extension) return;
if(std::any_of(parsed.begin(),parsed.end(),[&](const auto &existing){return existing.extension==style.extension;}))
return;
const auto color=[&](const char *key)->std::optional<COLORREF>
{
if(!row.contains(key)) return {};
const auto value=row.at(key).get<DWORD>();
if(value>0xffffff) throw std::runtime_error("Invalid file style color");
return value;
};
style.foreground=color("foreground");
style.background=color("background");
style.underline=row.value("underline",false);
style.strikeout=row.value("strikeout",false);
if(row.contains("states"))
{
const auto &states=row.at("states");
if(!states.is_array() || states.size()!=style.states.size()) return;
for(size_t i=0;i<style.states.size();++i)
{
const auto read=[&](const char *key)->std::optional<COLORREF>
{
if(!states[i].contains(key)) return {};
const auto value=states[i].at(key).get<DWORD>();
if(value>0xffffff) throw std::runtime_error("Invalid state color");
return value;
};
style.states[i]={read("foreground"),read("background")};
}
}
parsed.push_back(std::move(style));
}
fileStyles=std::move(parsed);
}
catch(const std::exception &) {}
}
bool saveFileStyles(const std::vector<FileStyle> &styles)
{
auto rows=Json::array();
for(const auto &style:styles)
{
Json row{{"extension",ks::utf8(style.extension)},{"underline",style.underline},{"strikeout",style.strikeout}};
if(style.foreground) row["foreground"]=*style.foreground;
if(style.background) row["background"]=*style.background;
row["states"]=Json::array();
for(const auto &state:style.states)
{
auto entry=Json::object();
if(state.foreground) entry["foreground"]=*state.foreground;
if(state.background) entry["background"]=*state.background;
row["states"].push_back(std::move(entry));
}
rows.push_back(std::move(row));
}
const auto data=rows.dump();
return RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,REG_BINARY,data.data(),
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
}
struct FileRowStyle
{
COLORREF foreground,background;
unsigned decorations{};
};
FileRowStyle resolveFileStyle(std::wstring_view extension,bool file,bool dimmed,bool protectedRow,
COLORREF foreground,COLORREF background,const std::vector<FileStyle> &styles=fileStyles,
FileRowState state=FileRowState::Normal,bool transferred=false)
{
FileRowStyle result{foreground,background};
const FileStyle *extensionRule=nullptr,*attributeRule=nullptr;
for(const auto &style:styles)
{
if(!file || !sameFileExtension(style.extension,extension)) continue;
extensionRule=&style;
result.foreground=style.foreground.value_or(result.foreground);
if(!protectedRow) result.background=style.background.value_or(result.background);
result.decorations=(style.underline?1u:0u)|(style.strikeout?2u:0u);
break;
}
if(dimmed)
for(const auto &style:styles)
{
if(style.extension!=L"@system-hidden") continue;
attributeRule=&style;
if(style.foreground) result.foreground=*style.foreground;
else
{
const auto fg=result.foreground,bg=result.background;
result.foreground=RGB((3*GetRValue(fg)+2*GetRValue(bg))/5,
(3*GetGValue(fg)+2*GetGValue(bg))/5,(3*GetBValue(fg)+2*GetBValue(bg))/5);
}
if(!protectedRow) result.background=style.background.value_or(result.background);
result.decorations|=(style.underline?1u:0u)|(style.strikeout?2u:0u);
break;
}
if(transferred && state!=FileRowState::Marked)
return {RGB(255,223,115),background,result.decorations};
if(state==FileRowState::Marked) result.foreground=RGB(255,255,0);
if(state!=FileRowState::Normal)
{
const auto index=static_cast<size_t>(state)-1;
for(const auto *rule:{extensionRule,attributeRule})
if(rule)
{
result.foreground=rule->states[index].foreground.value_or(result.foreground);
result.background=rule->states[index].background.value_or(result.background);
}
}
if(result.foreground==result.background && state!=FileRowState::Normal)
result.foreground=GetRValue(result.background)+GetGValue(result.background)+GetBValue(result.background)>382?RGB(0,0,0):RGB(255,255,255);
return result;
}
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// The reader keeps draining the output pipe while ClosePseudoConsole shuts down.
class FilesTerminal
{
public:
~FilesTerminal() { close(); }
bool start(const wchar_t *directory, COORD size)
{
if(alive()) return true;
close();
pending.clear();
HANDLE inputRead{}, outputWrite{};
if(!CreatePipe(&inputRead,&input,nullptr,0)) return false;
if(!CreatePipe(&output,&outputWrite,nullptr,0))
{
CloseHandle(inputRead);
close();
return false;
}
const auto created=CreatePseudoConsole(size,inputRead,outputWrite,0,&console);
CloseHandle(inputRead);
CloseHandle(outputWrite);
if(FAILED(created)) { close(); return false; }
reader=std::thread([this]
{
char buffer[4096];
DWORD count{};
while(ReadFile(output,buffer,sizeof(buffer),&count,nullptr) && count)
{
std::lock_guard lock(mutex);
pending.append(buffer,count);
if(pending.size()>1024*1024) pending.erase(0,pending.size()-1024*1024);
}
});
SIZE_T bytes{};
InitializeProcThreadAttributeList(nullptr,1,0,&bytes);
std::vector<unsigned char> storage(bytes);
auto attributes=reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
if(!InitializeProcThreadAttributeList(attributes,1,0,&bytes)) { close(); return false; }
STARTUPINFOEXW startup{};
startup.StartupInfo.cb=sizeof(startup);
startup.lpAttributeList=attributes;
PROCESS_INFORMATION info{};
wchar_t command[]=L"powershell.exe -NoLogo -NoProfile -NoExit -Command \"Remove-Module PSReadLine -ErrorAction SilentlyContinue; function global:prompt { $location = Get-Location; $path = if ($location.Provider.Name -eq 'FileSystem') { [Uri]::EscapeDataString($location.ProviderPath) }; ([string][char]27) + ']633;ks-directory:' + $path + [char]7 + [char]27 + '[?25l' }; Clear-Host\"";
wchar_t system[MAX_PATH]{};
const auto length=GetSystemDirectoryW(system,MAX_PATH);
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
const bool started=length && length<MAX_PATH &&
UpdateProcThreadAttribute(attributes,0,PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
console,sizeof(console),nullptr,nullptr) &&
CreateProcessW(shell.c_str(),command,nullptr,nullptr,FALSE,EXTENDED_STARTUPINFO_PRESENT,
nullptr,directory,&startup.StartupInfo,&info);
DeleteProcThreadAttributeList(attributes);
if(!started) { close(); return false; }
process=info.hProcess;
CloseHandle(info.hThread);
return true;
}
bool alive() const { return process && WaitForSingleObject(process,0)==WAIT_TIMEOUT; }
bool write(const std::wstring &value)
{
if(!alive()) return false;
const int length=WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
nullptr,0,nullptr,nullptr);
if(!length) return false;
std::string utf8(length,'\0');
WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
utf8.data(),length,nullptr,nullptr);
DWORD written{};
return WriteFile(input,utf8.data(),length,&written,nullptr) && written==utf8.size();
}
void resize(COORD size) { if(console) ResizePseudoConsole(console,size); }
std::string takeOutput()
{
std::lock_guard lock(mutex);
std::string result;
result.swap(pending);
return result;
}
void close()
{
if(input) CloseHandle(input);
if(console) ClosePseudoConsole(console);
if(reader.joinable()) reader.join();
if(output) CloseHandle(output);
if(process) CloseHandle(process);
input=output=process=nullptr;
console=nullptr;
}
private:
HPCON console{};
HANDLE input{},output{},process{};
std::thread reader;
std::mutex mutex;
std::string pending;
};
+186
View File
@@ -0,0 +1,186 @@
#pragma once
#include <WebView2.h>
#ifdef __MINGW32__
__CRT_UUID_DECL(ICoreWebView2_3, 0xA0D6DF20, 0x3B92, 0x416D, 0xAA, 0x0C, 0x43, 0x7A, 0x9C, 0x72, 0x78, 0x57)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler, 0x4e8a3389, 0xc9d8, 0x4bd2, 0xb6, 0xb5, 0x12, 0x4f, 0xee, 0x6c, 0xc1, 0x4d)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2ControllerCompletedHandler, 0x6c4819f3, 0xc9b7, 0x4260, 0x81, 0x27, 0xc9, 0xf5, 0xbd, 0xe7, 0xf6, 0x8c)
__CRT_UUID_DECL(ICoreWebView2WebMessageReceivedEventHandler, 0x57213f19, 0x00e6, 0x49fa, 0x8e, 0x07, 0x89, 0x8e, 0xa0, 0x1e, 0xcb, 0xd2)
__CRT_UUID_DECL(ICoreWebView2NewWindowRequestedEventHandler, 0xd4c185fe, 0xc81c, 0x4989, 0x97, 0xaf, 0x2d, 0x3f, 0xa7, 0xab, 0x56, 0x51)
__CRT_UUID_DECL(ICoreWebView2NavigationStartingEventHandler, 0x9adbe429, 0xf36d, 0x432b, 0x9d, 0xdc, 0xf8, 0x88, 0x1f, 0xbd, 0x76, 0xe3)
#endif
#include <wrl/client.h>
#include <functional>
#include <memory>
#include <utility>
template<class Interface, class... Args>
class TerminalCallback final : public Interface
{
ULONG refs=1;
std::function<HRESULT(Args...)> invoke;
public:
explicit TerminalCallback(std::function<HRESULT(Args...)> fn):invoke(std::move(fn)) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void **value) override
{
if(iid!=IID_IUnknown && iid!=__uuidof(Interface)) { *value=nullptr; return E_NOINTERFACE; }
*value=static_cast<Interface*>(this);
AddRef();
return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs; }
ULONG STDMETHODCALLTYPE Release() override
{
const auto remaining=--refs;
if(!remaining) delete this;
return remaining;
}
HRESULT STDMETHODCALLTYPE Invoke(Args... args) override { return invoke(args...); }
};
template<class Interface,class... Args,class Fn>
auto terminalCallback(Fn fn)
{
Microsoft::WRL::ComPtr<Interface> result;
result.Attach(new TerminalCallback<Interface,Args...>(std::move(fn)));
return result;
}
class FilesTerminalView
{
Microsoft::WRL::ComPtr<ICoreWebView2Controller> controller;
Microsoft::WRL::ComPtr<ICoreWebView2> web;
HMODULE loader{};
bool ready{};
bool visible{};
int viewportHeight{};
public:
bool initialized() const { return ready; }
void enableTrace() { if(ready) web->PostWebMessageAsString(L"trace:on"); }
void setFont(const LOGFONTW &font,int points)
{
if(!ready) return;
const auto message=L"font:"+std::to_wstring(points)+L":"+
std::to_wstring(font.lfWeight?font.lfWeight:FW_NORMAL)+L":"+
std::to_wstring(font.lfItalic!=0)+L":"+font.lfFaceName;
web->PostWebMessageAsString(message.c_str());
}
void layout(HWND host,bool show,int fullHeight)
{
visible=show;
viewportHeight=fullHeight;
if(!controller) return;
RECT bounds{};
GetClientRect(host,&bounds);
bounds.top=bounds.bottom-viewportHeight;
RECT current{};
if(FAILED(controller->get_Bounds(&current)) || !EqualRect(&current,&bounds))
controller->put_Bounds(bounds);
BOOL currentVisibility{};
if(FAILED(controller->get_IsVisible(&currentVisibility)) || !!currentVisibility!=show)
controller->put_IsVisible(show);
}
void close()
{
if(controller) controller->Close();
web.Reset();
controller.Reset();
ready=false;
}
bool send(const std::string &bytes)
{
if(!ready) return false;
constexpr wchar_t hex[]=L"0123456789abcdef";
std::wstring message;
message.reserve(bytes.size()*2);
for(unsigned char c:bytes)
{
message+=hex[c>>4];
message+=hex[c&15];
}
return SUCCEEDED(web->PostWebMessageAsString(message.c_str()));
}
bool create(HWND host,std::function<void(const std::wstring&)> receive,std::function<void()> failed)
{
wchar_t exe[MAX_PATH]{};
if(!GetModuleFileNameW(nullptr,exe,MAX_PATH)) return false;
const auto folder=std::filesystem::path(exe).parent_path();
loader=LoadLibraryExW((folder/L"WebView2Loader.dll").c_str(),nullptr,LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|LOAD_LIBRARY_SEARCH_SYSTEM32);
if(!loader) return false;
using Create=HRESULT (STDAPICALLTYPE*)(PCWSTR,PCWSTR,ICoreWebView2EnvironmentOptions*,
ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler*);
const auto createEnvironment=reinterpret_cast<Create>(
reinterpret_cast<void*>(GetProcAddress(loader,"CreateCoreWebView2EnvironmentWithOptions")));
if(!createEnvironment) return false;
wchar_t local[MAX_PATH]{};
const auto length=GetEnvironmentVariableW(L"LOCALAPPDATA",local,MAX_PATH);
if(!length || length>=MAX_PATH) return false;
const auto profile=std::filesystem::path(local)/L"KSwitcher"/L"TerminalWebView";
const auto assets=folder/L"terminal";
auto environment=terminalCallback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler,
HRESULT,ICoreWebView2Environment*>([=,this](HRESULT hr,ICoreWebView2Environment *env)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(hr) || !env) { failed(); return S_OK; }
auto completed=terminalCallback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler,
HRESULT,ICoreWebView2Controller*>([=,this](HRESULT result,ICoreWebView2Controller *view)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(result) || !view) { failed(); return S_OK; }
controller=view;
view->get_CoreWebView2(&web);
Microsoft::WRL::ComPtr<ICoreWebView2_3> mapped;
if(!web || FAILED(web.As(&mapped)) || FAILED(mapped->SetVirtualHostNameToFolderMapping(
L"kswitcher-terminal.invalid",assets.c_str(),COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY)))
{ failed(); return S_OK; }
Microsoft::WRL::ComPtr<ICoreWebView2Settings> settings;
if(SUCCEEDED(web->get_Settings(&settings)))
{
settings->put_AreDevToolsEnabled(FALSE);
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_AreHostObjectsAllowed(FALSE);
}
EventRegistrationToken token{};
auto navigation=terminalCallback<ICoreWebView2NavigationStartingEventHandler,
ICoreWebView2*,ICoreWebView2NavigationStartingEventArgs*>([](auto*,auto *args)
{
PWSTR uri{};
if(SUCCEEDED(args->get_Uri(&uri)))
{
args->put_Cancel(wcscmp(uri,L"https://kswitcher-terminal.invalid/index.html")!=0);
CoTaskMemFree(uri);
}
return S_OK;
});
auto newWindow=terminalCallback<ICoreWebView2NewWindowRequestedEventHandler,
ICoreWebView2*,ICoreWebView2NewWindowRequestedEventArgs*>([](auto*,auto *args)
{ args->put_Handled(TRUE); return S_OK; });
auto messages=terminalCallback<ICoreWebView2WebMessageReceivedEventHandler,
ICoreWebView2*,ICoreWebView2WebMessageReceivedEventArgs*>([=,this](auto*,auto *args)
{
PWSTR source{}, value{};
if(SUCCEEDED(args->get_Source(&source)) &&
wcscmp(source,L"https://kswitcher-terminal.invalid/index.html")==0 &&
SUCCEEDED(args->TryGetWebMessageAsString(&value)))
{
if(wcscmp(value,L"ready")==0) ready=true;
if(ready) receive(value);
}
CoTaskMemFree(value);
CoTaskMemFree(source);
return S_OK;
});
if(FAILED(web->add_NavigationStarting(navigation.Get(),&token)) ||
FAILED(web->add_NewWindowRequested(newWindow.Get(),&token)) ||
FAILED(web->add_WebMessageReceived(messages.Get(),&token)) ||
FAILED(web->Navigate(L"https://kswitcher-terminal.invalid/index.html")))
failed();
layout(host,visible,viewportHeight);
return S_OK;
});
if(FAILED(env->CreateCoreWebView2Controller(host,completed.Get()))) failed();
return S_OK;
});
return SUCCEEDED(createEnvironment(nullptr,profile.c_str(),nullptr,environment.Get()));
}
};
+17 -3
View File
@@ -4,14 +4,28 @@
#include <string>
namespace ks {
inline constexpr size_t HistoryThumbnailBudget = 1024 * 1024;
inline constexpr size_t HistoryRichTextBudget = 256 * 1024;
// Keep the visible working set even in unusually tall windows. Never evict a
// visible preview to admit another visible preview: that creates a paint loop.
template<class T, class Weight>
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
const std::set<std::wstring> &visible, size_t limit,
size_t byteLimit, Weight weight)
{
size_t bytes = 0;
for (const auto &[id, value] : cache) bytes += weight(value);
for(auto it=cache.begin(); (cache.size()>limit || bytes>byteLimit) && it!=cache.end();)
if(!visible.contains(it->first)) {
bytes -= weight(it->second);
it=cache.erase(it);
}
else ++it;
}
template<class T>
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
const std::set<std::wstring> &visible, size_t limit=32)
{
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
if(!visible.contains(it->first)) it=cache.erase(it);
else ++it;
trimHistoryPreviews(cache, visible, limit, 0, [](const T &) { return size_t{}; });
}
}
+580
View File
@@ -0,0 +1,580 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include <iphlpapi.h>
#include "lan.h"
#include "lan_files.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{}; bool files{}; };
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;
std::unique_ptr<lan::ClipboardExchange> transfers;
std::deque<lan::ClipboardExchange::Delivery> incomingFiles;
struct LogDownload {std::string peer, id; uint64_t deadline; Bytes bytes;};
std::optional<LogDownload> logDownload;
bool shareLogs{};
bool enabled{};
std::atomic<bool> stopping{false}, refreshFiles{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.";
bool inputNeedsHelp{};
HWND inputWindow{};std::atomic<bool> inputNotification{false};
explicit Impl(const std::filesystem::path &root,HWND window) : path(root/L"lan-pairing.bin"),inputWindow(window)
{
if(std::filesystem::exists(path))
{
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);
shareLogs=j.value("shareLogs",false);
if(inputEdge<0||inputEdge>3||(shareInput&&trusted.empty()))throw std::runtime_error("Invalid input sharing configuration.");
if(!trusted.is_object()||trusted.size()>64) throw std::runtime_error("Invalid paired computer list.");
for(auto &[key,entry]:trusted.items())
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);
transfers=std::make_unique<lan::ClipboardExchange>(id,"file-transfer");
configureRouter();
if(enabled) start();
}
~Impl() { stop(); }
void save() { atomicWrite(path,protect(encoded({{"version",1},{"id",id},{"enabled",enabled},{"trusted",trusted},
{"shareInput",shareInput},{"shareLogs",shareLogs},{"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);
transfers=std::make_unique<lan::ClipboardExchange>(id,"file-transfer");incomingFiles.clear();logDownload.reset();
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,0,FALSE);
}
void send(SOCKET socket,const sockaddr_in &address,const Json &j)
{
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 receiveTransfers(uint64_t now)
{
for(auto &item:transfers->deliveries)
{
if(item.app=="file")
{
auto end=std::find(item.bytes.begin(),item.bytes.end(),0);
if(end!=item.bytes.begin() && end!=item.bytes.end() && end-item.bytes.begin()<=1024 &&
item.bytes.end()-end-1<=16*1024*1024)
{
incomingFiles.push_back(std::move(item));
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),TRUE);
}
}
else if(item.app=="log-request" && item.bytes.size()==33)
{
std::string request(item.bytes.begin()+1,item.bytes.end());
if(!lan::identifier(request)||item.bytes[0]>1)continue;
Bytes response{2};
if(shareLogs)try
{
auto log=readFile(path.parent_path()/(item.bytes[0]?L"kswitcher.log.1":L"kswitcher.log"),2*1024*1024);
response[0]=0;
response.insert(response.end(),log.begin(),log.end());
}
catch(const std::exception &){response={1};}
try{transfers->queue({item.peer},std::move(response),"log-reply-"+request,now);}
catch(const std::exception &){}
}
else if(logDownload && now<logDownload->deadline && logDownload->peer==item.peer &&
item.app=="log-reply-"+logDownload->id && item.bytes.size()<=2*1024*1024+1 && logDownload->bytes.empty())
logDownload->bytes=std::move(item.bytes);
}
transfers->deliveries.clear();
}
void receive(SOCKET socket,const sockaddr_in &address,const Json &j,uint64_t now)
{
if(j.value("app","")!="KSwitcher-LAN" || j.value("version",0)!=1) return;
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",""));
transfers->peerSession(other,j.value("fileSession",""));
peer.files=j.value("filesBrowse",false);
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=="file-transfer" && trusted.contains(other))
{
size_t retainedBytes=0;
for(const auto &item:incomingFiles)retainedBytes+=item.bytes.size();
try {transfers->receive(other,trusted.at(other).at("key"),j,now,0,
[&](const auto &target,const auto &packet){send(socket,peers.at(target).address,packet);},
incomingFiles.size(),retainedBytes);}
catch(const std::exception &){return;}
receiveTransfers(now);
return;
}
if(type=="clipboard" && trusted.contains(other))
{
try {clipboard->receive(other,trusted.at(other).at("key"),j,now,GetClipboardSequenceNumber(),
[&](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="Pair saved automatically. Arrange the monitors, then enable input sharing 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);
lan::FilesListener files(id,[&](const std::string &peer)
{
std::lock_guard lock(mutex);
return !stopping && trusted.contains(peer)?trusted.at(peer).at("key").get<std::string>():std::string{};
});
hub.datagrams([&](const auto &target,const auto &packet){
std::lock_guard lock(mutex);
if(shareInput&&trusted.contains(target)&&peers.contains(target))send(socket,peers.at(target).address,packet);
});
const bool filesListening=files.port()!=0;
if(!filesListening) diagnosticEvent(L"LAN Files could not listen on TCP 45885.");
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);
});
transfers->poll(trusted,now,[&](const auto &target,const auto &packet){
if(peers.contains(target))send(socket,peers.at(target).address,packet);
});
clipboardActive=clipboard->active()||transfers->active();
if(shareInput)for(auto &[peer,entry]:trusted.items())
endpoints.push_back({peer,entry.at("key"),peers.contains(peer)?peers.at(peer).address.sin_addr.s_addr:0});
if(refreshFiles.exchange(false) || now>=nextHello)
{
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
hello["fileSession"]=transfers->session();
hello["filesBrowse"]=filesListening;
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);
files.poll();
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();inputNeedsHelp=hub.needsHelp();}
fd_set read; FD_ZERO(&read); FD_SET(socket,&read); timeval delay{0,!endpoints.empty()||clipboardActive?5000:100000};
int ready=select(0,&read,nullptr,nullptr,&delay);
if(ready==SOCKET_ERROR) throw std::runtime_error("LAN discovery socket failed.");
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);
if(j.value("type","")=="input-udp"){
bool allowed=false;auto peer=j.value("id","");
{std::lock_guard lock(mutex);allowed=shareInput&&trusted.contains(peer)&&peers.contains(peer)&&peers.at(peer).address.sin_addr.s_addr==sender.sin_addr.s_addr;}
if(allowed)hub.receiveDatagram(j,GetTickCount64());
}else {std::lock_guard lock(mutex);receive(socket,sender,j,GetTickCount64());}
}
catch(const Json::exception &) {} // Malformed network data cannot interrupt discovery.
}
}
}
catch(const 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);
auto now=GetTickCount64();
if(action=="filesPeers") refreshFiles=true;
if(action=="shareLogs")
{
bool previous=shareLogs;
shareLogs=j.at("enabled");
try {save();} catch(...) {shareLogs=previous;throw;}
if(!shareLogs)
for(const auto &[peer,entry]:trusted.items())transfers->cancel(peer,"log-reply-");
}
if(action=="sendFile" || action=="requestLog")
{
auto peer=j.at("peer").get<std::string>();
if(!enabled||!trusted.contains(peer)||!peers.contains(peer)||!peers.at(peer).verified||
now-peers.at(peer).verified>=lan::PeerLifetime)
throw std::runtime_error("Select an online paired computer.");
if(!transfers->hasSession(peer))throw std::runtime_error("Update KSwitcher on the selected computer to use file transfers.");
if(action=="sendFile")
{
auto file=std::filesystem::path(wide(j.at("path").get<std::string>()));
auto name=utf8(file.filename().wstring());
if(name.empty()||name.size()>1024)throw std::runtime_error("Invalid file name.");
Bytes payload(name.begin(),name.end());
payload.push_back(0);
constexpr size_t fileLimit=16*1024*1024;
if(std::filesystem::file_size(file)>fileLimit)throw std::runtime_error("File size limit is 16 MiB.");
auto bytes=readFile(file,fileLimit);
payload.insert(payload.end(),bytes.begin(),bytes.end());
transfers->queue({peer},std::move(payload),"file",now);
return encoded({{"queued",true}});
}
if(logDownload && now<logDownload->deadline)
throw std::runtime_error("A remote log download is already pending.");
auto request=lan::randomHex(16);
Bytes payload{static_cast<unsigned char>(j.value("previous",false))};
payload.insert(payload.end(),request.begin(),request.end());
transfers->queue({peer},std::move(payload),"log-request",now);
logDownload=LogDownload{peer,request,now+30000,{}};
return encoded({{"request",request}});
}
if(action=="takeLog")
{
if(!logDownload||logDownload->id!=j.at("request").get<std::string>()||now>=logDownload->deadline)
return encoded({{"state","expired"}});
if(logDownload->bytes.empty())return encoded({{"state","waiting"}});
auto bytes=std::move(logDownload->bytes);
logDownload.reset();
if(bytes.front()!=0)return encoded({{"state",bytes.front()==2?"denied":"unavailable"}});
std::string hex;
hex.reserve((bytes.size()-1)*2);
for(auto it=bytes.begin()+1;it!=bytes.end();++it)
{
hex+="0123456789abcdef"[*it>>4];
hex+="0123456789abcdef"[*it&15];
}
return encoded({{"state","ready"},{"logHex",hex}});
}
if(action=="saveFile"||action=="discardFile")
{
auto found=std::find_if(incomingFiles.begin(),incomingFiles.end(),[&](const auto &file){
return file.id==j.at("id").get<std::string>() && file.peer==j.at("peer").get<std::string>();
});
if(found==incomingFiles.end())throw std::runtime_error("This file is no longer available.");
if(action=="saveFile")
{
auto start=std::find(found->bytes.begin(),found->bytes.end(),0)+1;
atomicWrite(wide(j.at("path").get<std::string>()),Bytes(start,found->bytes.end()));
}
incomingFiles.erase(found);
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),FALSE);
}
if(action=="position")
{
auto peer=j.at("peer").get<std::string>();int x=j.at("x"),y=j.at("y");
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=="connectionHelp"){
auto exe=utf8(executable());std::string quoted;
for(char c:exe){quoted+=c;if(c=='\'')quoted+='\'';}
auto script="$ksPath = '"+quoted+"'\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884,45885 -RemoteAddress LocalSubnet -Profile Any\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN UDP' -Direction Inbound -Action Allow -Program $ksPath -Protocol UDP -LocalPort 45883 -RemoteAddress LocalSubnet -Profile Any";
return encoded({{"script",script}});
}
if(action=="cancel") { pending.reset(); status="Pairing cancelled."; }
if(action=="pair")
{
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;}
std::erase_if(incomingFiles,[&](const auto &file){return file.peer==peer;});
if(inputWindow)PostMessageW(inputWindow,LanFilesChangedMessage,incomingFiles.size(),FALSE);
if(logDownload && logDownload->peer==peer)logDownload.reset();
if(pending&&pending->peer==peer)pending.reset();
configureRouter();
status="Pair removed on this computer.";
}
Json result={{"enabled",enabled},{"id",id},{"name",name},{"status",status},{"pending",pending&&!pending->confirmed},{"peers",Json::array()},
{"positions",positions},{"shareInput",shareInput},{"inputPeer",inputPeer},{"controlPeer",router?router->peer():std::string{}},{"inputEdge",inputEdge},{"inputConnected",inputConnected.load()},
{"inputStatus",enabled&&shareInput?inputStatus:"Input sharing disabled."},{"inputNeedsHelp",enabled&&shareInput&&inputNeedsHelp},{"controlMode",router?int(router->mode()):0}};
{std::lock_guard inputLock(inputMutex);result["inputReady"]=inputReady;}
result["shareLogs"]=shareLogs;
result["files"]=Json::array();
result["transferStatus"]=transfers->status;
for(const auto &file:incomingFiles)
{
auto end=std::find(file.bytes.begin(),file.bytes.end(),0);
auto filename=utf8(wide(std::string(file.bytes.begin(),end)));
result["files"].push_back({{"id",file.id},{"peer",file.peer},{"sender",trusted.at(file.peer).at("name")},
{"name",filename},{"size",file.bytes.end()-end-1}});
}
result["pendingPeer"]=pending&&!pending->confirmed ? pending->peer : "";
result["pairSecondsRemaining"]=pending&&!pending->confirmed&&!pending->expired(now)
? (lan::PairLifetime-(now-pending->started)+999)/1000 : 0;
for(const auto &[peer,p]:peers)
{
if(action=="filesPeers" && !trusted.contains(peer)) continue;
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},
{"fileTransfer",transfers->hasSession(peer)},{"filesBrowse",p.files}});
}
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;}
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "platform.h"
#include <memory>
namespace ks
{
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
inline constexpr UINT LanFilesChangedMessage=WM_APP+70;
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
// Discovery, pairing and optional encrypted input within the resident process.
class LanService
{
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();
};
}
+324
View File
@@ -0,0 +1,324 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include "lan_channel.h"
#include "lan_datagram.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,size_t limit)
{
if(plain.empty()||plain.size()>limit)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,size_t limit)
{
if(packet.size()<17||packet.size()-16>limit)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...",lastError;
int edge;uint16_t port;SOCKET listener=INVALID_SOCKET,socket=INVALID_SOCKET;
bool connecting{},authenticated{},helloRead{},managed{};
uint64_t changed{},lastRead{},lastPing{},retry{},waitingSince{};
bool reverse{};
Bytes incoming,outgoing;
std::unique_ptr<InputCipher> tx,rx;
Impl(std::string a,std::string b,std::string key,int side,uint16_t p,bool multiple):local(std::move(a)),peer(std::move(b)),secret(std::move(key)),edge(side),port(p),managed(multiple){}
~Impl(){closeSocket(socket);closeSocket(listener);}
void reset(uint64_t now,const std::string &reason)
{
closeSocket(socket);connecting=authenticated=helloRead=false;incoming.clear();outgoing.clear();tx.reset();rx.reset();++changed;retry=now+1500;waitingSince=0;state=reason;lastError=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(!waitingSince)waitingSince=now;
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||(managed&&now-waitingSince>=1500))
{
reverse=local>peer;
socket=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(socket==INVALID_SOCKET||!nonblocking(socket))throw std::runtime_error("Cannot open input connection.");
sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);a.sin_addr.s_addr=address;
int result=connect(socket,(sockaddr*)&a,sizeof(a));
if(result && WSAGetLastError()!=WSAEWOULDBLOCK)throw std::runtime_error("Paired computer is not accepting input connections.");
connecting=result!=0;begin(now);
if(connecting)state=reverse?"Trying reverse TCP connection...":"Trying direct TCP connection...";
}
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;lastError.clear();state="Input connected. Move through the configured screen edge.";}
else if(f.type==InputType::Ready)throw std::runtime_error("Repeated input handshake.");
else if(f.type!=InputType::Ping)received.push_back(f);
}
}
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->authenticated||!nonblocking(SOCKET(accepted)))return false;
// Reverse connection wins a simultaneous connect race. Never replace an
// authenticated channel or accept two sockets for the same input stream.
if(impl->socket!=INVALID_SOCKET){
if(impl->local>impl->peer||!impl->connecting)return false;
impl->reset(now,"Accepting reverse TCP connection.");
}
impl->reverse=impl->local<impl->peer;
impl->connecting=false;impl->begin(now);impl->socket=SOCKET(accepted);return true;
}
bool InputChannel::ready()const{return impl->authenticated;}
uint64_t InputChannel::generation()const{return impl->changed;}
std::string InputChannel::status()const{
return impl->lastError.empty() ? impl->state : impl->lastError+" Retrying automatically.";
}
void InputChannel::disconnect(){impl->reset(GetTickCount64(),"Input disconnected; local control restored.");}
std::string InputChannel::transport()const{return impl->reverse?"reverse TCP":"direct TCP";}
struct InputHub::Impl
{
struct Link { InputEndpoint endpoint;std::unique_ptr<InputChannel> channel;std::unique_ptr<InputDatagram> udp;uint64_t generation{},epoch{},since{},failedSince{};bool udpReady{};std::string lastState; };
struct Pending {SOCKET socket;uint32_t address;uint64_t since;};
std::string local;uint16_t port;SOCKET listener=INVALID_SOCKET;
std::map<std::string,Link> links;std::vector<Pending> pending;uint64_t epoch{},retry{};
std::string error;
InputHub::SendDatagram sendDatagram;
uint64_t currentTime{};
Impl(std::string id,uint16_t p):local(std::move(id)),port(p){}
~Impl(){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);}
void poll(const std::vector<InputEndpoint> &endpoints,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now)
{
currentTime=now;
for(auto it=links.begin();it!=links.end();){
auto match=std::find_if(endpoints.begin(),endpoints.end(),[&](auto &e){return e.id==it->first&&e.key==it->second.endpoint.key&&e.port==it->second.endpoint.port;});
if(match==endpoints.end())it=links.erase(it);else ++it;
}
for(auto &e:endpoints)if(e.id!=local&&!links.contains(e.id)){
Link link;link.endpoint=e;link.channel=std::make_unique<InputChannel>(local,e.id,e.key,0,e.port,true);
link.udp=std::make_unique<InputDatagram>(local,e.id,e.key);link.since=link.failedSince=now;link.epoch=++epoch;links.emplace(e.id,std::move(link));
}
// Discovery is a hint, not the lifetime of authenticated input sessions.
// Keep the last usable address across missed announcements. A new address
// is used only once a channel is down, never to tear down working input.
for(auto &e:endpoints){if(e.id==local)continue;auto &link=links.at(e.id);
if(e.address&&!link.channel->ready()&&e.address!=link.endpoint.address){
link.channel->disconnect();link.endpoint.address=e.address;
}
}
bool listenNeeded=!links.empty();
if(!listenNeeded){closeSocket(listener);for(auto &p:pending)closeSocket(p.socket);pending.clear();}
if(listenNeeded&&listener==INVALID_SOCKET&&now>=retry){
listener=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);BOOL yes=TRUE;sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(port);
if(listener==INVALID_SOCKET||setsockopt(listener,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,(char*)&yes,sizeof(yes))||!nonblocking(listener)||bind(listener,(sockaddr*)&a,sizeof(a))||listen(listener,SOMAXCONN)){
auto detail="TCP input port "+std::to_string(port)+" is unavailable (Windows error "+std::to_string(WSAGetLastError())+").";
if(error!=detail)log(L"LAN input: "+wide(detail));
closeSocket(listener);retry=now+1500;error=std::move(detail);
}else {error.clear();log(L"LAN input listener ready on TCP "+std::to_wstring(port));}
}
if(listener!=INVALID_SOCKET)for(int n=0;n<16&&pending.size()<64;++n){
sockaddr_in a{};int size=sizeof(a);SOCKET sock=accept(listener,(sockaddr*)&a,&size);if(sock==INVALID_SOCKET)break;
if(!nonblocking(sock)){closeSocket(sock);continue;}pending.push_back({sock,a.sin_addr.s_addr,now});
}
for(auto it=pending.begin();it!=pending.end();){
bool done=now-it->since>2000;unsigned char bytes[516];int count=recv(it->socket,(char*)bytes,sizeof(bytes),MSG_PEEK);
if(count==0||(count<0&&WSAGetLastError()!=WSAEWOULDBLOCK))done=true;
if(!done&&count>=4){
auto length=get32(bytes);if(length<32||length>512)done=true;
else if(count>=int(length+4)){
done=true;
try{auto hello=Json::parse(bytes+4,bytes+4+length);auto id=hello.value("id","");auto link=links.find(id);
if(hello.value("app","")=="KSwitcher-input"&&hello.value("to","")==local&&hello.value("version",0)==2&&link!=links.end()&&!link->second.udp->active()&&link->second.channel->adopt(uintptr_t(it->socket),now))it->socket=INVALID_SOCKET;
}catch(const std::exception &){}
}
}
if(done){closeSocket(it->socket);it=pending.erase(it);}else ++it;
}
for(auto &[id,link]:links){
std::vector<InputFrame> frames,received;for(auto &f:out)if(f.peer==id&&(!f.epoch||f.epoch==link.epoch))enqueueInput(frames,f.frame);
if(sendDatagram&&!link.channel->ready()&&!link.udp->active()&&local<id&&now-link.since>=5000){link.udp->start(now);link.channel->disconnect();frames.clear();}
if(link.udp->active()){
link.udp->poll(frames,received,now,sendDatagram);
if(!link.udp->active()){link.since=now;link.channel->disconnect();received.clear();}
}else link.channel->poll(link.endpoint.address,frames,received,now);
if(link.udpReady!=link.udp->ready()){link.udpReady=link.udp->ready();link.epoch=++epoch;}
bool ready=link.channel->ready()||link.udp->ready();
if(ready)link.failedSince=link.since=now;
auto state=link.udp->ready()?"Input connected via encrypted UDP.":link.udp->active()?"Trying encrypted UDP over discovery...":link.channel->ready()?"Input connected via "+link.channel->transport()+".":link.channel->status();
if(state!=link.lastState){link.lastState=state;log(L"LAN input peer "+wide(id.substr(0,8))+L": "+wide(state));}
if(link.generation!=link.channel->generation()){link.generation=link.channel->generation();link.epoch=++epoch;}
for(auto &f:received)in.push_back({id,f,link.epoch});
}
}
};
InputHub::InputHub(std::string id,uint16_t port):impl(std::make_unique<Impl>(std::move(id),port)){}
InputHub::~InputHub()=default;
void InputHub::poll(const std::vector<InputEndpoint> &peers,const std::vector<RoutedInput> &out,std::vector<RoutedInput> &in,uint64_t now){impl->poll(peers,out,in,now);}
std::map<std::string,uint64_t> InputHub::ready()const{std::map<std::string,uint64_t> result;for(auto &[id,l]:impl->links)if(l.channel->ready()||l.udp->ready())result[id]=l.epoch;return result;}
std::string InputHub::status()const{
auto count=ready().size();if(count==impl->links.size()&&count){
std::string routes;for(auto &[id,l]:impl->links){if(!routes.empty())routes+=", ";routes+=id.substr(0,8)+": "+(l.udp->ready()?"encrypted UDP":l.channel->transport());}
return "Input connected ("+routes+"). Move through a neighboring screen edge.";
}
if(needsHelp())return "Automatic connection attempts have not succeeded for every PC. Enable input on both PCs. Open Connection help for firewall commands.";
if(!impl->error.empty())return impl->error;
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready())return l.lastState;
return "Waiting for paired computers...";
}
void InputHub::disconnect(){for(auto &[id,l]:impl->links){l.channel->disconnect();l.udp->reset();l.since=impl->currentTime;}}
void InputHub::datagrams(SendDatagram send){impl->sendDatagram=std::move(send);}
void InputHub::receiveDatagram(const Json &j,uint64_t now){
if(!impl->sendDatagram)return;
auto it=impl->links.find(j.value("id",""));if(it==impl->links.end()||it->second.channel->ready())return;
auto &link=it->second;bool active=link.udp->active();
try{link.udp->receive(j,now,impl->sendDatagram);}catch(const std::exception &){return;}
if(!active&&link.udp->active())link.channel->disconnect();
}
bool InputHub::needsHelp()const{
for(auto &[id,l]:impl->links)if(!l.channel->ready()&&!l.udp->ready()&&impl->currentTime-l.failedSince>=15000)return true;
return false;
}
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "lan_pairing.h"
#include <memory>
#include <map>
#include <functional>
namespace ks::lan
{
enum class InputType : uint32_t { Ready, Ping, Enter, Accept, Return, Move, Key, Button, Wheel, Cross };
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 &,size_t limit=1024);
Bytes decryptPayload(const Bytes &,size_t limit=1024);
};
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();
std::string transport()const;
};
struct InputEndpoint {std::string id,key;uint32_t address{};uint16_t port=45884;};
struct RoutedInput {std::string peer;InputFrame frame;uint64_t epoch{};};
class InputHub
{
struct Impl;std::unique_ptr<Impl> impl;
public:
explicit InputHub(std::string local,uint16_t port=45884);
~InputHub();
void poll(const std::vector<InputEndpoint> &,const std::vector<RoutedInput> &,std::vector<RoutedInput> &,uint64_t now);
std::map<std::string,uint64_t> ready()const;
std::string status()const;
void disconnect();
using SendDatagram=std::function<void(const std::string &,const Json &)>;
void datagrams(SendDatagram);
void receiveDatagram(const Json &,uint64_t now);
bool needsHelp()const;
};
}
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include "lan_channel.h"
#include <map>
#include <deque>
#include <set>
namespace ks::lan
{
// Reliable bounded messages over the existing discovery socket.
// Every datagram has a fresh directional AES-GCM key; no plaintext transfer
// metadata or contents appear outside the encrypted CBOR body.
class ClipboardExchange
{
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, type, domain, 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,domain+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
auto packet=envelope(type,local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
packet["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));return packet;
}
public:
struct Delivery {std::string peer,id,app;Bytes bytes;DWORD sequence{};};
std::vector<Delivery> deliveries;
std::string status="Clipboard sharing ready.";
explicit ClipboardExchange(std::string id,std::string messageType="clipboard")
:local(std::move(id)),type(std::move(messageType)),domain("KSwitcher "+type+" v1|")
{
if(type!="clipboard")status.clear();
}
const std::string &session()const{return boot;}
bool active()const{return !sending.empty();}
bool hasSession(const std::string &peer)const{return sessions.contains(peer);}
void cancel(const std::string &peer,const std::string &prefix)
{
auto found=sending.find(peer);
if(found==sending.end())return;
auto &s=found->second;
if(s.queue.empty())return;
bool reset=s.queue.front()->app.starts_with(prefix);
std::erase_if(s.queue,[&](const auto &item){return item->app.starts_with(prefix);});
if(reset){s.ack=0;s.begun=false;s.last=0;s.progress=GetTickCount64();}
}
void peerSession(const std::string &peer,const std::string &value){
if(!identifier(value))return;
if(sessions.contains(peer)&&sessions.at(peer)!=value){
auto found=sending.find(peer);if(found!=sending.end()){found->second.ack=0;found->second.begun=false;found->second.last=0;}
if(type!="clipboard"){
auto previous=receiving.find(peer);
if(previous!=receiving.end()){incomingBytes-=previous->second.size;receiving.erase(previous);}
receivedPackets.erase(peer);
}
}
sessions[peer]=value;
}
void queue(const std::vector<std::string> &peers,Bytes bytes,std::string app,uint64_t now)
{
if(bytes.empty()||bytes.size()>Limit)throw std::runtime_error("LAN transfer limit is 32 MiB.");
size_t total=bytes.size();std::set<const Item*> counted;
for(auto &[peer,s]:sending)for(auto &item:s.queue)if(counted.insert(item.get()).second)total+=item->bytes.size();
if(total>2*Limit)throw std::runtime_error("LAN clipboard queue is full.");
auto item=std::make_shared<Item>(Item{randomHex(16),app.size()>128?std::string("Application"):app,std::move(bytes)});
for(auto &peer:peers){auto &s=sending[peer];if(s.queue.size()>=8){if(type!="clipboard")throw std::runtime_error("LAN transfer queue is full.");continue;}if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
if(!peers.empty())status=type=="clipboard"?"Sending clipboard to "+std::to_string(peers.size())+" paired computers...":"Sending…";
}
template<class Send>void poll(const Json &trusted,uint64_t now,Send send)
{
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="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,
size_t retained=0,size_t retainedBytes=0)
{
if(packet.value("session","")!=boot||!sessions.contains(peer)||packet.value("to","")!=local||!identifier(packet.value("nonce","")))return;
auto senderBoot=packet.value("fromSession","");auto number=packet.value("number",uint64_t{});
if(!identifier(senderBoot)||!number)return;
if(type!="clipboard"&&senderBoot!=sessions.at(peer))return;
auto &seen=receivedPackets[peer];
if((seen.contains(senderBoot)&&number<=seen.at(senderBoot))||(!seen.contains(senderBoot)&&seen.size()>=1024))return;
InputCipher cipher(mac(key,domain+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
auto body=Json::from_cbor(cipher.decryptPayload(unhex(packet.at("data").get<std::string>())));
seen[senderBoot]=number; // Retransmissions use new numbers; reordered old packets are retried.
auto id=body.value("item","");if(!identifier(id))return;
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=type=="clipboard"?"Clipboard delivered.":"Transfer 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+retainedBytes+size>2*Limit||deliveries.size()+retained>=8||
(type!="clipboard"&&receiving.size()+retained>=8))return;
receiving.emplace(peer,Receiving{id,app,{},size,now,sequence});incomingBytes+=size;ack(0);return;
}
if(op!="chunk")return;
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=type=="clipboard"?"Clipboard received.":"Transfer received.";
}
}
};
}
+25
View File
@@ -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;
}
}
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "lan_channel.h"
#include <deque>
namespace ks::lan
{
// Ordered, bounded input on the discovery socket. Retransmission uses fresh
// authenticated packets; event sequence numbers prevent duplicate injection.
// Fresh challenge/response nonces bind every reconnect to a new session.
class InputDatagram
{
std::string local,peer,secret,nonce,remote,txKey,rxKey;
uint64_t started{},lastRead{},lastSend{},sentNumber{},seenNumber{},nextEvent{1},receivedEvent{},acked{},progress{};
bool live{},ackDue{};
std::deque<std::pair<uint64_t,InputFrame>> queue;
std::vector<InputFrame> incoming;
static std::string hex(const Bytes &b){std::string s;for(auto v:b){s+="0123456789abcdef"[v>>4];s+="0123456789abcdef"[v&15];}return s;}
static Bytes unhex(const std::string &s){
if(s.empty()||s.size()>2080||s.size()%2||s.find_first_not_of("0123456789abcdef")!=s.npos)throw std::runtime_error("Invalid UDP input packet.");
Bytes b;for(size_t i=0;i<s.size();i+=2)b.push_back(static_cast<unsigned char>(std::stoul(s.substr(i,2),nullptr,16)));return b;
}
std::string proof(const std::string &from,const std::string &to,const std::string &a,const std::string &b)const{
return mac(secret,"KSwitcher UDP input v1|"+from+"|"+to+"|"+a+"|"+b);
}
void keys(){
// Handshake proofs are public. Session keys use a separate domain and
// must never be the MAC transmitted in a welcome packet.
txKey=mac(secret,"KSwitcher UDP input key v1|"+local+"|"+peer+"|"+nonce+"|"+remote);
rxKey=mac(secret,"KSwitcher UDP input key v1|"+peer+"|"+local+"|"+remote+"|"+nonce);
}
public:
using Send=InputHub::SendDatagram;
explicit InputDatagram(std::string a,std::string b,std::string key):local(std::move(a)),peer(std::move(b)),secret(std::move(key)){}
bool active()const{return !nonce.empty();}
bool ready()const{return live;}
void reset(){nonce.clear();remote.clear();txKey.clear();rxKey.clear();queue.clear();incoming.clear();live=ackDue=false;sentNumber=seenNumber=receivedEvent=acked=lastSend=0;nextEvent=1;}
void start(uint64_t now){reset();nonce=randomHex(16);started=lastRead=progress=now;}
void receive(const Json &j,uint64_t now,const Send &send)
{
if(j.value("app","")!="KSwitcher-LAN"||j.value("version",0)!=1||j.value("type","")!="input-udp"||j.value("id","")!=peer||j.value("to","")!=local)return;
auto op=j.value("op","");
if(op=="hello"){
auto other=j.value("nonce","");if(local<peer||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,"")))return;
if(active()&&remote!=other)return;
if(!active()){start(now);remote=other;keys();}
auto response=envelope("input-udp",local);response["to"]=peer;response["op"]="welcome";response["nonce"]=nonce;response["echo"]=remote;response["proof"]=proof(local,peer,nonce,remote);send(peer,response);return;
}
if(op=="welcome"){
auto other=j.value("nonce","");
if(local>peer||!active()||j.value("echo","")!=nonce||!identifier(other)||!equal(j.value("proof",""),proof(peer,local,other,nonce)))return;
if(!remote.empty()&&remote!=other)return;
remote=other;keys();ackDue=true;return;
}
if(op!="data"||rxKey.empty()||j.value("session","")!=nonce||j.value("fromSession","")!=remote)return;
auto number=j.value("number",uint64_t{});if(!number||number<=seenNumber)return;
InputCipher cipher(mac(rxKey,"packet|"+std::to_string(number)));
auto body=Json::from_cbor(cipher.decryptPayload(unhex(j.at("data").get<std::string>())));
auto ack=body.at("ack").get<uint64_t>(),first=body.at("first").get<uint64_t>();auto frames=body.at("frames");
if(ack>=nextEvent||!frames.is_array()||frames.size()>12||!first)return;
std::vector<InputFrame> decoded;
for(auto &f:frames){
if(!f.is_array()||f.size()!=5)return;
auto type=f[0].get<uint32_t>();if(type<uint32_t(InputType::Enter)||type>uint32_t(InputType::Cross))return;
decoded.push_back({InputType(type),f[1].get<uint32_t>(),f[2].get<int32_t>(),f[3].get<int32_t>(),f[4].get<int32_t>()});
}
seenNumber=number;lastRead=now;live=true;
if(ack>acked){acked=ack;progress=now;while(!queue.empty()&&queue.front().first<=ack)queue.pop_front();}
if(!decoded.empty()){
if(first==receivedEvent+1){
if(incoming.size()+decoded.size()>512){reset();return;}
incoming.insert(incoming.end(),decoded.begin(),decoded.end());receivedEvent+=decoded.size();
}
ackDue=true;
}
}
void poll(const std::vector<InputFrame> &out,std::vector<InputFrame> &in,uint64_t now,const Send &send)
{
if(!active())return;
if((live&&(now-lastRead>2000||(!queue.empty()&&now-progress>2000)))||(!live&&now-started>3000)){reset();return;}
if(live){
if(queue.empty()&&!out.empty())progress=now;
if(queue.size()+out.size()>512){reset();return;}
for(auto &f:out)queue.emplace_back(nextEvent++,f);
in.insert(in.end(),incoming.begin(),incoming.end());incoming.clear();
}
if(remote.empty()){
if(!lastSend||now-lastSend>=250){auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="hello";j["nonce"]=nonce;j["proof"]=proof(local,peer,nonce,"");send(peer,j);lastSend=now;}return;
}
if(!ackDue&&lastSend&&now-lastSend<(queue.empty()?300:50))return;
Json frames=Json::array();for(auto &[sequence,f]:queue){frames.push_back(Json::array({uint32_t(f.type),f.token,f.a,f.b,f.c}));if(frames.size()==12)break;}
Json body={{"ack",receivedEvent},{"first",queue.empty()?nextEvent:queue.front().first},{"frames",frames}};
auto number=++sentNumber;InputCipher cipher(mac(txKey,"packet|"+std::to_string(number)));
auto j=envelope("input-udp",local);j["to"]=peer;j["op"]="data";j["number"]=number;j["session"]=remote;j["fromSession"]=nonce;j["data"]=hex(cipher.encryptPayload(Json::to_cbor(body)));
send(peer,j);lastSend=now;ackDue=false;
}
};
}
+825
View File
@@ -0,0 +1,825 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include <shellapi.h>
#include <shlobj.h>
#include <atomic>
#include <bcrypt.h>
#include <thread>
#include "lan_files.h"
#include "lan_channel.h"
namespace ks::lan
{
namespace fs = std::filesystem;
Json fileRequest(std::string_view action, const fs::path &path)
{
return {{"action",action},{"path",utf8(path.native())}};
}
fs::path filePath(const Json &value)
{
return wide(value.get<std::string>());
}
void checkFileReply(const Json &reply)
{
const auto error=reply.at("error").get<DWORD>();
if(error) throw std::system_error(static_cast<int>(error),std::system_category());
}
bool recycleFile(HWND owner,const fs::path &path)
{
const auto initialized=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
if(FAILED(initialized)) return false;
IFileOperation *operation{};
IShellItem *item{};
auto result=CoCreateInstance(__uuidof(FileOperation),nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&operation));
if(SUCCEEDED(result))
result=operation->SetOperationFlags(FOFX_RECYCLEONDELETE|FOF_SILENT|FOF_NOCONFIRMATION|
FOF_NOERRORUI|FOFX_EARLYFAILURE);
if(SUCCEEDED(result) && owner) result=operation->SetOwnerWindow(owner);
if(SUCCEEDED(result)) result=SHCreateItemFromParsingName(path.c_str(),nullptr,IID_PPV_ARGS(&item));
if(SUCCEEDED(result)) result=operation->DeleteItem(item,nullptr);
if(SUCCEEDED(result)) result=operation->PerformOperations();
BOOL aborted=TRUE;
if(SUCCEEDED(result)) result=operation->GetAnyOperationsAborted(&aborted);
if(item) item->Release();
if(operation) operation->Release();
CoUninitialize();
return SUCCEEDED(result) && !aborted;
}
namespace
{
constexpr size_t FrameLimit=256*1024;
constexpr DWORD SocketTimeout=5000;
struct WinSocket
{
SOCKET value=INVALID_SOCKET;
~WinSocket(){if(value!=INVALID_SOCKET) closesocket(value);}
};
void socketError()
{
throw std::system_error(WSAGetLastError(),std::system_category());
}
struct Network
{
Network()
{
WSADATA data{};
if(const auto error=WSAStartup(MAKEWORD(2,2),&data))
throw std::system_error(error,std::system_category());
}
~Network(){WSACleanup();}
};
class Wire
{
SOCKET socket;
std::unique_ptr<InputCipher> tx,rx;
void bytes(void *buffer,size_t size,bool sending)
{
auto *next=static_cast<char*>(buffer);
const auto deadline=GetTickCount64()+SocketTimeout;
while(size)
{
if(GetTickCount64()>=deadline)
throw std::system_error(WSAETIMEDOUT,std::system_category());
const auto n=sending?send(socket,next,static_cast<int>(size),0):
recv(socket,next,static_cast<int>(size),0);
if(n==SOCKET_ERROR) socketError();
if(!n) throw std::system_error(WSAECONNRESET,std::system_category());
size-=n;
next+=n;
}
}
public:
explicit Wire(SOCKET socket):socket(socket)
{
if(setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO,reinterpret_cast<const char*>(&SocketTimeout),sizeof(SocketTimeout)) ||
setsockopt(socket,SOL_SOCKET,SO_SNDTIMEO,reinterpret_cast<const char*>(&SocketTimeout),sizeof(SocketTimeout)))
socketError();
const BOOL yes=TRUE;
setsockopt(socket,IPPROTO_TCP,TCP_NODELAY,reinterpret_cast<const char*>(&yes),sizeof(yes));
}
void write(const Json &message)
{
auto data=Json::to_cbor(message);
if(tx) data=tx->encryptPayload(data,FrameLimit-16);
if(data.size()>FrameLimit) throw std::runtime_error("LAN file frame too large.");
uint32_t length=htonl(static_cast<uint32_t>(data.size()));
bytes(&length,sizeof(length),true);
bytes(data.data(),data.size(),true);
}
Json read(bool idle=false)
{
if(idle)
{
fd_set ready{};
FD_SET(socket,&ready);
if(select(0,&ready,nullptr,nullptr,nullptr)==SOCKET_ERROR) socketError();
}
uint32_t length{};
bytes(&length,sizeof(length),false);
length=ntohl(length);
if(!length || length>(rx?FrameLimit:1024))
throw std::runtime_error("Invalid LAN file frame.");
Bytes data(length);
bytes(data.data(),data.size(),false);
if(rx) data=rx->decryptPayload(data,FrameLimit-16);
return Json::from_cbor(data);
}
void authenticate(const std::string &secret,const std::string &client,const std::string &server,
const std::string &challenge,const std::string &nonce,bool outbound)
{
if(!identifier(client)||!identifier(server)||client==server||!identifier(challenge)||!identifier(nonce))
throw std::runtime_error("Invalid LAN file session.");
const auto context="KSwitcher files v1|"+client+"|"+server+"|"+challenge+"|"+nonce;
tx=std::make_unique<InputCipher>(mac(secret,context+(outbound?"|request":"|response")));
rx=std::make_unique<InputCipher>(mac(secret,context+(outbound?"|response":"|request")));
}
};
void connectPeer(SOCKET socket,const FilesPeer &peer)
{
sockaddr_in address{};
address.sin_family=AF_INET;
address.sin_port=htons(peer.port);
if(InetPtonA(AF_INET,peer.address.c_str(),&address.sin_addr)!=1)
throw std::system_error(WSAEHOSTUNREACH,std::system_category());
u_long mode=1;
if(ioctlsocket(socket,FIONBIO,&mode)) socketError();
if(connect(socket,reinterpret_cast<sockaddr*>(&address),sizeof(address)))
{
if(WSAGetLastError()!=WSAEWOULDBLOCK) socketError();
fd_set writable{},failed{};
FD_SET(socket,&writable);
FD_SET(socket,&failed);
timeval timeout{2,0};
const auto result=select(0,nullptr,&writable,&failed,&timeout);
if(result==SOCKET_ERROR) socketError();
if(!result) throw std::system_error(WSAETIMEDOUT,std::system_category());
int error{},size=sizeof(error);
if(getsockopt(socket,SOL_SOCKET,SO_ERROR,reinterpret_cast<char*>(&error),&size)) socketError();
if(error) throw std::system_error(error,std::system_category());
}
mode=0;
if(ioctlsocket(socket,FIONBIO,&mode)) socketError();
}
uint64_t fileSize(DWORD high,DWORD low){return (uint64_t(high)<<32)|low;}
Json ok(){return {{"error",ERROR_SUCCESS}};}
Json failure(DWORD error=GetLastError()){return {{"error",error?error:ERROR_GEN_FAILURE}};}
struct FileHandle
{
HANDLE value=INVALID_HANDLE_VALUE;
void close(){if(value!=INVALID_HANDLE_VALUE) CloseHandle(std::exchange(value,INVALID_HANDLE_VALUE));}
~FileHandle(){close();}
};
fs::path temporaryFile(const fs::path &target)
{
return target.parent_path()/wide(".kswitcher-"+randomHex(16)+".tmp");
}
std::string hashPrefix(HANDLE file,uint64_t size,const FileProgress &progress,uint64_t offset=0)
{
struct Hash
{
BCRYPT_ALG_HANDLE algorithm{};
BCRYPT_HASH_HANDLE value{};
~Hash(){if(value) BCryptDestroyHash(value);if(algorithm) BCryptCloseAlgorithmProvider(algorithm,0);}
} hash;
const auto checked=[](NTSTATUS status)
{
if(status<0) throw std::system_error(ERROR_GEN_FAILURE,std::system_category());
};
LARGE_INTEGER position{};
position.QuadPart=offset;
if(!SetFilePointerEx(file,position,nullptr,FILE_BEGIN))
throw std::system_error(GetLastError(),std::system_category());
checked(BCryptOpenAlgorithmProvider(&hash.algorithm,BCRYPT_SHA256_ALGORITHM,nullptr,0));
checked(BCryptCreateHash(hash.algorithm,&hash.value,nullptr,0,nullptr,0,0));
Bytes buffer(FileChunk),digest(32);
const auto total=size;
auto last=GetTickCount64();
while(size)
{
const auto now=GetTickCount64();
if(progress && now-last>=100)
{
if(!progress(total-size,total)) throw std::system_error(ERROR_CANCELLED,std::system_category());
last=now;
}
const auto count=static_cast<DWORD>(std::min<uint64_t>(size,buffer.size()));
DWORD read{};
if(!ReadFile(file,buffer.data(),count,&read,nullptr) || read!=count)
throw std::system_error(ERROR_READ_FAULT,std::system_category());
checked(BCryptHashData(hash.value,buffer.data(),read,0));
size-=read;
}
checked(BCryptFinishHash(hash.value,digest.data(),static_cast<ULONG>(digest.size()),0));
std::string hex;
for(const auto byte:digest){hex+="0123456789abcdef"[byte>>4];hex+="0123456789abcdef"[byte&15];}
return hex;
}
class FileOperations
{
bool remote;
HANDLE search=INVALID_HANDLE_VALUE;
WIN32_FIND_DATAW entry{};
bool pending{};
FileHandle input,output;
fs::path temporary,target;
bool resumable{};
uint64_t appendBase{},expectedSize{};
void closeSearch()
{
if(search!=INVALID_HANDLE_VALUE) FindClose(std::exchange(search,INVALID_HANDLE_VALUE));
}
void discard(bool removePartial=false)
{
output.close();
if(resumable && !removePartial) {temporary.clear();resumable=false;return;}
resumable=false;
if(!temporary.empty() && !DeleteFileW(temporary.c_str()) && GetLastError()==ERROR_ACCESS_DENIED)
{
SetFileAttributesW(temporary.c_str(),FILE_ATTRIBUTE_NORMAL);
DeleteFileW(temporary.c_str());
}
temporary.clear();
}
fs::path path(const Json &value) const
{
auto result=filePath(value);
const auto &name=result.native();
if(name.find(L'\0')!=name.npos) throw std::system_error(ERROR_INVALID_NAME,std::system_category());
if(remote)
{
// Only drive paths from the remote Windows namespace. Reject NT devices and streams.
if(name.size()<3 || !((name[0]>=L'A'&&name[0]<=L'Z')||(name[0]>=L'a'&&name[0]<=L'z')) ||
name[1]!=L':' || (name[2]!=L'\\' && name[2]!=L'/') || name.find(L':',2)!=name.npos)
throw std::system_error(ERROR_INVALID_NAME,std::system_category());
result=result.lexically_normal();
for(const auto &part:result.relative_path())
{
const auto &component=part.native();
if(component.empty()) continue;
const auto dosName=component.substr(0,component.find_first_of(L". "));
if(part==L".." || component.find_first_of(L"<>|?*\"")!=component.npos ||
component.back()==L' ' || component.back()==L'.' ||
std::any_of(component.begin(),component.end(),[](wchar_t ch){return ch<32;}) ||
_wcsicmp(dosName.c_str(),L"CON")==0 || _wcsicmp(dosName.c_str(),L"PRN")==0 ||
_wcsicmp(dosName.c_str(),L"AUX")==0 || _wcsicmp(dosName.c_str(),L"NUL")==0 ||
(dosName.size()==4 && (_wcsnicmp(dosName.c_str(),L"COM",3)==0 ||
_wcsnicmp(dosName.c_str(),L"LPT",3)==0) && std::wstring_view(L"123456789¹²³").find(dosName.back())!=component.npos))
throw std::system_error(ERROR_INVALID_NAME,std::system_category());
}
}
return result;
}
Json listNext()
{
auto reply=ok();
auto &entries=reply["entries"]=Json::array();
while(search!=INVALID_HANDLE_VALUE && entries.size()<64)
{
if(!pending && !FindNextFileW(search,&entry))
{
const auto error=GetLastError();
closeSearch();
if(error!=ERROR_NO_MORE_FILES) return failure(error);
break;
}
pending=false;
const std::wstring name(entry.cFileName);
if(name==L"." || name==L".." ||
((entry.dwFileAttributes&FILE_ATTRIBUTE_HIDDEN) && name.starts_with(L".kswitcher-") &&
name.ends_with(L".partial") && name.size()==83)) continue;
entries.push_back({{"name",utf8(name)},{"attributes",entry.dwFileAttributes},
{"size",fileSize(entry.nFileSizeHigh,entry.nFileSizeLow)},
{"modified",fileSize(entry.ftLastWriteTime.dwHighDateTime,entry.ftLastWriteTime.dwLowDateTime)}});
}
reply["more"]=search!=INVALID_HANDLE_VALUE;
return reply;
}
static DWORD CALLBACK copyProgress(LARGE_INTEGER total,LARGE_INTEGER transferred,LARGE_INTEGER,LARGE_INTEGER,
DWORD,DWORD,HANDLE,HANDLE,LPVOID context)
{
const auto &callback=*static_cast<const FileProgress*>(context);
try {return !callback || callback(transferred.QuadPart,total.QuadPart)?PROGRESS_CONTINUE:PROGRESS_CANCEL;}
catch(...) {return PROGRESS_CANCEL;}
}
Json copy(const fs::path &source,const fs::path &destination,bool replace,bool append,
const FileProgress &progress)
{
discard();
temporary=temporaryFile(destination);
if(!append)
{
if(!CopyFileExW(source.c_str(),temporary.c_str(),copyProgress,
const_cast<FileProgress*>(&progress),nullptr,COPY_FILE_FAIL_IF_EXISTS))
{
const auto error=GetLastError();
if(error==ERROR_FILE_EXISTS || error==ERROR_ALREADY_EXISTS) temporary.clear();
return failure(error);
}
}
else
{
FileHandle in{CreateFileW(source.c_str(),GENERIC_READ,FILE_SHARE_READ,nullptr,OPEN_EXISTING,0,nullptr)};
if(in.value==INVALID_HANDLE_VALUE){temporary.clear();return failure();}
if(!CopyFileExW(destination.c_str(),temporary.c_str(),nullptr,nullptr,nullptr,COPY_FILE_FAIL_IF_EXISTS))
{
const auto error=GetLastError();
if(error==ERROR_FILE_EXISTS || error==ERROR_ALREADY_EXISTS) temporary.clear();
return failure(error);
}
if(!SetFileAttributesW(temporary.c_str(),FILE_ATTRIBUTE_NORMAL)) return failure();
FileHandle out{CreateFileW(temporary.c_str(),FILE_APPEND_DATA,0,nullptr,OPEN_EXISTING,0,nullptr)};
if(out.value==INVALID_HANDLE_VALUE) return failure();
LARGE_INTEGER size{};
if(!GetFileSizeEx(in.value,&size)) return failure();
Bytes buffer(FileChunk);
uint64_t done=0;
for(;;)
{
DWORD read{},written{};
if(!ReadFile(in.value,buffer.data(),static_cast<DWORD>(buffer.size()),&read,nullptr)) return failure();
if(!read) break;
if(!WriteFile(out.value,buffer.data(),read,&written,nullptr)) return failure();
if(written!=read) return failure(ERROR_WRITE_FAULT);
done+=written;
if(progress && !progress(done,size.QuadPart)) return failure(ERROR_CANCELLED);
}
}
return commit(destination,replace);
}
static Json replaceFile(const fs::path &source,const fs::path &destination,bool replace)
{
const auto attributes=GetFileAttributesW(destination.c_str());
const bool readOnly=replace && attributes!=INVALID_FILE_ATTRIBUTES &&
(attributes&FILE_ATTRIBUTE_READONLY)!=0;
if(replace && attributes!=INVALID_FILE_ATTRIBUTES &&
(attributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT))) return failure(ERROR_ACCESS_DENIED);
if(readOnly && !SetFileAttributesW(destination.c_str(),attributes&~FILE_ATTRIBUTE_READONLY))
return failure();
if(!MoveFileExW(source.c_str(),destination.c_str(),replace?MOVEFILE_REPLACE_EXISTING:0))
{
const auto error=GetLastError();
if(readOnly) SetFileAttributesW(destination.c_str(),attributes);
return failure(error);
}
return ok();
}
Json commit(const fs::path &destination,bool replace)
{
const auto reply=replaceFile(temporary,destination,replace);
if(reply.at("error")==ERROR_SUCCESS) temporary.clear();
return reply;
}
public:
explicit FileOperations(bool remote):remote(remote){}
~FileOperations(){closeSearch();discard();}
Json call(const Json &request,const FileProgress &progress)
{
try {return perform(request,progress);}
catch(const std::system_error &e)
{
if(e.code().value()==ERROR_CANCELLED) discard(true);
return failure(static_cast<DWORD>(e.code().value()));
}
}
Json perform(const Json &request,const FileProgress &progress)
{
const auto action=request.at("action").get<std::string>();
if(action=="drives")
{
auto reply=ok();
auto &drives=reply["drives"]=Json::array();
const auto mask=GetLogicalDrives();
if(!mask) return failure();
for(int letter=0;letter<26;++letter)
{
if(!(mask&(1UL<<letter))) continue;
const wchar_t root[]{static_cast<wchar_t>(L'A'+letter),L':',L'\\',0};
const auto type=GetDriveTypeW(root);
ULARGE_INTEGER total{},free{};
if(type==DRIVE_FIXED || type==DRIVE_RAMDISK)
GetDiskFreeSpaceExW(root,&free,&total,nullptr);
drives.push_back({{"path",utf8(root)},{"type",type},{"total",total.QuadPart},{"free",free.QuadPart}});
}
return reply;
}
if(action=="listNext") return listNext();
if(action=="read")
{
Bytes data(FileChunk);
DWORD count{};
if(!ReadFile(input.value,data.data(),static_cast<DWORD>(data.size()),&count,nullptr)) return failure();
data.resize(count);
return {{"error",0},{"data",Json::binary(std::move(data))}};
}
if(action=="closeRead"){input.close();return ok();}
if(action=="abort"){discard(true);return ok();}
if(action=="hashRead")
{
if(input.value==INVALID_HANDLE_VALUE) return failure(ERROR_INVALID_HANDLE);
return {{"error",0},{"hash",hashPrefix(input.value,request.at("size"),progress)}};
}
if(action=="write")
{
const auto &data=request.at("data").get_binary();
if(data.size()>FileChunk) return failure(ERROR_INVALID_DATA);
LARGE_INTEGER zero{},start{};
if(!SetFilePointerEx(output.value,zero,&start,FILE_CURRENT)) return failure();
const auto end=uint64_t(start.QuadPart)+data.size();
if(end<appendBase || end-appendBase>expectedSize) return failure(ERROR_INVALID_DATA);
DWORD count{};
if(WriteFile(output.value,data.data(),static_cast<DWORD>(data.size()),&count,nullptr) &&
count==data.size() && FlushFileBuffers(output.value)) return ok();
const auto error=GetLastError();
if(!SetFilePointerEx(output.value,start,nullptr,FILE_BEGIN) || !SetEndOfFile(output.value))
discard(true);
return failure(error?error:ERROR_WRITE_FAULT);
}
if(action=="commit")
{
if(output.value==INVALID_HANDLE_VALUE) return failure(ERROR_INVALID_HANDLE);
LARGE_INTEGER size{};
if(!GetFileSizeEx(output.value,&size)) return failure();
if(uint64_t(size.QuadPart)!=appendBase+expectedSize) return failure(ERROR_FILE_INVALID);
const auto stamp=request.at("modified").get<uint64_t>();
const FILETIME time{static_cast<DWORD>(stamp),static_cast<DWORD>(stamp>>32)};
if(!SetFileTime(output.value,nullptr,nullptr,&time) || !FlushFileBuffers(output.value)) return failure();
output.close();
const auto reply=commit(target,request.at("replace").get<bool>());
if(reply.at("error")==ERROR_SUCCESS) SetFileAttributesW(target.c_str(),FILE_ATTRIBUTE_ARCHIVE);
return reply;
}
const auto name=path(request.at("path"));
if(action=="list")
{
closeSearch();
search=FindFirstFileW((name/L"*").c_str(),&entry);
pending=search!=INVALID_HANDLE_VALUE;
if(!pending && GetLastError()!=ERROR_FILE_NOT_FOUND) return failure();
return listNext();
}
if(action=="stat")
{
WIN32_FILE_ATTRIBUTE_DATA data{};
if(!GetFileAttributesExW(name.c_str(),GetFileExInfoStandard,&data)) return failure();
return {{"error",0},{"attributes",data.dwFileAttributes},{"size",fileSize(data.nFileSizeHigh,data.nFileSizeLow)},
{"modified",fileSize(data.ftLastWriteTime.dwHighDateTime,data.ftLastWriteTime.dwLowDateTime)}};
}
if(action=="canonical")
{
std::error_code error;
const auto canonical=fs::weakly_canonical(name,error);
return error?failure(error.value()):Json{{"error",0},{"path",utf8(canonical.native())}};
}
if(action=="mkdir") return CreateDirectoryW(name.c_str(),nullptr)?ok():failure();
if(action=="rmdir") return RemoveDirectoryW(name.c_str())?ok():failure();
if(action=="recycle") return recycleFile(nullptr,name)?ok():failure(ERROR_ACCESS_DENIED);
if(action=="remove")
{
if(request.contains("size"))
{
WIN32_FILE_ATTRIBUTE_DATA data{};
if(!GetFileAttributesExW(name.c_str(),GetFileExInfoStandard,&data)) return failure();
if((data.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT)) ||
fileSize(data.nFileSizeHigh,data.nFileSizeLow)!=request.at("size") ||
fileSize(data.ftLastWriteTime.dwHighDateTime,data.ftLastWriteTime.dwLowDateTime)!=request.at("modified"))
return failure(ERROR_FILE_INVALID);
}
const auto attributes=GetFileAttributesW(name.c_str());
if(attributes==INVALID_FILE_ATTRIBUTES) return failure();
if(attributes&FILE_ATTRIBUTE_DIRECTORY) return RemoveDirectoryW(name.c_str())?ok():failure();
const bool clear=request.value("readOnly",false) && (attributes&FILE_ATTRIBUTE_READONLY) &&
!(attributes&FILE_ATTRIBUTE_REPARSE_POINT);
if(clear && !SetFileAttributesW(name.c_str(),attributes&~FILE_ATTRIBUTE_READONLY)) return failure();
if(DeleteFileW(name.c_str())) return ok();
const auto error=GetLastError();
if(clear) SetFileAttributesW(name.c_str(),attributes);
return failure(error);
}
if(action=="move")
{
const auto destination=path(request.at("target"));
const bool replace=request.at("replace").get<bool>();
if(!replace) return MoveFileExW(name.c_str(),destination.c_str(),0)?ok():failure();
std::error_code error;
if(fs::equivalent(name,destination,error)) return failure(ERROR_ALREADY_EXISTS);
return replaceFile(name,destination,true);
}
if(action=="copy")
{
const auto destination=path(request.at("target"));
if(remote && request.contains("size"))
{
// Both streams execute inside the serving process.
FilesConnection source,target;
streamFile(source,target,"same host",name,destination,request.at("size"),request.at("modified"),
request.at("replace"),request.at("append"),progress);
return ok();
}
return copy(name,destination,request.at("replace"),request.at("append"),progress);
}
if(action=="openRead")
{
input.close();
input.value=CreateFileW(name.c_str(),GENERIC_READ,FILE_SHARE_READ,nullptr,OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT|FILE_FLAG_SEQUENTIAL_SCAN,nullptr);
if(input.value==INVALID_HANDLE_VALUE) return failure();
BY_HANDLE_FILE_INFORMATION info{};
if(!GetFileInformationByHandle(input.value,&info)) return failure();
if(info.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT))
return failure(ERROR_ACCESS_DENIED);
const auto size=fileSize(info.nFileSizeHigh,info.nFileSizeLow);
const auto modified=fileSize(info.ftLastWriteTime.dwHighDateTime,info.ftLastWriteTime.dwLowDateTime);
if(size!=request.at("size") || modified!=request.at("modified"))
return failure(ERROR_FILE_INVALID);
return ok();
}
if(action=="openWrite")
{
discard();
target=name;
const auto transfer=request.at("transfer").get<std::string>();
if(transfer.size()!=64 || transfer.find_first_not_of("0123456789abcdef")!=transfer.npos)
return failure(ERROR_INVALID_DATA);
expectedSize=request.at("size").get<uint64_t>();
const bool append=request.value("append",false);
appendBase=0;
auto staging=target.parent_path()/wide(".kswitcher-"+transfer+".partial");
FileHandle original;
if(append)
{
original.value=CreateFileW(target.c_str(),GENERIC_READ,FILE_SHARE_READ,nullptr,OPEN_EXISTING,0,nullptr);
if(original.value==INVALID_HANDLE_VALUE) return failure();
BY_HANDLE_FILE_INFORMATION info{};
if(!GetFileInformationByHandle(original.value,&info)) return failure();
appendBase=fileSize(info.nFileSizeHigh,info.nFileSizeLow);
}
output.value=CreateFileW(staging.c_str(),GENERIC_READ|GENERIC_WRITE,0,nullptr,OPEN_ALWAYS,
FILE_ATTRIBUTE_HIDDEN|FILE_FLAG_OPEN_REPARSE_POINT,nullptr);
if(output.value==INVALID_HANDLE_VALUE) return failure();
const bool created=GetLastError()!=ERROR_ALREADY_EXISTS;
BY_HANDLE_FILE_INFORMATION info{};
if(!GetFileInformationByHandle(output.value,&info)) return failure();
if(info.nNumberOfLinks!=1 || (info.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT)))
return failure(ERROR_ACCESS_DENIED);
temporary=staging;
resumable=true;
if(created && appendBase)
{
Bytes buffer(FileChunk);
for(uint64_t left=appendBase;left;)
{
const auto count=static_cast<DWORD>(std::min<uint64_t>(left,buffer.size()));
DWORD read{},written{};
if(!ReadFile(original.value,buffer.data(),count,&read,nullptr) || read!=count ||
!WriteFile(output.value,buffer.data(),read,&written,nullptr) || written!=read)
{
discard(true);
return failure(ERROR_WRITE_FAULT);
}
left-=written;
}
}
const auto size=created?appendBase:fileSize(info.nFileSizeHigh,info.nFileSizeLow);
const bool valid=size>=appendBase && size-appendBase<=expectedSize &&
(!appendBase || hashPrefix(original.value,appendBase,progress)==hashPrefix(output.value,appendBase,progress));
if(!valid)
{
discard(true);
return {{"error",0},{"restart",true}};
}
const auto offset=size-appendBase;
return {{"error",0},{"offset",offset},{"hash",hashPrefix(output.value,offset,progress,appendBase)}};
}
return failure(ERROR_INVALID_FUNCTION);
}
};
}
struct FilesConnection::Impl
{
std::optional<Network> network;
WinSocket socket;
std::unique_ptr<Wire> wire;
FileOperations local{false};
Impl()=default;
explicit Impl(const FilesPeer *peer)
{
if(!peer) return;
const auto pairing=Json::parse(unprotect(readFile(dataDirectory()/L"lan-pairing.bin",256*1024)));
if(!pairing.value("enabled",false) || !pairing.at("trusted").contains(peer->id))
throw std::system_error(ERROR_ACCESS_DENIED,std::system_category());
const auto id=pairing.at("id").get<std::string>();
const auto key=pairing.at("trusted").at(peer->id).at("key").get<std::string>();
connect(*peer,id,key);
}
void connect(const FilesPeer &peer,const std::string &id,const std::string &key)
{
network.emplace();
socket.value=::socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(socket.value==INVALID_SOCKET) socketError();
connectPeer(socket.value,peer);
wire=std::make_unique<Wire>(socket.value);
const auto nonce=randomHex(16);
wire->write({{"version",1},{"client",id},{"server",peer.id},{"nonce",nonce}});
const auto challenge=wire->read().at("challenge").get<std::string>();
wire->authenticate(key,id,peer.id,challenge,nonce,true);
wire->write({{"action","authenticate"}});
checkFileReply(wire->read());
}
};
FilesConnection::FilesConnection(const FilesPeer *peer):impl(std::make_unique<Impl>(peer)){}
FilesConnection::FilesConnection(const FilesPeer &peer,const std::string &local,const std::string &key):
impl(std::make_unique<Impl>())
{
impl->connect(peer,local,key);
}
FilesConnection::~FilesConnection()=default;
Json FilesConnection::call(const Json &request,const FileProgress &progress)
{
if(!impl->wire) return impl->local.call(request,progress);
impl->wire->write(request);
for(;;)
{
auto reply=impl->wire->read();
if(!reply.contains("progress")) return reply;
const auto &value=reply.at("progress");
const bool proceed=!progress || progress(value[0].get<uint64_t>(),value[1].get<uint64_t>());
impl->wire->write({{"continue",proceed}});
}
}
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
bool replace,bool append,const FileProgress &progress)
{
auto openSource=fileRequest("openRead",from);
openSource["size"]=size;
openSource["modified"]=modified;
checkFileReply(source.call(openSource));
auto upload=fileRequest("openWrite",to);
upload["append"]=append;
upload["size"]=size;
const auto identity=Json::array({sourceId,ks::utf8(from.native()),ks::utf8(to.native()),
size,modified,append});
upload["transfer"]=ks::lan::mac("KSwitcher partial v1",identity.dump());
uint64_t done=0;
const auto resumeProgress=[&](uint64_t,uint64_t){return !progress || progress(done,size);};
try
{
for(int attempt=0;;++attempt)
{
auto reply=target.call(upload,resumeProgress);
checkFileReply(reply);
if(!reply.value("restart",false))
{
done=reply.at("offset");
const auto sourceHash=source.call({{"action","hashRead"},{"size",done}},resumeProgress);
checkFileReply(sourceHash);
if(sourceHash.at("hash")==reply.at("hash")) break;
checkFileReply(target.call({{"action","abort"}}));
}
if(attempt>0) throw std::system_error(ERROR_FILE_INVALID,std::system_category());
}
while(!progress || progress(done,size))
{
auto packet=source.call({{"action","read"}});
checkFileReply(packet);
auto &data=packet.at("data").get_binary();
if(data.empty())
{
if(done!=size) throw std::system_error(ERROR_FILE_INVALID,std::system_category());
checkFileReply(target.call({{"action","commit"},{"replace",replace},{"modified",modified}}));
checkFileReply(source.call({{"action","closeRead"}}));
return;
}
const auto chunkSize=data.size();
checkFileReply(target.call({{"action","write"},{"data",std::move(packet.at("data"))}}));
done+=chunkSize;
}
throw std::system_error(ERROR_CANCELLED,std::system_category());
}
catch(const std::system_error &error)
{
if(error.code().value()==ERROR_CANCELLED)
checkFileReply(target.call({{"action","abort"}}));
throw;
}
}
struct FilesListener::Impl
{
struct Client
{
WinSocket socket;
std::atomic<bool> done{};
std::thread worker;
~Client()
{
shutdown(socket.value,SD_BOTH);
if(worker.joinable()) worker.join();
}
};
std::string local;
PairKey key;
WinSocket listener;
std::vector<std::unique_ptr<Client>> clients;
uint16_t boundPort{};
Impl(std::string id,PairKey lookup,uint16_t port):local(std::move(id)),key(std::move(lookup))
{
listener.value=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(listener.value==INVALID_SOCKET) return;
BOOL exclusive=TRUE;
u_long mode=1;
sockaddr_in address{};
address.sin_family=AF_INET;
address.sin_port=htons(port);
if(setsockopt(listener.value,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,reinterpret_cast<const char*>(&exclusive),sizeof(exclusive)) ||
ioctlsocket(listener.value,FIONBIO,&mode) ||
bind(listener.value,reinterpret_cast<sockaddr*>(&address),sizeof(address)) ||
listen(listener.value,8))
{
closesocket(std::exchange(listener.value,INVALID_SOCKET));
return;
}
int length=sizeof(address);
if(!getsockname(listener.value,reinterpret_cast<sockaddr*>(&address),&length))
boundPort=ntohs(address.sin_port);
}
void serve(SOCKET socket)
{
Wire wire(socket);
const auto hello=wire.read();
const auto peer=hello.at("client").get<std::string>();
const auto nonce=hello.at("nonce").get<std::string>();
if(hello.at("version")!=1 || hello.at("server")!=local || !identifier(peer) || !identifier(nonce)) return;
const auto secret=key(peer);
if(secret.empty()) return;
const auto challenge=randomHex(16);
wire.write({{"challenge",challenge}});
wire.authenticate(secret,peer,local,challenge,nonce,false);
if(wire.read().at("action")!="authenticate") return;
wire.write(ok());
FileOperations operations(true);
for(;;)
{
const auto request=wire.read(true);
if(!equal(key(peer),secret)) return;
wire.write(operations.call(request,[&](uint64_t done,uint64_t total)
{
if(!equal(key(peer),secret))
throw std::system_error(ERROR_ACCESS_DENIED,std::system_category());
wire.write({{"progress",{done,total}}});
const bool proceed=wire.read(true).value("continue",false);
if(!equal(key(peer),secret)) throw std::system_error(ERROR_ACCESS_DENIED,std::system_category());
return proceed;
}));
}
}
void poll()
{
std::erase_if(clients,[](const auto &client){return client->done.load();});
if(listener.value==INVALID_SOCKET || clients.size()>=16) return;
sockaddr_in address{};
int size=sizeof(address);
WinSocket accepted{accept(listener.value,reinterpret_cast<sockaddr*>(&address),&size)};
const auto ip=ntohl(address.sin_addr.s_addr);
if((ip>>24)!=10 && (ip>>24)!=127 && (ip>>20)!=0xac1 && (ip>>16)!=0xc0a8 && (ip>>16)!=0xa9fe)
return;
if(accepted.value==INVALID_SOCKET) return;
u_long mode=0;
if(ioctlsocket(accepted.value,FIONBIO,&mode)) return;
auto client=std::make_unique<Client>();
client->socket.value=std::exchange(accepted.value,INVALID_SOCKET);
auto *connection=client.get();
connection->worker=std::thread([this,connection]
{
try {serve(connection->socket.value);} catch(const std::exception &) {}
connection->done=true;
});
clients.push_back(std::move(client));
}
};
FilesListener::FilesListener(std::string local,PairKey key,uint16_t port):
impl(std::make_unique<Impl>(std::move(local),std::move(key),port)){}
FilesListener::~FilesListener()=default;
uint16_t FilesListener::port()const{return impl->boundPort;}
void FilesListener::poll(){impl->poll();}
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include "lan_pairing.h"
#include <functional>
namespace ks::lan
{
inline constexpr uint16_t FilesPort=45885;
struct FilesPeer
{
std::string id, address;
std::wstring name;
uint16_t port=FilesPort;
bool filesBrowse{};
};
using FileProgress = std::function<bool(uint64_t, uint64_t)>;
inline constexpr size_t FileChunk = 64 * 1024;
// One connection owns its enumeration, file handles and uncommitted upload.
class FilesConnection
{
struct Impl;
std::unique_ptr<Impl> impl;
public:
explicit FilesConnection(const FilesPeer *peer = nullptr);
FilesConnection(const FilesPeer &peer,const std::string &local,const std::string &key);
~FilesConnection();
Json call(const Json &request, const FileProgress &progress = {});
};
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
bool replace,bool append,const FileProgress &progress);
class FilesListener
{
struct Impl;
std::unique_ptr<Impl> impl;
public:
using PairKey = std::function<std::string(const std::string &)>;
FilesListener(std::string local, PairKey key,uint16_t port=FilesPort);
uint16_t port() const;
~FilesListener();
void poll();
};
Json fileRequest(std::string_view action, const std::filesystem::path &path);
std::filesystem::path filePath(const Json &path);
void checkFileReply(const Json &reply);
bool recycleFile(HWND owner, const std::filesystem::path &path);
}
+357
View File
@@ -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;
}
}
+64
View File
@@ -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");
};
}
+43
View File
@@ -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.");
}
}
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "core.h"
namespace ks
{
inline Bytes decodeLanLog(const std::string &hex)
{
auto digit = [](char value) -> unsigned
{
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
throw std::runtime_error("Invalid diagnostic log response.");
};
if (hex.size() % 2) throw std::runtime_error("Invalid diagnostic log response.");
Bytes bytes;
bytes.reserve(hex.size() / 2);
for (size_t i = 0; i < hex.size(); i += 2)
bytes.push_back(static_cast<unsigned char>(digit(hex[i]) * 16 + digit(hex[i + 1])));
return bytes;
}
}
+82
View File
@@ -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);
}
};
}
+97
View File
@@ -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 {};
}
}
+26
View File
@@ -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);
};
}
+101 -8
View File
@@ -1,12 +1,61 @@
#include "app.h"
#include <shellapi.h>
#include <shlobj.h>
#include "configuration.h"
#include "storage.h"
namespace
{
constexpr std::wstring_view elevatedRestart = L"--elevated-restart=";
bool shouldElevateOnLaunch()
{
if (ks::isLocalBuild() || IsUserAnAdmin())
return false;
try
{
auto settings = ks::loadSettings(ks::dataDirectory() / L"settings.json", {});
if (!settings.autostart || !settings.elevatedAutostart)
return false;
}
catch (const std::exception &)
{
// The normal startup path handles unreadable settings and reports them.
return false;
}
try
{
// Let the resident explain the stale path before asking to elevate the repair.
return ks::staleAutostartPath(true).empty();
}
catch (const std::exception &)
{
// The elevated resident can retry reading the task.
return true;
}
}
}
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
int argc{};
auto argv = CommandLineToArgvW(GetCommandLineW(), &argc);
std::wstring arg = argc > 1 ? argv[1] : L"";
if (argc == 2 && arg.starts_with(L"--history-host=")) {
LocalFree(argv);
int result = 1;
try {
auto service = reinterpret_cast<HWND>(uintptr_t(std::stoull(arg.substr(15))));
wchar_t name[64]{}; GetClassNameW(service, name, 64);
if (std::wstring(name) != L"KSwitcher.Main" || !ks::configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("Invalid history service");
ks::App app(instance, true, {}, service);
result = app.execute();
} catch (...) {}
CoUninitialize();
return result;
}
if (arg.starts_with(L"--configure-autostart="))
{
int result = 1;
@@ -28,8 +77,28 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
CoUninitialize();
return result;
}
if (arg.starts_with(elevatedRestart))
{
bool approved = false;
try
{
approved = argc == 2 && IsUserAnAdmin() && arg.substr(elevatedRestart.size()) == ks::currentUserSid();
}
catch (const std::exception &) {}
if (!approved)
{
MessageBoxW(nullptr, L"Administrator approval for the same Windows account is required.",
L"KSwitcher", MB_OK | MB_ICONERROR);
LocalFree(argv);
CoUninitialize();
return 1;
}
arg.clear();
}
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
const DWORD mutexError = GetLastError();
// Opening an existing elevated instance's mutex can be denied to a normal process.
bool existing = mutexError == ERROR_ALREADY_EXISTS || (!mutex && mutexError == ERROR_ACCESS_DENIED);
int cmd = ks::ShowHistory;
if (arg == L"--capture")
cmd = ks::Screenshot;
@@ -58,7 +127,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
}
CloseHandle(mutex);
if (mutex) CloseHandle(mutex);
CoUninitialize();
return 0;
}
@@ -71,12 +140,36 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
int result = 0;
try
{
ks::App app(instance, arg == L"--smoke-test");
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
if (argc == 1 && mutex && shouldElevateOnLaunch())
{
auto file = ks::executable();
auto parameters = std::wstring(elevatedRestart) + ks::currentUserSid();
// Let the elevated process acquire the singleton before it starts.
ReleaseMutex(mutex);
CloseHandle(mutex);
mutex = nullptr;
SHELLEXECUTEINFOW request{sizeof(request)};
request.lpVerb = L"runas";
request.lpFile = file.c_str();
request.lpParameters = parameters.c_str();
request.nShow = SW_HIDE;
if (!ShellExecuteExW(&request))
{
if (GetLastError() != ERROR_CANCELLED)
throw std::runtime_error("Windows could not restart KSwitcher as administrator.");
result = 1;
}
}
else
{
ks::App app(instance, arg == L"--smoke-test");
app.isolateHistory = !app.smoke;
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
}
catch (const std::exception &e)
{
+113
View File
@@ -0,0 +1,113 @@
#pragma once
#include "../vendor/json.hpp"
#include "lan_log.h"
#include <iostream>
namespace ks
{
inline int runLogMcp(const std::function<Bytes(const Bytes &)> &lan)
{
using Json = nlohmann::json;
auto command = [&](const Json &request)
{
auto text = request.dump();
return Json::parse(lan(Bytes(text.begin(), text.end())));
};
const Json tools = Json::parse(R"({"tools":[
{"name":"list_log_computers","description":"List paired computers that support remote log downloads.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true}},
{"name":"read_diagnostic_log","description":"Read KSwitcher diagnostic log lines. Omit peer for the local computer. Use offset to page backward from the newest line.","inputSchema":{"type":"object","properties":{"peer":{"type":"string"},"previous":{"type":"boolean"},"lines":{"type":"integer","minimum":1,"maximum":500,"default":100},"offset":{"type":"integer","minimum":0,"default":0}}},"annotations":{"readOnlyHint":true}}
]})");
std::string line;
while (std::getline(std::cin, line))
{
Json id, reply;
try
{
auto message = Json::parse(line);
if (!message.contains("id")) continue;
id = message.at("id");
auto method = message.at("method").get<std::string>();
if (method == "initialize")
{
auto version = message.at("params").value("protocolVersion", "");
if (version != "2025-03-26" && version != "2025-06-18" && version != "2025-11-25")
version = "2025-11-25";
reply["result"] = {{"protocolVersion", version}, {"capabilities", {{"tools", Json::object()}}},
{"serverInfo", {{"name", "kswitcher-logs"}, {"version", "1.0.0"}}}};
}
else if (method == "ping") reply["result"] = Json::object();
else if (method == "tools/list") reply["result"] = tools;
else if (method == "tools/call")
{
std::string output;
bool failed = false;
try
{
auto params = message.at("params");
auto arguments = params.value("arguments", Json::object());
auto name = params.at("name").get<std::string>();
if (name == "list_log_computers")
{
auto peers = Json::array();
for (const auto &peer : command({{"action", "status"}}).at("peers").get<Json::array_t>())
if (peer.value("paired", false))
peers.push_back({{"id", peer.at("id")}, {"name", peer.at("name")},
{"available", peer.value("verified", false) && peer.value("fileTransfer", false)}});
output = peers.dump();
}
else if (name == "read_diagnostic_log")
{
auto peer = arguments.value("peer", "");
bool previous = arguments.value("previous", false);
int lines = arguments.value("lines", 100);
if (lines < 1 || lines > 500) throw std::runtime_error("lines must be between 1 and 500.");
int offset = arguments.value("offset", 0);
if (offset < 0) throw std::runtime_error("offset must be nonnegative.");
if (peer.empty()) output = utf8(readDiagnosticLog(previous));
else
{
auto request = command({{"action", "requestLog"}, {"peer", peer}, {"previous", previous}}).at("request");
for (;;)
{
auto result = command({{"action", "takeLog"}, {"request", request}});
auto state = result.at("state").get<std::string>();
if (state == "ready")
{
auto bytes = decodeLanLog(result.at("logHex"));
output.assign(bytes.begin(), bytes.end());
break;
}
if (state == "denied") throw std::runtime_error("Remote log downloads are disabled on that computer.");
if (state == "unavailable") throw std::runtime_error("The remote log is unavailable.");
if (state != "waiting") throw std::runtime_error("The remote log download timed out.");
Sleep(250);
}
}
auto previousLine = [&](size_t end)
{
if (!end) return size_t{};
if (output[end - 1] == '\n') --end;
return end ? output.rfind('\n', end - 1) + 1 : size_t{};
};
size_t end = output.size();
while (offset-- > 0 && end) end = previousLine(end);
size_t start = end;
while (lines-- > 0 && start) start = previousLine(start);
output = output.substr(start, end - start);
}
else throw std::runtime_error("Unknown tool.");
}
catch (const std::exception &error) { output = error.what(); failed = true; }
reply["result"] = {{"content", Json::array({{{"type", "text"}, {"text", output}}})}, {"isError", failed}};
}
else reply["error"] = {{"code", -32601}, {"message", "Method not found"}};
}
catch (const std::exception &) { reply["error"] = {{"code", -32600}, {"message", "Invalid request"}}; }
reply["jsonrpc"] = "2.0";
reply["id"] = id;
std::cout << reply.dump(-1, ' ', false, Json::error_handler_t::replace) << std::endl;
}
return 0;
}
}
+171
View File
@@ -0,0 +1,171 @@
#pragma once
#include "core.h"
#include <cstdlib>
namespace ks
{
inline constexpr const wchar_t *MouseButtonNames[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2"};
inline constexpr const wchar_t *MouseEvents[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2",
L"Double left", L"Double right", L"Double middle",
L"Wheel up", L"Wheel down", L"Wheel left", L"Wheel right"};
inline constexpr int BuiltInMouseEventCount = 8;
inline int mouseWheelEvent(UINT message, SHORT delta)
{
if (!delta) return -1;
if (message == WM_MOUSEWHEEL) return BuiltInMouseEventCount + (delta < 0);
if (message == WM_MOUSEHWHEEL) return BuiltInMouseEventCount + 2 + (delta > 0);
return -1;
}
inline int rawMouseSignal(const RAWMOUSE &mouse)
{
constexpr USHORT downFlags[] = {RI_MOUSE_LEFT_BUTTON_DOWN, RI_MOUSE_RIGHT_BUTTON_DOWN,
RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN};
int signal = -1;
for (int button = 0; button < static_cast<int>(std::size(downFlags)); ++button)
{
if (!(mouse.usButtonFlags & downFlags[button])) continue;
if (signal >= 0) return -1;
signal = button;
}
const auto wheel = mouse.usButtonFlags & (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL);
if (!wheel) return signal;
if (signal >= 0 || wheel == (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL)) return -1;
return mouseWheelEvent(wheel == RI_MOUSE_WHEEL ? WM_MOUSEWHEEL : WM_MOUSEHWHEEL,
static_cast<SHORT>(mouse.usButtonData));
}
inline int mouseWheelSteps(int &remainder, SHORT delta)
{
const int total = remainder + std::abs(static_cast<int>(delta));
remainder = total % WHEEL_DELTA;
return total / WHEEL_DELTA;
}
inline bool mouseEventEnabled(const Settings &settings, int event)
{
return event >= 0 && event < static_cast<int>(std::size(MouseEvents)) &&
(event < BuiltInMouseEventCount || settings.customMouseEvents.contains(MouseEvents[event]));
}
inline std::wstring mouseEventName(const Settings &settings, int event)
{
auto name = settings.customMouseEvents.find(MouseEvents[event]);
return name == settings.customMouseEvents.end() ? MouseEvents[event] : name->second;
}
inline constexpr const wchar_t *MouseActions[] = {L"Standard behavior", L"Send key combination", L"Back", L"Forward",
L"Volume up", L"Volume down", L"Mute", L"Launch application"};
inline constexpr wchar_t GlobalMouseProfile[] = L"*";
inline constexpr std::wstring_view NamedMouseProfilePrefix = L"name:";
bool validMouseApplication(const std::wstring &app);
inline bool isNamedMouseProfile(std::wstring_view profile)
{
return profile.starts_with(NamedMouseProfilePrefix);
}
inline std::wstring mouseProfileName(const std::wstring &profile)
{
if (profile == GlobalMouseProfile) return L"Global";
return isNamedMouseProfile(profile) ? profile.substr(NamedMouseProfilePrefix.size()) : profile;
}
inline bool mouseActionApplies(const MouseAction &action, const std::wstring &app)
{
return action.applications.empty() || action.applications.contains(app);
}
inline const std::set<std::wstring> &mouseActionScope(const Settings &settings, const std::wstring &profile,
const MouseAction &action)
{
const auto scope = settings.mouseProfileApplications.find(profile);
return scope == settings.mouseProfileApplications.end() ? action.applications : scope->second;
}
inline bool isMouseVolumeAction(const MouseAction *action)
{
return action && (action->type == MouseActionType::VolumeUp ||
action->type == MouseActionType::VolumeDown || action->type == MouseActionType::Mute);
}
inline bool isMouseVolumeStep(MouseActionType action)
{
return action == MouseActionType::VolumeUp || action == MouseActionType::VolumeDown;
}
inline constexpr UINT MouseVolumeHoldDelay = 350;
inline UINT mouseVolumeRepeatInterval(ULONGLONG heldFor)
{
if (heldFor < 1000) return 140;
if (heldFor < 2000) return 80;
return 40;
}
inline const MouseAction *mouseBinding(const Settings &settings, const std::wstring &app, int event)
{
auto profile = settings.mouseBindings.find(app);
if (profile == settings.mouseBindings.end()) return nullptr;
auto action = profile->second.find(MouseEvents[event]);
return action == profile->second.end() ? nullptr : &action->second;
}
inline const MouseAction *effectiveMouseAction(const Settings &settings, const std::wstring &app, int event,
bool foreground = true, bool *profileScoped = nullptr)
{
if (profileScoped) *profileScoped = false;
if (!mouseEventEnabled(settings, event)) return nullptr;
auto *action = mouseBinding(settings, app, event);
if (!action)
{
const MouseAction *unrestricted = nullptr;
for (auto profile = settings.mouseBindings.lower_bound(std::wstring(NamedMouseProfilePrefix));
profile != settings.mouseBindings.end() && isNamedMouseProfile(profile->first); ++profile)
{
if (settings.disabledMouseProfiles.contains(profile->first)) continue;
const auto binding = profile->second.find(MouseEvents[event]);
if (binding == profile->second.end()) continue;
const auto &candidate = binding->second;
const auto &scope = mouseActionScope(settings, profile->first, candidate);
if (foreground && scope.contains(app))
{
if (profileScoped) *profileScoped = true;
return candidate.type == MouseActionType::Standard ? nullptr : &candidate;
}
if (!unrestricted && scope.empty()) unrestricted = &candidate;
}
const auto *global = mouseBinding(settings, GlobalMouseProfile, event);
if (foreground && global && global->applications.contains(app)) action = global;
else if (unrestricted) action = unrestricted;
else if (global && global->applications.empty()) action = global;
}
return action && action->type != MouseActionType::Standard && mouseActionApplies(*action, app) ? action : nullptr;
}
inline bool doubleMouseClick(POINT first, POINT second, ULONGLONG elapsed)
{
return elapsed <= GetDoubleClickTime() &&
std::abs(static_cast<long long>(first.x) - second.x) * 2 <= GetSystemMetrics(SM_CXDOUBLECLK) &&
std::abs(static_cast<long long>(first.y) - second.y) * 2 <= GetSystemMetrics(SM_CYDOUBLECLK);
}
inline int mouseButton(UINT message, WORD xbutton)
{
switch (message)
{
case WM_LBUTTONDOWN: case WM_LBUTTONUP: return 0;
case WM_RBUTTONDOWN: case WM_RBUTTONUP: return 1;
case WM_MBUTTONDOWN: case WM_MBUTTONUP: return 2;
case WM_XBUTTONDOWN: case WM_XBUTTONUP:
if (xbutton == XBUTTON1) return 3;
if (xbutton == XBUTTON2) return 4;
}
return -1;
}
inline bool mouseButtonDown(UINT message)
{
return message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN ||
message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN;
}
}
+11 -6
View File
@@ -123,13 +123,16 @@ struct OcrStatics : IInspectable
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
{
Bytes encoded;
const Bytes *input = &encoded;
for (const auto &f : clip.formats)
if (f.name == L"PNG")
{
encoded = f.data;
input = &f.data;
break;
}
if (encoded.empty())
if (input->empty())
{
input = &encoded;
for (const auto &f : clip.formats)
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
{
@@ -146,19 +149,21 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
file.bfType = 0x4d42;
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
file.bfOffBits = (DWORD)offset;
encoded.resize(sizeof(file));
encoded.resize(sizeof(file) + f.data.size());
memcpy(encoded.data(), &file, sizeof(file));
encoded.insert(encoded.end(), f.data.begin(), f.data.end());
memcpy(encoded.data() + sizeof(file), f.data.data(), f.data.size());
break;
}
if (encoded.empty())
}
if (input->empty())
throw std::runtime_error("This clipboard item has no supported image");
Com<IWICImagingFactory> wic;
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
(void **)wic.out()));
Com<IWICStream> stream;
check(wic->CreateStream(stream.out()));
check(stream->InitializeFromMemory(encoded.data(), (DWORD)encoded.size()));
// WIC decoding only reads the stream. The clip outlives every WIC object here.
check(stream->InitializeFromMemory(const_cast<BYTE *>(input->data()), (DWORD)input->size()));
Com<IWICBitmapDecoder> decoder;
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
Com<IWICBitmapFrameDecode> frame;
+30 -6
View File
@@ -13,7 +13,7 @@ class PasteProbe
public:
std::wstring status = L"not-requested";
PasteProbe(bool enabled, const std::wstring &original)
PasteProbe(bool enabled, HWND target)
{
if (!enabled)
return;
@@ -25,6 +25,18 @@ class PasteProbe
automation->put_TransactionTimeout(200);
if (FAILED(automation->GetFocusedElement(&element)) || !element)
return;
if (target)
{
DWORD process{};
GetWindowThreadProcessId(target, &process);
int focusedProcess{};
if (!process || FAILED(element->get_CurrentProcessId(&focusedProcess)) ||
process != static_cast<DWORD>(focusedProcess) || GetForegroundWindow() != target)
{
status = L"focused-control-changed";
return;
}
}
BOOL password = TRUE;
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
{
@@ -35,6 +47,11 @@ class PasteProbe
(void **)&pattern)) ||
!pattern)
return;
}
PasteProbe(bool enabled, const std::wstring &original, HWND target = nullptr)
: PasteProbe(enabled, target)
{
if (!pattern) return;
auto selected = read(1, false);
auto normalize = [](std::wstring value)
{
@@ -73,15 +90,22 @@ class PasteProbe
return {};
}
IUIAutomationElement *focused{};
BOOL same = FALSE;
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
if (FAILED(automation->GetFocusedElement(&focused)) || !focused)
{
automation->CompareElements(element, focused, &same);
if (focused) focused->Release();
status = L"accessibility-focus-unavailable";
return {};
}
BOOL same = FALSE;
if (FAILED(automation->CompareElements(element, focused, &same)))
{
focused->Release();
status = L"accessibility-focus-comparison-failed";
return {};
}
if (!same)
{
if (focused)
focused->Release();
focused->Release();
status = L"focused-control-changed";
return {};
}
+207 -10
View File
@@ -4,12 +4,19 @@
#include <fstream>
#include <iomanip>
#include <imm.h>
#include <endpointvolume.h>
#include <mmdeviceapi.h>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <shellapi.h>
#include <shlobj.h>
#include <exdisp.h>
#include <shldisp.h>
#include <tlhelp32.h>
#include <wincrypt.h>
#include <wrl/client.h>
namespace ks
{
@@ -159,6 +166,31 @@ void showRequestedWindow(HWND window)
if (!IsWindowVisible(window))
ShowWindow(window, SW_SHOW);
}
void showMenuAccessKeys(HMENU menu)
{
// Mouse-opened popups ignore the owner's keyboard-cue state.
// Preserve the mnemonic and expose it in the right-hand hint column.
for (int i = 0; i < GetMenuItemCount(menu); ++i)
{
if (auto child = GetSubMenu(menu, i)) showMenuAccessKeys(child);
int length = GetMenuStringW(menu, i, nullptr, 0, MF_BYPOSITION);
std::wstring text(length + 1, L'\0');
GetMenuStringW(menu, i, text.data(), (int)text.size(), MF_BYPOSITION);
text.resize(length);
if (text.find(L'\t') != text.npos) continue;
for (size_t p = 0; p + 1 < text.size(); ++p)
{
if (text[p] != L'&') continue;
if (text[p + 1] == L'&') { ++p; continue; }
text += L"\t" + upper(text.substr(p + 1, 1));
MENUITEMINFOW item{sizeof(item)};
item.fMask = MIIM_STRING;
item.dwTypeData = text.data();
SetMenuItemInfoW(menu, i, TRUE, &item);
break;
}
}
}
bool activateWindow(HWND window)
{
if (!IsWindow(window))
@@ -240,15 +272,104 @@ std::wstring processName(HWND window)
CloseHandle(h);
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
}
Clip readClipboard(HWND window, size_t limit)
std::wstring mouseApplicationName(HWND window)
{
auto name = lower(processName(window));
if (name != L"applicationframehost") return name;
std::wstring hosted;
EnumChildWindows(window, [](HWND child, LPARAM output) -> BOOL {
wchar_t kind[64]{};
if (!GetClassNameW(child, kind, std::size(kind)) || wcscmp(kind, L"Windows.UI.Core.CoreWindow") != 0)
return TRUE;
auto &app = *reinterpret_cast<std::wstring *>(output);
app = lower(processName(child));
return app.empty();
}, reinterpret_cast<LPARAM>(&hosted));
return hosted.empty() ? name : hosted;
}
bool launchThroughExplorer(const std::wstring &path)
{
HANDLE token{};
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false;
TOKEN_ELEVATION elevation{};
DWORD size{};
const auto queried = GetTokenInformation(token, TokenElevation, &elevation, sizeof(elevation), &size);
CloseHandle(token);
if (!queried) return false;
if (!elevation.TokenIsElevated)
{
SHELLEXECUTEINFOW launch{sizeof(launch)};
launch.fMask = SEE_MASK_FLAG_NO_UI;
launch.lpFile = path.c_str();
launch.nShow = SW_SHOWNORMAL;
return ShellExecuteExW(&launch) != FALSE;
}
// An elevated resident delegates execution to the interactive desktop shell.
auto release = [](IUnknown *object) { if (object) object->Release(); };
IShellWindows *windowsRaw{};
auto hr = CoCreateInstance(CLSID_ShellWindows, nullptr, CLSCTX_ALL, IID_IShellWindows,
reinterpret_cast<void **>(&windowsRaw));
std::unique_ptr<IShellWindows, decltype(release)> windows(windowsRaw, release);
if (FAILED(hr) || !windows) return false;
VARIANT empty{}, desktop{};
desktop.vt = VT_I4;
long windowHandle{};
IDispatch *desktopRaw{};
hr = windows->FindWindowSW(&desktop, &empty, SWC_DESKTOP, &windowHandle, SWFO_NEEDDISPATCH, &desktopRaw);
std::unique_ptr<IDispatch, decltype(release)> dispatch(desktopRaw, release);
if (FAILED(hr) || !dispatch) return false;
IServiceProvider *providerRaw{};
hr = dispatch->QueryInterface(IID_IServiceProvider, reinterpret_cast<void **>(&providerRaw));
std::unique_ptr<IServiceProvider, decltype(release)> provider(providerRaw, release);
if (FAILED(hr) || !provider) return false;
IShellBrowser *browserRaw{};
hr = provider->QueryService(SID_STopLevelBrowser, IID_IShellBrowser, reinterpret_cast<void **>(&browserRaw));
std::unique_ptr<IShellBrowser, decltype(release)> browser(browserRaw, release);
if (FAILED(hr) || !browser) return false;
IShellView *viewRaw{};
hr = browser->QueryActiveShellView(&viewRaw);
std::unique_ptr<IShellView, decltype(release)> view(viewRaw, release);
if (FAILED(hr) || !view) return false;
IShellFolderViewDual *folderRaw{};
hr = view->GetItemObject(SVGIO_BACKGROUND, IID_IShellFolderViewDual, reinterpret_cast<void **>(&folderRaw));
std::unique_ptr<IShellFolderViewDual, decltype(release)> folder(folderRaw, release);
if (FAILED(hr) || !folder) return false;
IDispatch *applicationRaw{};
hr = folder->get_Application(&applicationRaw);
std::unique_ptr<IDispatch, decltype(release)> application(applicationRaw, release);
if (FAILED(hr) || !application) return false;
IShellDispatch2 *shellRaw{};
hr = application->QueryInterface(IID_IShellDispatch2, reinterpret_cast<void **>(&shellRaw));
std::unique_ptr<IShellDispatch2, decltype(release)> shell(shellRaw, release);
if (FAILED(hr) || !shell) return false;
BSTR executable = SysAllocString(path.c_str());
if (!executable) return false;
hr = shell->ShellExecute(executable, empty, empty, empty, empty);
SysFreeString(executable);
return SUCCEEDED(hr);
}
Clip readClipboard(HWND window, size_t limit, ClipboardRead mode, std::optional<DWORD> expected)
{
ClipboardLock lock(window);
if (expected && *expected != GetClipboardSequenceNumber())
throw std::runtime_error("Clipboard changed while taking snapshot");
if (mode == ClipboardRead::WithoutImages &&
(IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
IsClipboardFormatAvailable(CF_BITMAP) || IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG"))))
throw std::runtime_error("Clipboard changed to an image");
Clip c;
size_t total = 0;
for (UINT f = EnumClipboardFormats(0); f; f = EnumClipboardFormats(f))
{
if (!globalFormat(f))
continue;
wchar_t name[256]{};
if (f >= 0xc000) GetClipboardFormatNameW(f, name, 256);
if (mode == ClipboardRead::Selection && f != CF_UNICODETEXT &&
std::wstring_view(name) != L"VisualStudioEditorOperationsLineCutCopyClipboardTag" &&
std::wstring_view(name) != L"MSDEVLineSelect" && std::wstring_view(name) != L"vscode-editor-data")
continue;
HANDLE handle = GetClipboardData(f);
if (!handle)
continue;
@@ -266,8 +387,6 @@ Clip readClipboard(HWND window, size_t limit)
GlobalUnlock(handle);
if (f >= 0xc000)
{
wchar_t name[256]{};
GetClipboardFormatNameW(f, name, 256);
format.name = name;
}
total += size;
@@ -275,7 +394,8 @@ Clip readClipboard(HWND window, size_t limit)
}
return c;
}
DWORD writeClipboard(HWND window, const Clip &c)
DWORD writeClipboard(HWND window, const Clip &c) { return writeClipboardGuarded(window, c, {}); }
DWORD writeClipboardGuarded(HWND window, const Clip &c, std::optional<DWORD> expected)
{
// Allocate all blocks before replacing any existing data.
std::vector<std::pair<UINT, HGLOBAL>> blocks;
@@ -302,6 +422,8 @@ DWORD writeClipboard(HWND window, const Clip &c)
blocks.emplace_back(id, h);
}
ClipboardLock lock(window);
if (expected && *expected != GetClipboardSequenceNumber())
throw std::runtime_error("Clipboard changed before restoring snapshot");
if (!EmptyClipboard())
throw std::runtime_error("Cannot replace clipboard");
for (auto &[id, h] : blocks)
@@ -373,7 +495,9 @@ bool chord(WORD key, UINT mods)
auto add = [&](WORD k, bool up)
{
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
if ((k >= VK_PRIOR && k <= VK_DOWN) || k == VK_INSERT || k == VK_DELETE ||
k == VK_DIVIDE || k == VK_LWIN || k == VK_SNAPSHOT ||
(k >= VK_VOLUME_MUTE && k <= VK_VOLUME_UP))
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
};
@@ -393,11 +517,47 @@ bool chord(WORD key, UINT mods)
add(k, true);
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
}
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step)
{
using Microsoft::WRL::ComPtr;
ComPtr<IMMDeviceEnumerator> devices;
ComPtr<IMMDevice> output;
ComPtr<IAudioEndpointVolume> volume;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&devices))) ||
FAILED(devices->GetDefaultAudioEndpoint(eRender, eMultimedia, &output)) ||
FAILED(output->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_INPROC_SERVER, nullptr,
reinterpret_cast<void **>(volume.GetAddressOf()))))
return std::nullopt;
float level{};
BOOL muted{};
HRESULT changed;
switch (action)
{
case MouseActionType::VolumeUp:
case MouseActionType::VolumeDown:
{
if (FAILED(volume->GetMasterVolumeLevelScalar(&level))) return std::nullopt;
const float delta = (action == MouseActionType::VolumeUp ? step : -step) / 100.0f;
changed = volume->SetMasterVolumeLevelScalar(std::clamp(level + delta, 0.0f, 1.0f), nullptr);
break;
}
case MouseActionType::Mute:
{
if (FAILED(volume->GetMute(&muted))) return std::nullopt;
changed = volume->SetMute(!muted, nullptr);
break;
}
default: return std::nullopt;
}
if (FAILED(changed) || FAILED(volume->GetMasterVolumeLevelScalar(&level)) || FAILED(volume->GetMute(&muted)))
return std::nullopt;
return SystemVolume{std::clamp(static_cast<int>(level * 100 + 0.5f), 0, 100), muted != FALSE};
}
bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
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)
{
@@ -407,12 +567,16 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
// Shift+Left events can all use the same caret position and select only one
// character. Give each change time to settle before sending the next one.
const auto target = GetForegroundWindow();
diagnosticEvent(L"SelectPrevious requested=" + std::to_wstring(count) +
L" mode=" + (needsSelectionDelay(target) ? std::wstring(L"paced-25ms") : std::wstring(L"batch")));
if (needsSelectionDelay(target))
{
for (size_t i = 0; i < count; ++i)
{
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
return false;
if (i < 32 || i + 1 == count)
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
Sleep(25);
}
return valid() && GetForegroundWindow() == target;
@@ -434,7 +598,10 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
}
add(VK_SHIFT, KEYEVENTF_KEYUP);
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
auto sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
diagnosticEvent(L"SelectPrevious batch offset=" + std::to_wstring(offset) + L" eventsRequested=" +
std::to_wstring(inputs.size()) + L" eventsSent=" + std::to_wstring(sent));
if (sent != inputs.size())
return false;
}
return valid();
@@ -570,12 +737,28 @@ namespace
{
std::recursive_mutex logGate;
bool loggingEnabled = false;
std::wstring logFilename = L"kswitcher.log";
DiagnosticBuffer diagnosticBuffer;
}
size_t DiagnosticBuffer::expire(ULONGLONG now)
{
size_t removed = 0;
while (!entries.empty() && now - entries.front().first > 30000)
{
entries.pop_front();
++removed;
}
return removed;
}
void expireDiagnostics(ULONGLONG now)
{
std::lock_guard lock(logGate);
diagnosticBuffer.expire(now);
}
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
{
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
entries.pop_front();
expire(now);
while (entries.size() >= 128) entries.pop_front();
entries.emplace_back(now, message);
if (!problem)
return {};
@@ -634,6 +817,13 @@ void diagnosticOperation(const std::string &json)
auto record = nlohmann::json::parse(json);
if (!record.value("problem", true))
{
if (record.value("elapsedMs", 0ULL) >= 2000 && record.value("outcome", "") == "Paste verified")
{
auto summary = record;
for (auto field : {"original", "expected", "actual", "selectionExpected"})
summary.erase(field);
log(L"Operation " + wide(summary.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
}
// Keep successful operations only in the bounded, short-lived context.
// They are persisted with a subsequent failure, never as standalone rows.
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
@@ -643,6 +833,11 @@ void diagnosticOperation(const std::string &json)
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
}
void setLogFilename(std::wstring filename)
{
std::lock_guard lock(logGate);
logFilename = std::move(filename);
}
void log(const std::wstring &message)
{
std::lock_guard lock(logGate);
@@ -650,7 +845,7 @@ void log(const std::wstring &message)
return;
try
{
auto path = dataDirectory() / L"kswitcher.log";
auto path = dataDirectory() / logFilename;
std::error_code ec;
auto size = std::filesystem::file_size(path, ec);
if (!ec && size > 256 * 1024)
@@ -673,3 +868,5 @@ void log(const std::wstring &message)
}
}
} // namespace ks
#include "browser_chat.inc"
+24 -3
View File
@@ -8,7 +8,7 @@
namespace ks
{
inline std::wstring localBuildTitle(const std::wstring &title)
inline bool isLocalBuild()
{
static const bool development=[] {
wchar_t path[32768]{};
@@ -16,7 +16,11 @@ inline std::wstring localBuildTitle(const std::wstring &title)
std::error_code error;
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
}();
return development?title+L" [LOCAL DEV]":title;
return development;
}
inline std::wstring localBuildTitle(const std::wstring &title)
{
return isLocalBuild()?title+L" [LOCAL DEV]":title;
}
using Bytes = std::vector<unsigned char>;
struct Format
@@ -38,14 +42,26 @@ HKL windowLayout(HWND);
bool switchWindowLayout(HWND, HKL, const std::function<bool()> &valid);
bool activateWindow(HWND);
void showRequestedWindow(HWND);
void showMenuAccessKeys(HMENU);
bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
std::wstring processName(HWND);
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
std::wstring mouseApplicationName(HWND);
bool launchThroughExplorer(const std::wstring &);
enum class ClipboardRead { All, Selection, WithoutImages };
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024,
ClipboardRead = ClipboardRead::All, std::optional<DWORD> expected = {});
DWORD writeClipboard(HWND, const Clip &);
DWORD writeClipboardGuarded(HWND, const Clip &, std::optional<DWORD> expected);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
struct SystemVolume
{
int percent{};
bool muted{};
};
std::optional<SystemVolume> changeSystemVolume(MouseActionType action, int step);
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
ULONG_PTR inputMarker();
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
@@ -60,6 +76,8 @@ Bytes readFile(const std::filesystem::path &, size_t limit);
Bytes protect(const Bytes &);
Bytes unprotect(const Bytes &);
void setAutostart(bool, bool elevated = false);
std::wstring staleAutostartPath(bool elevated);
void repairAutostartPath(bool elevated);
std::wstring currentUserSid();
void configureAutostart(int mode);
std::wstring executable();
@@ -71,10 +89,13 @@ class DiagnosticBuffer
{
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
public:
size_t expire(ULONGLONG now);
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
void clear() { entries.clear(); }
};
void setLoggingEnabled(bool enabled);
void setLogFilename(std::wstring filename);
void expireDiagnostics(ULONGLONG now);
void diagnosticEvent(const std::wstring &message, bool problem = false);
void diagnosticOperation(const std::string &json);
std::wstring readDiagnosticLog(bool previous);
+2 -1
View File
@@ -252,7 +252,7 @@ DWORD CALLBACK readStream(DWORD_PTR cookie, LPBYTE buffer, LONG size, LONG *read
return 0;
}
} // namespace
std::string richPreview(const Clip &clip)
std::string richPreview(const Clip &clip, bool plainFallback)
{
for (const auto &f : clip.formats)
if (f.name == L"Rich Text Format" && f.data.size() <= 65536)
@@ -276,6 +276,7 @@ std::string richPreview(const Clip &clip)
{
}
}
if (!plainFallback) return {};
auto text = clip.text();
if (text.size() > 4096)
text.resize(4096);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
std::string richPreview(const Clip &);
std::string richPreview(const Clip &, bool plainFallback = true);
HWND createRichText(HWND parent, int id, bool visible);
bool setRichText(HWND, const std::string &);
} // namespace ks
+139 -26
View File
@@ -54,7 +54,7 @@ bool confirmScreenshotClose(HWND owner)
// If Windows cannot display the confirmation, keep the screenshot open.
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
{
HDC dc = CreateCompatibleDC(nullptr);
@@ -124,7 +124,7 @@ bool chooseTextFont(HWND owner,Mark &m)
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
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)
{
// 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 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 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 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; }
@@ -177,6 +178,9 @@ struct Capture
Mark textDefaults{};
ScreenshotShape shape=ScreenshotShape::None;
LineStyle lineStyle=LineStyle::Solid;
int smoothing=25;
bool smoothingDrag=false;
bool smoothingPreferenceDirty=false;
LineEnds arrowEnds=LineEnds::End, freehandEnds=LineEnds::None;
COLORREF currentColor=RGB(225,42,50);
COLORREF customColors[16]{};
@@ -218,8 +222,24 @@ struct Capture
MarkPart hoveredPart=MarkPart::None;
std::wstring statusText;
std::filesystem::path savedPath;
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor)
{
if(a.smoothingPreference.empty()) return;
try {
auto stored=readFile(a.smoothingPreference,1);
if(stored.size()==1 && stored.front()<=100) smoothing=stored.front();
} catch(const std::exception &) {} // Missing or unreadable preference uses the default.
}
void saveSmoothingPreference()
{
if(!smoothingPreferenceDirty || app.smoothingPreference.empty()) return;
try {
atomicWrite(app.smoothingPreference,Bytes{static_cast<unsigned char>(smoothing)});
smoothingPreferenceDirty=false;
} catch(const std::exception &) {} // A failed preference write must not interrupt editing.
}
~Capture() {
saveSmoothingPreference();
if(pencilCursor) DestroyCursor(pencilCursor);
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
if (font) DeleteObject(font);
@@ -268,6 +288,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.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.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 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)))+
@@ -409,6 +430,14 @@ std::vector<POINT> shapeOutline(const Mark &m)
auto r=region(m.from,m.to);
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}};
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)
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)
@@ -550,7 +579,7 @@ void drawMarks(HDC dc, const std::vector<Mark> &marks, const Mark *preview = nul
if(m.shape!=ScreenshotShape::None) {
auto hollow=SelectObject(dc,GetStockObject(HOLLOW_BRUSH));
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()));
}
else if(m.shape==ScreenshotShape::Rectangle) Rectangle(dc,r.left,r.top,r.right+1,r.bottom+1);
@@ -620,6 +649,13 @@ bool selectedLine(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,35),c.selected>=0 && c.marks[c.selected].isLine());
bool selected=selectedLine(c),enabled=selected || c.arrow || c.freehand;
@@ -668,7 +704,7 @@ void layout(HWND w, Capture &c)
RECT statusRect{}; GetWindowRect(status,&statusRect);
c.statusHeight=statusRect.bottom-statusRect.top;
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));
for (size_t i = 0; i < std::size(ToolIds); ++i)
{
@@ -677,10 +713,14 @@ void layout(HWND w, Capture &c)
MulDiv(32,dpi,96),MulDiv(32,dpi,96),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);
c.panelHeight=c.unit+MulDiv(40,dpi,96);
for(int id:{30,31,32,33,34,35}) SendDlgItemMessageW(w,id,WM_SETFONT,(WPARAM)c.font,TRUE);
c.panelHeight=c.unit+MulDiv(80,dpi,96);
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,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);
@@ -771,7 +811,7 @@ HCURSOR toolCursor(const Capture &c)
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
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)
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
return LoadCursorW(nullptr,IDC_CROSS);
@@ -783,12 +823,13 @@ HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
else if(hit.second>=MarkPart::Vertex0 && hit.second<=MarkPart::Vertex2) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Radius)
cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Thickness) {
const auto &mark=c.marks[hit.first];
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
auto [from,to]=thicknessSegment(mark);
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
angle+=3.141592653589793/2;
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis];
@@ -882,7 +923,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)||
(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==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;
POINT mouse; GetCursorPos(&mouse); ScreenToClient(item.hwndItem,&mouse);
bool hover=PtInRect(&r,mouse) && !disabled;
@@ -896,13 +937,14 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
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;
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];
if(item.CtlID==36 || item.CtlID==37) {
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 || item.CtlID==38) {
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)),
disabled?RGB(148,163,184):active?RGB(37,99,235):RGB(51,65,85));
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); }
SelectObject(dc,previous); DeleteObject(trianglePen);
}
@@ -1024,7 +1066,7 @@ void edit(HWND w, Capture &c)
InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
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)"};
static_assert(std::size(labels)==std::size(ToolIds));
auto saveButton=CreateWindowW(L"BUTTON",L"Save...",WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_PUSHBUTTON,
@@ -1060,6 +1102,14 @@ void edit(HWND w, Capture &c)
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");
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();
auto work = monitor.rcWork;
SetWindowPos(w,HWND_NOTOPMOST,work.left+20,work.top+20,
@@ -1119,7 +1169,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
break;
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;
case WM_CTLCOLORSTATIC:
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
@@ -1128,6 +1178,27 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case WM_SETCURSOR:
if ((HWND)wp==w && LOWORD(lp)==HTCLIENT) { captureCursor(w,*c); return TRUE; }
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) {
c->smoothing=amount;
c->smoothingPreferenceDirty=true;
}
if(LOWORD(wp)!=TB_THUMBTRACK) {
c->smoothingDrag=false;
c->saveSmoothingPreference();
}
syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
return 0;
}
break;
case WM_COMMAND:
if(LOWORD(wp)==40) {
if(c->inlineText && HIWORD(wp)==EN_CHANGE) { readInlineText(*c); positionInlineText(*c); }
@@ -1135,6 +1206,21 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0;
}
if (c->dragging) return 0;
if(LOWORD(wp)==44) {
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
if(c->smoothing!=amount) {
c->smoothing=amount;
c->smoothingPreferenceDirty=true;
}
c->saveSmoothingPreference();
if(std::any_of(c->marks.begin(),c->marks.end(),[&](const Mark &m){return m.freehand && m.smoothing!=amount;})) {
c->remember(c->marks);
for(auto &mark:c->marks) if(mark.freehand) setScreenshotSmoothing(mark,amount);
resolveScreenshotLinks(c->marks);
}
c->smoothingDrag=false; syncLineOptions(w,*c); InvalidateRect(w,nullptr,FALSE);
return 0;
}
if(LOWORD(wp)==35) {
finishInlineText(w,*c,true);
if(c->selected>=0 && c->marks[c->selected].isLine()) beginMarkText(w,*c,c->selected);
@@ -1213,13 +1299,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; }
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->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->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->highlight=LOWORD(wp)==19;
c->freehand=LOWORD(wp)==21;
@@ -1232,6 +1318,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);
InvalidateRect(GetDlgItem(w,36),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;
@@ -1292,6 +1379,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(wp=='E') SendMessageW(w,WM_COMMAND,25,0);
if(wp=='G') SendMessageW(w,WM_COMMAND,36,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=='H') SendMessageW(w,WM_COMMAND,19,0);
@@ -1529,6 +1617,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
c->remember(c->marks); c->marks.push_back(std::move(c->stroke));
if(c->freehand) {
auto &mark=c->marks.back();
mark.rawPoints=mark.points;
linkScreenshotEndpoint(mark,true,c->start,c->marks,6);
linkScreenshotEndpoint(mark,false,c->end,c->marks,6);
resolveScreenshotLinks(c->marks);
@@ -1539,10 +1628,18 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
ScreenshotShape recognized{};
bool straightLine=false;
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()) {
c->remember(c->marks);
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();
if(straightLine) { mark.points.clear(); mark.freehand=false; }
if(recognized!=ScreenshotShape::None) {
@@ -1562,7 +1659,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().shape=c->shape;
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,false,c->end,c->marks,6);
resolveScreenshotLinks(c->marks);
@@ -1585,7 +1682,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (c->editing)
{
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;
}
break;
@@ -1900,7 +1997,14 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
POINT start=from?from->from:m.from, end=to?to->from:m.to;
if(from) m.from=rim(*from,end,true);
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;
};
@@ -2054,10 +2158,15 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
return low<=high?std::clamp(delta,low,high):0L;
};
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 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;
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)
{
@@ -2082,6 +2191,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.rawPoints.empty()) {
(part==MarkPart::Start?m.rawPoints.front():m.rawPoints.back())=endpoint;
setScreenshotSmoothing(m,m.smoothing);
}
if(m.shape!=ScreenshotShape::None && m.rotation!=0) {
auto fixedBefore=rotateShapePoint(original,part==MarkPart::Start?original.to:original.from);
auto fixedAfter=rotateShapePoint(m,part==MarkPart::Start?m.to:m.from);
+4 -1
View File
@@ -9,13 +9,14 @@ struct ScreenshotHost {
unsigned windows{};
bool (*notify)(const Clip &){};
bool autoOpenEditor = false;
std::filesystem::path smoothingPreference;
};
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &);
void showScreenshotEditor(ScreenshotHost &,const Clip &);
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
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 ScreenshotLink {
unsigned long long target{};
@@ -39,6 +40,8 @@ struct ScreenshotMark {
bool labelRotationManual=false;
unsigned long long id{};
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 isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
+24
View File
@@ -50,6 +50,30 @@ void registerScreenshotNotifications()
// Notify the shell that notification identity/activation registration changed.
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
}
HANDLE startScreenshotNotificationRegistration()
{
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
auto command=L"\""+path.wstring()+L"\" --register-notifications";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags=STARTF_USESHOWWINDOW; startup.wShowWindow=SW_HIDE;
PROCESS_INFORMATION process{};
if(!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW,
nullptr,path.parent_path().c_str(),&startup,&process))
throw std::runtime_error(utf8(lastError(L"Cannot start notification registration helper")));
CloseHandle(process.hThread);
return process.hProcess;
}
void registerScreenshotNotificationsInHelper()
{
auto process=startScreenshotNotificationRegistration();
auto wait=WaitForSingleObject(process,10000);
DWORD code=ERROR_GEN_FAILURE;
bool completed=wait==WAIT_OBJECT_0 && GetExitCodeProcess(process,&code);
CloseHandle(process);
if(!completed || code)
throw std::runtime_error(completed ? "Notification registration helper failed: "+std::to_string(code)
: "Notification registration helper did not complete within 10 seconds");
}
bool validScreenshotToken(const std::wstring &id)
{
if(id.size()!=36) return false;
+15
View File
@@ -1,5 +1,6 @@
#include "screenshot.h"
#include "screenshot_notifications.h"
#include "storage.h"
#include <commctrl.h>
#include <shellapi.h>
#include <roapi.h>
@@ -49,6 +50,18 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
std::wstring token;
bool autoOpenEditor=false, writeLog=false;
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
if(argc==3 && std::wstring(argv[1])==L"--clipboard-snapshot") {
std::filesystem::path request=argv[2]; LocalFree(argv);
return ks::runClipboardSnapshot(request);
}
if(argc==3 && (std::wstring(argv[1])==L"--capture-history" || std::wstring(argv[1])==L"--history-ocr")) {
bool ocr=std::wstring(argv[1])==L"--history-ocr";
std::filesystem::path request=argv[2]; LocalFree(argv);
auto initialized=CoInitializeEx(nullptr,COINIT_MULTITHREADED);
if(FAILED(initialized)) return 1;
int result=ocr ? ks::runHistoryOcr(request) : ks::runImageHistoryCapture(request);
CoUninitialize(); return result;
}
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
writeLog=true; --argc;
ks::setLoggingEnabled(true);
@@ -97,6 +110,8 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
InitCommonControlsEx(&controls);
ks::ScreenshotHost host{instance};
host.notify=ks::notifyScreenshot;
try { host.smoothingPreference=ks::dataDirectory()/L"screenshot-smoothing"; }
catch(const std::exception &) {} // The editor can still run without local preferences.
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
RegisterClassW(&cls);
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
+1 -1
View File
@@ -206,7 +206,7 @@ bool notifyUpdateAvailable(const std::wstring &version)
{
try {
// Reuse the screenshot notification identity, Windows settings and COM activator.
registerScreenshotNotifications();
registerScreenshotNotificationsInHelper();
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
+4
View File
@@ -4,6 +4,10 @@ namespace ks {
bool notifyScreenshot(const Clip &);
std::string screenshotNotificationError();
void registerScreenshotNotifications();
// Caller owns the returned process handle; the helper exits after registration.
HANDLE startScreenshotNotificationRegistration();
// For background notification workers only; bounded wait, never on the UI thread.
void registerScreenshotNotificationsInHelper();
int runScreenshotToastActivator();
DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &);
+23
View File
@@ -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 &current,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;
}
}
+718 -25
View File
@@ -1,13 +1,31 @@
#include "storage.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include "ocr.h"
#include <algorithm>
#include <set>
#include <sstream>
#include <wincrypt.h>
namespace ks
{
using Json = nlohmann::json;
std::wstring snippetId(const Snippet &snippet)
{
if (!snippet.id.empty()) return snippet.id;
// Deterministic migration of pre-ID profiles on every existing device.
std::wstring id = L"legacy-";
const wchar_t *hex = L"0123456789abcdef";
for (unsigned char c : utf8(lower(snippet.trigger))) { id += hex[c >> 4]; id += hex[c & 15]; }
return id;
}
std::wstring newSnippetId()
{
GUID guid{}; wchar_t text[40]{};
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create snippet ID.");
StringFromGUID2(guid, text, 40);
return text;
}
namespace
{
Bytes jsonBytes(const Json &j)
@@ -41,9 +59,143 @@ bool validId(const std::wstring &id)
{
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
}
void validateLanguageCycle(const std::vector<std::wstring> &cycle)
{
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select between 1 and 64 languages.");
std::set<std::wstring> seen;
for (const auto &id : cycle)
if (id.size() != 12 || !id.starts_with(L"win:") ||
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos || !seen.insert(lower(id)).second)
throw std::runtime_error("Invalid or duplicate language layout.");
}
} // namespace
bool validMouseApplication(const std::wstring &app)
{
return !app.empty() && app.size() <= 260 && app == lower(app) &&
app.find_first_of(L"\\/:*?\"<>|\r\n") == app.npos &&
app != L"kswitcher" && app != L"kswitcher.config";
}
static void validateMouseProfileConflicts(const Settings &s)
{
std::map<std::pair<std::wstring, std::wstring>, std::wstring> claimed;
for (const auto &[profile, bindings] : s.mouseBindings)
{
if (s.disabledMouseProfiles.contains(profile)) continue;
for (const auto &[event, action] : bindings)
{
auto claim = [&](const std::wstring &app)
{
const auto [other, added] = claimed.try_emplace({event, app}, profile);
if (!added && (isNamedMouseProfile(profile) || isNamedMouseProfile(other->second)))
throw std::runtime_error(utf8(L"Mouse action conflict: " + event + L" for " +
(app.empty() ? L"all applications" : app + L".exe") + L" in " +
mouseProfileName(other->second) + L" and " + mouseProfileName(profile) + L"."));
};
if (profile != GlobalMouseProfile && !isNamedMouseProfile(profile))
{
if (mouseActionApplies(action, profile)) claim(profile);
}
else
{
const auto &scope = mouseActionScope(s, profile, action);
if (scope.empty()) claim(L"");
else for (const auto &app : scope) claim(app);
}
}
}
}
void validateSettings(const Settings &s)
{
std::set<std::wstring> eventNames;
for (int event = 0; event < BuiltInMouseEventCount; ++event) eventNames.insert(lower(MouseEvents[event]));
for (const auto &[signal, name] : s.customMouseEvents)
{
if (std::find(std::begin(MouseEvents) + BuiltInMouseEventCount, std::end(MouseEvents), signal) == std::end(MouseEvents) ||
name.empty() || name.size() > 80 || name.find_first_of(L"\r\n\t") != name.npos ||
!eventNames.insert(lower(name)).second)
throw std::runtime_error("Enter a unique event name (up to 80 characters) for a supported signal.");
}
if (s.mouseBindings.size() > 1000) throw std::runtime_error("Too many mouse profiles.");
for (const auto &[app, bindings] : s.mouseBindings)
{
if (isNamedMouseProfile(app))
{
const auto name = mouseProfileName(app);
if (name.empty() || name.size() > 80 || lower(name) == L"global" || name.find_first_of(L"\r\n\t") != name.npos)
throw std::runtime_error("Enter a profile name other than Global (up to 80 characters).");
}
else if (app != GlobalMouseProfile && !validMouseApplication(app))
throw std::runtime_error("Enter the target application's executable name.");
for (const auto &[event, action] : bindings)
{
if (action.applications.size() > 1000 ||
!std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication))
throw std::runtime_error("Enter valid target application executable names.");
const auto signal = std::find(std::begin(MouseEvents), std::end(MouseEvents), event);
if (!mouseEventEnabled(s, static_cast<int>(signal - std::begin(MouseEvents))) ||
static_cast<size_t>(action.type) >= std::size(MouseActions))
throw std::runtime_error("Invalid mouse event or action.");
if (isMouseVolumeStep(action.type) && (action.volumeStep < 1 || action.volumeStep > 100))
throw std::runtime_error("Enter a volume step from 1 to 100.");
if (action.type == MouseActionType::Shortcut &&
(!action.shortcut.key || Hotkey::parse(action.shortcut.format()) != action.shortcut))
throw std::runtime_error("Enter a shortcut such as Ctrl+Shift+Tab.");
if (action.type == MouseActionType::Launch)
{
const std::filesystem::path path(action.executable);
const bool executableName = !path.has_root_path() && !path.has_parent_path() &&
action.executable.find_first_of(L"\\/:*?\"<>|\r\n") == std::wstring::npos;
if ((!executableName && !path.is_absolute()) || lower(path.extension().wstring()) != L".exe")
throw std::runtime_error("Enter an executable name such as calc.exe, or choose a file with Browse.");
}
}
}
for (const auto &profile : s.disabledMouseProfiles)
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile))
throw std::runtime_error("Unknown disabled mouse profile.");
for (const auto &[profile, apps] : s.mouseProfileApplications)
if (!isNamedMouseProfile(profile) || !s.mouseBindings.contains(profile) || apps.size() > 1000 ||
!std::all_of(apps.begin(), apps.end(), validMouseApplication))
throw std::runtime_error("Invalid applications for the mouse profile.");
validateMouseProfileConflicts(s);
if (s.appLanguageSets.size() > 1000) throw std::runtime_error("Too many application language sets");
std::set<std::wstring> assigned;
for (const auto &[name, set] : s.appLanguageSets)
{
if (name.empty() || name.size() > 128 || name.find_first_of(L"\r\n") != name.npos)
throw std::runtime_error("Enter a language set name (up to 128 characters).");
if (set.applications.size() > 1000) throw std::runtime_error("Too many applications in a language set");
for (auto app : set.applications)
{
app = lower(app);
if (app.ends_with(L".exe")) app.resize(app.size() - 4);
if (app.empty() || app.size() > 260 || app.find_first_of(L"\\/:*?\"<>|\r\n") != app.npos)
throw std::runtime_error("Use an application executable name, not a path.");
if (!assigned.insert(app).second) throw std::runtime_error("An application can belong to only one language set.");
}
validateLanguageCycle(set.languages);
}
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
for (const auto &[key, cycle] : s.chatCycles)
{
auto separator = key.find(L'\n');
if (separator == key.npos || separator + 1 == key.size() || key.size() > 1024 ||
key.find(L'\n', separator + 1) != key.npos || key.find(L'\r') != key.npos ||
(key.substr(0, separator) != L"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber" && key.substr(0, separator) != L"telegram-web"))
throw std::runtime_error("Invalid chat language profile");
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select at least one chat language");
std::vector<std::wstring> seen;
for (const auto &id : cycle)
{
if (id.size() != 12 || !id.starts_with(L"win:") ||
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos ||
std::find(seen.begin(), seen.end(), id) != seen.end())
throw std::runtime_error("Invalid or duplicate chat layout");
seen.push_back(id);
}
}
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
throw std::runtime_error("Invalid Tesseract executable path");
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
@@ -53,6 +205,7 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
if (s.filesHotkey) keys.push_back(*s.filesHotkey);
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
for (auto &text : keys)
{
@@ -92,6 +245,22 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions");
if (s.autoReplacements.size() > 10000)
throw std::runtime_error("Too many automatic replacements");
for (const auto &[from, to] : s.autoReplacements)
{
bool valid = false;
for (auto alphabet : {L"abcdefghijklmnopqrstuvwxyz", L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
from == lower(from) && to == lower(to) && from != to &&
validAutoReplacementSource(from, alphabet) &&
validAutoCorrection(to, alphabet);
if (!valid)
throw std::runtime_error("Invalid replacement: " + utf8(from.substr(0, 32)) + "=" +
utf8(to.substr(0, 32)) + "\n\nUse one lowercase word of 1-32 characters. The typo may include "
"digits and internal apostrophes. Use a different correction of 1-32 characters in the same language.");
}
for (const auto &[language, words] : s.autoWords)
{
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
@@ -107,13 +276,30 @@ void validateSettings(const Settings &s)
s.maxImageMiB > 64)
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
std::set<std::wstring> seen;
std::set<std::wstring> ids;
for (const auto &id : s.snippetTombstones)
if (id.empty() || id.size() > 600 || !ids.insert(id).second)
throw std::runtime_error("Invalid snippet deletion record.");
for (const auto &snippet : s.snippets)
{
auto id = snippetId(snippet);
if (id.size() > 600 || !ids.insert(id).second) throw std::runtime_error("Duplicate snippet ID or deleted snippet.");
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
if (!seen.insert(lower(snippet.trigger)).second)
throw std::runtime_error("Duplicate snippet trigger");
if (snippet.apps.size() > 64) throw std::runtime_error("Too many snippet applications");
std::vector<std::wstring> scopes;
for (const auto &app : snippet.apps)
{
auto normalized = snippetAppName(app);
if (normalized.empty() || normalized.size() > 250 || normalized.find_first_of(L"\\/:*?\"<>|,\r\n\t") != normalized.npos)
throw std::runtime_error("Use application names such as chrome.exe, not paths or wildcards");
scopes.push_back(normalized);
}
if (scopes.empty()) scopes.push_back(L"");
for (const auto &scope : scopes)
if (!seen.insert(lower(snippet.trigger) + L"\n" + scope).second)
throw std::runtime_error("Duplicate snippet trigger for the same application or global scope");
auto t = parseTemplate(snippet.text);
if (t.text.empty() || t.cursors > 1)
throw std::runtime_error("Each snippet needs text and at most one {cursor} marker");
@@ -150,6 +336,15 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
s.autoCorrect = j.value("autoCorrectLayout", false);
s.autoReplace = j.value("autoCorrectReplace", false);
s.autoTypos = j.value("autoCorrectTypos", false);
if (j.contains("autoCorrectReplacements"))
for (auto &[from, to] : j.at("autoCorrectReplacements").items())
{
auto key = lower(wide(from));
if (!s.autoReplacements.emplace(key, lower(wide(to.get<std::string>()))).second)
throw std::runtime_error("Duplicate automatic replacement");
}
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
s.autoMinLength = j.value("autoCorrectMinLength", 3);
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
@@ -176,6 +371,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
if (j.contains("filesHotkey")) s.filesHotkey = wide(j.at("filesHotkey").get<std::string>());
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
if (j.contains("layouts"))
{
@@ -187,6 +383,93 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.cycle.push_back(w);
}
}
if (j.contains("appLanguageSets"))
{
if (!j.at("appLanguageSets").is_object()) throw std::runtime_error("Invalid application language sets");
for (const auto &[name, item] : j.at("appLanguageSets").items())
{
AppLanguageSet set;
if (!item.at("languages").is_array() || !item.at("applications").is_array())
throw std::runtime_error("Invalid application language set");
for (const auto &id : item.at("languages")) set.languages.push_back(wide(id.get<std::string>()));
for (const auto &app : item.at("applications")) set.applications.push_back(wide(app.get<std::string>()));
s.appLanguageSets[wide(name)] = std::move(set);
}
}
if (j.contains("customMouseEvents"))
{
const auto &events = j.at("customMouseEvents");
if (!events.is_object()) throw std::runtime_error("Invalid custom mouse events.");
for (const auto &[signal, name] : events.items())
s.customMouseEvents.emplace(wide(signal), wide(name.get<std::string>()));
}
if (j.contains("mouseBindings"))
{
const auto &profiles = j.at("mouseBindings");
if (!profiles.is_object()) throw std::runtime_error("Invalid mouse profiles.");
for (const auto &[app, bindings] : profiles.items())
{
if (!bindings.is_object()) throw std::runtime_error("Invalid mouse profile.");
if (isNamedMouseProfile(wide(app))) s.mouseBindings.try_emplace(wide(app));
for (const auto &[event, item] : bindings.items())
{
MouseAction action;
if (item.is_string())
{
auto shortcut = Hotkey::parse(wide(item.get<std::string>()));
if (!shortcut || !shortcut->key) throw std::runtime_error("Invalid mouse shortcut.");
action = {MouseActionType::Shortcut, *shortcut};
}
else
{
auto name = wide(item.at("action").get<std::string>());
if (name == L"Send shortcut") name = MouseActions[static_cast<size_t>(MouseActionType::Shortcut)];
const auto found = std::find(std::begin(MouseActions), std::end(MouseActions), name);
if (found == std::end(MouseActions)) throw std::runtime_error("Invalid mouse action.");
action.type = static_cast<MouseActionType>(found - std::begin(MouseActions));
if (action.type == MouseActionType::Shortcut)
action.shortcut = Hotkey::parse(wide(item.at("shortcut").get<std::string>())).value_or(Hotkey{});
if (action.type == MouseActionType::Launch)
action.executable = wide(item.at("executable").get<std::string>());
if (isMouseVolumeStep(action.type) && item.contains("volumeStep"))
{
const auto &step = item.at("volumeStep");
if (!step.is_number_integer() || step.get<double>() < 1 || step.get<double>() > 100)
throw std::runtime_error("Enter a volume step from 1 to 100.");
action.volumeStep = step.get<int>();
}
if (item.contains("applications"))
for (const auto &name : item.at("applications").get<std::vector<std::string>>())
action.applications.insert(wide(name));
}
s.mouseBindings[wide(app)][wide(event)] = std::move(action);
}
}
}
if (j.contains("mouseProfileApplications"))
{
const auto &scopes = j.at("mouseProfileApplications");
if (!scopes.is_object()) throw std::runtime_error("Invalid mouse profile applications.");
for (const auto &[profile, names] : scopes.items())
{
if (names.is_null()) continue;
auto &scope = s.mouseProfileApplications[wide(profile)];
for (const auto &name : names.get<std::vector<std::string>>()) scope.insert(wide(name));
}
}
if (j.contains("disabledMouseProfiles") && !j.at("disabledMouseProfiles").is_null())
for (const auto &profile : j.at("disabledMouseProfiles").get<std::vector<std::string>>())
s.disabledMouseProfiles.insert(wide(profile));
if (j.contains("chatCycles"))
{
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
for (const auto &[key, cycle] : j.at("chatCycles").items())
{
if (!cycle.is_array() || cycle.empty()) throw std::runtime_error("Empty chat language profile");
for (const auto &id : cycle)
s.chatCycles[wide(key)].push_back(wide(id.get<std::string>()));
}
}
if (j.contains("layoutHotkeys"))
{
std::fill(s.direct.begin(), s.direct.end(), L"");
@@ -203,7 +486,19 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
}
if (j.contains("snippets"))
for (auto &sn : j.at("snippets"))
s.snippets.push_back({str(sn, "trigger"), str(sn, "text")});
{
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
if (sn.contains("apps"))
{
if (!sn.at("apps").is_array()) throw std::runtime_error("Snippet apps must be a list");
for (const auto &app : sn.at("apps")) item.apps.push_back(snippetAppName(wide(app.get<std::string>())));
}
if (item.id.empty() && !item.apps.empty()) item.id = newSnippetId();
item.id = snippetId(item);
s.snippets.push_back(std::move(item));
}
if (j.contains("snippetTombstones"))
for (const auto &id : j.at("snippetTombstones")) s.snippetTombstones.push_back(wide(id.get<std::string>()));
validateSettings(s);
return s;
}
@@ -236,10 +531,62 @@ Bytes encodeSettings(const Settings &s)
{"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
if (s.filesHotkey) j["filesHotkey"] = utf8(*s.filesHotkey);
j["layouts"] = Json::array();
j["appLanguageSets"] = Json::object();
for (const auto &[name, set] : s.appLanguageSets)
{
auto &item = j["appLanguageSets"][utf8(name)];
item["languages"] = Json::array();
item["applications"] = Json::array();
for (const auto &id : set.languages) item["languages"].push_back(utf8(id));
for (const auto &app : set.applications) item["applications"].push_back(utf8(app));
}
j["customMouseEvents"] = Json::object();
for (const auto &[signal, name] : s.customMouseEvents) j["customMouseEvents"][utf8(signal)] = utf8(name);
j["mouseBindings"] = Json::object();
for (const auto &[app, bindings] : s.mouseBindings)
{
if (isNamedMouseProfile(app)) j["mouseBindings"][utf8(app)] = Json::object();
for (const auto &[event, action] : bindings)
{
auto &item = j["mouseBindings"][utf8(app)][utf8(event)];
if (action.type == MouseActionType::Shortcut && action.applications.empty())
item = utf8(action.shortcut.format());
else
{
item = {{"action", utf8(MouseActions[static_cast<size_t>(action.type)])}};
if (action.type == MouseActionType::Shortcut)
{
item["action"] = "Send shortcut";
item["shortcut"] = utf8(action.shortcut.format());
}
if (action.type == MouseActionType::Launch) item["executable"] = utf8(action.executable);
if (isMouseVolumeStep(action.type) && action.volumeStep != DefaultMouseVolumeStep)
item["volumeStep"] = action.volumeStep;
for (const auto &app : action.applications) item["applications"].push_back(utf8(app));
}
}
}
j["mouseProfileApplications"] = Json::object();
for (const auto &[profile, apps] : s.mouseProfileApplications)
{
auto &scope = j["mouseProfileApplications"][utf8(profile)] = Json::array();
for (const auto &app : apps) scope.push_back(utf8(app));
}
j["disabledMouseProfiles"] = Json::array();
for (const auto &profile : s.disabledMouseProfiles) j["disabledMouseProfiles"].push_back(utf8(profile));
j["chatCycles"] = Json::object();
for (const auto &[key, cycle] : s.chatCycles)
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
j["autoCorrectLayout"] = s.autoCorrect;
j["autoCorrectReplace"] = s.autoReplace;
j["autoCorrectTypos"] = s.autoTypos;
j["autoCorrectReplacements"] = Json::object();
for (const auto &[from, to] : s.autoReplacements)
j["autoCorrectReplacements"][utf8(from)] = utf8(to);
j["autoCorrectMaxRank"] = s.autoMaxRank;
j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
@@ -264,7 +611,17 @@ Bytes encodeSettings(const Settings &s)
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
j["snippets"] = Json::array();
for (auto &sn : s.snippets)
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}});
{
Json item{{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}};
if (!sn.apps.empty())
{
item["apps"] = Json::array();
for (const auto &app : sn.apps) item["apps"].push_back(utf8(snippetAppName(app)));
}
j["snippets"].push_back(std::move(item));
}
j["snippetTombstones"] = Json::array();
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
j["excludedApps"] = Json::array();
for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name));
@@ -286,6 +643,20 @@ Bytes encodeClip(const Clip &clip)
}
return b;
}
Bytes selectionClipboardIdentity(const Clip &clip)
{
auto normalized = clip;
// GlobalSize includes allocation slack. A clipboard relay may allocate a
// different-sized block for the same NUL-terminated Unicode string.
for (auto &format : normalized.formats)
if (format.id == CF_UNICODETEXT)
format = textClip(clip.text()).formats.front();
std::sort(normalized.formats.begin(), normalized.formats.end(), [](const Format &a, const Format &b)
{
return std::tie(a.name, a.id) < std::tie(b.name, b.id);
});
return encodeClip(normalized);
}
Clip decodeClip(const Bytes &b)
{
size_t p = 0;
@@ -317,9 +688,17 @@ Clip decodeClip(const Bytes &b)
History::History(const std::filesystem::path &folder) : folder_(folder)
{
std::filesystem::create_directories(folder_);
loadIndex();
}
void History::loadIndex()
{
std::vector<Entry> loaded;
auto index = folder_ / L"index.dat";
if (!std::filesystem::exists(index))
{
entries_.clear();
return;
}
auto j = parse(unprotect(readFile(index, 16 * 1024 * 1024)));
if (!j.is_array() || j.size() > 10000)
throw std::runtime_error("Invalid history index");
@@ -334,8 +713,170 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
e.value("image", false),
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
item.computer=e.value("computer",std::string{});item.computerName=str(e,"computerName");
if (e.value("fingerprintVersion", 0) == 1) {
item.contentHash = e.value("contentHash", std::string{});
item.imageHash = e.value("imageHash", std::string{});
item.pixelHash = e.value("pixelHash", std::string{});
}
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
entries_.push_back(std::move(item));
loaded.push_back(std::move(item));
}
entries_ = std::move(loaded);
}
void History::reload() { std::lock_guard lock(mutex_); loadIndex(); }
void ClipboardSnapshot::cleanup() noexcept
{
if (request_.empty()) return;
std::error_code ignored;
for (const auto *suffix : {L"", L".snapshot", L".result"})
std::filesystem::remove(std::filesystem::path(request_.wstring() + suffix), ignored);
request_.clear();
}
ClipboardSnapshot::~ClipboardSnapshot() { cleanup(); }
DWORD ClipboardSnapshot::run(bool restore, DWORD sequence)
{
atomicWrite(request_, protect(jsonBytes(Json{{"schema", 1}, {"restore", restore}, {"sequence", sequence}})));
auto helper = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Screenshot.exe";
auto command = L"\"" + helper.wstring() + L"\" --clipboard-snapshot \"" + request_.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION process{};
if (!CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, helper.parent_path().c_str(), &startup, &process))
throw std::runtime_error("Cannot start clipboard snapshot helper");
CloseHandle(process.hThread);
auto wait = WaitForSingleObject(process.hProcess, 10000);
DWORD result = ERROR_GEN_FAILURE;
if (wait != WAIT_OBJECT_0)
{
TerminateProcess(process.hProcess, ERROR_TIMEOUT);
WaitForSingleObject(process.hProcess, INFINITE);
}
GetExitCodeProcess(process.hProcess, &result);
CloseHandle(process.hProcess);
if (wait != WAIT_OBJECT_0 || result) throw std::runtime_error("Clipboard snapshot helper failed");
return parse(unprotect(readFile(request_.wstring() + L".result", 4096))).at("sequence").get<DWORD>();
}
void ClipboardSnapshot::capture(HWND window, DWORD sequence)
{
cleanup();
clip_ = {};
if (!IsClipboardFormatAvailable(CF_DIB) && !IsClipboardFormatAvailable(CF_DIBV5) &&
!IsClipboardFormatAvailable(CF_BITMAP) && !IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG")))
{
clip_ = readClipboard(window, 64 * 1024 * 1024, ClipboardRead::WithoutImages, sequence);
return;
}
request_ = std::filesystem::temp_directory_path() / (L"ks-clipboard-" + newSnippetId() + L".request");
try { run(false, sequence); }
catch (...) { cleanup(); throw; }
}
DWORD ClipboardSnapshot::restore(HWND window, DWORD sequence)
{
return request_.empty() ? writeClipboardGuarded(window, clip_, sequence) : run(true, sequence);
}
int runClipboardSnapshot(const std::filesystem::path &request)
{
try
{
if (!request.is_absolute() || !request.filename().wstring().starts_with(L"ks-clipboard-") ||
request.extension() != L".request") return 1;
auto job = parse(unprotect(readFile(request, 4096)));
if (job.at("schema") != 1) return 1;
auto sequence = job.at("sequence").get<DWORD>();
const auto snapshot = request.wstring() + L".snapshot";
if (job.at("restore").get<bool>())
{
auto clip = decodeClip(unprotect(readFile(snapshot, 70 * 1024 * 1024)));
HWND window = CreateWindowExW(0, L"STATIC", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, nullptr, nullptr);
if (!window) return 1;
struct WindowCleanup { HWND window; ~WindowCleanup() { DestroyWindow(window); } } cleanup{window};
sequence = writeClipboardGuarded(window, clip, sequence);
}
else
{
auto clip = readClipboard(nullptr, 64 * 1024 * 1024, ClipboardRead::All, sequence);
if (sequence != GetClipboardSequenceNumber()) return 2;
atomicWrite(snapshot, protect(encodeClip(clip)));
}
atomicWrite(request.wstring() + L".result", protect(jsonBytes(Json{{"sequence", sequence}})));
return 0;
}
catch (...) { return 1; }
}
bool History::captureImage(DWORD sequence, const std::wstring &app, const Settings &settings)
{
// Serialize helper index changes with all resident history mutations. Only
// metadata is reloaded: GetClipboardData's image allocations die with the child.
std::lock_guard lock(mutex_);
if (sequence != GetClipboardSequenceNumber()) return false;
auto request = std::filesystem::absolute(folder_) / (L"capture-" + newSnippetId() + L".request");
struct Cleanup {
std::filesystem::path path;
~Cleanup() { std::error_code ignored; std::filesystem::remove(path, ignored); }
} cleanup{request};
Json job{{"schema", 1}, {"sequence", sequence}, {"app", utf8(app)},
{"settings", Json::parse(encodeSettings(settings))}};
atomicWrite(request, protect(jsonBytes(job)));
auto helper = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Screenshot.exe";
auto command = L"\"" + helper.wstring() + L"\" --capture-history \"" + request.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION process{};
if (!CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, helper.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start image history helper")));
CloseHandle(process.hThread);
auto wait = WaitForSingleObject(process.hProcess, 30000);
DWORD result = ERROR_GEN_FAILURE;
if (wait != WAIT_OBJECT_0)
{
// Never release the index lock while a timed-out child can still write it.
TerminateProcess(process.hProcess, ERROR_TIMEOUT);
WaitForSingleObject(process.hProcess, INFINITE);
}
GetExitCodeProcess(process.hProcess, &result);
CloseHandle(process.hProcess);
loadIndex(); // Also reconcile any completed atomic save before a child failure.
if (wait != WAIT_OBJECT_0) throw std::runtime_error("Image history helper timed out");
if (result == 2) return false;
if (result == 3) throw std::runtime_error("Clipboard exceeds the configured size limit");
if (result) throw std::runtime_error("Image history helper failed: " + std::to_string(result));
return true;
}
int runImageHistoryCapture(const std::filesystem::path &request)
{
try
{
if (!request.is_absolute() || !request.filename().wstring().starts_with(L"capture-") ||
request.extension() != L".request") return 1;
auto job = parse(unprotect(readFile(request, 4 * 1024 * 1024)));
if (job.at("schema") != 1) return 1;
auto sequence = job.at("sequence").get<DWORD>();
auto settings = decodeSettings(jsonBytes(job.at("settings")), readLayouts());
auto valid = [&] {
return settings.history && sequence == GetClipboardSequenceNumber() &&
!IsClipboardFormatAvailable(RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) &&
!IsClipboardFormatAvailable(RegisterClipboardFormatW(L"Clipboard Viewer Ignore"));
};
if (!valid()) return 2;
if (!IsClipboardFormatAvailable(CF_DIB) && !IsClipboardFormatAvailable(CF_DIBV5) &&
!IsClipboardFormatAvailable(CF_BITMAP) &&
!IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG"))) return 2;
auto clip = readClipboard(nullptr, size_t(settings.maxImageMiB) * 1024 * 1024);
if (!valid()) return 2;
for (const auto &format : clip.formats)
if (format.name == L"CanIncludeInClipboardHistory" && format.data.size() >= 4 &&
!format.data[0] && !format.data[1] && !format.data[2] && !format.data[3]) return 2;
History history(request.parent_path());
if (!valid()) return 2;
history.add(clip, str(job, "app"), settings);
return 0;
}
catch (const std::exception &e)
{
return std::string(e.what()) == "Clipboard exceeds the configured size limit" ? 3 : 1;
}
}
void History::saveIndex()
@@ -344,12 +885,14 @@ void History::saveIndex()
for (const auto &e : entries_)
j.push_back({{"id", utf8(e.id)},
{"preview", utf8(e.preview)},
{"app", utf8(e.app)},
{"app", utf8(e.app)}, {"computer", e.computer}, {"computerName", utf8(e.computerName)},
{"bytes", e.bytes},
{"at", e.at},
{"pinned", e.pinned},
{"image", e.image},
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)},
{"fingerprintVersion", 1}, {"contentHash", e.contentHash},
{"imageHash", e.imageHash}, {"pixelHash", e.pixelHash}});
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
}
std::vector<Entry> History::entries() const
@@ -382,12 +925,10 @@ void History::prune(const Settings &s)
entries_.erase(entries_.begin() + i);
}
}
bool sameClipboardImage(const Clip &a, const Clip &b)
static const Format *clipboardImageFormat(const Clip &c)
{
// OLE DataObject / Ole Private Data change when Snipping Tool republishes
// the same capture. They are transport metadata, not image identity.
auto image = [](const Clip &c) -> const Format *
{
if (!c.text().empty())
return nullptr;
for (const auto &f : c.formats)
@@ -401,8 +942,42 @@ bool sameClipboardImage(const Clip &a, const Clip &b)
if (f.id == id && !f.data.empty())
return &f;
return nullptr;
};
const auto *left = image(a), *right = image(b);
}
static std::string clipboardHash(const Bytes &bytes)
{
struct Hash {
HCRYPTPROV provider{}; HCRYPTHASH hash{};
~Hash() { if (hash) CryptDestroyHash(hash); if (provider) CryptReleaseContext(provider, 0); }
} state;
if (!CryptAcquireContextW(&state.provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT) ||
!CryptCreateHash(state.provider, CALG_SHA_256, 0, 0, &state.hash) ||
!CryptHashData(state.hash, bytes.data(), static_cast<DWORD>(bytes.size()), 0))
throw std::runtime_error("Cannot hash clipboard data");
BYTE digest[32]{}; DWORD size = sizeof(digest);
if (!CryptGetHashParam(state.hash, HP_HASHVAL, digest, &size, 0) || size != sizeof(digest))
throw std::runtime_error("Cannot read clipboard hash");
static constexpr char hex[] = "0123456789abcdef";
std::string result; result.reserve(64);
for (auto b : digest) { result += hex[b >> 4]; result += hex[b & 15]; }
return result;
}
static void imageFingerprints(Entry &entry, const Clip &clip, bool decodePixels = true)
{
const auto *image = clipboardImageFormat(clip);
if (!image) return;
if (entry.imageHash.empty())
entry.imageHash = std::to_string(image->id) + ":" + utf8(image->name) + ":" + clipboardHash(image->data);
if (!decodePixels) return;
try {
auto pixels = decodeImage(clip);
entry.pixelHash = std::to_string(pixels.width) + "x" + std::to_string(pixels.height) + ":" + clipboardHash(pixels.bgra);
} catch (const std::exception &) {
// Undecodable images can still be compared by their original bytes.
}
}
bool sameClipboardImage(const Clip &a, const Clip &b)
{
const auto *left = clipboardImageFormat(a), *right = clipboardImageFormat(b);
if (!left || !right)
return false;
if (left->id == right->id && left->name == right->name && left->data == right->data)
@@ -419,22 +994,47 @@ bool sameClipboardImage(const Clip &a, const Clip &b)
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())
return;
auto data = encodeClip(clip);
if (data.size() > uint64_t(s.maxImageMiB) * 1024 * 1024)
return;
bool image = false, files = false;
for (const auto &f : clip.formats)
{
image |= f.id == CF_DIB || f.id == CF_DIBV5 || f.name == L"PNG";
files |= f.id == CF_HDROP;
}
std::lock_guard lock(mutex_);
// Compare only the newest record; never keep image payloads in the resident index.
if (!entries_.empty())
Entry fingerprints;
fingerprints.contentHash = clipboardHash(data);
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().contentHash == fingerprints.contentHash)
return;
imageFingerprints(fingerprints, clip, false);
if (!entries_.empty() && entries_.front().computer == computer && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
return;
imageFingerprints(fingerprints, clip);
// Different payload kinds cannot be duplicates. Consult the index first so
// copying text after a screenshot never loads/decrypts the previous image.
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().image == image && entries_.front().files == files)
{
try
{
auto previous = load(entries_.front().id);
if (encodeClip(previous) == data || sameClipboardImage(previous, clip))
auto &previous = entries_.front();
bool migrated = previous.contentHash.empty();
if (migrated) {
auto oldClip = load(previous.id);
previous.contentHash = clipboardHash(encodeClip(oldClip));
imageFingerprints(previous, oldClip);
}
if (previous.contentHash == fingerprints.contentHash ||
(!fingerprints.imageHash.empty() && previous.imageHash == fingerprints.imageHash) ||
(!fingerprints.pixelHash.empty() && previous.pixelHash == fingerprints.pixelHash)) {
if (migrated) saveIndex();
return;
}
}
catch (...)
{
@@ -449,12 +1049,6 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
auto encrypted = protect(data);
atomicWrite(folder_ / (id + L".dat"), encrypted);
auto text = clip.text();
bool image = false, files = false;
for (auto &f : clip.formats)
{
image |= f.id == CF_DIB || f.id == CF_DIBV5 || f.name == L"PNG";
files |= f.id == CF_HDROP;
}
if (text.empty())
text = files ? L"[Files]" : image ? L"[Image]" : L"[Clipboard data]";
for (auto &c : text)
@@ -467,7 +1061,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
entries_.insert(entries_.begin(),
Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
image ? 1 : 0, ocrProfile(s)});
image ? 1 : 0, ocrProfile(s), std::move(fingerprints.contentHash),
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash), computer, computerName});
prune(s);
saveIndex();
}
@@ -506,6 +1101,103 @@ void History::clear()
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
}
}
static Bytes historyImageJob(const std::filesystem::path &folder, const std::wstring &id,
const Json &job, const std::function<bool()> &cancelled)
{
if (!validId(id)) throw std::runtime_error("Invalid history ID");
if (cancelled && cancelled()) throw std::runtime_error("History OCR cancelled");
auto request = std::filesystem::absolute(folder) / (L"ocr-" + newSnippetId() + L".request");
auto response = request; response += L".result";
struct Cleanup {
std::filesystem::path request, response;
~Cleanup() {
std::error_code ignored;
std::filesystem::remove(request, ignored);
std::filesystem::remove(response, ignored);
}
} cleanup{request, response};
atomicWrite(request, protect(jsonBytes(job)));
auto helper = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Screenshot.exe";
auto command = L"\"" + helper.wstring() + L"\" --history-ocr \"" + request.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION process{};
if (!CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, helper.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start history OCR helper")));
CloseHandle(process.hThread);
struct Child {
HANDLE handle;
~Child() {
if (WaitForSingleObject(handle, 0) != WAIT_OBJECT_0) {
TerminateProcess(handle, ERROR_CANCELLED);
WaitForSingleObject(handle, INFINITE);
}
CloseHandle(handle);
}
} child{process.hProcess};
auto deadline = GetTickCount64() + 60000;
for (;;) {
auto wait = WaitForSingleObject(child.handle, 100);
if (wait == WAIT_OBJECT_0) break;
if (wait != WAIT_TIMEOUT) throw std::runtime_error("Cannot wait for history OCR helper");
if (cancelled && cancelled()) throw std::runtime_error("History OCR cancelled");
if (GetTickCount64() >= deadline) throw std::runtime_error("History OCR helper timed out");
}
DWORD result{};
if (!GetExitCodeProcess(child.handle, &result) || result)
throw std::runtime_error("History OCR helper failed: " + std::to_string(result));
return unprotect(readFile(response, 8 * 1024 * 1024));
}
std::wstring History::recognizeImage(const std::wstring &id, const Settings &settings,
const std::function<bool()> &cancelled) const
{
Json job{{"schema", 1}, {"id", utf8(id)}, {"tesseract", settings.useTesseract},
{"path", utf8(settings.tesseractPath)}, {"languages", utf8(settings.tesseractLanguages)}};
auto reply = parse(historyImageJob(folder_, id, job, cancelled));
if (reply.at("schema") != 1 || str(reply, "id") != id)
throw std::runtime_error("Invalid history OCR response");
return str(reply, "text");
}
Pixels History::thumbnail(const std::wstring &id, const std::function<bool()> &cancelled) const
{
auto bytes = historyImageJob(folder_, id, Json{{"schema", 1}, {"id", utf8(id)}, {"thumbnail", true}}, cancelled);
size_t at = 0;
auto width = get32(bytes, at), height = get32(bytes, at);
if (!width || !height || width > 208 || height > 128 || bytes.size() - at != width * height * 4)
throw std::runtime_error("Invalid history thumbnail response");
bytes.erase(bytes.begin(), bytes.begin() + at);
return Pixels{width, height, std::move(bytes)};
}
int runHistoryOcr(const std::filesystem::path &request)
{
try {
if (!request.is_absolute() || !request.filename().wstring().starts_with(L"ocr-") ||
request.extension() != L".request") return 1;
auto job = parse(unprotect(readFile(request, 1024 * 1024)));
auto id = str(job, "id");
if (job.at("schema") != 1 || !validId(id)) return 1;
auto clip = decodeClip(unprotect(readFile(request.parent_path() / (id + L".dat"), 80 * 1024 * 1024)));
if (job.value("thumbnail", false)) {
auto pixels = decodeImage(clip, 208, 128);
if (!pixels.width || !pixels.height) return 1;
Bytes reply;
put32(reply, pixels.width); put32(reply, pixels.height);
reply.insert(reply.end(), pixels.bgra.begin(), pixels.bgra.end());
atomicWrite(request.wstring() + L".result", protect(reply));
return 0;
}
Settings settings;
settings.useTesseract = job.at("tesseract").get<bool>();
settings.tesseractPath = str(job, "path");
settings.tesseractLanguages = str(job, "languages");
auto text = utf8(recognizeText(clip, settings));
if (text.size() > 1024 * 1024) return 1;
auto response = request; response += L".result";
atomicWrite(response, protect(jsonBytes(Json{{"schema", 1}, {"id", utf8(id)}, {"text", text}})));
return 0;
} catch (...) { return 1; }
}
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
{
std::lock_guard lock(mutex_);
@@ -546,9 +1238,10 @@ std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
return wide(std::string(bytes.begin() + 1, bytes.end()));
}
std::vector<Entry> History::search(const std::wstring &query) const
std::vector<Entry> History::search(const std::wstring &query, bool pinnedOnly) const
{
auto items = entries();
if (pinnedOnly) std::erase_if(items, [](const Entry &entry) { return !entry.pinned; });
if (query.empty())
return items;
auto q = lower(query);
+32 -2
View File
@@ -1,8 +1,10 @@
#pragma once
#include "platform.h"
#include <mutex>
#include <functional>
namespace ks
{
struct Pixels;
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
Settings decodeSettings(const Bytes &, const std::vector<Layout> &);
Bytes encodeSettings(const Settings &);
@@ -15,6 +17,9 @@ struct Entry
bool pinned{}, image{}, files{};
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
std::wstring ocrProfile = L"windows";
std::string contentHash, imageHash, pixelHash;
std::string computer;
std::wstring computerName;
};
class History
{
@@ -22,21 +27,46 @@ class History
std::vector<Entry> entries_;
mutable std::mutex mutex_;
void saveIndex();
void loadIndex();
void prune(const Settings &);
public:
explicit History(const std::filesystem::path &);
void reload();
std::vector<Entry> entries() 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 &);
std::wstring recognizeImage(const std::wstring &id, const Settings &,
const std::function<bool()> &cancelled = {}) const;
Pixels thumbnail(const std::wstring &id, const std::function<bool()> &cancelled = {}) const;
bool saveOcr(const std::wstring &id, const std::optional<std::wstring> &, const Settings &);
std::optional<std::wstring> ocrText(const std::wstring &id, const std::wstring &profile = L"") const;
void remove(const std::wstring &);
void pin(const std::wstring &);
void clear();
std::vector<Entry> search(const std::wstring &) const;
std::vector<Entry> search(const std::wstring &, bool pinnedOnly = false) const;
};
Bytes encodeClip(const Clip &);
Bytes selectionClipboardIdentity(const Clip &);
Clip decodeClip(const Bytes &);
bool sameClipboardImage(const Clip &, const Clip &);
int runImageHistoryCapture(const std::filesystem::path &request);
int runHistoryOcr(const std::filesystem::path &request);
// Image backups stay encrypted on disk and are read/written only by a short-lived helper.
class ClipboardSnapshot
{
Clip clip_;
std::filesystem::path request_;
void cleanup() noexcept;
DWORD run(bool restore, DWORD sequence);
public:
ClipboardSnapshot() = default;
ClipboardSnapshot(const ClipboardSnapshot &) = delete;
ClipboardSnapshot &operator=(const ClipboardSnapshot &) = delete;
~ClipboardSnapshot();
void capture(HWND, DWORD sequence);
DWORD restore(HWND, DWORD sequence);
};
int runClipboardSnapshot(const std::filesystem::path &request);
} // namespace ks
+36
View File
@@ -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 {
// 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.
// Report recognized geometry separately from a smoothed freehand path.
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr,
+382
View File
@@ -0,0 +1,382 @@
#include "sync.h"
#include "mouse_buttons.h"
#include "json.hpp"
#include <winhttp.h>
#include <wincred.h>
#include <set>
#include <algorithm>
namespace ks
{
using Json = nlohmann::json;
namespace
{
constexpr size_t Limit = 4 * 1024 * 1024;
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
Json configJson(const SyncConfig &c)
{
return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)},
{"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)},
{"username", utf8(c.username)}, {"keepLocalLayouts", c.keepLocalLayouts}};
}
std::wstring credentialName(const SyncConfig &c)
{
return L"KSwitcher.Sync/" + c.server + L"/" + c.authMount + L"/" + c.username;
}
bool safePath(const std::wstring &s)
{
if (s.empty() || s.size() > 512 || s.front() == '/' || s.back() == '/' ||
s.find(L"//") != s.npos) return false;
return s.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-/") == s.npos;
}
struct HttpHandle
{
HINTERNET h{};
~HttpHandle() { if (h) WinHttpCloseHandle(h); }
};
class OpenBao final : public SyncProvider
{
SyncConfig config;
SyncTransport transport;
std::wstring token;
Json response(unsigned status, const Bytes &result, bool missing, const std::wstring &path, const wchar_t *method)
{
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
if (status == 404 && missing)
{
auto j = result.empty() ? Json::object() : parse(result);
if (j.contains("data") && !j["data"].is_null())
throw std::runtime_error("Remote profile is deleted. Restore it in OpenBao first.");
return nullptr;
}
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
if (status == 400)
{
if (path.starts_with(L"/v1/auth/"))
throw std::runtime_error("OpenBao rejected the authentication request (HTTP 400). Check username, password and login mount.");
if (std::wstring_view(method) == L"GET")
throw std::runtime_error("OpenBao rejected the download request (HTTP 400). Check KV v2 mount and profile path.");
throw std::runtime_error("OpenBao rejected the write (HTTP 400) or its version changed. Sync again; check KV v2 configuration.");
}
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
return result.empty() ? Json::object() : parse(result);
}
Json request(const wchar_t *method, const std::wstring &path, const Json *body = nullptr, bool missing = false)
{
if (transport)
{
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
return response(reply.status, reply.body, missing, path, method);
}
URL_COMPONENTS url{sizeof(url)};
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
if (!WinHttpCrackUrl(config.server.c_str(), 0, 0, &url)) throw std::runtime_error("Invalid server URL.");
std::wstring host(url.lpszHostName, url.dwHostNameLength);
HttpHandle session{WinHttpOpen(L"KSwitcher Sync/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)};
if (!session.h || !WinHttpSetTimeouts(session.h, 5000, 5000, 5000, 5000))
throw std::runtime_error("Cannot initialize sync connection.");
HttpHandle connection{WinHttpConnect(session.h, host.c_str(), url.nPort, 0)};
HttpHandle req{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr,
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) : nullptr};
DWORD redirects = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
if (!req.h || !WinHttpSetOption(req.h, WINHTTP_OPTION_REDIRECT_POLICY, &redirects, sizeof(redirects)))
throw std::runtime_error("Cannot initialize sync request.");
std::wstring headers = L"Content-Type: application/json\r\n";
if (!token.empty()) headers += L"X-Vault-Token: " + token + L"\r\n";
std::string payload = body ? body->dump() : "";
bool sent = WinHttpSendRequest(req.h, headers.c_str(), (DWORD)-1,
payload.empty() ? WINHTTP_NO_REQUEST_DATA : payload.data(), (DWORD)payload.size(), (DWORD)payload.size(), 0);
if (!payload.empty()) SecureZeroMemory(payload.data(), payload.size());
if (!sent || !WinHttpReceiveResponse(req.h, nullptr))
throw std::runtime_error("OpenBao connection failed. Check network, HTTPS certificate and server address.");
DWORD status{}, length = sizeof(status);
if (!WinHttpQueryHeaders(req.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &length, WINHTTP_NO_HEADER_INDEX))
throw std::runtime_error("Cannot read OpenBao response.");
Bytes result;
ULONGLONG deadline = GetTickCount64() + 15000;
for (;;)
{
unsigned char chunk[8192]; DWORD received{};
if (GetTickCount64() > deadline || !WinHttpReadData(req.h, chunk, sizeof(chunk), &received))
throw std::runtime_error("OpenBao response timed out.");
if (!received) break;
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
result.insert(result.end(), chunk, chunk + received);
}
return response(status, result, missing, path, method);
}
public:
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
{
validateSyncConfig(c);
PCREDENTIALW credential{};
if (!CredReadW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0, &credential))
throw std::runtime_error("Sign in: no saved password for this server and user.");
std::string password(reinterpret_cast<char *>(credential->CredentialBlob), credential->CredentialBlobSize);
SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize);
CredFree(credential);
Json login{{"password", password}};
SecureZeroMemory(password.data(), password.size());
Json response;
try { response = request(L"POST", L"/v1/auth/" + c.authMount + L"/login/" + c.username, &login); }
catch (...) { auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size()); throw; }
auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size());
token = wide(response.at("auth").at("client_token").get<std::string>());
if (token.empty() || token.find_first_of(L"\r\n") != token.npos)
throw std::runtime_error("Invalid OpenBao authentication response.");
}
~OpenBao() override
{
if (token.empty()) return;
// Sessions are scoped to a sync attempt; do not accumulate login tokens.
try { request(L"POST", L"/v1/auth/token/revoke-self"); } catch (...) {}
SecureZeroMemory(token.data(), token.size() * sizeof(wchar_t));
}
SyncDocument read() override
{
auto j = request(L"GET", L"/v1/" + config.mount + L"/data/" + config.path, nullptr, true);
if (j.is_null()) return {};
const auto &data = j.at("data");
if (!data.at("metadata").value("deletion_time", std::string()).empty() ||
data.at("metadata").value("destroyed", false))
throw std::runtime_error("Remote profile is deleted or destroyed.");
auto payload = data.at("data");
if (payload.value("syncSchema", 0) != 1 && payload.value("syncSchema", 0) != 2) throw std::runtime_error("Unsupported remote sync format.");
return {data.at("metadata").at("version").get<uint64_t>(), bytes(payload.at("settings"))};
}
uint64_t write(const Bytes &settings, uint64_t revision) override
{
Json body{{"options", {{"cas", revision}}}, {"data", {{"syncSchema", 2}, {"settings", parse(settings)}}}};
auto response = request(L"POST", L"/v1/" + config.mount + L"/data/" + config.path, &body);
return response.at("data").at("version").get<uint64_t>();
}
};
}
bool syncVisible(const std::filesystem::path &root) { return std::filesystem::exists(root / L"sync.json"); }
SyncConfig loadSyncConfig(const std::filesystem::path &root)
{
SyncConfig c;
if (!syncVisible(root)) return c;
auto j = parse(readFile(root / L"sync.json", 16384));
c.enabled = j.value("enabled", false);
c.keepLocalLayouts = j.value("keepLocalLayouts", false);
c.provider = wide(j.value("provider", "openbao"));
c.server = wide(j.value("server", ""));
c.mount = wide(j.value("mount", "kswitcher"));
c.path = wide(j.value("path", "personal/settings"));
c.authMount = wide(j.value("authMount", "userpass"));
c.username = wide(j.value("username", ""));
return c;
}
void validateSyncConfig(const SyncConfig &c)
{
if (c.provider != L"openbao") throw std::runtime_error("Unsupported sync provider.");
URL_COMPONENTS url{sizeof(url)};
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = url.dwUserNameLength = url.dwPasswordLength = (DWORD)-1;
if (c.server.size() > 1024 || !WinHttpCrackUrl(c.server.c_str(), 0, 0, &url) ||
url.nScheme != INTERNET_SCHEME_HTTPS || !url.dwHostNameLength || url.dwUserNameLength ||
url.dwPasswordLength || url.dwExtraInfoLength ||
(url.dwUrlPathLength && std::wstring(url.lpszUrlPath, url.dwUrlPathLength) != L"/"))
throw std::runtime_error("Server must be an HTTPS origin, for example https://bao.example.com (no path or credentials).");
bool userValid = !c.username.empty() && c.username.size() <= 256 && c.username != L"." && c.username != L".." &&
c.username.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.@") == c.username.npos;
if (!safePath(c.mount) || !safePath(c.path) || !safePath(c.authMount) || !userValid)
throw std::runtime_error("Use letters, numbers, underscores and hyphens in paths and login; / is allowed only in paths.");
if (credentialName(c).size() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH)
throw std::runtime_error("Sync connection name is too long.");
}
void saveSyncConfig(const std::filesystem::path &root, const SyncConfig &c)
{
if (c.enabled) validateSyncConfig(c);
atomicWrite(root / L"sync.json", bytes(configJson(c)));
}
void saveSyncPassword(const SyncConfig &c, const std::wstring &password)
{
validateSyncConfig(c);
auto secret = utf8(password);
if (secret.empty() || secret.size() > CRED_MAX_CREDENTIAL_BLOB_SIZE) throw std::runtime_error("Password is empty or too long.");
auto target = credentialName(c);
CREDENTIALW credential{}; credential.Type = CRED_TYPE_GENERIC; credential.TargetName = target.data();
credential.UserName = const_cast<wchar_t *>(c.username.c_str());
credential.CredentialBlob = reinterpret_cast<BYTE *>(secret.data());
credential.CredentialBlobSize = (DWORD)secret.size(); credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
bool ok = CredWriteW(&credential, 0) != 0;
SecureZeroMemory(secret.data(), secret.size());
if (!ok) throw std::runtime_error("Cannot save password in Windows Credential Manager.");
}
void forgetSyncPassword(const SyncConfig &c)
{
if (!CredDeleteW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0) && GetLastError() != ERROR_NOT_FOUND)
throw std::runtime_error("Cannot remove saved sync password.");
}
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTransport transport)
{
return std::make_unique<OpenBao>(c, std::move(transport));
}
namespace {
Json canonicalProfile(Json);
constexpr const char *PreviouslyLocalSettings[] = {
"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock",
"appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents"
};
void preserveMissingSettings(Json &profile, const Json &fallback)
{
for (auto key : PreviouslyLocalSettings)
if (!profile.contains(key) && fallback.contains(key)) profile[key] = fallback.at(key);
}
}
Bytes sharedSyncSettings(const Settings &s)
{
auto j = parse(encodeSettings(s));
// Windows may enumerate the same installed layouts in a different order on
// another PC. Binding order is not user intent (unlike the layout cycle).
auto &bindings = j["layoutHotkeys"];
std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) {
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
});
return bytes(canonicalProfile(j));
}
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
{
auto j = parse(shared), original = parse(encodeSettings(local));
preserveMissingSettings(j, original);
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
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));
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
throw std::runtime_error("Unsupported synchronized settings format.");
for (auto it = j.begin(); it != j.end(); ++it)
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported fields.");
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
std::set<std::string> installed;
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
std::set<std::string> missing;
for (const auto &id : j.at("layouts"))
if (!installed.contains(id.get<std::string>())) missing.insert(id.get<std::string>());
for (const auto &key : j.at("layoutHotkeys"))
if (!installed.contains(key.at("layoutId").get<std::string>())) missing.insert(key.at("layoutId").get<std::string>());
if (!keepLocalLayouts && !missing.empty())
{
std::wstring message = L"Missing keyboard layouts on this PC: ";
for (const auto &id : missing)
{
auto code = id.starts_with("win:") ? id.substr(4) : id;
wchar_t language[128]{};
if (code.size() == 8 && code.find_first_not_of("0123456789abcdefABCDEF") == code.npos)
GetLocaleInfoW(MAKELCID(LOWORD(std::stoul(code, nullptr, 16)), SORT_DEFAULT), LOCALE_SENGLISHLANGUAGENAME,
language, 128);
if (message.back() != L' ') message += L"; ";
message += (language[0] ? std::wstring(language) : L"Unknown layout") + L" [" + wide(id) + L"]";
}
message += L". Install these exact layouts, or enable 'Keep this PC's layouts' below and sync other settings.";
throw std::runtime_error(utf8(message));
}
if (keepLocalLayouts)
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = original.at(key);
original.update(j);
return decodeSettings(bytes(original), layouts);
}
#include "sync_merge.inl"
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices, bool keepLocalLayouts)
{
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
if (validate) validate(local);
auto remote = provider.read();
if (!remote.revision)
{
if (action != SyncAction::Upload) throw std::runtime_error("No remote profile. Choose Upload local to create it.");
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
}
if (validate && action != SyncAction::Upload) validate(remote.settings);
auto mergeLocal = local, mergeBase = base;
if (keepLocalLayouts)
{
// Never upload a locally filtered projection over another PC's layout setup.
const auto remoteJson = parse(remote.settings);
auto preserve = [&](const Bytes &profile)
{
auto j = parse(profile);
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = remoteJson.at(key);
return bytes(j);
};
mergeLocal = preserve(local);
if (!base.empty()) mergeBase = preserve(base);
}
SyncMerge merged;
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
else
{
auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal);
const auto current = parse(local);
if (!profile.contains("chatCycles")) profile["chatCycles"] = current.value("chatCycles", Json::object());
preserveMissingSettings(profile, current);
auto selected = bytes(normalizeProfile(profile));
merged = {selected, selected, selected, {}};
}
if (validate) { validate(merged.local); validate(merged.remote); }
auto revision = remote.revision;
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
return {true, merged.local, revision,
merged.conflicts.empty() ? std::wstring(action == SyncAction::Download ? L"Downloaded remote settings. Server version " :
action == SyncAction::Upload ? L"Uploaded local settings. Server version " : L"Synchronized. Server version ") + std::to_wstring(revision) + L"." :
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
merged.base, merged.conflicts, action == SyncAction::Download};
}
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
{
Json j = Json::array();
for (const auto &c : conflicts) j.push_back({{"key", c.key}, {"label", utf8(c.label)}, {"local", parse(c.local)}, {"remote", parse(c.remote)}});
return bytes(j);
}
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &data)
{
std::vector<SyncConflict> result;
for (const auto &c : parse(data)) result.push_back({c.at("key"), wide(c.at("label").get<std::string>()), bytes(c.at("local")), bytes(c.at("remote"))});
return result;
}
Bytes encodeSyncChoices(const std::vector<SyncChoice> &choices)
{
Json j = Json::array();
for (const auto &c : choices) j.push_back({{"conflict", parse(encodeSyncConflicts({c.conflict})).at(0)}, {"useLocal", c.useLocal}});
return bytes(j);
}
std::vector<SyncChoice> decodeSyncChoices(const Bytes &data)
{
auto j = parse(data);
if (!j.is_array() || j.size() > 10000) throw std::runtime_error("Invalid conflict choices.");
std::vector<SyncChoice> result;
for (const auto &c : j) result.push_back({decodeSyncConflicts(bytes(Json::array({c.at("conflict")}))).at(0), c.at("useLocal").get<bool>()});
return result;
}
std::wstring syncValueText(const Bytes &data)
{
auto j = parse(data);
if (j.is_null()) return L"(not present)";
if (tombstone(j)) return L"(deleted)";
return wide(j.is_string() ? j.get<std::string>() : j.dump(2));
}
Bytes loadSyncBase(const std::filesystem::path &root, const SyncConfig &c)
{
auto path = root / L"sync-base.bin";
if (!std::filesystem::exists(path)) return {};
auto j = parse(unprotect(readFile(path, Limit + 65536)));
auto connection = j.at("connection");
if (!connection.contains("keepLocalLayouts")) connection["keepLocalLayouts"] = false;
return connection == configJson(c) ? bytes(j.at("settings")) : Bytes{};
}
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
{
atomicWrite(root / L"sync-base.bin", protect(bytes({{"connection", configJson(c)}, {"settings", parse(base)}})));
}
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "storage.h"
#include <memory>
namespace ks
{
struct SyncConfig
{
bool enabled{};
bool keepLocalLayouts{};
std::wstring provider = L"openbao", server, mount = L"kswitcher",
path = L"personal/settings", authMount = L"userpass", username;
bool operator==(const SyncConfig &) const = default;
};
bool syncVisible(const std::filesystem::path &);
SyncConfig loadSyncConfig(const std::filesystem::path &);
void saveSyncConfig(const std::filesystem::path &, const SyncConfig &);
void validateSyncConfig(const SyncConfig &);
void saveSyncPassword(const SyncConfig &, const std::wstring &);
void forgetSyncPassword(const SyncConfig &);
struct SyncDocument { uint64_t revision{}; Bytes settings; };
class SyncProvider
{
public:
virtual ~SyncProvider() = default;
virtual SyncDocument read() = 0;
virtual uint64_t write(const Bytes &, uint64_t expectedRevision) = 0;
};
struct SyncHttpResponse { unsigned status{}; Bytes body; };
using SyncTransport = std::function<SyncHttpResponse(const std::wstring &method, const std::wstring &path,
const std::wstring &token, const Bytes &body)>;
// An injectable transport permits protocol regression tests without a live vault.
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &, SyncTransport transport = {});
inline constexpr ULONGLONG SyncIntervalMs = 10 * 60 * 1000;
struct SyncConflict
{
std::string key;
std::wstring label;
Bytes local, remote;
};
struct SyncChoice { SyncConflict conflict; bool useLocal{}; };
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &);
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &);
Bytes encodeSyncChoices(const std::vector<SyncChoice> &);
std::vector<SyncChoice> decodeSyncChoices(const Bytes &);
std::wstring syncValueText(const Bytes &);
struct SyncMerge
{
Bytes local, remote, base;
std::vector<SyncConflict> conflicts;
};
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote,
const std::vector<SyncChoice> &choices = {});
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
Bytes sharedSyncSettings(const Settings &);
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &, bool keepLocalLayouts = false);
enum class SyncAction { Automatic, Download, Upload };
struct SyncResult
{
bool success{};
Bytes settings;
uint64_t revision{};
std::wstring status;
Bytes base;
std::vector<SyncConflict> conflicts;
bool downloaded{};
};
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
bool keepLocalLayouts = false);
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
}
+258
View File
@@ -0,0 +1,258 @@
// Three-way merge operates on individual values; unresolved values retain their
// respective local/remote versions and their old common ancestor.
namespace
{
Json normalizeProfile(Json j)
{
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
if (!j.contains("chatCycles")) j["chatCycles"] = Json::object();
for (auto &sn : j.at("snippets"))
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
return j;
}
Json treeProfile(const Json &source)
{
auto j = normalizeProfile(source);
Json items = Json::object();
for (const auto &sn : j.at("snippets"))
{
auto id = sn.at("id").get<std::string>();
if (items.contains(id)) throw std::runtime_error("Duplicate snippet ID.");
items[id] = sn;
}
for (const auto &id : j.at("snippetTombstones"))
{
auto key = id.get<std::string>();
if (items.contains(key)) throw std::runtime_error("Duplicate or deleted snippet ID.");
items[key] = {{"$deleted", true}};
}
j["snippets"] = items;
j.erase("snippetTombstones");
items = Json::object();
for (const auto &binding : j.at("layoutHotkeys")) items[binding.at("layoutId").get<std::string>()] = binding.at("hotkey");
j["layoutHotkeys"] = items;
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
{
items = Json::object();
for (const auto &item : j.at(key)) items[item.get<std::string>()] = true;
j[key] = items;
}
for (auto &language : j.at("autoCorrectWords").items())
{
items = Json::object();
for (const auto &word : language.value()) items[word.get<std::string>()] = true;
language.value() = items;
}
return j;
}
Json flatProfile(Json j)
{
auto &replacements = j["autoCorrectReplacements"];
for (auto it = replacements.begin(); it != replacements.end();)
if (it.value().is_null()) it = replacements.erase(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;
if (j.contains("mouseBindings"))
{
auto &profiles = j.at("mouseBindings");
for (auto profile = profiles.begin(); profile != profiles.end();)
{
auto &buttons = profile.value();
for (auto button = buttons.begin(); button != buttons.end();)
if (button.value().is_null()) button = buttons.erase(button);
else ++button;
if (buttons.is_null() || (buttons.empty() && !isNamedMouseProfile(wide(profile.key()))))
profile = profiles.erase(profile);
else ++profile;
}
}
if (j.contains("customMouseEvents"))
{
auto &events = j.at("customMouseEvents");
for (auto event = events.begin(); event != events.end();)
if (event.value().is_null()) event = events.erase(event);
else ++event;
if (events.is_null()) j.erase("customMouseEvents");
}
Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items())
{
if (item.is_null()) continue;
if (item.value("$deleted", false)) deleted.push_back(id);
else snippets.push_back(item);
}
j["snippets"] = snippets; j["snippetTombstones"] = deleted;
Json bindings = Json::array();
for (const auto &[id, hotkey] : j.at("layoutHotkeys").items())
if (!hotkey.is_null()) bindings.push_back({{"layoutId", id}, {"hotkey", hotkey}});
j["layoutHotkeys"] = bindings;
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
{
Json list = Json::array();
for (const auto &[item, present] : j.at(key).items()) if (present == true) list.push_back(item);
j[key] = list;
}
for (auto &language : j.at("autoCorrectWords").items())
{
Json list = Json::array();
if (language.value().is_object())
for (const auto &[word, present] : language.value().items()) if (present == true) list.push_back(word);
language.value() = list;
}
return j;
}
std::string pointerKey(const std::string &s)
{
std::string out;
for (char c : s) out += c == '~' ? "~0" : c == '/' ? "~1" : std::string(1, c);
return out;
}
Json canonicalProfile(Json j) { return flatProfile(treeProfile(j)); }
std::wstring fieldLabel(const std::string &key)
{
static const std::map<std::string, std::wstring> labels{
{"toggleHotkey", L"Next layout shortcut"}, {"caseHotkey", L"Change case shortcut"},
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"}, {"filesHotkey", L"Files shortcut"},
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
{"chatCycles", L"Chat languages"}, {"mouseBindings", L"Mouse buttons"},
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
{"autoCorrectReplace", L"Custom automatic replacements"}, {"autoCorrectTypos", L"Dictionary typo correction"},
{"autoCorrectReplacements", L"Replacement pairs"},
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
{"trigger", L"Trigger"}, {"text", L"Text"}};
auto it = labels.find(key); return it == labels.end() ? wide(key) : it->second;
}
struct MergeNode { Json local, remote, base; };
bool tombstone(const Json &j) { return j.is_object() && j.value("$deleted", false); }
MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::string &path,
const std::wstring &label, const std::vector<SyncChoice> &choices, std::vector<SyncConflict> &conflicts,
const std::set<std::string> &forced)
{
if (l == r) return {l, l, l};
for (const auto &choice : choices)
if (choice.conflict.key == path && parse(choice.conflict.local) == l && parse(choice.conflict.remote) == r)
{
auto selected = choice.useLocal ? l : r;
if (selected.is_null() && path.starts_with("/snippets/") && path.find('/', 10) == std::string::npos)
selected = {{"$deleted", true}};
return {selected, selected, selected};
}
if (forced.contains(path))
{
conflicts.push_back({path, label + L" (duplicate trigger or shortcut)", bytes(l), bytes(r)});
return {l, r, b};
}
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
if (!forcedChild && l == b) return {r, r, r};
if (!forcedChild && r == b) return {l, l, l};
// A mouse action and its application scope must come from the same edit.
constexpr std::string_view mouseProfiles = "/mouseBindings/";
const bool mouseAction = path.starts_with(mouseProfiles) && path.find('/', mouseProfiles.size()) != std::string::npos;
if (!mouseAction && l.is_object() && r.is_object() && (b.is_object() || b.is_null()) &&
!tombstone(l) && !tombstone(r) && !tombstone(b))
{
MergeNode out{Json::object(), Json::object(), Json::object()};
std::set<std::string> keys;
for (const auto *j : {&b, &l, &r}) if (j->is_object()) for (auto it = j->begin(); it != j->end(); ++it) keys.insert(it.key());
for (const auto &key : keys)
{
auto get = [&](const Json &j) { return j.is_object() && j.contains(key) ? j.at(key) : Json(); };
auto childLabel = label.empty() ? fieldLabel(key) : label + L" / " + fieldLabel(key);
if (path == "/snippets")
{
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);
}
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);
// 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;
}
return out;
}
conflicts.push_back({path, label, bytes(l), bytes(r)});
return {l, r, b};
}
void collisionPaths(const Json &j, std::set<std::string> &paths)
{
std::map<std::wstring, std::string> triggers;
for (const auto &[id, sn] : j.at("snippets").items())
{
if (sn.is_null() || tombstone(sn)) continue;
auto path = "/snippets/" + pointerKey(id);
auto key = lower(wide(sn.at("trigger").get<std::string>()));
std::vector<std::wstring> scopes;
if (sn.contains("apps")) for (const auto &app : sn.at("apps")) scopes.push_back(snippetAppName(wide(app.get<std::string>())));
if (scopes.empty()) scopes.push_back(L"");
for (const auto &scope : scopes)
{
auto [it, inserted] = triggers.emplace(key + L"\n" + scope, path);
if (!inserted) { paths.insert(path); paths.insert(it->second); }
}
}
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
auto add = [&](const Json &text, const std::string &path) {
if (!text.is_string()) return;
auto hotkey = Hotkey::parse(wide(text.get<std::string>()));
if (!hotkey || !hotkey->key) return;
auto [it, inserted] = shortcuts.emplace(std::make_pair(hotkey->modifiers, hotkey->key), path);
if (!inserted) { paths.insert(path); paths.insert(it->second); }
};
for (const auto *key : {"toggleHotkey", "caseHotkey", "translitHotkey", "undoHotkey", "clipboardHotkey", "screenshotHotkey"}) add(j.at(key), std::string("/") + key);
if (j.contains("filesHotkey")) add(j.at("filesHotkey"), "/filesHotkey");
for (const auto &[id, key] : j.at("layoutHotkeys").items()) add(key, "/layoutHotkeys/" + pointerKey(id));
}
}
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
{
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");
if (!remoteProfile.contains("filesHotkey") && localProfile.contains("filesHotkey"))
remoteProfile["filesHotkey"] = localProfile.at("filesHotkey");
if (!localProfile.contains("filesHotkey") && remoteProfile.contains("filesHotkey"))
localProfile["filesHotkey"] = remoteProfile.at("filesHotkey");
// Older server backups omit the newly synchronized fields. An explicit
// empty value from a current client still means the user cleared it.
preserveMissingSettings(remoteProfile, localProfile);
preserveMissingSettings(localProfile, remoteProfile);
auto l = treeProfile(localProfile), r = treeProfile(remoteProfile);
// With no common history, union independent entries; differing scalar values
// remain on their respective devices until the user resolves the conflict.
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
SyncMerge result;
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
std::set<std::string> collisions;
collisionPaths(merged.local, collisions); collisionPaths(merged.remote, collisions);
if (!collisions.empty())
{
result.conflicts.clear();
merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, collisions);
}
result.local = bytes(flatProfile(merged.local));
result.remote = bytes(flatProfile(merged.remote));
result.base = bytes(flatProfile(merged.base));
return result;
}
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote)
{
auto result = mergeSyncProfile(base, local, remote);
if (!result.conflicts.empty()) throw std::runtime_error("Conflicting setting: " + result.conflicts.front().key);
return result.local;
}
+1109 -221
View File
File diff suppressed because it is too large. Load diff
+318
View File
@@ -0,0 +1,318 @@
// Included inside ui.cpp's anonymous namespace, alongside the settings editors.
std::wstring trimApplicationText(const std::wstring &text)
{
auto first = text.find_first_not_of(L" \t\r\n");
return first == text.npos ? L"" : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
}
struct AppLanguagesView
{
HWND parent{};
SettingsView *settings{};
std::map<std::wstring, std::vector<std::wstring>> profiles;
std::vector<std::wstring> applications;
int selection = -1;
bool dirty{};
HWND pickerOutline{};
};
constexpr UINT AppWindowPicked = WM_APP + 78;
void showAppLanguageStatus(HWND w, const AppLanguagesView &v)
{
if (v.selection < 0)
value(w, 721, L"Select an application, drag the crosshair, or enter an executable name.");
else if (v.dirty || v.profiles.contains(v.applications[v.selection]))
value(w, 721, L"Custom languages for this application. Other applications are unchanged.");
else
value(w, 721, L"Using default languages. Click languages to customize this application.");
}
void fillAppLanguages(HWND w, AppLanguagesView *v)
{
bool selected = v->selection >= 0 && v->selection < (int)v->applications.size();
EnableWindow(GetDlgItem(w, 722), selected);
SendDlgItemMessageW(w, 722, LB_SETSEL, FALSE, -1);
if (!selected)
{
EnableWindow(GetDlgItem(w, 725), FALSE);
showAppLanguageStatus(w, *v);
return;
}
auto found = v->profiles.find(v->applications[v->selection]);
const auto &cycle = found == v->profiles.end() ? v->settings->draft.cycle : found->second;
for (size_t i = 0; i < v->settings->order.size(); ++i)
{
const auto &id = v->settings->app->layouts[v->settings->order[i]].id;
SendDlgItemMessageW(w, 722, LB_SETSEL,
std::find(cycle.begin(), cycle.end(), id) != cycle.end(), i);
}
showAppLanguageStatus(w, *v);
EnableWindow(GetDlgItem(w, 725), found != v->profiles.end());
}
void collectAppLanguages(HWND w, AppLanguagesView *v)
{
if (v->selection < 0 || !v->dirty) return;
std::vector<std::wstring> cycle;
for (size_t i = 0; i < v->settings->order.size(); ++i)
if (SendDlgItemMessageW(w, 722, LB_GETSEL, i, 0) > 0)
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
v->profiles[v->applications[v->selection]] = std::move(cycle);
v->dirty = false;
}
void selectAppLanguageProfile(HWND w, AppLanguagesView *v, const std::wstring &app)
{
collectAppLanguages(w, v);
auto found = std::find(v->applications.begin(), v->applications.end(), app);
int index = (int)(found - v->applications.begin());
if (found == v->applications.end())
{
auto row = SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
if (row < 0) throw std::runtime_error("Could not add the application to the list.");
v->applications.push_back(app);
}
v->selection = index;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0);
fillAppLanguages(w, v);
}
HWND appPickerTarget(HWND button, LPARAM position)
{
POINT point{(short)LOWORD(position), (short)HIWORD(position)};
ClientToScreen(button, &point);
auto target = GetAncestor(WindowFromPoint(point), GA_ROOT);
auto dialog = GetParent(button);
return target && target != dialog && target != GetWindow(dialog, GW_OWNER) ? target : nullptr;
}
void updatePickerOutline(HWND dialog, HWND &outline, HWND target)
{
RECT bounds{};
if (!target || !GetWindowRect(target, &bounds) || IsRectEmpty(&bounds))
{
if (outline) ShowWindow(outline, SW_HIDE);
return;
}
if (!outline)
{
WNDCLASSW windowClass{};
windowClass.lpfnWndProc = DefWindowProcW;
windowClass.hInstance = GetModuleHandleW(nullptr);
windowClass.hbrBackground = GetSysColorBrush(COLOR_HIGHLIGHT);
windowClass.lpszClassName = L"KSwitcher.AppPickerOutline";
RegisterClassW(&windowClass);
outline = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
windowClass.lpszClassName, nullptr, WS_POPUP, 0, 0, 0, 0, dialog, nullptr, windowClass.hInstance, nullptr);
if (!outline) return;
if (!SetLayeredWindowAttributes(outline, 0, 255, LWA_ALPHA))
{
DestroyWindow(outline);
outline = nullptr;
return;
}
}
RECT previous{};
if (IsWindowVisible(outline) && GetWindowRect(outline, &previous) && EqualRect(&bounds, &previous)) return;
int width = bounds.right - bounds.left, height = bounds.bottom - bounds.top;
int stroke = scale(target, 4);
HRGN frame = CreateRectRgn(0, 0, width, height);
if (!frame) return;
if (width > 2 * stroke && height > 2 * stroke)
{
HRGN opening = CreateRectRgn(stroke, stroke, width - stroke, height - stroke);
if (opening)
{
CombineRgn(frame, frame, opening, RGN_DIFF);
DeleteObject(opening);
}
}
if (!SetWindowRgn(outline, frame, TRUE))
{
DeleteObject(frame);
return;
}
SetWindowPos(outline, HWND_TOPMOST, bounds.left, bounds.top, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR data)
{
HWND dialog = GetParent(button);
auto &outline = *reinterpret_cast<HWND *>(data);
if (msg == WM_LBUTTONDOWN)
{
SetFocus(button);
SetCapture(button);
if (GetCapture() == button)
{
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
SendMessageW(dialog, AppWindowPicked, 0, -1);
}
return 0;
}
if (msg == WM_CAPTURECHANGED || msg == WM_NCDESTROY)
{
if (outline)
{
DestroyWindow(outline);
outline = nullptr;
}
if (msg == WM_CAPTURECHANGED) SendMessageW(dialog, AppWindowPicked, 0, 0);
}
if (GetCapture() == button)
{
if (msg == WM_GETDLGCODE && wp == VK_ESCAPE) return DLGC_WANTMESSAGE;
if (msg == WM_CANCELMODE || msg == WM_RBUTTONDOWN || (msg == WM_KEYDOWN && wp == VK_ESCAPE))
{
ReleaseCapture();
return 0;
}
if (msg == WM_MOUSEMOVE || msg == WM_SETCURSOR)
{
SetCursor(LoadCursorW(nullptr, IDC_CROSS));
if (msg == WM_MOUSEMOVE) updatePickerOutline(dialog, outline, appPickerTarget(button, lp));
return TRUE;
}
if (msg == WM_LBUTTONUP)
{
HWND target = appPickerTarget(button, lp);
ReleaseCapture();
SendMessageW(dialog, AppWindowPicked, (WPARAM)target, 1);
return 0;
}
}
if (msg == WM_NCDESTROY) RemoveWindowSubclass(button, appWindowPickerProc, id);
return DefSubclassProc(button, msg, wp, lp);
}
void createAppWindowPicker(HWND w, int id, int x, int y, int width, HWND &outline)
{
control(w, L"BUTTON", L" ⊕ Drag to window", WS_TABSTOP | BS_PUSHBUTTON | BS_LEFT,
id, x, y, width, 28);
SetWindowSubclass(GetDlgItem(w, id), appWindowPickerProc, 1, reinterpret_cast<DWORD_PTR>(&outline));
}
LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *v = (AppLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
if (!v) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == AppWindowPicked)
{
if (lp == -1)
{
value(w, 721, L"Drag to the application's window and release. Esc cancels.");
return 0;
}
if (!lp) { showAppLanguageStatus(w, *v); return 0; }
HWND target = (HWND)wp;
auto app = target && target != w && target != v->parent ? applicationName(processName(target)) : L"";
if (app.empty()) value(w, 721, L"No application found. Release the crosshair over another window.");
else selectAppLanguageProfile(w, v, app);
return 0;
}
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 722 && HIWORD(wp) == LBN_SELCHANGE)
{
v->dirty = true;
EnableWindow(GetDlgItem(w, 725), TRUE);
showAppLanguageStatus(w, *v);
return 0;
}
if (id == 720 && HIWORD(wp) == LBN_SELCHANGE)
{
int next = (int)SendDlgItemMessageW(w, 720, LB_GETCURSEL, 0, 0);
try { collectAppLanguages(w, v); }
catch (...) { SendDlgItemMessageW(w, 720, LB_SETCURSEL, v->selection, 0); throw; }
v->selection = next;
fillAppLanguages(w, v);
return 0;
}
if (id == 725 && v->selection >= 0)
{
v->profiles.erase(v->applications[v->selection]);
v->dirty = false;
fillAppLanguages(w, v);
return 0;
}
if (id == 727)
{
auto app = applicationName(trimApplicationText(value(w, 726)));
Settings check;
check.appLanguageSets[L"check"] = {{L"win:04090409"}, {app}};
validateSettings(check);
selectAppLanguageProfile(w, v, app);
value(w, 726, L"");
SetFocus(GetDlgItem(w, 722));
return 0;
}
if (id == IDOK)
{
collectAppLanguages(w, v);
auto next = v->settings->draft;
next.appLanguageSets.clear();
// Keep the existing on-disk format, but save independent application profiles.
for (const auto &[app, cycle] : v->profiles) next.appLanguageSets[app] = {cycle, {app}};
validateSettings(next);
v->settings->draft.appLanguageSets = std::move(next.appLanguageSets);
DestroyWindow(w);
return 0;
}
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
if (msg == WM_NCDESTROY)
{
EnableWindow(v->parent, TRUE);
SetForegroundWindow(v->parent);
disposeFont(w);
delete v;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
}
catch (const std::exception &e) { error(w, e); }
return DefWindowProcW(w, msg, wp, lp);
}
void showAppLanguages(HWND parent, SettingsView *settings)
{
std::set<std::wstring> apps;
for (const auto &[name, set] : settings->draft.appLanguageSets)
for (const auto &app : set.applications) apps.insert(applicationName(app));
EnumWindows([](HWND candidate, LPARAM context) -> BOOL {
if (IsWindowVisible(candidate) && !chatTitle(candidate).empty())
{
auto name = applicationName(processName(candidate));
if (!name.empty()) ((std::set<std::wstring> *)context)->insert(name);
}
return TRUE;
}, (LPARAM)&apps);
HWND w = makeWindow(*settings->app, L"KSwitcher.AppLanguages", L"Languages for applications", appLanguagesProc, 720, 500);
auto *v = new AppLanguagesView{parent, settings};
for (const auto &[name, set] : settings->draft.appLanguageSets)
for (const auto &app : set.applications) v->profiles[applicationName(app)] = set.languages;
v->applications.assign(apps.begin(), apps.end());
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Application", 18, 16, 108);
createAppWindowPicker(w, 728, 132, 10, 164, v->pickerOutline);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
720, 18, 44, 278, 280);
label(w, L"Allowed languages (click to include)", 316, 16, 386);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
722, 316, 44, 386, 280);
for (int i : settings->order)
SendDlgItemMessageW(w, 722, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
for (const auto &app : v->applications) SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
control(w, L"STATIC", L"", 0, 721, 18, 338, 684, 22);
label(w, L"Add an executable name (e.g. Codex.exe)", 18, 372, 684);
edit(w, 726, 18, 400, 526);
button(w, L"&Add application", 727, 558, 398, 144);
label(w, L"Switch applications freely. Use languages, then Save settings. Chat profiles take priority.", 18, 436, 684);
button(w, L"Use &defaults", 725, 18, 466, 170);
button(w, L"&Use languages", IDOK, 414, 466, 144);
button(w, L"&Cancel", IDCANCEL, 572, 466, 130);
if (!v->applications.empty())
{
v->selection = 0;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, 0, 0);
}
fillAppLanguages(w, v);
EnableWindow(parent, FALSE);
showRequestedWindow(w);
SetFocus(GetDlgItem(w, v->selection < 0 ? 726 : 720));
}
+295
View File
@@ -0,0 +1,295 @@
// 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;
std::string logRequest;
std::filesystem::path logDestination;
std::wstring transferMessage;
};
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)] : "";
}
std::filesystem::path pickLanFile(HWND owner, bool save, const wchar_t *suggestion=L"")
{
wchar_t path[32768]{};
wcsncpy_s(path,suggestion,_TRUNCATE);
OPENFILENAMEW dialog{sizeof(dialog)};
dialog.hwndOwner=owner;
dialog.lpstrFilter=L"All files\0*.*\0";
dialog.lpstrFile=path;
dialog.nMaxFile=std::size(path);
dialog.Flags=OFN_EXPLORER|OFN_NOCHANGEDIR|OFN_PATHMUSTEXIST|(save?OFN_OVERWRITEPROMPT:OFN_FILEMUSTEXIST);
if(save?GetSaveFileNameW(&dialog):GetOpenFileNameW(&dialog))return path;
return {};
}
LRESULT CALLBACK lanHelpProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
{
if(msg==WM_DPICHANGED){resizeForDpi(w,wp,lp);return 0;}
if(msg==WM_COMMAND&&LOWORD(wp)==724){SendDlgItemMessageW(w,723,EM_SETSEL,0,-1);SendDlgItemMessageW(w,723,WM_COPY,0,0);value(w,724,L"Copied");return 0;}
if(msg==WM_CLOSE||(msg==WM_COMMAND&&LOWORD(wp)==IDCANCEL)){DestroyWindow(w);return 0;}
if(msg==WM_DESTROY){auto parent=GetWindow(w,GW_OWNER);EnableWindow(parent,TRUE);SetForegroundWindow(parent);return 0;}
return DefWindowProcW(w,msg,wp,lp);
}
void showLanHelp(HWND parent,App &app)
{
auto script=wide(lanRequest(app,{{"action","connectionHelp"}}).at("script").get<std::string>());
auto w=makeWindow(app,L"KSwitcher.LAN.Help",L"KSwitcher — connection help",lanHelpProc,740,430,0,false);
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
control(w,L"STATIC",L"First enable input sharing on both PCs. KSwitcher tries direct TCP, reverse TCP, then encrypted UDP automatically. Both PCs need an updated version for all three methods.",0,725,18,16,700,60);
control(w,L"STATIC",L"Still disconnected? A firewall may be blocking the app. On EACH PC, open this help and run its own command in PowerShell as administrator. This allows only local-network connections to that PC's current KSwitcher executable. No restart or new pairing is needed.",0,726,18,88,700,80);
auto field=edit(w,723,18,180,700,180,ES_MULTILINE|ES_READONLY|ES_AUTOVSCROLL|WS_VSCROLL);
SetWindowTextW(field,script.c_str());
button(w,L"&Copy command",724,18,380,180);button(w,L"&Close",IDCANCEL,618,380,100);
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
}
void refreshLan(HWND w,LanView &v)
{
if(!v.logRequest.empty())
{
auto reply=lanRequest(*v.app,{{"action","takeLog"},{"request",v.logRequest}});
auto state=reply.at("state").get<std::string>();
if(state!="waiting")
{
if(state=="ready")
{
v.logRequest.clear();
v.transferMessage.clear();
atomicWrite(v.logDestination,decodeLanLog(reply.at("logHex")));
v.transferMessage=L"Diagnostic log saved.";
}
else if(state=="denied")v.transferMessage=L"Remote diagnostic downloads are disabled on that PC.";
else if(state=="unavailable")v.transferMessage=L"The selected log is unavailable on that PC.";
else v.transferMessage=L"The remote log download timed out.";
v.logRequest.clear();
}
}
auto selected=selectedLan(w,v);
auto next=lanRequest(*v.app,{{"action","status"}});
if(selected.empty())
{
std::string onlyPaired;size_t pairedCount=0;
for(const auto &peer:next["peers"])if(peer.value("paired",false))
{onlyPaired=peer.at("id").get<std::string>();++pairedCount;}
if(pairedCount==1)selected=onlyPaired;
}
std::vector<std::string> ids;
std::vector<std::wstring> rows;
for(const auto &peer:next["peers"])
{
auto id=peer.at("id").get<std::string>();ids.push_back(id);
std::wstring state=L"Not paired";
if(peer.at("paired").get<bool>()){
bool connected=next.value("inputReady",nlohmann::json::object()).contains(id);
state=connected?L"Pair saved — input connected":
!next.value("shareInput",false)?L"Pair saved — input disabled here":
!peer.value("online",false)?L"Pair saved — computer offline":L"Pair saved — input not connected";
}
rows.push_back(wide(peer.at("name").get<std::string>())+L" ["+wide(id.substr(0,8))+L"] — "+state);
}
// Presence/challenge refreshes must not repaint or reset the pairing list.
if(v.ids!=ids||v.rows!=rows)
{
SendDlgItemMessageW(w,702,WM_SETREDRAW,FALSE,0);
SendDlgItemMessageW(w,702,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);
// Keep the code available while the user enters it on the other PC, and
// after cancellation/timeout for retry. Clear it only after trust is saved.
if(v.snapshot.value("pending",false))
for(const auto &peer:next["peers"])
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{})&&peer.value("paired",false))value(w,703,L"");
v.snapshot=std::move(next);
bool enabled=v.snapshot.at("enabled"), pending=v.snapshot.at("pending"), online=false, paired=false;
for(const auto &peer:v.snapshot["peers"]) if(peer.at("id")==selected) {online=peer.at("online");paired=peer.at("paired");}
value(w,701,enabled ? L"&Disable discovery" : L"&Enable discovery");
value(w,709,L"This KSwitcher: "+wide(v.snapshot.at("name").get<std::string>())+L" ["+wide(v.snapshot.at("id").get<std::string>().substr(0,8))+L"]");
auto pairingStatus=wide(v.snapshot.at("status").get<std::string>());
if(pending)
{
std::wstring peerName;
for(const auto &peer:v.snapshot["peers"])
if(peer.at("id")==v.snapshot.value("pendingPeer",std::string{}))peerName=wide(peer.at("name").get<std::string>());
pairingStatus=L"Waiting for "+peerName+L" ("+std::to_wstring(v.snapshot.value("pairSecondsRemaining",0))+L" s).\nEnter the same code and click Pair there too. Pair is not saved yet.";
}
value(w,708,v.error.empty() ? pairingStatus : v.error);
value(w,705,pending ? L"Waiting..." : L"&Pair");
value(w,710,v.ids.empty() ? (enabled ? L"No KSwitcher found. Enable discovery on the other PC too. UDP 45883 must be allowed on your private network." : L"Discovery is disabled on this computer.") : L"Select the matching KSwitcher name and ID shown on the other computer.");
EnableWindow(GetDlgItem(w,705),enabled&&online&&!pending&&!paired);
EnableWindow(GetDlgItem(w,706),pending);
EnableWindow(GetDlgItem(w,707),paired);
bool canTransfer=false;
for(const auto &peer:v.snapshot["peers"])
if(peer.at("id")==selected)canTransfer=paired&&peer.value("verified",false)&&peer.value("fileTransfer",false);
EnableWindow(GetDlgItem(w,726),canTransfer);
EnableWindow(GetDlgItem(w,727),canTransfer&&v.logRequest.empty());
EnableWindow(GetDlgItem(w,728),canTransfer&&v.logRequest.empty());
EnableWindow(GetDlgItem(w,730),!v.snapshot["files"].empty());
CheckDlgButton(w,729,v.snapshot.value("shareLogs",false)?BST_CHECKED:BST_UNCHECKED);
auto fileCount=v.snapshot["files"].size();
auto transferStatus=v.transferMessage;
if(transferStatus.empty())transferStatus=wide(v.snapshot.value("transferStatus",std::string{}));
value(w,731,fileCount?L"Incoming files: "+std::to_wstring(fileCount):transferStatus);
for(int id:{703,704}) EnableWindow(GetDlgItem(w,id),enabled&&!pending&&!paired);
auto positions=v.snapshot.value("positions",nlohmann::json::object());
EnableWindow(GetDlgItem(w,715),enabled&&!positions.empty()&&!v.snapshot.value("shareInput",false));
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);
value(w,722,v.snapshot.value("inputNeedsHelp",false)?L"Connection failed — &help":L"Connection &help");
}
#include "ui_lan_layout.inl"
LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
{
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==722){showLanHelp(w,*v->app);return 0;}
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))}});
if(id==706) lanRequest(*v->app,{{"action","cancel"}});
if(id==707) lanRequest(*v->app,{{"action","forget"},{"peer",peer}});
if(id==726)
{
auto file=pickLanFile(w,false);
if(!file.empty())
{
lanRequest(*v->app,{{"action","sendFile"},{"peer",peer},{"path",utf8(file.wstring())}});
v->transferMessage.clear();
}
}
if(id==727||id==728)
{
auto destination=pickLanFile(w,true,id==728?L"kswitcher-previous.log":L"kswitcher-remote.log");
if(!destination.empty())
{
auto reply=lanRequest(*v->app,{{"action","requestLog"},{"peer",peer},{"previous",id==728}});
v->logDestination=std::move(destination);
v->logRequest=reply.at("request").get<std::string>();
v->transferMessage=L"Downloading diagnostic log...";
}
}
if(id==729)lanRequest(*v->app,{{"action","shareLogs"},{"enabled",IsDlgButtonChecked(w,729)==BST_CHECKED}});
if(id==730 && !v->snapshot["files"].empty())
{
const auto file=v->snapshot["files"].front();
auto name=wide(file.at("name").get<std::string>());
auto question=wide(file.at("sender").get<std::string>())+L" sent "+name+L" ("+
std::to_wstring(file.at("size").get<size_t>())+L" bytes).\n\nSave this file? Choose No to discard it.";
auto answer=MessageBoxW(w,question.c_str(),L"Incoming file",MB_YESNOCANCEL|MB_ICONQUESTION);
if(answer==IDYES || answer==IDNO)
{
// A peer supplies the display name. Only offer it as a plain Windows filename.
const bool validName = !name.empty() && name.size() <= 255 &&
name.find_first_of(L"<>:\"/\\|?*") == name.npos &&
name.back() != L'.' && name.back() != L' ' &&
std::none_of(name.begin(), name.end(), [](wchar_t c) { return c < L' '; });
auto destination=answer==IDYES?pickLanFile(w,true,validName?name.c_str():L"received-file"):
std::filesystem::path{};
if(answer==IDNO||!destination.empty())
lanRequest(*v->app,{{"action",answer==IDYES?"saveFile":"discardFile"},{"id",file.at("id")},
{"peer",file.at("peer")},{"path",utf8(destination.wstring())}});
}
v->transferMessage.clear();
}
if(id==715)
{
lanRequest(*v->app,{{"action","input"},{"enabled",true}});
}
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",lanProc,740,908,0,false);
SetWindowLongPtrW(w,GWLP_HWNDPARENT,(LONG_PTR)parent);
auto v=new LanView{parent,&app};SetWindowLongPtrW(w,GWLP_USERDATA,(LONG_PTR)v);
label(w,L"Changes here save automatically. No need to click Save in Settings.",18,16,700);
control(w,L"STATIC",L"",0,709,18,45,700,24);
button(w,L"&Enable discovery",701,18,78,210);
label(w,L"Only enabled KSwitcher instances announce themselves.",244,82,470);
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,30);
button(w,L"Send file…",726,18,774,144);
button(w,L"Incoming files…",730,174,774,160);
button(w,L"Download log…",727,346,774,164);
button(w,L"Previous log…",728,522,774,196);
control(w,L"BUTTON",L"Allow paired computers to download diagnostic logs",BS_AUTOCHECKBOX|WS_TABSTOP,729,18,808,700,24);
control(w,L"STATIC",L"",0,731,18,835,700,24);
button(w,L"Connection &help",722,18,864,300);
button(w,L"&Close",IDCANCEL,618,864,100);
EnableWindow(parent,FALSE);showRequestedWindow(w);SetForegroundWindow(w);
SetTimer(w,1,1000,nullptr);
try {refreshLan(w,*v);} catch(const std::exception &e){v->error=wide(e.what());value(w,708,v->error);}
}
+111
View File
@@ -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);
}
+10 -14
View File
@@ -177,11 +177,7 @@ void copyLogRows(HWND w, App &app)
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
}
if (!copied.empty())
{
auto clip = textClip(copied);
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
app.ownSequence = writeClipboard(app.window, clip);
}
writeClipboard(app.window, textClip(copied));
}
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
@@ -309,18 +305,18 @@ void showLogs(App &app)
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
SendMessageW(choice, CB_SETCURSEL, 0, 0);
button(w, L"Refresh", LogRefresh, 310, 14, 100);
button(w, L"Copy selected", LogCopy, 424, 14, 140);
button(w, L"Clear log", LogClear, 578, 14, 100);
button(w, L"&Refresh", LogRefresh, 310, 14, 100);
button(w, L"&Copy selected", LogCopy, 424, 14, 140);
button(w, L"Clear &log", LogClear, 578, 14, 100);
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
label(w, L"Program", 16, 78, 170);
label(w, L"&Program", 16, 78, 170);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
label(w, L"Command", 206, 78, 200);
label(w, L"Co&mmand", 206, 78, 200);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
label(w, L"Result", 426, 78, 300);
label(w, L"Res&ult", 426, 78, 300);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
button(w, L"Reset filters", LogReset, 938, 98, 146);
checkbox(w, L"&Only problems", LogProblems, 752, 102, 166);
button(w, L"Reset &filters", LogReset, 938, 98, 146);
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&common);
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
@@ -344,7 +340,7 @@ void showLogs(App &app)
w, L"STATIC",
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
16, 712, 860, 22);
button(w, L"Close", IDCANCEL, 974, 708, 110);
button(w, L"Clos&e", IDCANCEL, 974, 708, 110);
auto *layout = windowLayoutState(w);
layout->stretch = list;
layout->details = details;
File diff suppressed because it is too large. Load diff
+154
View File
@@ -0,0 +1,154 @@
#include "ui_sync_conflicts.inl"
struct SyncView { HWND parent; App *app; bool actionError{}; };
void updateSyncControls(HWND w, App &app)
{
bool enabled = checked(w, 601);
bool savedEnabled = loadSyncConfig(app.data).enabled;
for (int id : {602, 603, 604, 605, 606, 607, 610, 611, 613})
EnableWindow(GetDlgItem(w, id), enabled);
EnableWindow(GetDlgItem(w, 612), enabled && savedEnabled);
EnableWindow(GetDlgItem(w, IDOK), enabled || savedEnabled);
value(w, IDOK, enabled ? L"&Save / Sign in / Sync" : L"&Save");
std::wstring status;
if (!enabled)
status = savedEnabled ? L"Click Save to disable synchronization." :
L"Synchronization is disabled. Enable it above to configure and sync.";
else if (!savedEnabled)
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
else
status = app.syncCommand();
auto *view = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if ((!view || !view->actionError) && value(w, 608) != status) value(w, 608, status);
}
SyncConfig syncFields(HWND w)
{
SyncConfig c;
c.enabled = checked(w, 601);
c.keepLocalLayouts = checked(w, 613);
c.server = value(w, 602);
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
c.username = lower(value(w, 603)); c.mount = value(w, 605);
c.path = value(w, 606); c.authMount = value(w, 607);
return c;
}
LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
auto *v = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!v) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == WM_TIMER)
{
updateSyncControls(w, *v->app);
return 0;
}
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 601 && HIWORD(wp) == BN_CLICKED)
{
v->actionError = false;
updateSyncControls(w, *v->app);
return 0;
}
if ((id == IDOK || id == 610 || id == 611 || id == 612) &&
!IsWindowEnabled(GetDlgItem(w, id))) return 0;
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
if (id == 609)
{
v->actionError = false;
auto c = loadSyncConfig(v->app->data);
c.enabled = false;
saveSyncConfig(v->app->data, c);
forgetSyncPassword(c);
check(w, 601, false);
value(w, 604, L"");
updateSyncControls(w, *v->app);
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
KillTimer(w, 1);
return 0;
}
if (id == IDOK || id == 610 || id == 611)
{
v->actionError = false;
if (!checked(w, 601))
{
if (id != IDOK) return 0;
auto c = loadSyncConfig(v->app->data);
c.enabled = false;
saveSyncConfig(v->app->data, c);
value(w, 604, L"");
updateSyncControls(w, *v->app);
return 0;
}
if (id != IDOK && MessageBoxW(w, id == 610 ?
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. The open Settings window will refresh automatically; its unsaved edits will be discarded." :
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
auto c = syncFields(w);
if (c.enabled) validateSyncConfig(c);
auto password = value(w, 604);
try { if (!password.empty()) saveSyncPassword(c, password); }
catch (...) { SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t)); throw; }
SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t));
value(w, 604, L"");
saveSyncConfig(v->app->data, c);
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
updateSyncControls(w, *v->app);
SetTimer(w, 1, 1000, nullptr);
return 0;
}
}
}
catch (const std::exception &e)
{
v->actionError = true; // Keep this action's error visible across status polling.
// Never display parser exceptions containing connection file contents.
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
if (dynamic_cast<const std::runtime_error *>(&e))
value(w, 608, wide(e.what()));
return 0;
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_NCDESTROY)
{
KillTimer(w, 1);
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
return DefWindowProcW(w, msg, wp, lp);
}
void showSyncOptions(HWND parent, App &app)
{
auto c = loadSyncConfig(app.data);
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 672);
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
checkbox(w, L"&Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
check(w, 601, c.enabled);
label(w, L"Se&rver (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
label(w, L"User&name", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
label(w, L"&Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
label(w, L"Leave password blank to use the saved Windows credential.", 190, 166, 450);
label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
label(w, L"Sync uses saved settings. Download remote refreshes the open Settings window.", 18, 330, 622);
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 608, app.syncCommand());
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
check(w, 613, c.keepLocalLayouts);
button(w, L"&Save / Sign in / Sync", IDOK, 18, 484, 200);
button(w, L"&Download remote", 610, 230, 484, 195);
button(w, L"&Upload local", 611, 437, 484, 203);
button(w, L"Resolve &individual conflicts...", 612, 18, 530, 300);
label(w, L"Background check: every 10 minutes", 330, 534, 310);
button(w, L"Disconnect / &forget password", 609, 18, 582, 290);
button(w, L"&Close", IDCANCEL, 540, 582, 100);
updateSyncControls(w, app);
EnableWindow(parent, FALSE);
SetTimer(w, 1, 1000, nullptr);
showRequestedWindow(w); SetForegroundWindow(w);
}
+80
View File
@@ -0,0 +1,80 @@
struct SyncConflictView
{
HWND parent;
App *app;
std::vector<SyncConflict> conflicts;
std::map<size_t, bool> choices;
};
void showConflictValues(HWND w, SyncConflictView *v)
{
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
if (selected < 0 || selected >= (int)v->conflicts.size()) return;
const auto &c = v->conflicts[selected];
value(w, 621, syncValueText(c.local)); value(w, 622, syncValueText(c.remote));
}
LRESULT CALLBACK syncConflictsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
auto *v = reinterpret_cast<SyncConflictView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!v) return DefWindowProcW(w, msg, wp, lp);
try
{
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 620 && HIWORD(wp) == LBN_SELCHANGE) { showConflictValues(w, v); return 0; }
if (id == 623 || id == 624)
{
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
if (selected < 0 || selected >= (int)v->conflicts.size()) return 0;
v->choices[selected] = id == 623;
auto text = v->conflicts[selected].label + (id == 623 ? L" [This PC]" : L" [Server]");
SendDlgItemMessageW(w, 620, LB_DELETESTRING, selected, 0);
SendDlgItemMessageW(w, 620, LB_INSERTSTRING, selected, (LPARAM)text.c_str());
SendDlgItemMessageW(w, 620, LB_SETCURSEL, selected, 0);
return 0;
}
if (id == IDOK)
{
if (v->choices.empty()) throw std::runtime_error("Select a setting and choose This PC or Server first.");
std::vector<SyncChoice> choices;
for (const auto &[index, local] : v->choices) choices.push_back({v->conflicts.at(index), local});
auto status = v->app->syncCommand(4, choices);
value(v->parent, 608, status);
if (status != L"Connecting to OpenBao...") throw std::runtime_error(utf8(status));
DestroyWindow(w); return 0;
}
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
}
}
catch (const std::exception &e) { error(w, e); return 0; }
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_NCDESTROY)
{
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
return DefWindowProcW(w, msg, wp, lp);
}
void showSyncConflicts(HWND parent, App &app)
{
auto conflicts = decodeSyncConflicts(app.syncConflictData());
if (conflicts.empty()) { value(parent, 608, L"No pending conflicts. Run Sync to check for changes."); return; }
HWND w = makeWindow(app, L"KSwitcher.SyncConflicts", L"Resolve individual settings", syncConflictsProc, 800, 560);
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
auto *v = new SyncConflictView{parent, &app, std::move(conflicts), {}};
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
label(w, L"Choose a value for each conflicting setting. Other changes are already synchronized.", 18, 16, 760);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | WS_BORDER, 620, 18, 48, 760, 130);
for (const auto &c : v->conflicts) SendDlgItemMessageW(w, 620, LB_ADDSTRING, 0, (LPARAM)c.label.c_str());
label(w, L"This PC", 18, 190, 360); label(w, L"Server", 410, 190, 368);
edit(w, 621, 18, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
edit(w, 622, 410, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
button(w, L"Use this &PC for selected setting", 623, 18, 412, 368);
button(w, L"Use &server for selected setting", 624, 410, 412, 368);
label(w, L"Unselected conflicts stay pending. Changed values will be checked again before saving.", 18, 454, 760);
button(w, L"&Apply choices", IDOK, 526, 488, 140);
button(w, L"&Cancel", IDCANCEL, 678, 488, 100);
SendDlgItemMessageW(w, 620, LB_SETCURSEL, 0, 0); showConflictValues(w, v);
EnableWindow(parent, FALSE); showRequestedWindow(w); SetForegroundWindow(w);
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.25";
inline constexpr wchar_t AppVersion[] = L"0.6.80";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <algorithm>
#include <cwchar>
#include <filesystem>
#include <string>
#include <vector>
namespace ks
{
// Scan each time the menu opens, relative to the resident executable, not its working directory.
inline std::vector<std::filesystem::path> discoverUtilities(const std::filesystem::path &folder,
std::error_code &error)
{
error.clear();
std::vector<std::filesystem::path> result;
std::filesystem::directory_iterator it(folder, error), end;
if (error == std::errc::no_such_file_or_directory) error.clear();
while (!error && it != end)
{
std::error_code entryError;
if (_wcsicmp(it->path().extension().c_str(), L".exe") == 0 && it->is_regular_file(entryError))
result.push_back(it->path());
it.increment(error);
}
if (error) result.clear();
std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) {
return _wcsicmp(a.filename().c_str(), b.filename().c_str()) < 0;
});
return result;
}
inline std::wstring utilityMenuLabel(const std::filesystem::path &path)
{
std::wstring label;
for (wchar_t ch : path.stem().wstring())
{
label += ch;
if (ch == L'&') label += ch; // Win32 uses ampersands for access keys.
}
return label;
}
}
+82
View File
@@ -0,0 +1,82 @@
// Experimental Viber header OCR; never called on the keyboard hook thread.
#include <uiautomation.h>
#include <mutex>
namespace ks {
namespace {
std::mutex viberMutex;
HWND viberWindow{};
std::wstring viberKey;
ULONGLONG viberTime{};
std::wstring elementClass(IUIAutomationElement *e) {
BSTR s{}; e->get_CurrentClassName(&s); std::wstring out=s?s:L""; SysFreeString(s); return out;
}
bool findViberHeader(IUIAutomationTreeWalker *walker,IUIAutomationElement *e,RECT &pane,RECT &search,int depth,int &budget) {
if(depth>5 || --budget<0) return false;
bool stack=elementClass(e).starts_with(L"StackView_");
Com<IUIAutomationElement> child;walker->GetFirstChildElement(e,child.out());
bool found=false,info=false;
while(child.p && --budget>0) {
if(stack) {
auto name=elementClass(child.p);
if(name.starts_with(L"SearchButton_")){child->get_CurrentBoundingRectangle(&search);found=true;}
if(name.starts_with(L"InfoButton_")) info=true;
} else if(findViberHeader(walker,child.p,pane,search,depth+1,budget)) return true;
Com<IUIAutomationElement> next;walker->GetNextSiblingElement(child.p,next.out());
child.p->Release();child.p=next.p;next.p=nullptr;
}
if(found && info){e->get_CurrentBoundingRectangle(&pane);return true;}
return false;
}
Pixels viberHeader(HWND window) {
if(!IsWindow(window)||IsIconic(window)) return {};
Com<IUIAutomation2> automation;
check(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_IUIAutomation2,(void**)automation.out()));
automation->put_ConnectionTimeout(200);automation->put_TransactionTimeout(200);
Com<IUIAutomationElement> root;check(automation->ElementFromHandle(window,root.out()));
Com<IUIAutomationTreeWalker> walker;check(automation->get_RawViewWalker(walker.out()));
RECT pane{},search{},rect{};int budget=160;
if(!findViberHeader(walker.p,root.p,pane,search,0,budget)||!GetWindowRect(window,&rect)) return {};
double scale=GetDpiForWindow(window)/96.0;
int x=pane.left+int(76*scale)-rect.left,y=search.top-int(12*scale)-rect.top;
int width=search.left-int(12*scale)-rect.left-x,height=int(28*scale);
int ww=rect.right-rect.left,wh=rect.bottom-rect.top;
if(width<30||height<10||x<0||y<0||x+width>ww||y+height>wh||uint64_t(ww)*wh>16000000) return {};
HDC dc=CreateCompatibleDC(nullptr);BITMAPINFO bi{};bi.bmiHeader.biSize=sizeof(BITMAPINFOHEADER);
bi.bmiHeader.biWidth=ww;bi.bmiHeader.biHeight=-wh;bi.bmiHeader.biPlanes=1;bi.bmiHeader.biBitCount=32;
void *bits{};HBITMAP bitmap=CreateDIBSection(dc,&bi,DIB_RGB_COLORS,&bits,nullptr,0);
if(!bitmap){DeleteDC(dc);return {};}
auto old=SelectObject(dc,bitmap);Pixels p;
if(PrintWindow(window,dc,2)) {
p.width=width;p.height=height;p.bgra.resize(size_t(width)*height*4);
for(int row=0;row<height;++row) memcpy(p.bgra.data()+size_t(row)*width*4,(BYTE*)bits+(size_t(y+row)*ww+x)*4,size_t(width)*4);
}
SelectObject(dc,old);DeleteObject(bitmap);DeleteDC(dc);return p;
}
}
std::wstring cachedViberChat(HWND window) {
std::lock_guard lock(viberMutex);
return window==viberWindow && GetTickCount64()-viberTime<2000 ? viberKey : L"";
}
std::wstring recognizeViberChat(HWND window,const Settings &settings) {
std::wstring key;
try {
auto hr=CoInitializeEx(nullptr,COINIT_MULTITHREADED);
struct Apartment {bool owned;~Apartment(){if(owned)CoUninitialize();}} apartment{SUCCEEDED(hr)};
auto context=SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
struct Dpi {DPI_AWARENESS_CONTEXT old;~Dpi(){if(old)SetThreadDpiAwarenessContext(old);}} dpi{context};
auto before=viberHeader(window);
if(!before.bgra.empty()) {
Settings options=settings;options.useTesseract=true;
Clip clip;clip.formats.push_back(imageDib(before));
auto title=recognizeText(clip,options);
while(!title.empty() && iswspace(title.back())) title.pop_back();
while(!title.empty() && iswspace(title.front())) title.erase(0,1);
auto after=viberHeader(window);
if(!title.empty() && title.size()<512 && title.find_first_of(L"\r\n")==title.npos && before.width==after.width && before.height==after.height && before.bgra==after.bgra)
key=L"viber\n"+title;
}
} catch(...) {}
{std::lock_guard lock(viberMutex);viberWindow=window;viberKey=key;viberTime=GetTickCount64();}
return key;
}
}
+505
View File
@@ -0,0 +1,505 @@
#include "app.h"
#include "ocr.h"
#include "screenshot.h"
#include "lan_pairing.h"
#include <iostream>
#include <commctrl.h>
namespace
{
HWND findTestWindow(LPCWSTR className, LPCWSTR title)
{
HWND window=nullptr;
while((window=FindWindowExW(nullptr,window,className,title))) {
DWORD process{}; GetWindowThreadProcessId(window,&process);
if(process==GetCurrentProcessId()) return window;
}
return nullptr;
}
int passed{};
void require(bool ok, const char *message)
{
if (!ok) throw std::runtime_error(message);
++passed;
}
void contextChecks()
{
ks::Settings scoped;
scoped.snippets = {{L";sig", L"Global", L"global"},
{L";sig", L"Work", L"teams", {L"MS-Teams.EXE", L"OUTLOOK.exe"}},
{L";sig", L"Personal", L"telegram", {L"telegram"}}};
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"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");
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[0].apps.empty(), "absent scope remains global");
auto collision = scoped; collision.snippets.push_back({L";SIG", L"Ambiguous", L"other", {L"outlook.exe"}});
bool collisionRejected=false;try { ks::validateSettings(collision); } catch(...) { collisionRejected=true; }
require(collisionRejected, "overlapping app lists with same trigger rejected");
auto emptyScope = scoped; emptyScope.snippets[1].apps={L""};
bool emptyRejected=false;try { ks::validateSettings(emptyScope); } catch(...) { emptyRejected=true; }
require(emptyRejected, "empty explicit restriction cannot become global");
require(ks::telegramWebKey(L"https://web.telegram.org/k/#@Example") == L"telegram-web\n@example", "Telegram Web username normalized");
require(ks::telegramWebKey(L"web.telegram.org/a/#-123456") == L"telegram-web\n-123456", "Telegram Web numeric peer");
for (const auto *url : {L"https://web.telegram.org/k/", L"https://web.telegram.org.evil/k/#@example",
L"http://web.telegram.org/k/#@example", L"https://evil/web.telegram.org/k/#@example",
L"https://web.telegram.org/k/#@example?foo", L"https://web.telegram.org/k/#@",
L"https://web.telegram.org/k/#-", L"https://web.telegram.org/k/#settings"})
require(ks::telegramWebKey(url).empty(), "Reject non-chat or untrusted Telegram Web URL");
const auto key = ks::chatKey(L"Telegram.exe", L"\u200eТестовий чат \u2013 (12)");
require(key == L"telegram\nТестовий чат", "Telegram unread suffix");
require(ks::chatKey(L"Telegram", L"Тестовий чат") == key, "stable unread profile");
require(ks::chatKey(L"Telegram", L"Team (2026)") == L"telegram\nTeam (2026)", "preserve parentheses");
require(ks::chatKey(L"Telegram", L"Telegram").empty(), "no profile on home");
require(ks::chatKey(L"Viber", L"Rakuten Viber").empty(), "Viber generic window title is not a chat");
require(ks::chatKey(L"ms-teams", L"Chat | Alice | Microsoft Teams") == L"ms-teams\nAlice", "Teams chat");
require(ks::chatKey(L"ms-teams", L"Calendar | Alice | Microsoft Teams").empty(), "Teams calendar excluded");
require(ks::chatKey(L"ms-teams", L"Chat | Microsoft Teams").empty(), "Teams chat home excluded");
require(ks::chatKey(L"other", L"Chat | Alice | Microsoft Teams").empty(), "app identity required");
ks::Settings settings;
settings.cycle = {L"win:04090409", L"win:04220422", L"win:04190419"};
settings.chatCycles[key] = {settings.cycle[1], settings.cycle[0]};
settings.chatCycles[L"viber\nТестовий контакт"] = {settings.cycle[1], settings.cycle[0]};
settings.chatCycles[L"telegram-web\n@example"] = {settings.cycle[0], settings.cycle[1]};
std::vector<ks::Layout> installed(3);
for (size_t i = 0; i < installed.size(); ++i)
{
installed[i].id = settings.cycle[i];
installed[i].handle = (HKL)(uintptr_t)(i + 1);
}
auto subset = settings.chatCycles[key];
require(ks::nextChatLayout(subset, installed, installed[1].handle, false) == installed[0].handle, "cycle within subset");
require(ks::nextChatLayout(subset, installed, installed[0].handle, false) == installed[1].handle, "wrap skips excluded language");
require(ks::nextChatLayout(subset, installed, installed[2].handle, false) == installed[1].handle, "outside subset enters first language");
require(ks::nextChatLayout(settings.cycle, installed, installed[0].handle, true) == installed[2].handle, "reverse wraps");
require(ks::nextChatLayout({settings.cycle[1]}, installed, installed[0].handle, false) == installed[1].handle, "single language selects allowed layout");
require(!ks::nextChatLayout({L"win:04070407"}, installed, installed[0].handle, false), "missing layout is not activated");
require(ks::nextConversionLayout(L"", installed, {1}, 0) == 1, "conversion uses single allowed language");
require(ks::chatCycle(settings, key, installed) == settings.chatCycles[key], "profile order");
require(ks::chatCycle(settings, L"telegram\nOther", installed) == settings.cycle, "unknown fallback");
require(ks::decodeSettings(ks::encodeSettings(settings), installed).chatCycles == settings.chatCycles, "Unicode roundtrip");
require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles");
auto withoutProfiles = settings;
withoutProfiles.chatCycles.clear();
require(ks::sharedSyncSettings(settings) != ks::sharedSyncSettings(withoutProfiles), "profiles included in sync");
auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), withoutProfiles, installed, false);
require(restored.chatCycles == settings.chatCycles, "sync transfers chat profiles to another PC");
settings.chatCycles[key] = {settings.cycle[1]};
require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile");
settings.chatCycles[key] = {L"win:04070407"};
require(ks::chatCycle(settings, key, installed) == settings.cycle, "missing layouts fallback");
settings.chatCycles[key] = {};
bool rejected = false;
try { ks::encodeSettings(settings); } catch (...) { rejected = true; }
require(rejected, "empty profile rejected");
settings.chatCycles[key] = {settings.cycle[0], settings.cycle[0]};
rejected = false;
try { ks::encodeSettings(settings); } catch (...) { rejected = true; }
require(rejected, "duplicate profile layout rejected");
ks::ChatSwitchHook hook;
require(hook.update(VK_SPACE, true, MOD_WIN, false) == 0, "unprofiled Win+Space passes");
require(hook.update(VK_SPACE, true, MOD_WIN, true) == 1, "Win+Space forward");
require(hook.update(VK_SPACE, true, MOD_WIN, true) == -1, "consume repeat");
require(hook.update(VK_SPACE, false, 0, false) == -1, "release after profile change");
require(hook.update(VK_SPACE, true, MOD_WIN | MOD_SHIFT, true) == 2, "reverse cycle");
hook.update(VK_SPACE, false, 0, true);
require(hook.update(VK_LSHIFT, true, MOD_ALT, true) == 1, "Alt then Shift");
hook.update(VK_LSHIFT, false, 0, true);
require(hook.update(VK_LMENU, true, MOD_SHIFT, true) == 1, "Shift then Alt");
hook.update(VK_LMENU, false, 0, true);
require(hook.update(VK_LSHIFT, true, MOD_ALT | MOD_CONTROL, true) == 0, "do not consume Ctrl+Alt+Shift");
require(hook.update('A', true, MOD_ALT, true) == 0, "ordinary keys pass");
}
}
int main()
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() / (L"KSwitcher-chat-tests-" + std::to_wstring(GetCurrentProcessId()));
try
{
contextChecks();
{
ks::App app(GetModuleHandleW(nullptr), true, root);
require(!app.layouts.empty(), "installed layouts available");
app.settings.toggle = app.settings.casing = app.settings.translit = app.settings.undo = L"";
app.settings.clipboard = app.settings.screenshot = L"";
app.settings.direct.assign(app.layouts.size(), L"");
app.settings.history = app.settings.autoCorrect = false;
const std::wstring key = L"telegram\n! Fixture chat";
app.settings.chatCycles[key] = {app.layouts[0].id};
ks::showSettings(app);
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 settings.","new Settings shows clean state with Save disabled");
SendMessageW(parent,WM_COMMAND,IDOK,0);
require(IsWindow(parent),"Save command with no changes does not apply or close Settings");
bool originalLog=IsDlgButtonChecked(parent,208)==BST_CHECKED;
CheckDlgButton(parent,208,originalLog?BST_UNCHECKED:BST_CHECKED);
SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0);
require(saveEnabled() && status().find(L"Unsaved settings") == 0,"checkbox edit immediately marks Settings dirty");
CheckDlgButton(parent,208,originalLog?BST_CHECKED:BST_UNCHECKED);
SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0);
require(!saveEnabled(),"reverting checkbox disables Save again");
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=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
require(ocr!=nullptr,"OCR preferences open");
wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128);
SetDlgItemTextW(ocr,503,L"eng");
SendMessageW(ocr,WM_COMMAND,IDCANCEL,0);
require(!saveEnabled(),"cancelled child changes leave Settings clean");
SendMessageW(parent,WM_COMMAND,257,0);
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng");
SendMessageW(ocr,WM_COMMAND,IDOK,0);
require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save");
SendMessageW(parent,WM_COMMAND,257,0);
ocr=findTestWindow(L"KSwitcher.OcrOptions",nullptr);
SetDlgItemTextW(ocr,503,languages);
SendMessageW(ocr,WM_COMMAND,IDOK,0);
require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state");
SendMessageW(parent,WM_COMMAND,255,0);
HWND automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
require(automatic!=nullptr,"automatic correction preferences open");
bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED;
CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED);
SendMessageW(automatic,WM_COMMAND,IDOK,0);
require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save");
SendMessageW(parent,WM_COMMAND,255,0);
automatic=findTestWindow(L"KSwitcher.AutoOptions",nullptr);
CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED);
SendMessageW(automatic,WM_COMMAND,IDOK,0);
require(!saveEnabled(),"reverting auto-correction draft disables Save");
HWND tabs = GetDlgItem(parent, 260);
require(tabs && TabCtrl_GetItemCount(tabs) == 10, "ten settings categories");
wchar_t mcpConfig[4096]{};
GetDlgItemTextW(parent, 281, mcpConfig, std::size(mcpConfig));
const std::wstring config(mcpConfig);
require(config.starts_with(L"[mcp_servers.kswitcher]\r\ncommand = \"") &&
config.find(L"KSwitcher.Config.exe\"\r\nargs = [\"--mcp\"]") != std::wstring::npos,
"MCP tab shows a Codex stdio configuration");
for (int page = 0; page < TabCtrl_GetItemCount(tabs); ++page)
{
TabCtrl_SetCurSel(tabs, page);
NMHDR notification{tabs, 260, TCN_SELCHANGE};
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)&notification);
std::vector<RECT> boxes;
for (HWND child = GetWindow(parent, GW_CHILD); child; child = GetWindow(child, GW_HWNDNEXT))
{
if (!(GetWindowLongPtrW(child, GWL_STYLE) & WS_VISIBLE)) continue;
RECT rect{}; GetWindowRect(child, &rect);
for (const auto &other : boxes)
{
RECT overlap{};
require(!IntersectRect(&overlap, &rect, &other), "visible settings controls never overlap");
}
boxes.push_back(rect);
}
RedrawWindow(parent, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
RECT rect{}; GetClientRect(parent, &rect);
HDC dc = GetDC(parent);
auto clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
ReleaseDC(parent, dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / (L"settings-tab-" + std::to_wstring(page) + L".png"),
ks::encodePng(ks::decodeImage(clip)));
}
TabCtrl_SetCurSel(tabs, 1);
NMHDR notification{tabs, 260, TCN_SELCHANGE};
SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)&notification);
require(!saveEnabled(),"changing tabs leaves Settings clean");
SendMessageW(parent, WM_COMMAND, 259, 0);
HWND dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
auto findFixtureChat = [&](HWND chatDialog) {
SetDlgItemTextW(chatDialog, 714, L"! fIXTURE CHAT");
HWND list = GetDlgItem(chatDialog, 710);
require(ListView_GetItemCount(list) == 1, "case-insensitive contact search finds saved chat");
wchar_t appName[32]{};
ListView_GetItemText(list, 0, 1, appName, std::size(appName));
require(std::wstring(appName) == L"Telegram", "chat list shows the source app separately");
};
SetDlgItemTextW(dialog, 714, L"No matching fixture 8031");
require(ListView_GetItemCount(GetDlgItem(dialog, 710)) == 0 &&
!IsWindowEnabled(GetDlgItem(dialog, IDOK)), "empty search results disable profile actions");
findFixtureChat(dialog);
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "saved single language selected");
RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
RECT rect{}; GetClientRect(dialog, &rect);
HDC dc = GetDC(dialog);
auto clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
ReleaseDC(dialog, dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
require(!saveEnabled(),"cancelled chat edits do not enable Save");
SendMessageW(parent, WM_COMMAND, 259, 0);
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
findFixtureChat(dialog);
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
SendMessageW(parent, WM_COMMAND, IDOK, 0);
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");
ks::showSettings(app);
parent = app.settingsWindow;
require(!saveEnabled(),"reopened saved Settings is clean");
SendMessageW(parent, WM_COMMAND, 259, 0);
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
findFixtureChat(dialog);
SendMessageW(dialog, WM_COMMAND, 713, 0);
require(IsWindow(dialog), "chat reset keeps dialog open");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(saveEnabled() && status().find(L"Unsaved settings")==0,"removing child profile immediately marks parent dirty");
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
GetClientRect(parent,&rect); dc=GetDC(parent);
clip=ks::screenshotClip(dc,rect.right,rect.bottom,{}); ReleaseDC(parent,dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"settings-unsaved.png",
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
// Existing shared sets become independent application drafts on edit.
app.settings.appLanguageSets[L"Legacy shared"] = {{app.layouts[0].id}, {L"Codex.exe", L"notepad"}};
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
require(dialog && !IsWindowEnabled(parent), "application language editor opens");
auto selectApp = [&](const wchar_t *name) {
int index = (int)SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)name);
require(index >= 0, "application listed");
SendDlgItemMessageW(dialog, 720, LB_SETCURSEL, index, 0);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(720, LBN_SELCHANGE), 0);
};
selectApp(L"codex");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "legacy languages displayed");
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
HWND picker = GetDlgItem(dialog, 728);
RECT dialogBounds{};
GetWindowRect(dialog, &dialogBounds);
MONITORINFO monitor{sizeof(monitor)};
require(GetMonitorInfoW(MonitorFromWindow(dialog, MONITOR_DEFAULTTONEAREST), &monitor),
"monitor work area available");
const auto targetX = std::clamp(dialogBounds.left + 32, monitor.rcWork.left, monitor.rcWork.right - 120);
const auto targetY = std::clamp(dialogBounds.top + 100, monitor.rcWork.top, monitor.rcWork.bottom - 48);
HWND target = CreateWindowExW(WS_EX_TOPMOST | WS_EX_NOACTIVATE, L"EDIT", L"Picker fixture",
WS_POPUP | WS_VISIBLE, targetX, targetY, 120, 48,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
require(picker && target, "window picker and target available");
POINT drop{targetX + 1, targetY + 1};
require(GetAncestor(WindowFromPoint(drop), GA_ROOT) == target, "fixture window is under the drop point");
ScreenToClient(picker, &drop);
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
require(GetCapture() == picker, "window picker captures drag");
SendMessageW(picker, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(drop.x, drop.y));
HWND outline = findTestWindow(L"KSwitcher.AppPickerOutline", nullptr);
RECT outlineBounds{}, targetBounds{};
require(IsWindowVisible(outline) && GetWindowRect(outline, &outlineBounds) &&
GetWindowRect(target, &targetBounds) && EqualRect(&outlineBounds, &targetBounds), "drag highlights the target window");
SendMessageW(picker, WM_KEYDOWN, VK_ESCAPE, 0);
require(!IsWindow(outline) && GetCapture() != picker, "Escape clears the outline and releases capture");
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
SendMessageW(picker, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(drop.x, drop.y));
outline = findTestWindow(L"KSwitcher.AppPickerOutline", nullptr);
SendMessageW(picker, WM_LBUTTONUP, 0, MAKELPARAM(drop.x, drop.y));
require(!IsWindow(outline), "dropping clears the outline");
auto pickedApp = ks::applicationName(ks::processName(target));
DestroyWindow(target);
auto pickedRow = SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)pickedApp.c_str());
require(GetCapture() != picker && pickedRow >= 0 &&
SendDlgItemMessageW(dialog, 720, LB_GETCURSEL, 0, 0) == pickedRow, "dropping on a window selects its executable");
selectApp(L"notepad");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "editing one app leaves formerly shared app unchanged");
selectApp(L"codex");
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == (LRESULT)app.layouts.size(), "application navigation preserves drafts");
RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
GetClientRect(dialog, &rect);
dc = GetDC(dialog);
clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
ReleaseDC(dialog, dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"application-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(app.settings.appLanguageSets.size() == 1 && app.settings.appLanguageSets.contains(L"Legacy shared"), "cancel preserves legacy settings unchanged");
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
selectApp(L"codex");
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
selectApp(L"notepad");
SetDlgItemTextW(dialog, 726, L"Fixture-App.EXE");
SendMessageW(dialog, WM_COMMAND, 727, 0);
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "new application inherits defaults");
selectApp(L"codex");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(app.settings.appLanguageSets.size() == 2, "viewing an app does not create a profile");
require(app.settings.appLanguageSets.at(L"codex").languages.size() == app.layouts.size(), "edited application languages saved");
require(app.settings.appLanguageSets.at(L"notepad").languages == std::vector<std::wstring>{app.layouts[0].id}, "other application retains original languages");
require(ks::loadSettings(root / L"settings.json", app.layouts).appLanguageSets == app.settings.appLanguageSets, "independent application profiles saved to disk");
ks::showSettings(app);
parent = app.settingsWindow;
SendMessageW(parent, WM_COMMAND, 263, 0);
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
selectApp(L"codex");
SendMessageW(dialog, WM_COMMAND, 725, 0);
selectApp(L"notepad");
selectApp(L"codex");
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "default reset survives navigation");
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
SendMessageW(parent, WM_COMMAND, IDOK, 0);
require(!ks::applicationLanguageSet(app.settings, L"codex"), "use defaults removes only selected application profile");
require(ks::applicationLanguageSet(app.settings, L"notepad") != nullptr, "default reset leaves other application profile intact");
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
ks::showSettings(app); parent=app.settingsWindow;
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
ks::showSnippets(app);
HWND snippets = parent;
require(!app.snippetsWindow && IsWindowVisible(GetDlgItem(snippets, 301)),
"opening Snippets from Settings selects its inline editor");
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere");
CheckDlgButton(snippets,314,BST_CHECKED);
SendMessageW(snippets,WM_COMMAND,314,0);
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,318,0);
require(IsWindow(snippets) && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "inline snippet editor saves selected applications");
require(!saveEnabled() && status()==L"No unsaved settings.","separately saved snippet edit leaves Settings clean");
RedrawWindow(snippets,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT snippetRect{};GetClientRect(snippets,&snippetRect);
HDC snippetDc=GetDC(snippets);
auto snippetImage=ks::screenshotClip(snippetDc,snippetRect.right,snippetRect.bottom,{});
ReleaseDC(snippets,snippetDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"snippet-scopes.png",ks::encodePng(ks::decodeImage(snippetImage)));
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
ks::showSnippets(app);snippets=app.snippetsWindow;
SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0);
SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0);
require(IsDlgButtonChecked(snippets,314)==BST_CHECKED, "scope restored when reopening editor");
SetDlgItemTextW(snippets,315,L"chrome.exe");
SendMessageW(snippets,WM_COMMAND,IDCANCEL,0);
require(app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "cancel preserves existing scope");
ks::showSettings(app);parent=app.settingsWindow;
SendMessageW(parent,WM_COMMAND,262,0);
HWND lan=findTestWindow(L"KSwitcher.LAN",nullptr);
require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window");
require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer");
require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing");
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");
SendMessageW(lan,WM_COMMAND,722,0);
auto help=findTestWindow(L"KSwitcher.LAN.Help",nullptr);
require(help&&!IsWindowEnabled(lan),"connection help opens without changing network settings");
wchar_t command[4096]{};GetDlgItemTextW(help,723,command,4096);
require(std::wstring(command).find(ks::executable())!=std::wstring::npos&&
std::wstring(command).find(L"-LocalPort 45884")!=std::wstring::npos&&
std::wstring(command).find(L"-LocalPort 45883")!=std::wstring::npos&&
std::wstring(command).find(L"-RemoteAddress LocalSubnet")!=std::wstring::npos,
"help supplies local-subnet firewall commands for the actual resident executable and both transports");
RedrawWindow(help,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT helpRect{};GetClientRect(help,&helpRect);HDC helpDc=GetDC(help);
auto helpImage=ks::screenshotClip(helpDc,helpRect.right,helpRect.bottom,{});ReleaseDC(help,helpDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-connection-help.png",ks::encodePng(ks::decodeImage(helpImage)));
SendMessageW(help,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(lan),"closing connection help returns to LAN setup");
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan);
auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage)));
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state");
require(status().find(L"Network changes save automatically")!=std::wstring::npos,"Settings explains why network actions do not enable Save");
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
}
{
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=findTestWindow(L"KSwitcher.LAN",nullptr);
require(SendDlgItemMessageW(lan,702,LB_GETCURSEL,0,0)==0,"single saved pair selected automatically when opening LAN settings");
wchar_t pairState[512]{};SendDlgItemMessageW(lan,702,LB_GETTEXT,0,(LPARAM)pairState);
require(std::wstring(pairState).find(L"Pair saved — input disabled here")!=std::wstring::npos,"saved pairing does not imply an active input connection");
require(!IsWindowEnabled(GetDlgItem(lan,705))&&!IsWindowEnabled(GetDlgItem(lan,703))&&!IsWindowEnabled(GetDlgItem(lan,704)),"saved pair disables pairing and code controls");
SendDlgItemMessageW(lan,702,LB_SETCURSEL,-1,0);
SendMessageW(lan,WM_TIMER,1,0);
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=findTestWindow(L"KSwitcher.LAN",nullptr);auto grid=GetDlgItem(lan,721);
RECT r{};GetClientRect(grid,&r);
auto point=[&](int column,int row){return MAKELPARAM((2*column+1)*r.right/10,(2*row+1)*r.bottom/6);};
SendMessageW(grid,WM_LBUTTONDOWN,MK_LBUTTON,point(1,1));SendMessageW(grid,WM_MOUSEMOVE,MK_LBUTTON,point(1,0));SendMessageW(grid,WM_LBUTTONUP,0,point(1,0));
auto saved=J::parse(ks::unprotect(ks::readFile(root/L"lan-pairing.bin",256*1024)));
require(saved["positions"][b]==J::array({-1,-1})&&saved["positions"][c]==J::array({1,0}),"drag moves one monitor without moving another paired PC");
RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
GetClientRect(lan,&r);auto dc=GetDC(lan);auto screenshot=ks::screenshotClip(dc,r.right,r.bottom,{});ReleaseDC(lan,dc);
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-three-computers.png",ks::encodePng(ks::decodeImage(screenshot)));
SendMessageW(lan,WM_COMMAND,IDCANCEL,0);SendMessageW(layoutApp.settingsWindow,WM_COMMAND,IDCANCEL,0);
}
std::filesystem::remove_all(root);
std::cout << "Passed " << passed << " chat profile checks\n";
CoUninitialize();
return 0;
}
catch (const std::exception &e) { std::cerr << "FAIL: " << e.what() << '\n'; CoUninitialize(); return 1; }
}
+108
View File
@@ -0,0 +1,108 @@
// Run with the resident stopped. The suite restores the original clipboard.
#include "storage.h"
#include "ocr.h"
#include <psapi.h>
#include <iostream>
#include <algorithm>
static void require(bool ok, const char *name) {
if (!ok) throw std::runtime_error(name);
std::cout << "PASS " << name << '\n';
}
static HWND owner() {
return CreateWindowExW(0,L"STATIC",L"Snapshot test",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
}
static void child(const std::wstring &argument) {
auto exe=ks::executable(), command=L"\""+exe+L"\" "+argument;
STARTUPINFOW startup{sizeof(startup)}; startup.dwFlags=STARTF_USESHOWWINDOW; startup.wShowWindow=SW_HIDE;
PROCESS_INFORMATION p{};
if(!CreateProcessW(exe.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW,nullptr,nullptr,&startup,&p))
throw std::runtime_error("start fixture process");
CloseHandle(p.hThread);
auto wait=WaitForSingleObject(p.hProcess,30000); DWORD code=1;
if(wait!=WAIT_OBJECT_0) {TerminateProcess(p.hProcess,1);WaitForSingleObject(p.hProcess,INFINITE);}
GetExitCodeProcess(p.hProcess,&code); CloseHandle(p.hProcess);
if(code) throw std::runtime_error("fixture process failed");
}
static size_t resident() {
ks::Bytes b(1024*1024); auto ws=reinterpret_cast<PSAPI_WORKING_SET_INFORMATION*>(b.data());
if(!QueryWorkingSet(GetCurrentProcess(),ws,DWORD(b.size()))) throw std::runtime_error("working set query");
size_t pages=0; for(size_t i=0;i<ws->NumberOfEntries;++i) if(!ws->WorkingSetInfo[i].Shared) ++pages;
return pages*4096;
}
static void verifyImage(HWND w) {
auto c=ks::readClipboard(w,64*1024*1024);
for(auto id:{CF_DIB,CF_DIBV5}) {
auto it=std::find_if(c.formats.begin(),c.formats.end(),[&](const auto &f){return f.id==UINT(id);});
require(it!=c.formats.end(),"restores image representation");
auto pixels=ks::decodeImage(ks::Clip{{*it}});
require(pixels.width==1566 && pixels.height==1068 && pixels.bgra.front()==77,"preserves image pixels");
}
require(c.text()==L"Snapshot mixed text","preserves accompanying text");
}
int main(int argc,char**argv) {
CoInitializeEx(nullptr,COINIT_MULTITHREADED);
HWND w=owner(); int result=0; ks::Clip original; bool saved=false;
try {
std::string mode=argc>1?argv[1]:"suite";
if(mode=="publish") {
ks::Pixels p{1566,1068,ks::Bytes(1566*1068*4,255)}; p.bgra[0]=77;
auto dib=ks::imageDib(p); auto v5=dib;
BITMAPV5HEADER h{}; h.bV5Size=sizeof(h); h.bV5Width=p.width; h.bV5Height=-p.height;
h.bV5Planes=1; h.bV5BitCount=32; h.bV5Compression=BI_BITFIELDS;
h.bV5RedMask=0xff0000; h.bV5GreenMask=0xff00; h.bV5BlueMask=0xff; h.bV5AlphaMask=0xff000000;
v5.id=CF_DIBV5; v5.data.resize(sizeof(h)+p.bgra.size());
memcpy(v5.data.data(),&h,sizeof(h)); memcpy(v5.data.data()+sizeof(h),p.bgra.data(),p.bgra.size());
auto clip=ks::textClip(L"Snapshot mixed text"); clip.formats.push_back(dib); clip.formats.push_back(v5);
ks::writeClipboard(w,clip);
} else if(mode=="old" || mode=="new") {
child(L"publish"); auto baseline=resident();
if(mode=="old") {
auto clip=ks::readClipboard(w,64*1024*1024);
ks::writeClipboard(w,ks::textClip(L"temporary")); ks::writeClipboard(w,clip);
} else {
ks::ClipboardSnapshot snapshot; snapshot.capture(w,GetClipboardSequenceNumber());
auto seq=ks::writeClipboard(w,ks::textClip(L"temporary"));
require(snapshot.restore(w,seq)==GetClipboardSequenceNumber(),"helper returns current sequence");
}
auto after=resident();
auto report="baseline="+std::to_string(baseline)+" after="+std::to_string(after)+" delta="+std::to_string(int64_t(after)-int64_t(baseline))+"\n";
auto path=std::filesystem::path(ks::executable()).parent_path()/(L"snapshot-memory-"+ks::wide(mode)+L".txt");
ks::atomicWrite(path,ks::Bytes(report.begin(),report.end()));
verifyImage(w); // Only after measuring: verification intentionally reads images.
} else {
original=ks::readClipboard(w,128*1024*1024); saved=true;
child(L"old"); child(L"new"); child(L"publish");
auto seq=GetClipboardSequenceNumber();
auto selected=ks::readClipboard(w,32*1024*1024,ks::ClipboardRead::Selection);
require(selected.formats.size()==1 && selected.text()==L"Snapshot mixed text","selection read excludes image data");
bool rejected=false;
try {ks::readClipboard(w,32*1024*1024,ks::ClipboardRead::WithoutImages,seq);} catch(...) {rejected=true;}
require(rejected,"resident rejects images before reading their data");
{
ks::ClipboardSnapshot snapshot; snapshot.capture(w,seq);
auto stale=ks::writeClipboard(w,ks::textClip(L"temporary"));
ks::writeClipboard(w,ks::textClip(L"external copy")); rejected=false;
try {snapshot.restore(w,stale);} catch(...) {rejected=true;}
require(rejected && ks::readClipboard(w).text()==L"external copy","stale image restore preserves external copy");
}
{
ks::ClipboardSnapshot snapshot; snapshot.capture(w,GetClipboardSequenceNumber());
auto temporary=ks::writeClipboard(w,ks::textClip(L"temporary"));
snapshot.restore(w,temporary);
require(ks::readClipboard(w).text()==L"external copy","text snapshot fast path restores clipboard");
}
{
ks::Clip empty; auto seqEmpty=ks::writeClipboard(w,empty);
ks::ClipboardSnapshot snapshot; snapshot.capture(w,seqEmpty);
snapshot.restore(w,ks::writeClipboard(w,ks::textClip(L"temporary")));
require(ks::readClipboard(w).formats.empty(),"empty clipboard restored");
}
auto marked=ks::textClip(L"line"); marked.formats.push_back({0,L"MSDEVLineSelect",ks::Bytes{1}});
ks::writeClipboard(w,marked);
require(ks::readClipboard(w,1024,ks::ClipboardRead::Selection).formats.size()==2,"selection reader preserves editor guard metadata");
std::cout<<"PASS clipboard snapshot suite\n";
}
} catch(const std::exception &e) {std::cerr<<"FAIL "<<e.what()<<'\n';result=1;}
if(saved) try {ks::writeClipboard(w,original);} catch(...) {result=1;}
DestroyWindow(w); CoUninitialize(); return result;
}
+85
View File
@@ -0,0 +1,85 @@
#include "autocorrect.h"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <iostream>
#include <psapi.h>
#include <random>
namespace ks {
struct AutoCorrectorRegressionAccess {
static int rank(const AutoCorrector &c,const std::wstring &lang,const std::wstring &word) {
return c.dictionaries.at(lang)->rank(word);
}
static std::vector<int> ranks(const std::filesystem::path &path,const std::vector<std::wstring> &words) {
AutoCorrector::Dictionary d(path);
std::vector<int> values;for(const auto &word:words)values.push_back(d.rank(word));return values;
}
};
}
int main(int argc,char **argv) {
try {
auto start=std::chrono::steady_clock::now();
ks::AutoCorrector c;c.configure(L"dictionaries",{L"en",L"uk",L"ru"});
PROCESS_MEMORY_COUNTERS_EX m{};
GetProcessMemoryInfo(GetCurrentProcess(),reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&m),sizeof(m));
std::cout<<"dictionary_bytes="<<c.memoryBytes()<<" private_bytes="<<m.PrivateUsage
<<" load_ms="<<std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count()<<'\n';
struct Query {std::wstring language,word; int rank;};
std::vector<Query> queries;
for(const auto *lang:{L"en",L"uk",L"ru"}) {
std::ifstream input(std::filesystem::path(L"dictionaries")/(std::wstring(lang)+L".txt"));
std::map<std::wstring,int> expected;
for(std::string line;std::getline(input,line);) {
auto tab=line.find('\t');auto word=ks::wide(line.substr(0,tab));auto rank=std::stoi(line.substr(tab+1));
auto [it,added]=expected.emplace(word,rank);if(!added)it->second=std::min(it->second,rank);
}
if(expected.empty())throw std::runtime_error("Missing benchmark dictionary");
for(auto &[word,rank]:expected) {
if(ks::AutoCorrectorRegressionAccess::rank(c,lang,word)!=rank)throw std::runtime_error("Dictionary rank differs from source");
if(ks::AutoCorrectorRegressionAccess::rank(c,lang,word+L"~")!=0)throw std::runtime_error("False dictionary hit");
queries.push_back({lang,word,rank});queries.push_back({lang,word+L"~",0});
}
}
std::mt19937 random(42);std::shuffle(queries.begin(),queries.end(),random);
uint64_t checksum=0,expected=0;
for(auto &q:queries)expected+=q.rank;
start=std::chrono::steady_clock::now();
for(int pass=0;pass<5;++pass)for(auto &q:queries)checksum+=ks::AutoCorrectorRegressionAccess::rank(c,q.language,q.word);
auto ms=std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count();
if(checksum!=expected*5)throw std::runtime_error("Lookup checksum mismatch");
std::cout<<"verified_words="<<queries.size()/2<<" queries="<<queries.size()*5<<" lookup_ms="<<ms
<<" ns_per_lookup="<<ms*1000000/(queries.size()*5)<<" checksum="<<checksum<<'\n';
auto fixture=std::filesystem::temp_directory_path()/(L"KSwitcher-dictionary-"+
std::to_wstring(GetCurrentProcessId())+L"-"+std::to_wstring(GetTickCount64())+L".txt");
struct RemoveFixture { std::filesystem::path path; ~RemoveFixture(){std::error_code ec;std::filesystem::remove(path,ec);} } cleanup{fixture};
auto put=[&](const std::string &text){ks::atomicWrite(fixture,ks::Bytes(text.begin(),text.end()));};
std::string text="duplicate\t9\r\nduplicate\t2\n";
std::vector<std::wstring> words;
std::vector<int> expectedRanks;
for(int i=129;i>=0;--i) {
auto word=L"prefix_"+std::to_wstring(i);
text+=ks::utf8(word)+"\t"+std::to_string(i+1)+"\n";
words.push_back(word);expectedRanks.push_back(i+1);
}
text+=std::string(160,'x')+"\t2147483647"; // Maximum length/rank, no final newline.
words.insert(words.end(),{std::wstring(160,L'x'),L"DUPLICATE",L"",L"prefix_",L"zzzz"});
expectedRanks.insert(expectedRanks.end(),{2147483647,2,0,0,0});
for(auto rank:{65534,65535,65536}) {
auto word=std::wstring(159,L'x')+wchar_t(L'y'+rank-65534);
text+="\n"+ks::utf8(word)+"\t"+std::to_string(rank);
words.push_back(word);expectedRanks.push_back(rank);
}
put(text);
if(ks::AutoCorrectorRegressionAccess::ranks(fixture,words)!=expectedRanks)
throw std::runtime_error("Block boundaries, duplicate ranks or maximum values failed");
for(const auto &invalid:std::vector<std::string>{"","missing-tab","x\t0","x\t-1","x\t2147483648",std::string(161,'x')+"\t1","\t1","x\t1bad"}) {
put(invalid);bool rejected=false;
try {ks::AutoCorrectorRegressionAccess::ranks(fixture,{L"x"});}catch(const std::runtime_error &){rejected=true;}
if(!rejected)throw std::runtime_error("Invalid dictionary was accepted");
}
const bool baseline=argc==2 && std::string(argv[1])=="--baseline";
if(!baseline && c.memoryBytes()>=1536*1024)throw std::runtime_error("Bundled dictionaries exceed compact memory budget");
std::cout<<"Passed complete dictionary equivalence and format regressions\n";
return 0;
}catch(const std::exception&e){std::cerr<<e.what()<<'\n';return 1;}
}
+133
View File
@@ -0,0 +1,133 @@
// Explicit Windows integration test. Opens an isolated Far profile and never
// types into an existing Far window. Requires Far Manager at its default path.
#include "app.h"
#include <cstdlib>
#include <cwctype>
#include <fstream>
#include <future>
namespace ks {
struct InputRegressionAccess {
static void runQueued(App &app) {
std::function<void()> job;
{ std::lock_guard lock(app.jobsMutex);
if (app.jobs.empty()) throw std::runtime_error("snippet did not queue");
job=std::move(app.jobs.front());app.jobs.pop_front(); }
auto future=std::async(std::launch::async,[job=std::move(job)] {
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);job();CoUninitialize();
});
while(future.wait_for(std::chrono::milliseconds(5))!=std::future_status::ready) {
MSG message{};while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
TranslateMessage(&message);DispatchMessageW(&message);
}
}
future.get();app.replayTyping();
}
};
}
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) {
auto base=std::filesystem::path(ks::executable()).parent_path();
std::ofstream log(base/L"far-snippets.log");
int checks=0;
auto require=[&](bool ok,const char *message) {
if(!ok)throw std::runtime_error(message);
++checks;log<<"PASS "<<message<<'\n';log.flush();
};
auto folder=std::filesystem::temp_directory_path()/(L"ks-far-snippets-"+ks::newSnippetId());
std::filesystem::create_directories(folder);
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
ks::Clip saved;bool captured=false;PROCESS_INFORMATION child{};
HANDLE input=INVALID_HANDLE_VALUE,output=INVALID_HANDLE_VALUE;
HWND farWindow{};
HWND owner=CreateWindowExW(0,L"STATIC",L"Far snippet fixture clipboard",0,0,0,0,0,HWND_MESSAGE,nullptr,instance,nullptr);
auto originalForeground=GetForegroundWindow();
int result=1;
try {
saved=ks::readClipboard(owner,128*1024*1024);captured=true;
auto sentinel=ks::textClip(L"KSwitcher Far fixture clipboard");
sentinel.formats.push_back({0,L"ExcludeClipboardContentFromMonitorProcessing",ks::Bytes{0}});
ks::writeClipboard(owner,sentinel);
const auto programFiles=_wgetenv(L"ProgramFiles");
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe";
std::wstring command=L"\""+farExecutable.wstring()+L"\" -m -p -s \""+folder.wstring()+L"\\profile\"";
// A GUID-length working directory can horizontally scroll Far's command
// line, making absolute cursor-cell assertions invalid. Keep the prompt
// short; profile and application data still live in the unique fixture.
auto workingDirectory=folder.root_path();
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,workingDirectory.c_str(),&startup,&child),"start isolated Far without plugins/macros");
CloseHandle(child.hThread);Sleep(1800);
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation");
input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
output=CreateFileW(L"CONOUT$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
require(input!=INVALID_HANDLE_VALUE && output!=INVALID_HANDLE_VALUE,"open isolated console buffers");
auto target=GetConsoleWindow();farWindow=target;ShowWindow(target,SW_SHOW);
require(ks::activateWindow(target),"activate only the isolated Far window");
auto key=[&](WORD vk,wchar_t c=0,DWORD mods=0) {
INPUT_RECORD records[2]{};
for(int i=0;i<2;++i) {
auto &r=records[i];r.EventType=KEY_EVENT;
r.Event.KeyEvent={BOOL(i==0),1,vk,WORD(MapVirtualKeyW(vk,MAPVK_VK_TO_VSC)),{c},mods};
}
DWORD written{};
if(!WriteConsoleInputW(input,records,2,&written)||written!=2)throw std::runtime_error("test input failed");
Sleep(60);
};
auto position=[&] {CONSOLE_SCREEN_BUFFER_INFO info{};if(!GetConsoleScreenBufferInfo(output,&info))throw std::runtime_error("console position unavailable");return info.dwCursorPosition;};
auto row=[&] {
CONSOLE_SCREEN_BUFFER_INFO info{};GetConsoleScreenBufferInfo(output,&info);
std::wstring text(info.dwSize.X,L' ');DWORD count{};
if(!ReadConsoleOutputCharacterW(output,text.data(),DWORD(text.size()),{0,info.dwCursorPosition.Y},&count))throw std::runtime_error("console row unavailable");
return text;
};
{
ks::App app(instance,false,folder/L"data");app.window=owner;
app.settings.history=false;app.settings.autoCorrect=false;app.settings.writeLog=true;
auto prepare=[&](wchar_t last=L'r') {
if(!ks::activateWindow(target))throw std::runtime_error("isolated Far lost foreground");
key(VK_ESCAPE);key('Y',0,LEFT_CTRL_PRESSED);key(VK_OEM_1,L':',SHIFT_PRESSED);key(WORD(towupper(last)),last);
app.run.reset();app.run.window=target;app.run.layout=ks::windowLayout(target);
app.run.at=GetTickCount64();app.run.keys=L":r";
};
auto expand=[&](const std::wstring &replacement) {
app.settings.snippets={{L":r",replacement}};
app.transform(0,0);ks::InputRegressionAccess::runQueued(app);Sleep(120);
};
for(int panels=0;panels<2;++panels) {
prepare();auto before=position();auto original=row();expand(L"rider .");
auto expected=original;expected.replace(before.X-2,2,L"rider .");expected.resize(original.size());
// Far's rightmost scroll indicator is redrawn rather than shifted.
require(row().substr(0,before.X+5)==expected.substr(0,before.X+5),"actual snippet replaces :r with rider .");
require(position().X==before.X+5,"successful expansion leaves cursor after replacement");
require(ks::readClipboard(owner).text()==sentinel.text(),"snippet restores original clipboard");
prepare(L'x');before=position();original=row();expand(L"rider .{enter}");
require(row()==original && position().X==before.X,"mismatched trigger restores original caret and never executes Enter");
prepare();before=position();original=row();expand(L"left{cursor}right");
require(row().substr(before.X-2,9)==L"leftright" && position().X==before.X+2,"cursor marker uses Far selection semantics");
prepare();before=position();original=row();expand(L"first\nsecond{enter}");
require(row()==original && position().X==before.X,"multiline console template rejected before changing text or caret");
prepare();before=position();original=row();expand(L"echo KSWITCHER_FAR_FIXTURE{enter}");
for(int wait=0;wait<30 && position().X!=before.X-2;++wait)Sleep(100);
require(position().X==before.X-2 && row().substr(before.X-2,2)==L" ",
"verified Enter runs only the harmless fixture echo and returns to an empty command line");
key('O',0,LEFT_CTRL_PRESSED);
}
app.window=nullptr;
}
log<<"Passed "<<checks<<" Far snippet integration checks\n";result=0;
}catch(const std::exception &e){log<<"FAIL "<<e.what()<<" win32="<<GetLastError()<<'\n';}
// A real user or clipboard synchronizer can copy during this explicit test.
// Never replace that newer content with the fixture's original snapshot.
const auto restoreSequence=GetClipboardSequenceNumber();
const bool restoreClipboard=GetClipboardOwner()==owner || (farWindow && ks::ownsClipboard(farWindow));
if(input!=INVALID_HANDLE_VALUE)CloseHandle(input);
if(output!=INVALID_HANDLE_VALUE)CloseHandle(output);
FreeConsole();
if(child.hProcess){TerminateProcess(child.hProcess,0);WaitForSingleObject(child.hProcess,5000);CloseHandle(child.hProcess);}
if(captured && restoreClipboard && GetClipboardSequenceNumber()==restoreSequence)
try{ks::writeClipboard(owner,saved);}catch(...){result=1;}
DestroyWindow(owner);
if(IsWindow(originalForeground))ks::activateWindow(originalForeground);
CoUninitialize();return result;
}
+68
View File
@@ -0,0 +1,68 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type @'
using System;
using System.Text;
using System.Runtime.InteropServices;
public static class FilesDriveShortcutTest {
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr window, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
public static extern IntPtr SendMessage(IntPtr window, uint msg, IntPtr w, StringBuilder text);
}
'@
function Wait-Until([scriptblock]$Condition) {
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if (& $Condition) { return }
Start-Sleep -Milliseconds 50
}
throw "Timed out waiting for: $Condition"
}
function Read-Text($Control, $Message = 13, $Parameter = 32768) {
$buffer = [Text.StringBuilder]::new(32768)
[void][FilesDriveShortcutTest]::SendMessage($Control, $Message, [IntPtr]$Parameter, $buffer)
$buffer.ToString()
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
try {
Wait-Until { $process.Refresh(); $process.MainWindowHandle -ne [IntPtr]::Zero }
$window = $process.MainWindowHandle
$picker = [FilesDriveShortcutTest]::GetDlgItem($window, 121)
$path = [FilesDriveShortcutTest]::GetDlgItem($window, 142)
function Open-Picker {
[void][FilesDriveShortcutTest]::PostMessage($window, 260, [IntPtr]112, [IntPtr]::Zero)
Wait-Until { [FilesDriveShortcutTest]::IsWindowVisible($picker) }
}
function Press-Shortcut([char]$Key) {
[void][FilesDriveShortcutTest]::PostMessage($picker, 258, [IntPtr][int]$Key, [IntPtr]::Zero)
Wait-Until { ![FilesDriveShortcutTest]::IsWindowVisible($picker) }
}
Open-Picker
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$rows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
$disk = $rows | Where-Object { $_ -match '^[A-Z]: fixed\t' } | Select-Object -First 1
if (!$disk) { throw 'No fixed drive available for the shortcut test.' }
Press-Shortcut ([char]::ToLowerInvariant($disk[0]))
$root = $disk.Substring(0, 2) + '\'
Wait-Until { (Read-Text $path) -eq $root }
Write-Output 'Drive letter opened the drive immediately.'
Open-Picker
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$rows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
for ($round = 1; $round -le 2; $round++) {
Wait-Until {
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$script:remoteRows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
[bool]($script:remoteRows | Where-Object { $_ -match '^ [A-Z]: ' })
}
if ((Read-Text $path) -ne $root) { throw 'Loading the remote drives changed the local path.' }
[void][FilesDriveShortcutTest]::PostMessage($picker, 256, [IntPtr]27, [IntPtr]::Zero)
Wait-Until { ![FilesDriveShortcutTest]::IsWindowVisible($picker) }
if ($round -eq 1) { Open-Picker }
}
Write-Output 'Remote drives appeared automatically on both opens.'
} finally {
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
+78
View File
@@ -0,0 +1,78 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class PanelFrameWindow {
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
}
'@
function Get-Bounds($Window) {
$bounds = [PanelFrameWindow+Rect]::new()
if (![PanelFrameWindow]::GetWindowRect($Window, [ref]$bounds)) { throw 'Missing panel control.' }
$bounds
}
function Read-Strip($X, $Y, $Width, $Height) {
$bitmap = [Drawing.Bitmap]::new($Width, $Height)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try { $graphics.CopyFromScreen($X, $Y, 0, 0, $bitmap.Size) }
finally { $graphics.Dispose() }
for ($row = 0; $row -lt $Height; $row++) {
for ($column = 0; $column -lt $Width; $column++) {
$bitmap.GetPixel($column, $row).ToArgb()
}
}
} finally { $bitmap.Dispose() }
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
$previousDpi = [PanelFrameWindow]::SetThreadDpiAwarenessContext([IntPtr](-4))
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50; $attempt++) {
$process.Refresh()
$window = $process.MainWindowHandle
if ($window -and $keyboard.AppActivate($process.Id)) { break }
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 300
if (!$window -or [PanelFrameWindow]::GetForegroundWindow() -ne $window) {
throw 'The test window must be visible and in the foreground.'
}
foreach ($panel in 0, 1) {
$heading = [PanelFrameWindow]::GetDlgItem($window, 140 + $panel)
if (![PanelFrameWindow]::IsWindowVisible($heading)) {
throw 'Both panels must use Wide mode for the frame test.'
}
$header = Get-Bounds $heading
$list = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 102 + $panel))
$path = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 142 + $panel))
$middle = [int](($header.Top + $header.Bottom) / 2)
$side = @(Read-Strip ($list.Left - 4) ($header.Top - 2) 4 1)
$atHeading = @(Read-Strip ($list.Left - 4) $middle 4 1)
if ($side[0] -ne $side[3] -or $side[1] -ne $side[2] -or $side[0] -eq $side[1] -or
($atHeading -join ',') -ne ($side -join ',')) {
throw "Panel $panel has a broken double frame beside the column header."
}
$x = $list.Left + 16
$top = @(Read-Strip $x ($path.Top + [Math]::Floor(($path.Bottom - $path.Top) / 2) - 2) 1 4)
if (($top -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent top frame." }
$bottom = @(Read-Strip $x ($list.Bottom + 4) 1 ($header.Bottom - $header.Top + 6))
$bottom = $bottom[($bottom.Count - 4)..($bottom.Count - 1)]
if (($bottom -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent bottom frame." }
Write-Output "Panel $panel double frame verified on screen."
}
} finally {
if ($previousDpi) { [void][PanelFrameWindow]::SetThreadDpiAwarenessContext($previousDpi) }
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more