Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5137887b9 | ||
|
|
c75cc989f4 | ||
|
|
f580892ca9 | ||
|
|
372c4cb8bd | ||
|
|
37158c1ff0 | ||
|
|
02f81327fd | ||
|
|
2834510680 | ||
|
|
2c53e99efc | ||
|
|
fb98731ec2 | ||
|
|
531efec2eb | ||
|
|
27c397e375 | ||
|
|
186621eb3c | ||
|
|
d3a0b83692 | ||
|
|
f38886d1ea | ||
|
|
e2c6734817 | ||
|
|
10d5a42f2a | ||
|
|
34d89db95b | ||
|
|
c10b141483 | ||
|
|
0c77d5e46d | ||
|
|
70a53834e3 | ||
|
|
73448058d7 | ||
|
|
12fdd7c549 |
No files matched your search
@@ -33,3 +33,9 @@ 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.
|
||||
|
||||
## 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.
|
||||
+23
-3
@@ -1,10 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.71 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.77 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_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)
|
||||
@@ -19,7 +35,8 @@ foreach(utilityTarget StayHere StayHereTest)
|
||||
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
endforeach()
|
||||
add_library(kscore src/lan_channel.cpp src/lan_input.cpp src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
|
||||
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 ws2_32 bcrypt iphlpapi winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
@@ -97,6 +114,9 @@ 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)
|
||||
@@ -173,7 +193,7 @@ 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")
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Оформлення KSwitcher Files
|
||||
|
||||
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
|
||||
|
||||
## Скорочення
|
||||
|
||||
- Короткий 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
|
||||
|
||||
- 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 показує літеру, тип, загальний обсяг і вільне місце. Відкриття меню запускає нове LAN-виявлення. Секція KSwitcher LAN показує збережені пари, зокрема офлайн. Після кожного відкриття меню асинхронно запитує диски й показує їх під назвою відповідного ПК. Поки триває запит, видно стан завантаження. Якщо запит не вдався, видно помилку. Повторне відкриття запускає новий запит. Кожна панель має власний диск і шлях, а в заголовку віддаленої панелі перед шляхом показано назву ПК.
|
||||
|
||||
- Список дисків і файлові операції використовують права облікового запису Windows, під яким працює віддалений KSwitcher. Достатньо спарювання й доступного TCP 45885, спільні папки налаштовувати не потрібно.
|
||||
- F5, F6, F8 і Shift+Delete у віддалених панелях використовують спільні підтвердження, попередження й прогрес. F5/F6 працюють із файлами та деревами папок між локальними й віддаленими панелями, зокрема між двома різними ПК. Між шляхами одного віддаленого ПК вміст копіюється на ньому. F8 використовує кошик того ПК, на якому лежить файл.
|
||||
- Після розриву з’єднання повтори F5 або F6 до того самого призначення. Віддалене копіювання відновлюється з підтвердженої позиції, якщо збережені байти відповідають початку вихідного файла. Підтверджене скасування видаляє частковий запис поточного файла. Завершені файли лишаються на місці. Наявний файл призначення замінюється після повного запису, а при переміщенні джерело видаляється після підтвердження запису призначення.
|
||||
|
||||
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
|
||||
|
||||
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
|
||||
|
||||
- Бірюзовий курсор поточного рядка видно лише в активній панелі. Кожна панель зберігає свою позицію курсора.
|
||||
|
||||
- Поточний шлях відображається по центру верхньої подвійної лінії панелі. Лінію видно з обох боків напису. Бірюзове тло активної панелі охоплює лише напис шляху з невеликими відступами. Довгий шлях скорочується в межах панелі.
|
||||
|
||||
## Спільні елементи діалогів
|
||||
|
||||
- Enter активує кнопку у фокусі. Для кнопок із власним малюванням використовуй спільний обробник `dialogButtonEnter`. У підтвердженні скасування початковий фокус стоїть на No.
|
||||
|
||||
- При перенесенні вікна між моніторами з різним DPI оновлюй шрифт, висоту рядків панелей і компонування відкритого прогресу копіювання. Перевір перенесення під час активного копіювання.
|
||||
- Для власних діалогів використовуй подвійну рамку з одним заголовком у верхній лінії. Звичайний діалог має сіре тло, попередження — темно-червоне тло та білий текст.
|
||||
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` центрує текст, підсвічує фокус і додає фігурні дужки для дії за замовчуванням та квадратні для решти кнопок.
|
||||
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
|
||||
- Поля вводу й випадаючі списки використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
|
||||
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
|
||||
|
||||
## Діалог Copy
|
||||
|
||||
- Вирівнюй підпис Existing files по вертикальному центру списку. Ширше поле списку починається ближче до підпису, а його правий край збігається з правим краєм поля імені.
|
||||
- Зберігай однакові проміжки від верхнього роздільника до поля імені й до списку. Підпис, поля та прапорець мають читатися повністю.
|
||||
- Відступ після прапорця до нижнього роздільника дорівнює відступу від верхнього роздільника до списку.
|
||||
- Кнопки Copy та Cancel стоять по центру компактної нижньої секції. Висота кнопок має вміщати весь текст поточного шрифту.
|
||||
|
||||
## Вікно прогресу копіювання
|
||||
|
||||
- F6 використовує спільне оформлення діалогів Copy і прогресу. Заголовок підтвердження — Rename/Move, кнопка за замовчуванням — Rename, заголовок прогресу — Move.
|
||||
- Поле призначення F6 початково містить шлях іншої панелі. Для одного елемента просте ім’я задає перейменування в поточній папці. Для кількох елементів діалог показує їх кількість і приймає один шлях до папки.
|
||||
|
||||
- Секції поточного файла й загального прогресу мають однаковий вертикальний відступ від вмісту до верхньої та нижньої ліній. Cancel стоїть по центру спільної нижньої секції.
|
||||
- Обидві шкали займають усю ширину вмісту. Відсотки показуються по центру шкали, а її висота враховує поточний шрифт.
|
||||
|
||||
## Попередження про конфлікт
|
||||
|
||||
- Після розрахунку остаточної висоти центр 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. Після узгодження нового правила онови відповідний розділ цього документа.
|
||||
+29
-3
@@ -5,13 +5,39 @@ code on both computers, and shares mouse and keyboard through a screen edge.
|
||||
The module runs inside the resident process. Multiple paired computers can be
|
||||
arranged on a monitor grid. Mouse and keyboard follow neighboring cells, including
|
||||
A → B → C, without relaying input through B. New text and images are also shared
|
||||
with all online paired computers: history and the active clipboard. File transfer
|
||||
is not implemented by clipboard synchronization.
|
||||
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 → Tools → KSwitcher computers and enable discovery on both 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
|
||||
|
||||
@@ -6,6 +6,15 @@ 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**.
|
||||
|
||||
[](https://send.monobank.ua/jar/88h2TddKfS)
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
@@ -108,7 +117,7 @@ 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 changes** with **Save** disabled until an edit is made.
|
||||
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.
|
||||
@@ -155,8 +164,12 @@ the elevated task requests UAC approval if the app is not already elevated. No p
|
||||
the task runs in the signed-in user's interactive session, including on battery power.
|
||||
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. Saving unrelated settings does not recreate startup entries.
|
||||
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.
|
||||
|
||||
@@ -477,13 +490,13 @@ ChatGPT and Codex desktop editors use paced selection for snippet triggers. If o
|
||||
|
||||
### Application-specific snippets — v0.6.51
|
||||
|
||||
In **Tools → Snippets**, select a snippet and enable **Only in these applications**. Enter executable names separated by commas, or select a currently open application's process and choose **Add app**. For example: `ms-teams.exe, outlook.exe`. Uncheck the option for a global snippet. A checked but empty restriction cannot be saved. Save applies the changes; Cancel discards them.
|
||||
In **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.
|
||||
|
||||
**Українською.** У **Tools → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. **Save** зберігає, **Cancel** відкидає зміни.
|
||||
**Українською.** У **Settings → Snippets** вибери сніпет, увімкни **Only in these applications** та задай назви процесів через кому або вибери відкриту програму зі списку й натисни **Add app**. Наприклад: `ms-teams.exe, outlook.exe`. Вимкнений прапорець означає всі програми. Увімкнений прапорець із порожнім списком не можна зберегти. Натисни **Save snippets**, щоб застосувати зміни.
|
||||
|
||||
Сніпет конкретної програми має пріоритет над глобальним із тим самим тригером. Для різних програм однакові тригери дозволені, для перетинних областей — ні. Старі сніпети залишаються глобальними. Зіставлення за назвою процесу не залежить від регістру та суфікса `.exe`; OCR і таймерів немає. Для Chrome це весь браузер, а не окремий сайт. Обмеження входять до експорту/імпорту та синхронізації. Перед синхронізацією онови всі ПК до v0.6.51 або новішої: старі версії не розуміють обмеження та можуть застосувати сніпет глобально.
|
||||
### Existing features
|
||||
@@ -596,9 +609,9 @@ Windows exposes them; selection offsets are available for native EDIT controls a
|
||||
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.
|
||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||
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,
|
||||
@@ -820,13 +833,13 @@ default and require the main **Automatic correction (experimental)** checkbox to
|
||||
|
||||
- **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 contain 1–32 letters. The correction may contain several 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.60 or newer before sharing apostrophe pairs; older clients reject them.
|
||||
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
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,82 @@
|
||||
# Latest Windows release
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1 +1 @@
|
||||
42390860144e0b3a2b32874500ab9de8de60b76715cc583d306f761a749a83e9 KSwitcher-win-x64.zip
|
||||
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
|
||||
+22
-22
@@ -1,84 +1,84 @@
|
||||
{
|
||||
"version": "0.6.71",
|
||||
"version": "0.6.77",
|
||||
"files": [
|
||||
{
|
||||
"size": 2202624,
|
||||
"path": "KSwitcher.exe",
|
||||
"sha256": "fc85390dcc0a46c8cd10daad77482de4f374f9f61b4b4adee1a9ca022d17e4f8"
|
||||
"size": 2249216,
|
||||
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
|
||||
},
|
||||
{
|
||||
"size": 2129408,
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"sha256": "371c9bf2673049e52f432457a929173f5e5a2a91d1e9dc3e96ef8a2c4699e628"
|
||||
"size": 2251776,
|
||||
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
|
||||
},
|
||||
{
|
||||
"size": 3210240,
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"sha256": "bbcfed4d697c9053ff6ad828289805cd6d5f20b4c3d29027b4d26317b973f725"
|
||||
"size": 3247616,
|
||||
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
|
||||
},
|
||||
{
|
||||
"size": 4401,
|
||||
"path": "LICENSE",
|
||||
"size": 4401,
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||
},
|
||||
{
|
||||
"size": 1075,
|
||||
"path": "json-LICENSE.txt",
|
||||
"size": 1075,
|
||||
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
|
||||
},
|
||||
{
|
||||
"size": 92710,
|
||||
"path": "README.md",
|
||||
"sha256": "3d475635e3190c80dbf499bb06cb0c082c16c8977d735e21e8f658cc8fc2bc51"
|
||||
"size": 93885,
|
||||
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
|
||||
},
|
||||
{
|
||||
"size": 9900,
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e"
|
||||
"size": 11418,
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
|
||||
},
|
||||
{
|
||||
"size": 682867,
|
||||
"path": "dictionaries/en.txt",
|
||||
"size": 682867,
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
|
||||
},
|
||||
{
|
||||
"size": 20138,
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"size": 20138,
|
||||
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
},
|
||||
{
|
||||
"size": 2230,
|
||||
"path": "dictionaries/README.md",
|
||||
"size": 2230,
|
||||
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
|
||||
},
|
||||
{
|
||||
"size": 1060364,
|
||||
"path": "dictionaries/ru.txt",
|
||||
"size": 1060364,
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
|
||||
},
|
||||
{
|
||||
"size": 1279,
|
||||
"path": "dictionaries/sources.json",
|
||||
"size": 1279,
|
||||
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
|
||||
},
|
||||
{
|
||||
"size": 898248,
|
||||
"path": "dictionaries/uk.txt",
|
||||
"size": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
|
||||
},
|
||||
{
|
||||
"size": 993,
|
||||
"path": "tools/README.txt",
|
||||
"size": 993,
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||
},
|
||||
{
|
||||
"size": 134656,
|
||||
"path": "tools/StayHere.exe",
|
||||
"size": 134656,
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||
},
|
||||
{
|
||||
"size": 2796,
|
||||
"path": "tools/StayHere-README.md",
|
||||
"size": 2796,
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -22,7 +22,7 @@ try {
|
||||
& $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_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')) {
|
||||
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" }
|
||||
}
|
||||
@@ -35,17 +35,25 @@ 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/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $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/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||
& $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/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
||||
& $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' }
|
||||
@@ -59,10 +67,14 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'LAN input test build failed' }
|
||||
& .\build\KSwitcherLanInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'LAN input tests failed' }
|
||||
& $compiler @common -Werror tests/lan.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe
|
||||
& $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')
|
||||
@@ -71,11 +83,11 @@ try {
|
||||
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_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
||||
& $compiler @common tests/dictionary.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_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_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||
& $compiler @common tests/sync.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_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' }
|
||||
@@ -83,19 +95,19 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||
& .\build\KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||
& $compiler @common tests/tests.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $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/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
& $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/chat_profiles.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
|
||||
& $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_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||
& $compiler @common tests/screenshot_ui.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_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' }
|
||||
@@ -103,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/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
& $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' }
|
||||
@@ -115,7 +127,7 @@ try {
|
||||
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_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
|
||||
& $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')
|
||||
@@ -127,28 +139,28 @@ try {
|
||||
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_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
|
||||
& $compiler @common tests/history_host.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_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_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
|
||||
& $compiler @common tests/clipboard_snapshot.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_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_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
|
||||
& $compiler @common tests/image_capture.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_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_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
|
||||
& $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) {
|
||||
$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_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
|
||||
& $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' }
|
||||
& $ocrTest
|
||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||
|
||||
+4
-1
@@ -6,6 +6,9 @@ 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
|
||||
@@ -16,7 +19,7 @@ Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -
|
||||
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','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','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()}
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,71,0
|
||||
PRODUCTVERSION 0,6,71,0
|
||||
FILEVERSION 0,6,77,0
|
||||
PRODUCTVERSION 0,6,77,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.71"
|
||||
VALUE "FileVersion", "0.6.77"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
|
||||
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
|
||||
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, 114
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
CAPTION "Copy"
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
|
||||
CONTROL "", 211, "Edit", ES_MULTILINE | ES_AUTOHSCROLL | WS_TABSTOP, 14, 29, 322, 18
|
||||
LTEXT "Existing files:", 212, 14, 61, 122, 14, SS_CENTERIMAGE
|
||||
COMBOBOX 213, 140, 61, 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, 81, 322, 14
|
||||
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 96, 86, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 96, 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, 106
|
||||
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 "", 234, 12, 81, 1, 1
|
||||
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 57, 336, 18
|
||||
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 89, 110, 18
|
||||
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 89, 68, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 89, 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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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>
|
||||
@@ -0,0 +1,89 @@
|
||||
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.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();
|
||||
+183
-10
@@ -12,6 +12,7 @@
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <set>
|
||||
#include <shlobj.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
namespace ks
|
||||
@@ -23,6 +24,7 @@ namespace
|
||||
{
|
||||
constexpr UINT SnippetPopupMessage = WM_APP + 81;
|
||||
constexpr UINT SnippetCaretMessage = WM_APP + 82;
|
||||
constexpr UINT CheckAutostartMessage = WM_APP + 83;
|
||||
constexpr UINT_PTR SnippetPopupTimer = 21;
|
||||
constexpr size_t SnippetVisibleRows = 7;
|
||||
constexpr wchar_t SnippetPopupClass[] = L"KSwitcher.SnippetSuggestions";
|
||||
@@ -139,6 +141,8 @@ ULONG_PTR snippetReplayMarker()
|
||||
static const char marker{};
|
||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||
}
|
||||
constexpr int OpenFiles = 25, FilesHotkeyFirst = 0x4B10, FilesHotkeySecond = 0x4B11;
|
||||
|
||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||
{
|
||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||
@@ -186,6 +190,7 @@ class Transaction
|
||||
ClipboardSnapshot snapshot;
|
||||
DWORD expected{};
|
||||
bool captured{};
|
||||
bool clipboardModified{};
|
||||
mutable bool invalidLogged{};
|
||||
unsigned copyNumber{};
|
||||
std::wstring adopted;
|
||||
@@ -208,9 +213,14 @@ class Transaction
|
||||
expected = GetClipboardSequenceNumber();
|
||||
try
|
||||
{
|
||||
auto started = GetTickCount64();
|
||||
const bool image = IsClipboardFormatAvailable(CF_DIB) || IsClipboardFormatAvailable(CF_DIBV5) ||
|
||||
IsClipboardFormatAvailable(CF_BITMAP) || IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG"));
|
||||
snapshot.capture(app.window, expected);
|
||||
if (expected != GetClipboardSequenceNumber())
|
||||
throw std::runtime_error("Clipboard changed while taking snapshot");
|
||||
diagnosticEvent(L"Clipboard snapshot captured: elapsedMs=" +
|
||||
std::to_wstring(GetTickCount64() - started) + L" image=" + std::to_wstring(image));
|
||||
break;
|
||||
}
|
||||
catch (...)
|
||||
@@ -234,10 +244,12 @@ class Transaction
|
||||
~Transaction()
|
||||
{
|
||||
if (farSnippet) finishSelection(false);
|
||||
if (captured && GetClipboardSequenceNumber() == expected)
|
||||
if (captured && clipboardModified && GetClipboardSequenceNumber() == expected)
|
||||
try
|
||||
{
|
||||
auto started = GetTickCount64();
|
||||
app.ownSequence = snapshot.restore(app.window, expected);
|
||||
diagnosticEvent(L"Clipboard snapshot restored: elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -294,6 +306,7 @@ class Transaction
|
||||
auto temporary = c;
|
||||
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
|
||||
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
|
||||
clipboardModified = true;
|
||||
app.ownSequence = expected = writeClipboard(app.window, temporary);
|
||||
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
||||
// Never adopt that writer's sequence as our own and paste its content.
|
||||
@@ -670,6 +683,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
|
||||
if (snippetPopup) DestroyWindow(snippetPopup);
|
||||
lanService.reset();
|
||||
stopBrowserChatMonitor();
|
||||
@@ -809,11 +823,13 @@ void App::addTray()
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = window;
|
||||
icon.uID = 1;
|
||||
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP;
|
||||
icon.uCallbackMessage = TrayMessage;
|
||||
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
||||
auto tip = incomingLanFileCount ? L"KSwitcher — files ready to save: " + std::to_wstring(incomingLanFileCount)
|
||||
: localBuildTitle(L"KSwitcher — keyboard layouts & clipboard");
|
||||
wcsncpy_s(icon.szTip, tip.c_str(), _TRUNCATE);
|
||||
if (Shell_NotifyIconW(NIM_ADD, &icon))
|
||||
{
|
||||
KillTimer(window, TrayRetryTimer);
|
||||
@@ -841,6 +857,19 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
||||
registrationWarning = L"Keyboard interception could not start.";
|
||||
return false;
|
||||
}
|
||||
const auto files = Hotkey::parse(s.filesHotkey.value_or(L"")).value_or(Hotkey{});
|
||||
if (files != registeredFilesHotkey)
|
||||
{
|
||||
const int next = filesHotkeyId == FilesHotkeyFirst ? FilesHotkeySecond : FilesHotkeyFirst;
|
||||
if (files.key && !RegisterHotKey(window, next, files.modifiers | MOD_NOREPEAT, files.key))
|
||||
{
|
||||
if (!rollback) registrationWarning = L"The KSwitcher Files shortcut is unavailable. Choose another shortcut.";
|
||||
return false;
|
||||
}
|
||||
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
|
||||
filesHotkeyId = files.key ? next : 0;
|
||||
registeredFilesHotkey = files;
|
||||
}
|
||||
hookBindings = std::move(bindings);
|
||||
return true;
|
||||
}
|
||||
@@ -857,6 +886,56 @@ Bytes App::lanCommand(const Bytes &command)
|
||||
void App::applySettings(const Settings &requested)
|
||||
{
|
||||
applySettings(requested, true);
|
||||
if (settings.autostart && !smoke && !isLocalBuild())
|
||||
PostMessageW(window, CheckAutostartMessage, 0, 0);
|
||||
}
|
||||
|
||||
void App::checkAutostartPath()
|
||||
{
|
||||
if (checkingAutostartPath || !settings.autostart || installingUpdate)
|
||||
return;
|
||||
checkingAutostartPath = true;
|
||||
try
|
||||
{
|
||||
const bool elevated = settings.elevatedAutostart;
|
||||
const auto oldPath = staleAutostartPath(elevated);
|
||||
if (!oldPath.empty())
|
||||
{
|
||||
const auto attributes = GetFileAttributesW(oldPath.c_str());
|
||||
const auto error = attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS;
|
||||
const bool missingFile = error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
|
||||
const bool needsApproval = elevated && !IsUserAnAdmin();
|
||||
bool approved = missingFile && !needsApproval;
|
||||
if (!approved)
|
||||
{
|
||||
const auto text = L"Registered path:\n" + oldPath + L"\n\nCurrent path:\n" + executable() +
|
||||
(needsApproval ? L"\n\nWindows will ask for administrator approval." : L"");
|
||||
const TASKDIALOG_BUTTON update{IDYES, L"Update autostart path"};
|
||||
TASKDIALOGCONFIG dialog{sizeof(dialog)};
|
||||
dialog.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION;
|
||||
dialog.dwCommonButtons = TDCBF_CANCEL_BUTTON;
|
||||
dialog.pszWindowTitle = L"KSwitcher";
|
||||
dialog.pszMainInstruction = L"Windows autostart points to a different copy of KSwitcher";
|
||||
dialog.pszContent = text.c_str();
|
||||
dialog.cButtons = 1;
|
||||
dialog.pButtons = &update;
|
||||
dialog.nDefaultButton = IDCANCEL;
|
||||
int button{};
|
||||
approved = SUCCEEDED(TaskDialogIndirect(&dialog, &button, nullptr, nullptr)) && button == IDYES;
|
||||
}
|
||||
if (approved && settings.autostart && settings.elevatedAutostart == elevated &&
|
||||
!installingUpdate && staleAutostartPath(elevated) == oldPath)
|
||||
{
|
||||
repairAutostartPath(elevated);
|
||||
log(L"Updated Windows autostart path to " + executable());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
report(L"Windows autostart: " + wide(e.what()));
|
||||
}
|
||||
checkingAutostartPath = false;
|
||||
}
|
||||
void App::applySettings(const Settings &requested, bool validateOcrInstallation)
|
||||
{
|
||||
@@ -959,6 +1038,7 @@ void App::setHotkeyCapture(HWND field)
|
||||
}
|
||||
bool App::isHotkey(DWORD key, UINT mods)
|
||||
{
|
||||
if (registeredFilesHotkey.key && registeredFilesHotkey.key == key && registeredFilesHotkey.modifiers == mods) return true;
|
||||
for (auto &[id, text] : shortcuts(settings))
|
||||
{
|
||||
auto h = Hotkey::parse(text);
|
||||
@@ -1901,6 +1981,20 @@ void App::dispatch(int command)
|
||||
try
|
||||
{
|
||||
if (command == 23) { checkUpdate(true); return; }
|
||||
if (command == OpenFiles)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
|
||||
auto args = L"\"" + path.wstring() + L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), args.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
return;
|
||||
}
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
@@ -1974,7 +2068,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
||||
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
|
||||
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
|
||||
{"elapsedMs", GetTickCount64() - requestedAt}};
|
||||
{"elapsedMs", GetTickCount64() - requestedAt},
|
||||
{"activeAttempt", activeTransformAttempt.load()}};
|
||||
diagnosticOperation(record.dump());
|
||||
}
|
||||
catch (...) {}
|
||||
@@ -1986,6 +2081,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
autoPending = false;
|
||||
snippetPending = -1;
|
||||
}
|
||||
activeTransformAttempt = attempt;
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -2007,20 +2103,24 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
struct BusyReset
|
||||
{
|
||||
std::atomic<bool> &b;
|
||||
std::atomic<uint64_t> &active;
|
||||
HWND window;
|
||||
bool deferred{};
|
||||
~BusyReset()
|
||||
{
|
||||
active = 0;
|
||||
if (deferred)
|
||||
PostMessageW(window, ReplayTypingMessage, 0, 0);
|
||||
else
|
||||
b = false;
|
||||
}
|
||||
} reset{busy, window, preDeferred};
|
||||
} reset{busy, activeTransformAttempt, window, preDeferred};
|
||||
const auto workerStarted = GetTickCount64();
|
||||
auto effectiveChat = requestedChat;
|
||||
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
||||
config.cycle = chatCycle(config, languageProfileKey(config, target, effectiveChat), available);
|
||||
auto start = GetTickCount64();
|
||||
ULONGLONG verificationMs{};
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||
@@ -2035,6 +2135,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
|
||||
}
|
||||
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
|
||||
L" queueMs=" + std::to_wstring(workerStarted - requestedAt) +
|
||||
L" profileMs=" + std::to_wstring(start - workerStarted) +
|
||||
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
|
||||
L" trackedExpected=" + diagnosticText(expected));
|
||||
}
|
||||
@@ -2044,6 +2146,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
HWND target;
|
||||
uint64_t version, attempt;
|
||||
ULONGLONG start;
|
||||
const ULONGLONG &verificationMs;
|
||||
bool enabled;
|
||||
std::wstring &reason;
|
||||
std::wstring &text, &result, &expected;
|
||||
@@ -2064,7 +2167,8 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
||||
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
|
||||
!(command == 0 && reason == L"paste-sent-unverified")},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
||||
{"elapsedMs", GetTickCount64() - start}, {"verificationMs", verificationMs},
|
||||
{"foregroundSame", GetForegroundWindow() == target},
|
||||
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
||||
{"expected", bounded(verification == L"selection-mismatch" ? expected : result)}, {"actual", bounded(actual)},
|
||||
{"selectionExpected", bounded(expected)},
|
||||
@@ -2074,7 +2178,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
}
|
||||
catch (...) {} // Logging must never abort a completed input transaction.
|
||||
}
|
||||
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
|
||||
} audit{*this, target, version, attempt, requestedAt, verificationMs, manual || snippet >= 0, reason, text, result, expected,
|
||||
actual, verification, hint, to, command, available};
|
||||
try
|
||||
{
|
||||
@@ -2139,7 +2243,12 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
bool farEditor;
|
||||
~SelectionReset() { if (selected && !farEditor && valid()) chord(VK_RIGHT); }
|
||||
} selectionReset{autoSelected, valid, farSnippet};
|
||||
reason = L"context-changed-before-snapshot";
|
||||
if (!valid()) return;
|
||||
reason = L"clipboard-snapshot";
|
||||
Transaction tx(*this, target, version, farSnippet, snippetEditor);
|
||||
reason = L"context-changed-before-selection";
|
||||
if (!tx.valid()) return;
|
||||
reason = L"selection-unavailable";
|
||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
|
||||
@@ -2259,6 +2368,11 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
if (hint < 0)
|
||||
{
|
||||
if (!tx.valid())
|
||||
{
|
||||
reason = L"context-changed-during-selection";
|
||||
return;
|
||||
}
|
||||
actual = text;
|
||||
verification = L"selection-mismatch";
|
||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||
@@ -2334,7 +2448,10 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
}
|
||||
bool sent = false;
|
||||
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
|
||||
auto probeStarted = GetTickCount64();
|
||||
PasteProbe probe(!farSnippet && ((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
||||
verificationMs = GetTickCount64() - probeStarted;
|
||||
diagnosticEvent(L"Paste probe prepared: elapsedMs=" + std::to_wstring(verificationMs) + L" status=" + probe.status);
|
||||
if (requireReadback && !farSnippet && result != text && probe.status != L"ready")
|
||||
{
|
||||
verification = probe.status;
|
||||
@@ -2372,6 +2489,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
verification = probe.status;
|
||||
if (tx.valid())
|
||||
{
|
||||
auto readStarted = GetTickCount64();
|
||||
auto observed = farSnippet ? tx.readFarSnippet(caretSteps(result)) :
|
||||
probe.read((int)caretSteps(result));
|
||||
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||
@@ -2385,6 +2503,7 @@ void App::transform(int command, int snippet, size_t snippetLength, HWND snippet
|
||||
else
|
||||
observed.reset();
|
||||
}
|
||||
verificationMs += GetTickCount64() - readStarted;
|
||||
verification = farSnippet ? (observed ? L"read-back-via-far-clipboard" :
|
||||
L"far-readback-unavailable") : probe.status;
|
||||
if (observed && tx.valid())
|
||||
@@ -2681,6 +2800,7 @@ void App::trayMenu()
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
|
||||
AppendMenuW(menu, MF_STRING, OpenFiles, L"&Files…");
|
||||
SetMenuDefaultItem(menu, 11, FALSE);
|
||||
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||
@@ -2733,6 +2853,9 @@ void App::trayMenu()
|
||||
}
|
||||
switch (cmd)
|
||||
{
|
||||
case OpenFiles:
|
||||
dispatch(OpenFiles);
|
||||
break;
|
||||
case 24:
|
||||
dispatch(Screenshot);
|
||||
break;
|
||||
@@ -2912,7 +3035,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
}
|
||||
case WM_HOTKEY:
|
||||
app->dispatch((int)wp);
|
||||
if (app->filesHotkeyId && wp == static_cast<WPARAM>(app->filesHotkeyId))
|
||||
{
|
||||
app->dispatch(OpenFiles);
|
||||
}
|
||||
else if (wp != FilesHotkeyFirst && wp != FilesHotkeySecond) app->dispatch((int)wp);
|
||||
return 0;
|
||||
case MouseActionMessage:
|
||||
app->dispatchMouseActions();
|
||||
@@ -2933,7 +3060,10 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
case TrayMessage:
|
||||
if (LOWORD(lp) == NIN_BALLOONUSERCLICK)
|
||||
showLogs(*app);
|
||||
{
|
||||
if (std::exchange(app->incomingFileBalloon, false)) app->dispatch(20);
|
||||
else showLogs(*app);
|
||||
}
|
||||
else if (LOWORD(lp) == WM_CONTEXTMENU)
|
||||
app->trayMenu();
|
||||
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
|
||||
@@ -3005,9 +3135,33 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
wcsncpy_s(icon.szInfo, text->c_str(), _TRUNCATE);
|
||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher");
|
||||
icon.dwInfoFlags = NIIF_WARNING;
|
||||
app->incomingFileBalloon = false;
|
||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
||||
return 0;
|
||||
}
|
||||
case LanFilesChangedMessage:
|
||||
{
|
||||
app->incomingLanFileCount = wp;
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = hwnd;
|
||||
icon.uID = 1;
|
||||
icon.uFlags = NIF_TIP | NIF_SHOWTIP;
|
||||
auto tip = wp ? L"KSwitcher — files ready to save: " + std::to_wstring(wp)
|
||||
: localBuildTitle(L"KSwitcher — keyboard layouts & clipboard");
|
||||
wcsncpy_s(icon.szTip, tip.c_str(), _TRUNCATE);
|
||||
if (lp)
|
||||
{
|
||||
icon.uFlags |= NIF_INFO;
|
||||
wcscpy_s(icon.szInfoTitle, L"Incoming file ready to save");
|
||||
wcscpy_s(icon.szInfo, L"Open Settings → Computers → Manage computers → Incoming files.");
|
||||
icon.dwInfoFlags = NIIF_INFO;
|
||||
}
|
||||
if (Shell_NotifyIconW(NIM_MODIFY, &icon) && lp) app->incomingFileBalloon = true;
|
||||
return 0;
|
||||
}
|
||||
case CheckAutostartMessage:
|
||||
app->checkAutostartPath();
|
||||
return 0;
|
||||
case WM_COPYDATA:
|
||||
{
|
||||
auto *data = (COPYDATASTRUCT *)lp;
|
||||
@@ -3211,7 +3365,24 @@ int App::execute()
|
||||
}
|
||||
keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyProc, instance, 0);
|
||||
mouseHook = SetWindowsHookExW(WH_MOUSE_LL, mouseProc, instance, 0);
|
||||
registerHotkeys(settings);
|
||||
const bool useDefaultFilesHotkey = !settings.filesHotkey;
|
||||
if (useDefaultFilesHotkey)
|
||||
{
|
||||
const auto fallback = *Hotkey::parse(L"Ctrl+Alt+F");
|
||||
const auto bindings = shortcuts(settings);
|
||||
const bool conflict = std::any_of(bindings.begin(), bindings.end(), [&](const auto &binding)
|
||||
{
|
||||
return Hotkey::parse(binding.second) == fallback;
|
||||
});
|
||||
settings.filesHotkey = conflict ? L"" : L"Ctrl+Alt+F";
|
||||
}
|
||||
if (!registerHotkeys(settings) && settings.filesHotkey && !settings.filesHotkey->empty())
|
||||
{
|
||||
if (useDefaultFilesHotkey) settings.filesHotkey = L"";
|
||||
auto fallback = settings;
|
||||
fallback.filesHotkey = L"";
|
||||
registerHotkeys(fallback, true);
|
||||
}
|
||||
if (!keyboardHook || !mouseHook)
|
||||
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
||||
AddClipboardFormatListener(window);
|
||||
@@ -3238,6 +3409,8 @@ int App::execute()
|
||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
if (!smoke && !isLocalBuild() && settings.autostart)
|
||||
PostMessageW(window, CheckAutostartMessage, 0, 0);
|
||||
try { notificationRegistration = startScreenshotNotificationRegistration(); }
|
||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||
auto screenshotActivation=startScreenshotActivationListener();
|
||||
|
||||
@@ -107,6 +107,8 @@ class App
|
||||
friend struct InputRegressionAccess;
|
||||
void releaseIdleMemory(ULONGLONG now);
|
||||
void applySettings(const Settings &, bool validateOcrInstallation);
|
||||
void checkAutostartPath();
|
||||
bool checkingAutostartPath{};
|
||||
HHOOK keyboardHook{}, mouseHook{};
|
||||
struct MousePress
|
||||
{
|
||||
@@ -160,10 +162,15 @@ class App
|
||||
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{};
|
||||
|
||||
+5
-3
@@ -245,18 +245,20 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
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 &&
|
||||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||
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 && sameLanguage)
|
||||
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);
|
||||
}
|
||||
|
||||
+124
-14
@@ -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))
|
||||
|
||||
+23
-1
@@ -1,6 +1,7 @@
|
||||
#include "configuration.h"
|
||||
#include <commctrl.h>
|
||||
#include <cstring>
|
||||
#include "mcp_logs.h"
|
||||
|
||||
namespace ks
|
||||
{
|
||||
@@ -156,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");
|
||||
@@ -236,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)
|
||||
{
|
||||
|
||||
+22
-2
@@ -1,5 +1,6 @@
|
||||
#include "configuration.h"
|
||||
#include "settings_merge.h"
|
||||
#include "../vendor/json.hpp"
|
||||
#include <cstring>
|
||||
namespace ks
|
||||
{
|
||||
@@ -33,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;
|
||||
@@ -41,6 +43,23 @@ 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.");
|
||||
@@ -121,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;
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "app.h"
|
||||
#include "files_lan_ipc.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
|
||||
@@ -8,7 +9,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = ConfigRead + 13;
|
||||
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);
|
||||
|
||||
@@ -35,6 +35,11 @@ bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
|
||||
}
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ struct Layout
|
||||
};
|
||||
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);
|
||||
@@ -96,6 +97,7 @@ 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;
|
||||
|
||||
@@ -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;
|
||||
+3786
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,537 @@
|
||||
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286;
|
||||
constexpr int allOptions=3;
|
||||
enum OptionsPage { PanelModes, PanelColors, Appearance };
|
||||
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance"};
|
||||
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 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 OptionsDraft
|
||||
{
|
||||
LOGFONTW font=displayFont;
|
||||
int points=fontPoints;
|
||||
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=displayFont;
|
||||
// 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!=fontPoints ||
|
||||
memcmp(&pending,&applied,offsetof(LOGFONTW,lfFaceName))!=0 ||
|
||||
_wcsicmp(pending.lfFaceName,applied.lfFaceName)!=0;
|
||||
}
|
||||
|
||||
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 bool dirty=!valid || width!=static_cast<UINT>(sizeWidth) ||
|
||||
checked(273)!=panels[0].wide || checked(274)!=panels[1].wide ||
|
||||
checked(202)!=alignExtensions || checked(204)!=alignFolders || checked(203)!=showDetails ||
|
||||
fields!=statusFields || draft.palette!=colors || draft.styles!=fileStyles || optionsFontChanged(draft);
|
||||
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]):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);
|
||||
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<allOptions && 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);
|
||||
}
|
||||
show(appearanceControls,page==Appearance && section>=0);
|
||||
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<=Appearance;++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 addSection(L"Font",0,matches(L"Display font"));
|
||||
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);
|
||||
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);
|
||||
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))
|
||||
{
|
||||
EndDialog(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,0);
|
||||
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==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->font=defaultFilesFont();
|
||||
draft->points=defaultFontPoints;
|
||||
}
|
||||
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 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 bool stylesChanged=draft->styles!=fileStyles;
|
||||
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;
|
||||
}
|
||||
if(stylesChanged) fileStyles=draft->styles;
|
||||
sizeWidth=static_cast<int>(width);
|
||||
alignExtensions=IsDlgButtonChecked(dialog,202)==BST_CHECKED;
|
||||
showDetails=IsDlgButtonChecked(dialog,203)==BST_CHECKED;
|
||||
alignFolders=IsDlgButtonChecked(dialog,204)==BST_CHECKED;
|
||||
statusFields=0;
|
||||
for(int i=0;i<4;++i)
|
||||
if(IsDlgButtonChecked(dialog,205+i)==BST_CHECKED) statusFields|=1u<<i;
|
||||
savePreference(L"SizeWidth",sizeWidth);
|
||||
savePreference(L"AlignExtensions",alignExtensions);
|
||||
savePreference(L"ShowDetails",showDetails);
|
||||
savePreference(L"AlignFolders",alignFolders);
|
||||
savePreference(L"StatusFields",statusFields);
|
||||
for(int i=0;i<ColorCount;++i)
|
||||
if(colors[i]!=draft->palette[i])
|
||||
{
|
||||
colors[i]=draft->palette[i];
|
||||
savePreference(std::to_wstring(i).c_str(),colors[i]);
|
||||
}
|
||||
refreshColors();
|
||||
if(fontChanged)
|
||||
{
|
||||
savePreference(L"FontPoints",fontPoints);
|
||||
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"DisplayFont",REG_BINARY,&displayFont,sizeof(displayFont));
|
||||
draft->font=displayFont;
|
||||
}
|
||||
for(int i=0;i<2;++i)
|
||||
{
|
||||
const bool wide=IsDlgButtonChecked(dialog,273+i)==BST_CHECKED;
|
||||
if(panels[i].wide!=wide) setMode(i,wide);
|
||||
}
|
||||
layout(owner);
|
||||
showFileDetails(panels[active]);
|
||||
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
if(LOWORD(wp)==IDOK) EndDialog(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=DialogBoxParamW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
|
||||
reinterpret_cast<LPARAM>(&draft));
|
||||
SetFocus(panel);
|
||||
if(result==-1) setStatus(L"Could not open Files Options.");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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==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");
|
||||
|
||||
RestoreDC(dc,saved);
|
||||
if(previewFont) DeleteObject(previewFont);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,210,128)},{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;
|
||||
}
|
||||
@@ -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 { ([string][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;
|
||||
};
|
||||
@@ -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(¤t)) || !EqualRect(¤t,&bounds))
|
||||
controller->put_Bounds(bounds);
|
||||
BOOL currentVisibility{};
|
||||
if(FAILED(controller->get_IsVisible(¤tVisibility)) || !!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()));
|
||||
}
|
||||
};
|
||||
+164
-8
@@ -2,6 +2,7 @@
|
||||
#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"
|
||||
@@ -48,15 +49,20 @@ std::vector<ULONG> broadcasts()
|
||||
}
|
||||
struct LanService::Impl
|
||||
{
|
||||
struct Peer { std::string name,epoch; sockaddr_in address{}; uint64_t seen{},verified{}; };
|
||||
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};
|
||||
std::atomic<bool> stopping{false}, refreshFiles{false};
|
||||
std::mutex mutex;
|
||||
std::thread worker;
|
||||
bool shareInput{};
|
||||
@@ -83,6 +89,7 @@ struct LanService::Impl
|
||||
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())
|
||||
@@ -93,12 +100,13 @@ struct LanService::Impl
|
||||
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},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||
{"shareInput",shareInput},{"shareLogs",shareLogs},{"inputPeer",inputPeer},{"inputEdge",inputEdge},{"positions",positions}}))); }
|
||||
bool queue(const std::string &peer,lan::InputFrame frame)
|
||||
{
|
||||
std::lock_guard lock(inputMutex);
|
||||
@@ -153,6 +161,8 @@ struct LanService::Impl
|
||||
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)
|
||||
{
|
||||
@@ -161,6 +171,41 @@ struct LanService::Impl
|
||||
}
|
||||
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;
|
||||
@@ -180,6 +225,8 @@ struct LanService::Impl
|
||||
// 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))
|
||||
{
|
||||
@@ -192,6 +239,17 @@ struct LanService::Impl
|
||||
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(),
|
||||
@@ -233,10 +291,17 @@ struct LanService::Impl
|
||||
if(bind(socket,(sockaddr*)&bound,sizeof(bound))) throw std::runtime_error("UDP port 45883 is in use. Only one LAN-enabled KSwitcher may run per computer.");
|
||||
uint64_t nextHello=0,nextOffer=0;
|
||||
lan::InputHub hub(id);
|
||||
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;
|
||||
@@ -246,13 +311,18 @@ struct LanService::Impl
|
||||
clipboard->poll(trusted,now,[&](const auto &target,const auto &packet){
|
||||
if(peers.contains(target))send(socket,peers.at(target).address,packet);
|
||||
});
|
||||
clipboardActive=clipboard->active();
|
||||
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(now>=nextHello)
|
||||
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;
|
||||
}
|
||||
@@ -271,6 +341,7 @@ struct LanService::Impl
|
||||
{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){
|
||||
@@ -324,6 +395,77 @@ struct LanService::Impl
|
||||
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");
|
||||
@@ -350,7 +492,7 @@ struct LanService::Impl
|
||||
auto exe=utf8(executable());std::string quoted;
|
||||
for(char c:exe){quoted+=c;if(c=='\'')quoted+='\'';}
|
||||
auto script="$ksPath = '"+quoted+"'\r\n"
|
||||
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884 -RemoteAddress LocalSubnet -Profile Any\r\n"
|
||||
"New-NetFirewallRule -DisplayName 'KSwitcher LAN 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}});
|
||||
}
|
||||
@@ -368,6 +510,9 @@ struct LanService::Impl
|
||||
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.";
|
||||
@@ -376,15 +521,26 @@ struct LanService::Impl
|
||||
{"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;}
|
||||
auto now=GetTickCount64();
|
||||
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}});
|
||||
{"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}});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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
|
||||
|
||||
+4
-4
@@ -50,14 +50,14 @@ struct InputCipher::Impl
|
||||
};
|
||||
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
|
||||
InputCipher::~InputCipher()=default;
|
||||
Bytes InputCipher::encryptPayload(const Bytes &plain)
|
||||
Bytes InputCipher::encryptPayload(const Bytes &plain,size_t limit)
|
||||
{
|
||||
if(plain.empty()||plain.size()>1024)throw std::runtime_error("Invalid file packet size.");
|
||||
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)
|
||||
Bytes InputCipher::decryptPayload(const Bytes &packet,size_t limit)
|
||||
{
|
||||
if(packet.size()<17||packet.size()>1040)throw std::runtime_error("Invalid file packet size.");
|
||||
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)
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ class InputCipher
|
||||
~InputCipher();
|
||||
Bytes encrypt(const InputFrame &);
|
||||
InputFrame decrypt(const Bytes &);
|
||||
Bytes encryptPayload(const Bytes &);
|
||||
Bytes decryptPayload(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);
|
||||
|
||||
+38
-15
@@ -6,8 +6,8 @@
|
||||
|
||||
namespace ks::lan
|
||||
{
|
||||
// Reliable bounded clipboard messages over the existing discovery socket.
|
||||
// Every datagram has a fresh directional AES-GCM key; no plaintext clipboard
|
||||
// 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
|
||||
{
|
||||
@@ -15,7 +15,7 @@ class ClipboardExchange
|
||||
struct Item {std::string id,app;Bytes bytes;};
|
||||
struct Sending {std::deque<std::shared_ptr<Item>> queue;size_t ack{};bool begun{};uint64_t last{},progress{};};
|
||||
struct Receiving {std::string id,app;Bytes bytes;size_t size{};uint64_t last{};DWORD sequence{};};
|
||||
std::string local, boot=randomHex(16);
|
||||
std::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;
|
||||
@@ -31,33 +31,53 @@ class ClipboardExchange
|
||||
Json seal(const std::string &peer,const std::string &key,const Json &body)
|
||||
{
|
||||
auto nonce=randomHex(16);auto number=++nextPacket;
|
||||
InputCipher cipher(mac(key,"KSwitcher clipboard v1|"+local+"|"+peer+"|"+boot+"|"+sessions.at(peer)+"|"+std::to_string(number)+"|"+nonce));
|
||||
auto packet=envelope("clipboard",local);packet["to"]=peer;packet["nonce"]=nonce;packet["session"]=sessions.at(peer);packet["fromSession"]=boot;packet["number"]=number;
|
||||
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):local(std::move(id)){}
|
||||
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 clipboard limit is 32 MiB.");
|
||||
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)continue;if(s.queue.empty()){s.ack=0;s.begun=false;s.last=0;s.progress=now;}s.queue.push_back(item);}
|
||||
if(!peers.empty())status="Sending clipboard to "+std::to_string(peers.size())+" paired computers...";
|
||||
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)
|
||||
{
|
||||
@@ -67,7 +87,7 @@ class ClipboardExchange
|
||||
{
|
||||
auto &peer=it->first;auto &s=it->second;
|
||||
if(!trusted.contains(peer)||s.queue.empty()){it=sending.erase(it);continue;}
|
||||
if(now-s.progress>30000){status="Clipboard transfer timed out.";it=sending.erase(it);continue;}
|
||||
if(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>();
|
||||
@@ -80,14 +100,16 @@ class ClipboardExchange
|
||||
++it;
|
||||
}
|
||||
}
|
||||
template<class Send>void receive(const std::string &peer,const std::string &key,const Json &packet,uint64_t now,DWORD sequence,Send send)
|
||||
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,"KSwitcher clipboard v1|"+peer+"|"+local+"|"+senderBoot+"|"+boot+"|"+std::to_string(number)+"|"+packet.at("nonce").get<std::string>()));
|
||||
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;
|
||||
@@ -100,7 +122,7 @@ class ClipboardExchange
|
||||
if(item.id!=id||offset>item.bytes.size()||offset<s.ack||offset>s.ack+Window*Chunk||(offset!=item.bytes.size()&&offset%Chunk))return;
|
||||
if(offset==s.ack&&s.begun)return;
|
||||
s.begun=true;s.ack=offset;s.progress=now;s.last=0;
|
||||
if(offset==item.bytes.size()){s.queue.pop_front();s.ack=0;s.begun=false;status="Clipboard delivered.";}
|
||||
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;}
|
||||
@@ -111,7 +133,8 @@ class ClipboardExchange
|
||||
auto found=receiving.find(peer);
|
||||
if(found!=receiving.end()){if(found->second.id==id)ack(found->second.bytes.size());return;}
|
||||
size_t waiting=0;for(auto &d:deliveries)waiting+=d.bytes.size();
|
||||
if(incomingBytes+waiting+size>2*Limit||deliveries.size()>=8)return;
|
||||
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;
|
||||
@@ -126,7 +149,7 @@ class ClipboardExchange
|
||||
{
|
||||
deliveries.push_back({peer,r.id,r.app,std::move(r.bytes),r.sequence});
|
||||
auto &done=completed[peer];done.push_back({id,r.size});if(done.size()>64)done.pop_front();
|
||||
incomingBytes-=r.size;receiving.erase(found);status="Clipboard received.";
|
||||
incomingBytes-=r.size;receiving.erase(found);status=type=="clipboard"?"Clipboard received.":"Transfer received.";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -15,13 +15,24 @@ bool shouldElevateOnLaunch()
|
||||
try
|
||||
{
|
||||
auto settings = ks::loadSettings(ks::dataDirectory() / L"settings.json", {});
|
||||
return settings.autostart && settings.elevatedAutostart;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -90,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 {};
|
||||
}
|
||||
|
||||
@@ -816,6 +816,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,
|
||||
|
||||
@@ -76,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();
|
||||
|
||||
+31
-4
@@ -180,6 +180,7 @@ struct Capture
|
||||
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]{};
|
||||
@@ -221,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);
|
||||
@@ -1170,8 +1187,14 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
setScreenshotSmoothing(c->marks[c->selected],amount);
|
||||
resolveScreenshotLinks(c->marks);
|
||||
c->smoothingDrag=LOWORD(wp)==TB_THUMBTRACK;
|
||||
} else if(!pencil) c->smoothing=amount;
|
||||
if(LOWORD(wp)!=TB_THUMBTRACK) c->smoothingDrag=false;
|
||||
} 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;
|
||||
}
|
||||
@@ -1185,7 +1208,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (c->dragging) return 0;
|
||||
if(LOWORD(wp)==44) {
|
||||
int amount=int(SendDlgItemMessageW(w,41,TBM_GETPOS,0,0));
|
||||
c->smoothing=amount;
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 &);
|
||||
|
||||
@@ -110,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);
|
||||
|
||||
+7
-2
@@ -205,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)
|
||||
{
|
||||
@@ -253,10 +254,12 @@ void validateSettings(const Settings &s)
|
||||
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||
from == lower(from) && to == lower(to) && from != to &&
|
||||
from.find_first_not_of(alphabet) == from.npos &&
|
||||
validAutoReplacementSource(from, alphabet) &&
|
||||
validAutoCorrection(to, alphabet);
|
||||
if (!valid)
|
||||
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between words and apostrophes between letters are allowed.");
|
||||
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)
|
||||
{
|
||||
@@ -368,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"))
|
||||
{
|
||||
@@ -527,6 +531,7 @@ 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)
|
||||
|
||||
+6
-1
@@ -119,7 +119,7 @@ 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"},
|
||||
{"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"},
|
||||
@@ -213,6 +213,7 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -223,6 +224,10 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r
|
||||
// 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);
|
||||
|
||||
+419
-166
@@ -1,6 +1,7 @@
|
||||
#include "lan_layout.h"
|
||||
#include "../resources/resource.h"
|
||||
#include "app.h"
|
||||
#include "lan_log.h"
|
||||
#include "history_preview.h"
|
||||
#include "ui_scroll.h"
|
||||
#include "configuration.h"
|
||||
@@ -270,6 +271,53 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
}
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
}
|
||||
LRESULT CALLBACK listHeaderDraw(HWND list, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (message != WM_NOTIFY) return DefSubclassProc(list, message, wp, lp);
|
||||
const auto *notice = reinterpret_cast<const NMHDR *>(lp);
|
||||
auto header = ListView_GetHeader(list);
|
||||
if (notice->hwndFrom != header || notice->code != NM_CUSTOMDRAW)
|
||||
return DefSubclassProc(list, message, wp, lp);
|
||||
const auto &draw = *reinterpret_cast<const NMCUSTOMDRAW *>(lp);
|
||||
auto background = GetSysColorBrush(COLOR_3DFACE);
|
||||
auto border = GetSysColorBrush(COLOR_3DSHADOW);
|
||||
if (draw.dwDrawStage == CDDS_PREPAINT)
|
||||
{
|
||||
FillRect(draw.hdc, &draw.rc, background);
|
||||
return CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYPOSTPAINT;
|
||||
}
|
||||
if (draw.dwDrawStage == CDDS_ITEMPREPAINT)
|
||||
{
|
||||
RECT cell = draw.rc;
|
||||
FillRect(draw.hdc, &cell, background);
|
||||
RECT divider{cell.right - 1, cell.top, cell.right, cell.bottom};
|
||||
FillRect(draw.hdc, ÷r, border);
|
||||
wchar_t title[64]{};
|
||||
HDITEMW item{HDI_TEXT | HDI_FORMAT};
|
||||
item.pszText = title;
|
||||
item.cchTextMax = std::size(title);
|
||||
Header_GetItem(header, static_cast<int>(draw.dwItemSpec), &item);
|
||||
cell.left += scale(list, 5);
|
||||
cell.right -= scale(list, 5);
|
||||
int dc = SaveDC(draw.hdc);
|
||||
auto font = SendMessageW(header, WM_GETFONT, 0, 0);
|
||||
if (font) SelectObject(draw.hdc, reinterpret_cast<HFONT>(font));
|
||||
SetTextColor(draw.hdc, GetSysColor(COLOR_BTNTEXT));
|
||||
SetBkMode(draw.hdc, TRANSPARENT);
|
||||
UINT alignment = (item.fmt & HDF_RIGHT) ? DT_RIGHT : (item.fmt & HDF_CENTER) ? DT_CENTER : DT_LEFT;
|
||||
DrawTextW(draw.hdc, title, -1, &cell, alignment | DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||
RestoreDC(draw.hdc, dc);
|
||||
return CDRF_SKIPDEFAULT;
|
||||
}
|
||||
if (draw.dwDrawStage == CDDS_POSTPAINT)
|
||||
{
|
||||
RECT line{};
|
||||
GetClientRect(header, &line);
|
||||
line.top = line.bottom - 1;
|
||||
FillRect(draw.hdc, &line, border);
|
||||
}
|
||||
return CDRF_DODEFAULT;
|
||||
}
|
||||
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
|
||||
int width, int height)
|
||||
{
|
||||
@@ -278,6 +326,8 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
|
||||
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||
if (wcscmp(cls, WC_LISTVIEWW) == 0 && (style & LVS_TYPEMASK) == LVS_REPORT && !(style & LVS_NOCOLUMNHEADER))
|
||||
SetWindowSubclass(w, listHeaderDraw, 3, 0);
|
||||
if(wcscmp(cls,L"LISTBOX")==0 || wcscmp(cls,WC_LISTVIEWW)==0 ||
|
||||
wcscmp(cls,L"COMBOBOX")==0 || (wcscmp(cls,L"EDIT")==0 && (style&ES_MULTILINE)))
|
||||
enableWheelBatching(w,wcscmp(cls,L"LISTBOX")==0);
|
||||
@@ -1087,6 +1137,13 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
constexpr int SnippetsPage = 4, SaveSnippets = 318;
|
||||
struct SnippetView
|
||||
{
|
||||
App *app{};
|
||||
std::vector<Snippet> snippets, saved;
|
||||
int selection = -1;
|
||||
};
|
||||
struct SettingsView
|
||||
{
|
||||
App *app{};
|
||||
@@ -1099,7 +1156,11 @@ struct SettingsView
|
||||
std::vector<std::wstring> mouseProfiles;
|
||||
unsigned mouseEvents{}, rawMouseEvents{};
|
||||
bool rawMouseRegistered{};
|
||||
SnippetView snippets;
|
||||
};
|
||||
bool snippetCommand(HWND, SnippetView *, WPARAM, bool embedded);
|
||||
bool confirmSnippetClose(HWND, SnippetView *);
|
||||
void refreshSnippetDraft(HWND, SnippetView *);
|
||||
void updateSettingsState(HWND w, SettingsView *v);
|
||||
void selectSettingsPage(HWND w, SettingsView *v)
|
||||
{
|
||||
@@ -1116,6 +1177,8 @@ struct ChatLanguagesView
|
||||
Settings draft;
|
||||
int selection = -1;
|
||||
bool dirty{};
|
||||
bool updatingList{};
|
||||
std::wstring search;
|
||||
};
|
||||
void collectChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
{
|
||||
@@ -1130,9 +1193,17 @@ void collectChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
}
|
||||
void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
{
|
||||
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||
if (index < 0 || index >= (int)v->keys.size()) return;
|
||||
v->selection = index;
|
||||
int index = v->selection;
|
||||
EnableWindow(GetDlgItem(w, 711), index >= 0);
|
||||
EnableWindow(GetDlgItem(w, IDOK), index >= 0);
|
||||
if (index < 0)
|
||||
{
|
||||
SendDlgItemMessageW(w, 711, LB_SETSEL, FALSE, -1);
|
||||
EnableWindow(GetDlgItem(w, 713), FALSE);
|
||||
value(w, 712, v->keys.empty() ? L"Open a chat, then reopen this dialog to add it." :
|
||||
L"No contacts match your search.");
|
||||
return;
|
||||
}
|
||||
const auto &draft = v->draft;
|
||||
auto saved = draft.chatCycles.find(v->keys[index]);
|
||||
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
||||
@@ -1146,26 +1217,103 @@ void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
||||
L"Chat profile. Select languages, or remove it to use the defaults.");
|
||||
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
|
||||
}
|
||||
void selectChatRow(HWND list, int index)
|
||||
{
|
||||
int row = -1;
|
||||
if (index >= 0)
|
||||
{
|
||||
LVFINDINFOW find{LVFI_PARAM};
|
||||
find.lParam = index;
|
||||
row = ListView_FindItem(list, -1, &find);
|
||||
}
|
||||
ListView_SetItemState(list, -1, 0, LVIS_SELECTED | LVIS_FOCUSED);
|
||||
if (row >= 0)
|
||||
{
|
||||
ListView_SetItemState(list, row, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||
ListView_EnsureVisible(list, row, FALSE);
|
||||
}
|
||||
}
|
||||
void filterChatRows(HWND w, ChatLanguagesView *v)
|
||||
{
|
||||
auto list = GetDlgItem(w, 710);
|
||||
auto search = lower(v->search);
|
||||
int selection = -1;
|
||||
v->updatingList = true;
|
||||
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||
ListView_DeleteAllItems(list);
|
||||
for (size_t i = 0; i < v->keys.size(); ++i)
|
||||
{
|
||||
const auto &key = v->keys[i];
|
||||
auto contact = key.substr(key.find(L'\n') + 1);
|
||||
if (lower(contact).find(search) == std::wstring::npos) continue;
|
||||
auto app = key.starts_with(L"telegram\n") ? L"Telegram" :
|
||||
key.starts_with(L"telegram-web\n") ? L"Telegram Web" :
|
||||
key.starts_with(L"viber\n") ? L"Viber (inactive)" : L"Teams";
|
||||
LVITEMW item{LVIF_TEXT | LVIF_PARAM};
|
||||
item.iItem = ListView_GetItemCount(list);
|
||||
item.pszText = contact.data();
|
||||
item.lParam = static_cast<LPARAM>(i);
|
||||
int row = ListView_InsertItem(list, &item);
|
||||
if (row < 0) continue;
|
||||
ListView_SetItemText(list, row, 1, const_cast<wchar_t *>(app));
|
||||
if (selection < 0 || static_cast<int>(i) == v->selection) selection = static_cast<int>(i);
|
||||
}
|
||||
selectChatRow(list, selection);
|
||||
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
v->updatingList = false;
|
||||
v->selection = selection;
|
||||
fillChatLanguages(w, v);
|
||||
}
|
||||
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *v = (ChatLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA);
|
||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||
try
|
||||
{
|
||||
if (msg == WM_NOTIFY && !v->updatingList)
|
||||
{
|
||||
auto *change = reinterpret_cast<const NMLISTVIEW *>(lp);
|
||||
if (change->hdr.idFrom == 710 && change->hdr.code == LVN_ITEMCHANGED &&
|
||||
(change->uNewState & LVIS_SELECTED) && !(change->uOldState & LVIS_SELECTED))
|
||||
{
|
||||
int selected = static_cast<int>(change->lParam);
|
||||
if (selected == v->selection) return 0;
|
||||
try { collectChatLanguages(w, v); }
|
||||
catch (...)
|
||||
{
|
||||
v->updatingList = true;
|
||||
selectChatRow(change->hdr.hwndFrom, v->selection);
|
||||
v->updatingList = false;
|
||||
throw;
|
||||
}
|
||||
v->selection = selected;
|
||||
fillChatLanguages(w, v);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (msg == WM_COMMAND)
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 711 && HIWORD(wp) == LBN_SELCHANGE) { v->dirty = true; return 0; }
|
||||
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
if (id == 714 && HIWORD(wp) == EN_CHANGE && !v->updatingList)
|
||||
{
|
||||
try { collectChatLanguages(w, v); }
|
||||
catch (...) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, v->selection, 0); throw; }
|
||||
fillChatLanguages(w, v);
|
||||
catch (...)
|
||||
{
|
||||
v->updatingList = true;
|
||||
value(w, 714, v->search);
|
||||
v->updatingList = false;
|
||||
throw;
|
||||
}
|
||||
v->search = value(w, 714);
|
||||
filterChatRows(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK || id == 713)
|
||||
{
|
||||
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||
int selected = v->selection;
|
||||
if (id == IDOK && selected < 0) return 0;
|
||||
if (selected < 0 || selected >= (int)v->keys.size())
|
||||
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
|
||||
if (id == 713)
|
||||
@@ -1184,7 +1332,14 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
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_DPICHANGED)
|
||||
{
|
||||
resizeForDpi(w, wp, lp);
|
||||
auto list = GetDlgItem(w, 710);
|
||||
ListView_SetColumnWidth(list, 0, scale(w, 442));
|
||||
ListView_SetColumnWidth(list, 1, scale(w, 140));
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
EnableWindow(v->parent, TRUE);
|
||||
@@ -1210,19 +1365,25 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
if (!key.empty()) data.keys.insert(std::move(key));
|
||||
return TRUE;
|
||||
}, (LPARAM)&context);
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&controls);
|
||||
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
|
||||
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
||||
v->draft = settings->draft;
|
||||
v->updatingList = true;
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||
710, 18, 44, 604, 140);
|
||||
for (const auto &key : v->keys)
|
||||
label(w, L"&Find contact", 18, 19, 106);
|
||||
edit(w, 714, 130, 14, 492);
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SINGLESEL |
|
||||
LVS_SHOWSELALWAYS | LVS_NOSORTHEADER, 710, 18, 48, 604, 136);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_DOUBLEBUFFER | LVS_EX_LABELTIP);
|
||||
for (const auto &[title, width] : {std::pair{L"Contact", 442}, std::pair{L"App", 140}})
|
||||
{
|
||||
auto split = key.find(L'\n');
|
||||
auto name = std::wstring(key.starts_with(L"telegram\n") ? L"Telegram: " : key.starts_with(L"telegram-web\n") ? L"Telegram Web: " : key.starts_with(L"viber\n") ? L"Viber (inactive): " : L"Teams: ") + key.substr(split + 1);
|
||||
SendDlgItemMessageW(w, 710, LB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
LVCOLUMNW column{LVCF_TEXT | LVCF_WIDTH};
|
||||
column.pszText = const_cast<wchar_t *>(title);
|
||||
column.cx = scale(w, width);
|
||||
ListView_InsertColumn(list, Header_GetItemCount(ListView_GetHeader(list)), &column);
|
||||
}
|
||||
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
||||
label(w, L"Click languages to include (saved in the order shown)", 18, 228, 604);
|
||||
@@ -1235,12 +1396,10 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
||||
button(w, L"&Remove profile", 713, 18, 476, 170);
|
||||
button(w, L"&Use languages", IDOK, 300, 476, 170);
|
||||
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
|
||||
EnableWindow(GetDlgItem(w, IDOK), !keys.empty());
|
||||
EnableWindow(GetDlgItem(w, 713), FALSE);
|
||||
if (!keys.empty()) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, 0, 0); fillChatLanguages(w, v); }
|
||||
filterChatRows(w, v);
|
||||
EnableWindow(parent, FALSE);
|
||||
showRequestedWindow(w);
|
||||
SetFocus(GetDlgItem(w, 710));
|
||||
SetFocus(GetDlgItem(w, 714));
|
||||
}
|
||||
#include "ui_app_languages.inl"
|
||||
#include "ui_mouse_buttons.inl"
|
||||
@@ -1301,6 +1460,7 @@ void fillSettings(HWND w, SettingsView *v)
|
||||
value(w, 213, s.undo);
|
||||
value(w, 214, s.clipboard);
|
||||
value(w, 215, s.screenshot);
|
||||
value(w, 216, s.filesHotkey.value_or(L""));
|
||||
value(w, 240, std::to_wstring(s.maxItems));
|
||||
value(w, 241, std::to_wstring(s.maxMiB));
|
||||
value(w, 242, std::to_wstring(s.maxImageMiB));
|
||||
@@ -1354,6 +1514,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.undo = value(w, 213);
|
||||
s.clipboard = value(w, 214);
|
||||
s.screenshot = value(w, 215);
|
||||
s.filesHotkey = value(w, 216);
|
||||
s.maxItems = integer(w, 240);
|
||||
s.maxMiB = integer(w, 241);
|
||||
s.maxImageMiB = integer(w, 242);
|
||||
@@ -1700,9 +1861,9 @@ void updateSettingsState(HWND w, SettingsView *v)
|
||||
if (!v || !v->ready || v->filling || v->refreshing) return;
|
||||
v->refreshing = true;
|
||||
bool changed = settingsChanged(w, v);
|
||||
EnableWindow(GetDlgItem(w, IDOK), changed);
|
||||
const wchar_t *text = changed ? L"Unsaved changes — click Save to apply." :
|
||||
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved changes.";
|
||||
EnableWindow(GetDlgItem(w, IDOK), changed || (v->app->settings.autostart && !isLocalBuild()));
|
||||
const wchar_t *text = changed ? L"Unsaved settings — click Save to apply." :
|
||||
v->visitedLan ? L"No unsaved settings. Network changes save automatically in the Computers window." : L"No unsaved settings.";
|
||||
if (value(w, 261) != text) value(w, 261, text);
|
||||
v->refreshing = false;
|
||||
}
|
||||
@@ -1742,7 +1903,8 @@ void closeSettings(HWND w, SettingsView *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
DestroyWindow(w);
|
||||
if (confirmSnippetClose(w, &v->snippets))
|
||||
DestroyWindow(w);
|
||||
}
|
||||
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
@@ -1772,12 +1934,22 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
case WM_CTLCOLORSTATIC:
|
||||
if (reinterpret_cast<HWND>(lp) == GetDlgItem(w, 281))
|
||||
{
|
||||
auto dc = reinterpret_cast<HDC>(wp);
|
||||
SetTextColor(dc, GetSysColor(COLOR_WINDOWTEXT));
|
||||
SetBkColor(dc, GetSysColor(COLOR_WINDOW));
|
||||
return reinterpret_cast<LRESULT>(GetSysColorBrush(COLOR_WINDOW));
|
||||
}
|
||||
break;
|
||||
case WM_INPUT:
|
||||
readMouseTestInput(w, *v, reinterpret_cast<HRAWINPUT>(lp));
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
case ConfigSettingsDownloaded:
|
||||
v->draft = v->app->settings;
|
||||
fillSettings(w, v);
|
||||
refreshSnippetDraft(w, &v->snippets);
|
||||
return 0;
|
||||
case WM_ENABLE:
|
||||
if (wp) updateSettingsState(w, v);
|
||||
@@ -1814,6 +1986,14 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
SettingsStateRefresh refresh{w};
|
||||
int id = LOWORD(wp);
|
||||
if (id == 282 && HIWORD(wp) == BN_CLICKED)
|
||||
{
|
||||
writeClipboard(w, textClip(value(w, 281)));
|
||||
value(w, 283, L"Copied to clipboard.");
|
||||
return 0;
|
||||
}
|
||||
if (id >= 301 && id <= SaveSnippets && snippetCommand(w, &v->snippets, wp, true))
|
||||
return 0;
|
||||
if (id == 220 && HIWORD(wp) == LBN_SELCHANGE && !v->filling)
|
||||
{
|
||||
collectLayout(w, v);
|
||||
@@ -1851,7 +2031,8 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
if (id == IDOK)
|
||||
{
|
||||
if (!settingsChanged(w, v)) return 0;
|
||||
if (!settingsChanged(w, v) && (!v->app->settings.autostart || isLocalBuild())) return 0;
|
||||
if (!confirmSnippetClose(w, &v->snippets)) return 0;
|
||||
if (v->app->busy)
|
||||
throw std::runtime_error("Wait for the current text operation to finish");
|
||||
v->app->applySettings(collectSettings(w, v));
|
||||
@@ -1886,11 +2067,6 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
showAutoOptions(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 252)
|
||||
{
|
||||
showSnippets(*v->app);
|
||||
return 0;
|
||||
}
|
||||
if (id == 253)
|
||||
{
|
||||
if (v->app->busy)
|
||||
@@ -1927,12 +2103,6 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
struct SnippetView
|
||||
{
|
||||
App *app;
|
||||
std::vector<Snippet> snippets;
|
||||
int selection = -1;
|
||||
};
|
||||
void updateSnippetEditor(HWND w, SnippetView *v)
|
||||
{
|
||||
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
|
||||
@@ -1985,6 +2155,168 @@ void collectSnippet(HWND w, SnippetView *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
bool sameSnippets(const std::vector<Snippet> &left, const std::vector<Snippet> &right)
|
||||
{
|
||||
return std::equal(left.begin(), left.end(), right.begin(), right.end(), [](const Snippet &a, const Snippet &b) {
|
||||
return a.id == b.id && a.trigger == b.trigger && a.text == b.text && a.apps == b.apps;
|
||||
});
|
||||
}
|
||||
void refreshSnippetDraft(HWND w, SnippetView *v)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
if (!sameSnippets(v->snippets, v->saved) || sameSnippets(v->saved, v->app->settings.snippets))
|
||||
return;
|
||||
v->snippets = v->saved = v->app->settings.snippets;
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
value(w, 319, L"");
|
||||
}
|
||||
void saveSnippetDraft(HWND w, SnippetView *v)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
if (sameSnippets(v->snippets, v->saved)) return;
|
||||
auto settings = v->app->settings;
|
||||
if (v->app->busy)
|
||||
throw std::runtime_error("Wait for the current operation");
|
||||
if (!sameSnippets(settings.snippets, v->saved) && !sameSnippets(settings.snippets, v->snippets))
|
||||
throw std::runtime_error("Snippets changed elsewhere. Reopen Settings to load the current snippets.");
|
||||
settings.snippets = v->snippets;
|
||||
v->app->applySettings(settings);
|
||||
v->snippets = v->saved = v->app->settings.snippets;
|
||||
if (v->selection >= (int)v->snippets.size()) v->selection = -1;
|
||||
if (auto parent = v->app->settingsWindow)
|
||||
{
|
||||
auto *view = reinterpret_cast<SettingsView *>(GetWindowLongPtrW(parent, GWLP_USERDATA));
|
||||
view->draft.snippets = v->saved;
|
||||
view->draft.snippetTombstones = v->app->settings.snippetTombstones;
|
||||
updateSettingsState(parent, view);
|
||||
}
|
||||
}
|
||||
bool confirmSnippetClose(HWND w, SnippetView *v)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
if (sameSnippets(v->snippets, v->saved)) return true;
|
||||
const int choice = MessageBoxW(w, L"Save your snippet changes before closing?",
|
||||
L"KSwitcher — Unsaved snippets", MB_YESNOCANCEL | MB_ICONWARNING | MB_DEFBUTTON3);
|
||||
if (choice == IDYES) saveSnippetDraft(w, v);
|
||||
return choice == IDYES || choice == IDNO;
|
||||
}
|
||||
bool snippetCommand(HWND w, SnippetView *v, WPARAM wp, bool embedded)
|
||||
{
|
||||
const int id = LOWORD(wp);
|
||||
if (embedded && ((HIWORD(wp) == EN_CHANGE && (id == 302 || id == 303 || id == 315)) ||
|
||||
id == 310 || id == 311 || id == 314))
|
||||
value(w, 319, L"");
|
||||
if (id == 314)
|
||||
{
|
||||
bool restricted = IsDlgButtonChecked(w, 314) == BST_CHECKED;
|
||||
for (int field : {315, 316, 317}) EnableWindow(GetDlgItem(w, field), restricted);
|
||||
return true;
|
||||
}
|
||||
if (id == 317)
|
||||
{
|
||||
auto app = value(w, 316);
|
||||
if (!app.empty())
|
||||
{
|
||||
auto current = value(w, 315);
|
||||
std::wistringstream input(current); std::wstring entry;
|
||||
while (std::getline(input, entry, L',')) if (snippetAppName(entry) == snippetAppName(app)) return true;
|
||||
value(w, 315, current.empty() ? app : current + L", " + app);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
return true;
|
||||
}
|
||||
if (id == 310)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return true;
|
||||
}
|
||||
if (id == 311)
|
||||
{
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (id == 312 || id == 313)
|
||||
{
|
||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return true;
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||
return true;
|
||||
}
|
||||
if (id == (embedded ? SaveSnippets : IDOK))
|
||||
{
|
||||
saveSnippetDraft(w, v);
|
||||
if (embedded)
|
||||
{
|
||||
fillSnippets(w, v);
|
||||
value(w, 319, L"Snippets saved.");
|
||||
}
|
||||
else DestroyWindow(w);
|
||||
return true;
|
||||
}
|
||||
if (!embedded && id == IDCANCEL)
|
||||
{
|
||||
DestroyWindow(w);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void createSnippetEditor(HWND w, SnippetView *view, bool embedded)
|
||||
{
|
||||
const int top = embedded ? 60 : 16;
|
||||
const int textHeight = embedded ? 106 : 240;
|
||||
const int actions = top + 201 + textHeight;
|
||||
label(w, L"Triggers", 24, top, 186);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||
301, 24, top + 24, 186, 170 + textHeight);
|
||||
button(w, L"&Add", 310, 24, actions, 86);
|
||||
button(w, L"&Delete", 311, 120, actions, 90);
|
||||
label(w, L"&Trigger (for example ;addr)", 232, top, 452);
|
||||
edit(w, 302, 232, top + 24, 452);
|
||||
checkbox(w, L"Only in these &applications (unchecked: everywhere)", 314, 232, top + 55, 452);
|
||||
edit(w, 315, 232, top + 83, 452);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, 316, 232, top + 113, 330, 200);
|
||||
button(w, L"Add a&pp", 317, 572, top + 110, 112);
|
||||
label(w, L"Process names, comma-separated (for example chrome.exe)", 232, top + 144, 452);
|
||||
label(w, L"&Replacement text", 232, top + 170, 452);
|
||||
edit(w, 303, 232, top + 194, 452, textHeight, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
button(w, L"Insert cursor &marker", 312, 232, actions, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 432, actions, 190);
|
||||
if (embedded)
|
||||
{
|
||||
control(w, L"STATIC", L"", 0, 319, 232, actions + 35, 286, 24);
|
||||
button(w, L"Sa&ve snippets", SaveSnippets, 530, actions + 30, 154);
|
||||
}
|
||||
else
|
||||
{
|
||||
control(w, L"STATIC", L"Use {{cursor}} / {{enter}} for literal markers.", 0, 0, 232, actions + 35, 452, 22);
|
||||
button(w, L"&Save", IDOK, 474, actions + 65, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 584, actions + 65, 100);
|
||||
}
|
||||
std::set<std::wstring> apps;
|
||||
EnumWindows([](HWND candidate, LPARAM context)->BOOL {
|
||||
if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set<std::wstring>*)context)->insert(lower(name) + L".exe"); }
|
||||
return TRUE;
|
||||
}, (LPARAM)&apps);
|
||||
for (const auto &name : apps) SendDlgItemMessageW(w, 316, CB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
SendDlgItemMessageW(w, 316, CB_SETCURSEL, 0, 0);
|
||||
fillSnippets(w, view);
|
||||
}
|
||||
LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_DPICHANGED)
|
||||
@@ -1996,102 +2328,24 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (msg == WM_NCCREATE)
|
||||
{
|
||||
auto *app = (App *)((CREATESTRUCTW *)lp)->lpCreateParams;
|
||||
v = new SnippetView{app, app->settings.snippets};
|
||||
v = new SnippetView{app, app->settings.snippets, app->settings.snippets};
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||
return TRUE;
|
||||
}
|
||||
if (!v)
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||
try
|
||||
{
|
||||
switch (msg)
|
||||
if (msg == WM_COMMAND && snippetCommand(w, v, wp, false)) return 0;
|
||||
if (msg == WM_CLOSE)
|
||||
{
|
||||
case WM_COMMAND:
|
||||
{
|
||||
int id = LOWORD(wp);
|
||||
if (id == 314)
|
||||
{
|
||||
bool restricted = IsDlgButtonChecked(w, 314) == BST_CHECKED;
|
||||
for (int field : {315, 316, 317}) EnableWindow(GetDlgItem(w, field), restricted);
|
||||
return 0;
|
||||
}
|
||||
if (id == 317)
|
||||
{
|
||||
auto app = value(w, 316);
|
||||
if (!app.empty())
|
||||
{
|
||||
auto current = value(w, 315);
|
||||
std::wistringstream input(current); std::wstring entry;
|
||||
while (std::getline(input, entry, L',')) if (snippetAppName(entry) == snippetAppName(app)) return 0;
|
||||
value(w, 315, current.empty() ? app : current + L", " + app);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (id == 301 && HIWORD(wp) == LBN_SELCHANGE)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
|
||||
updateSnippetEditor(w, v);
|
||||
return 0;
|
||||
}
|
||||
if (id == 310)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
v->snippets.push_back({L";new", L"", newSnippetId()});
|
||||
v->selection = (int)v->snippets.size() - 1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 302));
|
||||
return 0;
|
||||
}
|
||||
if (id == 311)
|
||||
{
|
||||
if (v->selection >= 0)
|
||||
{
|
||||
v->snippets.erase(v->snippets.begin() + v->selection);
|
||||
v->selection = -1;
|
||||
fillSnippets(w, v);
|
||||
SetFocus(GetDlgItem(w, 301));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (id == 312 || id == 313)
|
||||
{
|
||||
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
|
||||
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
|
||||
return 0;
|
||||
}
|
||||
if (id == IDOK)
|
||||
{
|
||||
collectSnippet(w, v);
|
||||
auto s = v->app->settings;
|
||||
s.snippets = v->snippets;
|
||||
if (v->app->busy)
|
||||
throw std::runtime_error("Wait for the current operation");
|
||||
v->app->applySettings(s);
|
||||
if (v->app->settingsWindow)
|
||||
{
|
||||
auto *parent = (SettingsView *)GetWindowLongPtrW(v->app->settingsWindow, GWLP_USERDATA);
|
||||
parent->draft.snippets = s.snippets;
|
||||
updateSettingsState(v->app->settingsWindow, parent);
|
||||
}
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
}
|
||||
if (id == IDCANCEL)
|
||||
{
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(w);
|
||||
return 0;
|
||||
case WM_NCDESTROY:
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
disposeFont(w);
|
||||
v->app->snippetsWindow = nullptr;
|
||||
delete v;
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -2227,7 +2481,9 @@ void showSettings(App &app, bool visible)
|
||||
INITCOMMONCONTROLSEX tabsInit{sizeof(tabsInit), ICC_TAB_CLASSES};
|
||||
InitCommonControlsEx(&tabsInit);
|
||||
HWND tabs = control(w, WC_TABCONTROLW, L"", WS_TABSTOP, 260, 18, 16, 684, 32);
|
||||
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Tools", L"Mouse"};
|
||||
TabCtrl_SetPadding(tabs, scale(w, 3), scale(w, 3));
|
||||
const wchar_t *tabNames[] = {L"General", L"Languages", L"Shortcuts", L"Clipboard", L"Snippets", L"Mouse",
|
||||
L"Sync", L"Computers", L"Diagnostics", L"MCP"};
|
||||
for (int i = 0; i < (int)std::size(tabNames); ++i)
|
||||
{
|
||||
TCITEMW item{}; item.mask = TCIF_TEXT; item.pszText = const_cast<wchar_t *>(tabNames[i]);
|
||||
@@ -2268,13 +2524,13 @@ void showSettings(App &app, bool visible)
|
||||
page();
|
||||
|
||||
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture"};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture", L"Open KSwitcher Files"};
|
||||
for (int i = 0; i < static_cast<int>(std::size(names)); ++i)
|
||||
{
|
||||
label(w, names[i], 24, 116 + i * 40, 180);
|
||||
hotkeyEdit(app, w, 210 + i, 230, 112 + i * 40, 454);
|
||||
}
|
||||
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 378, 660);
|
||||
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 400, 660);
|
||||
page();
|
||||
|
||||
checkbox(w, L"Record clip&board history", 203, 24, 80, 660);
|
||||
@@ -2288,26 +2544,41 @@ void showSettings(App &app, bool visible)
|
||||
button(w, L"Clear &history...", 254, 24, 258, 200);
|
||||
label(w, L"Exclude applications (process names, comma-separated)", 24, 330, 660);
|
||||
edit(w, 243, 24, 362, 660);
|
||||
button(w, L"OCR op&tions...", 257, 24, 394, 200);
|
||||
page();
|
||||
|
||||
label(w, L"Additional tools and preferences", 24, 76, 660);
|
||||
button(w, L"S&nippets...", 252, 24, 116, 210);
|
||||
label(w, L"Manage reusable text and expansion triggers.", 254, 120, 430);
|
||||
button(w, L"OCR op&tions...", 257, 24, 170, 210);
|
||||
label(w, L"Configure text recognition from images.", 254, 174, 430);
|
||||
button(w, L"S&ync...", 258, 24, 224, 210);
|
||||
label(w, L"Synchronize preferences between computers.", 254, 228, 430);
|
||||
button(w, L"KSwitcher &computers...", 262, 24, 278, 210);
|
||||
label(w, L"Share mouse and keyboard with paired computers.", 254, 282, 430);
|
||||
button(w, L"View l&og...", 256, 24, 332, 210);
|
||||
label(w, L"Inspect recent operations and diagnostics.", 254, 336, 430);
|
||||
checkbox(w, L"Write lo&g", 208, 24, 378, 210);
|
||||
v->snippets = {&app, app.settings.snippets, app.settings.snippets};
|
||||
createSnippetEditor(w, &v->snippets, true);
|
||||
page();
|
||||
|
||||
createMousePage(w, *v);
|
||||
page();
|
||||
|
||||
control(w, L"STATIC", L"No unsaved changes.", 0, 261, 18, 430, 684, 24);
|
||||
label(w, L"Back up and synchronize settings through OpenBao.", 24, 76, 660);
|
||||
button(w, L"S&ync settings...", 258, 24, 110, 210);
|
||||
page();
|
||||
|
||||
label(w, L"Share mouse and keyboard with paired computers.", 24, 76, 660);
|
||||
button(w, L"Manage &computers...", 262, 24, 110, 210);
|
||||
page();
|
||||
|
||||
checkbox(w, L"Write lo&g", 208, 24, 76, 210);
|
||||
button(w, L"View l&og...", 256, 24, 110, 210);
|
||||
page();
|
||||
|
||||
label(w, L"Codex configuration", 24, 76, 660);
|
||||
label(w, L"Paste this block into ~/.codex/config.toml to connect Codex to KSwitcher.", 24, 108, 660);
|
||||
edit(w, 281, 24, 140, 660, 108, ES_MULTILINE | ES_READONLY | ES_AUTOHSCROLL | WS_HSCROLL | WS_BORDER);
|
||||
const auto server = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||
value(w, 281, L"[mcp_servers.kswitcher]\r\ncommand = " +
|
||||
wide(nlohmann::json(utf8(server.wstring())).dump()) + L"\r\nargs = [\"--mcp\"]\r\n");
|
||||
label(w, L"Select text and press Ctrl+C, or copy the entire block below.", 24, 258, 660);
|
||||
button(w, L"&Copy configuration", 282, 24, 288, 190);
|
||||
control(w, L"STATIC", L"", 0, 283, 232, 293, 452, 24);
|
||||
label(w, L"For remote logs, enable diagnostic log downloads on the paired computer.", 24, 338, 660);
|
||||
page();
|
||||
|
||||
control(w, L"STATIC", L"No unsaved settings.", 0, 261, 18, 430, 684, 24);
|
||||
button(w, L"Impo&rt...", 250, 18, 466, 125);
|
||||
button(w, L"&Export...", 251, 155, 466, 125);
|
||||
button(w, L"&Save", IDOK, 490, 466, 100);
|
||||
@@ -2325,40 +2596,22 @@ void showSettings(App &app, bool visible)
|
||||
}
|
||||
void showSnippets(App &app)
|
||||
{
|
||||
if (app.settingsWindow)
|
||||
{
|
||||
HWND w = app.settingsWindow;
|
||||
TabCtrl_SetCurSel(GetDlgItem(w, 260), SnippetsPage);
|
||||
selectSettingsPage(w, reinterpret_cast<SettingsView *>(GetWindowLongPtrW(w, GWLP_USERDATA)));
|
||||
activateWindow(w);
|
||||
return;
|
||||
}
|
||||
if (app.snippetsWindow)
|
||||
{
|
||||
SetForegroundWindow(app.snippetsWindow);
|
||||
return;
|
||||
}
|
||||
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 740, 620);
|
||||
HWND w = makeWindow(app, L"KSwitcher.Snippets", L"Text snippets", snippetProc, 720, 565);
|
||||
app.snippetsWindow = w;
|
||||
label(w, L"Triggers", 16, 16);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 301,
|
||||
16, 44, 200, 480);
|
||||
button(w, L"&Add", 310, 16, 538, 90);
|
||||
button(w, L"&Delete", 311, 116, 538, 100);
|
||||
label(w, L"&Trigger (for example ;addr)", 236, 16, 470);
|
||||
edit(w, 302, 236, 44, 488);
|
||||
control(w, L"BUTTON", L"Only in these &applications (unchecked: everywhere)", WS_TABSTOP | BS_AUTOCHECKBOX, 314, 236, 82, 488, 24);
|
||||
edit(w, 315, 236, 112, 488);
|
||||
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, 316, 236, 148, 364, 200);
|
||||
button(w, L"Add a&pp", 317, 612, 145, 112);
|
||||
std::set<std::wstring> apps;
|
||||
EnumWindows([](HWND candidate, LPARAM context)->BOOL {
|
||||
if (IsWindowVisible(candidate)) { auto name = processName(candidate); if (!name.empty()) ((std::set<std::wstring>*)context)->insert(lower(name) + L".exe"); }
|
||||
return TRUE;
|
||||
}, (LPARAM)&apps);
|
||||
for (const auto &name : apps) SendDlgItemMessageW(w, 316, CB_ADDSTRING, 0, (LPARAM)name.c_str());
|
||||
SendDlgItemMessageW(w, 316, CB_SETCURSEL, 0, 0);
|
||||
label(w, L"Process names, comma-separated; e.g. chrome.exe, ms-teams.exe", 236, 182, 488);
|
||||
label(w, L"&Replacement text", 236, 212, 480);
|
||||
edit(w, 303, 236, 240, 488, 240, ES_MULTILINE | ES_WANTRETURN | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||
control(w, L"STATIC", L"{cursor}: caret; {enter}: press Enter after insertion.\nUse {{cursor}} / {{enter}} for literal markers.", 0, 0, 236, 492, 488, 36);
|
||||
button(w, L"Insert cursor &marker", 312, 236, 530, 190);
|
||||
button(w, L"Insert &Enter marker", 313, 436, 530, 190);
|
||||
button(w, L"&Save", IDOK, 510, 578, 100);
|
||||
button(w, L"&Cancel", IDCANCEL, 624, 578, 100);
|
||||
fillSnippets(w, (SnippetView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
createSnippetEditor(w, reinterpret_cast<SnippetView *>(GetWindowLongPtrW(w, GWLP_USERDATA)), false);
|
||||
showRequestedWindow(w);
|
||||
SetForegroundWindow(w);
|
||||
}
|
||||
@@ -2372,7 +2625,7 @@ void showShortcuts(App &app)
|
||||
auto &s = app.settings;
|
||||
std::wstring text = L"Next layout: " + s.toggle + L"\nChange case: " + s.casing + L"\nTransliterate: " +
|
||||
s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard +
|
||||
L"\nScreen capture: " + s.screenshot + L"\n";
|
||||
L"\nScreen capture: " + s.screenshot + L"\nOpen KSwitcher Files: " + s.filesHotkey.value_or(L"") + L"\n";
|
||||
for (size_t i = 0; i < std::min(app.layouts.size(), s.direct.size()); ++i)
|
||||
if (!s.direct[i].empty())
|
||||
text += L"\n" + app.layouts[i].name + L": " + s.direct[i];
|
||||
|
||||
+98
-3
@@ -9,6 +9,9 @@ struct LanView
|
||||
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)
|
||||
{
|
||||
@@ -20,6 +23,19 @@ 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;}
|
||||
@@ -42,6 +58,25 @@ void showLanHelp(HWND parent,App &app)
|
||||
}
|
||||
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())
|
||||
@@ -104,6 +139,18 @@ void refreshLan(HWND w,LanView &v)
|
||||
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));
|
||||
@@ -154,6 +201,49 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
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}});
|
||||
@@ -167,7 +257,7 @@ LRESULT CALLBACK lanProc(HWND w,UINT msg,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
void showLan(HWND parent,App &app)
|
||||
{
|
||||
HWND w=makeWindow(app,L"KSwitcher.LAN",L"KSwitcher computers — mouse and keyboard",lanProc,740,908,0,false);
|
||||
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);
|
||||
@@ -190,8 +280,13 @@ void showLan(HWND parent,App &app)
|
||||
control(w,L"STATIC",L"",0,719,18,674,700,24);
|
||||
button(w,L"Enable &input sharing",715,18,706,330);
|
||||
button(w,L"&Stop input sharing",716,370,706,240);
|
||||
control(w,L"STATIC",L"",0,718,18,744,700,64);
|
||||
control(w,L"STATIC",L"Arrange the same physical layout on every PC. Pair each input source with every destination and enable input on all PCs. Clipboard goes to all paired PCs.",0,720,18,816,580,40);
|
||||
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);
|
||||
|
||||
+1
-5
@@ -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)
|
||||
{
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.71";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.77";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
+35
-23
@@ -134,13 +134,13 @@ int main()
|
||||
HWND parent = app.settingsWindow;
|
||||
auto saveEnabled=[&] { return IsWindowEnabled(GetDlgItem(parent,IDOK))!=FALSE; };
|
||||
auto status=[&] { wchar_t text[256]{}; GetDlgItemTextW(parent,261,text,256); return std::wstring(text); };
|
||||
require(!saveEnabled() && status()==L"No unsaved changes.","new Settings shows clean state with Save disabled");
|
||||
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 changes") == 0,"checkbox edit immediately marks Settings dirty");
|
||||
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");
|
||||
@@ -179,7 +179,13 @@ int main()
|
||||
SendMessageW(automatic,WM_COMMAND,IDOK,0);
|
||||
require(!saveEnabled(),"reverting auto-correction draft disables Save");
|
||||
HWND tabs = GetDlgItem(parent, 260);
|
||||
require(tabs && TabCtrl_GetItemCount(tabs) == 6, "six settings categories");
|
||||
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);
|
||||
@@ -212,10 +218,18 @@ int main()
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
HWND dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent");
|
||||
int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
require(index >= 0, "saved chat listed");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
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);
|
||||
@@ -231,9 +245,7 @@ int main()
|
||||
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
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);
|
||||
@@ -248,13 +260,11 @@ int main()
|
||||
require(!saveEnabled(),"reopened saved Settings is clean");
|
||||
SendMessageW(parent, WM_COMMAND, 259, 0);
|
||||
dialog = findTestWindow(L"KSwitcher.ChatLanguages", nullptr);
|
||||
index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat");
|
||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||
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 changes")==0,"removing child profile immediately marks parent dirty");
|
||||
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);
|
||||
@@ -357,7 +367,9 @@ int main()
|
||||
ks::showSettings(app); parent=app.settingsWindow;
|
||||
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
||||
ks::showSnippets(app);
|
||||
HWND snippets = app.snippetsWindow;
|
||||
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");
|
||||
@@ -371,20 +383,20 @@ int main()
|
||||
wchar_t appList[128]{};
|
||||
GetDlgItemTextW(snippets,315,appList,128);
|
||||
require(std::wstring(appList)==L"Notepad.exe, MS-Teams.EXE", "Add app appends a second application without duplicates");
|
||||
SendMessageW(snippets,WM_COMMAND,IDOK,0);
|
||||
require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector<std::wstring>{L"notepad",L"ms-teams"}, "snippet editor saves selected applications");
|
||||
require(!saveEnabled() && status()==L"No unsaved changes.","separately saved snippet edit leaves Settings clean");
|
||||
SendMessageW(parent,WM_COMMAND,IDCANCEL,0);
|
||||
ks::showSnippets(app);snippets=app.snippetsWindow;
|
||||
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");
|
||||
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");
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe", [switch]$GeometryOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (!$GeometryOnly) {
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -ReferencedAssemblies @([Drawing.Bitmap].Assembly.Location, [Drawing.Color].Assembly.Location,
|
||||
[Drawing.Graphics].Assembly.Location, [Drawing.Point].Assembly.Location, "System.Collections", "System.Runtime", "System.Threading.Thread", "System.Runtime.InteropServices", "System.Diagnostics.TraceSource", "System.Private.Windows.GdiPlus", "System.Private.Windows.Core") -TypeDefinition @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
public static class TerminalFrames
|
||||
{
|
||||
[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")]
|
||||
private static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void mouse_event(uint flags, uint x, uint y, uint data, UIntPtr extra);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
|
||||
private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string kind, string title);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
|
||||
private static extern IntPtr SendMessage(IntPtr window, int message, IntPtr length, System.Text.StringBuilder text);
|
||||
|
||||
public static void ClickOutput(IntPtr window)
|
||||
{
|
||||
Rect bounds;
|
||||
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
|
||||
SetCursorPos((bounds.Left + bounds.Right) / 2, (bounds.Top + bounds.Bottom) / 2);
|
||||
mouse_event(2, 0, 0, 0, UIntPtr.Zero);
|
||||
mouse_event(4, 0, 0, 0, UIntPtr.Zero);
|
||||
}
|
||||
|
||||
public static string ReadCommand(IntPtr window)
|
||||
{
|
||||
var input = FindWindowEx(window, IntPtr.Zero, "Edit", null);
|
||||
var text = new System.Text.StringBuilder(256);
|
||||
SendMessage(input, 13, new IntPtr(text.Capacity), text);
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public static int[] Capture(IntPtr window, byte direction, int repeats)
|
||||
{
|
||||
Rect bounds;
|
||||
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
|
||||
var positions = new List<int>();
|
||||
var clock = Stopwatch.StartNew();
|
||||
using (var bitmap = new Bitmap(150, 320))
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
var trigger = new Thread(() => {
|
||||
Thread.Sleep(100);
|
||||
keybd_event(17, 0, 0, UIntPtr.Zero);
|
||||
for (int i = 0; i < repeats; i++)
|
||||
{
|
||||
keybd_event(direction, 0, 1, UIntPtr.Zero);
|
||||
if (i + 1 < repeats) Thread.Sleep(30);
|
||||
}
|
||||
keybd_event(direction, 0, 3, UIntPtr.Zero);
|
||||
keybd_event(17, 0, 2, UIntPtr.Zero);
|
||||
});
|
||||
trigger.Start();
|
||||
while (clock.ElapsedMilliseconds < 650)
|
||||
{
|
||||
graphics.CopyFromScreen(bounds.Left + 4, bounds.Bottom - 340, 0, 0, bitmap.Size);
|
||||
int marker = -1;
|
||||
for (int y = 0; y < bitmap.Height && marker < 0; y++)
|
||||
for (int x = 0; x < bitmap.Width; x++)
|
||||
{
|
||||
var color = bitmap.GetPixel(x, y);
|
||||
if (color.R > 140 && color.B > 140 && color.R - color.G > 35 && color.B - color.G > 35) { marker = y; break; }
|
||||
}
|
||||
positions.Add(marker);
|
||||
Thread.Sleep(4);
|
||||
}
|
||||
trigger.Join();
|
||||
}
|
||||
return positions.ToArray();
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
function Wait-ForTrace([string]$Pattern, [int]$After = 0) {
|
||||
for ($attempt = 0; $attempt -lt 100; $attempt++) {
|
||||
$lines = @(Get-Content -LiteralPath $trace -ErrorAction SilentlyContinue)
|
||||
$match = $lines | Select-Object -Skip $After | Where-Object { $_ -match $Pattern } | Select-Object -First 1
|
||||
if ($match) { return $match }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
throw "Missing terminal event: $Pattern"
|
||||
}
|
||||
|
||||
$process = Start-Process -FilePath (Resolve-Path $Executable) -ArgumentList '--terminal-trace' -PassThru
|
||||
$trace = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher.Files.terminal-trace-$($process.Id).log"
|
||||
try {
|
||||
$keyboard = New-Object -ComObject WScript.Shell
|
||||
for ($attempt = 0; $attempt -lt 50 -and !$keyboard.AppActivate($process.Id); $attempt++) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if (!$keyboard.AppActivate($process.Id)) { throw 'Could not focus the test window.' }
|
||||
Wait-ForTrace ' layout ' | Out-Null
|
||||
for ($attempt = 0; $attempt -lt 20; $attempt++) {
|
||||
$lines = @(Get-Content -LiteralPath $trace)
|
||||
if ($lines -match ' layout \d+ \d+ \d+ 1 1$') { break }
|
||||
if ($keyboard.AppActivate($process.Id)) { $keyboard.SendKeys('^o') }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
Wait-ForTrace ' web-anchor ' | Out-Null
|
||||
if (!$GeometryOnly) {
|
||||
[TerminalFrames]::ClickOutput($process.MainWindowHandle)
|
||||
Start-Sleep -Milliseconds 200
|
||||
$keyboard.SendKeys('focus-check')
|
||||
Start-Sleep -Milliseconds 200
|
||||
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'focus-check') {
|
||||
throw 'Typing after clicking terminal output did not reach the command line.'
|
||||
}
|
||||
$keyboard.SendKeys('^a{BACKSPACE}')
|
||||
$keyboard.SendKeys('alpha beta gamma^{BACKSPACE}^{BACKSPACE}')
|
||||
Start-Sleep -Milliseconds 150
|
||||
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'alpha ') {
|
||||
throw 'Ctrl+Backspace did not delete one word per press.'
|
||||
}
|
||||
$keyboard.SendKeys('^a{BACKSPACE}')
|
||||
Write-Output 'Typing after clicking terminal output reached the command line.'
|
||||
}
|
||||
$keyboard.SendKeys('[Console]::WriteLine{(}{[}char{]}27 {+} "{[}95mMMMMMMMM" {+} {[}char{]}27 {+} "{[}0m"{)}{ENTER}')
|
||||
Start-Sleep -Milliseconds 500
|
||||
$keyboard.SendKeys('^o')
|
||||
for ($row = 0; $row -lt 5; $row++) { $keyboard.SendKeys('^{UP}') }
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
foreach ($scenario in @(@{Key='UP'; Count=1}, @{Key='DOWN'; Count=1}, @{Key='UP'; Count=10})) {
|
||||
$direction = $scenario.Key
|
||||
$repeats = $scenario.Count
|
||||
$after = @(Get-Content -LiteralPath $trace).Count
|
||||
$event = if ($direction -eq 'UP') { 'grow' } else { 'shrink' }
|
||||
$frames = $null
|
||||
if ($GeometryOnly) {
|
||||
$keyboard.SendKeys("^{$direction $repeats}")
|
||||
} else {
|
||||
$key = if ($direction -eq 'UP') { 38 } else { 40 }
|
||||
$frames = [TerminalFrames]::Capture($process.MainWindowHandle, $key, $repeats)
|
||||
}
|
||||
Wait-ForTrace " key-$event " $after | Out-Null
|
||||
Wait-ForTrace ' layout ' $after | Out-Null
|
||||
Start-Sleep -Milliseconds 250
|
||||
$events = @(Get-Content -LiteralPath $trace | Select-Object -Skip $after)
|
||||
if (@($events -match " key-$event ").Count -lt $repeats) {
|
||||
throw "Did not deliver all $repeats Ctrl+$direction events."
|
||||
}
|
||||
if ($events -match ' web-after-fit ') { throw "Terminal grid resized after Ctrl+$direction." }
|
||||
if (!$GeometryOnly) {
|
||||
$baseline = $frames[0]
|
||||
if ($baseline -lt 0 -or $frames[-1] -lt 0) {
|
||||
throw "Terminal marker was not visible at rest for Ctrl+$direction."
|
||||
}
|
||||
$shifted = @($frames | Where-Object { $_ -lt 0 -or [Math]::Abs($_ - $baseline) -gt 2 })
|
||||
if ($shifted.Count) {
|
||||
throw "Ctrl+$direction shifted or hid the marker in $($shifted.Count) of $($frames.Count) frames (rest Y=$baseline, sampled Y=$($frames | Sort-Object -Unique))."
|
||||
}
|
||||
}
|
||||
Write-Output "Ctrl+$direction x$repeats kept the terminal grid stable."
|
||||
}
|
||||
} finally {
|
||||
if (!$process.HasExited) { $null = $process.CloseMainWindow() }
|
||||
}
|
||||
@@ -800,12 +800,52 @@ int main(int argc, char **argv) {
|
||||
require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area");
|
||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||
// The fixture has a message-only window and no keyboard hook.
|
||||
app.settings.toggle = app.settings.casing = app.settings.translit =
|
||||
app.settings.undo = app.settings.clipboard = app.settings.screenshot = L"";
|
||||
std::fill(app.settings.direct.begin(), app.settings.direct.end(), L"");
|
||||
ks::showSettings(app,false);
|
||||
{
|
||||
auto settings = app.settingsWindow;
|
||||
auto tabs = GetDlgItem(settings, 260);
|
||||
auto capture = GetDlgItem(settings, 737);
|
||||
auto events = GetDlgItem(settings, 733);
|
||||
auto visible = [&](int id) { return (GetWindowLongPtrW(GetDlgItem(settings, id), GWL_STYLE) & WS_VISIBLE) != 0; };
|
||||
auto selectPage = [&](int index) {
|
||||
TabCtrl_SetCurSel(tabs, index);
|
||||
NMHDR changed{tabs, 260, TCN_SELCHANGE};
|
||||
SendMessageW(settings, WM_NOTIFY, 260, reinterpret_cast<LPARAM>(&changed));
|
||||
};
|
||||
RECT lastTab{}, tabClient{};
|
||||
GetClientRect(tabs, &tabClient);
|
||||
require(TabCtrl_GetItemCount(tabs) == 10 && TabCtrl_GetItemRect(tabs, 9, &lastTab) &&
|
||||
lastTab.right <= tabClient.right, "all Settings tabs fit in one row");
|
||||
selectPage(3);
|
||||
require(visible(257) && !visible(301), "OCR options are in Clipboard");
|
||||
selectPage(6);
|
||||
require(visible(258) && !visible(262), "Sync has its own tab");
|
||||
selectPage(7);
|
||||
require(visible(262) && !visible(258), "Computers has its own tab");
|
||||
selectPage(8);
|
||||
require(visible(208) && visible(256), "logging controls are in Diagnostics");
|
||||
selectPage(4);
|
||||
require(visible(301) && visible(303) && visible(318), "Snippets has an inline editor and its own Save button");
|
||||
const bool awake = app.settings.awake;
|
||||
SendDlgItemMessageW(settings, 205, BM_SETCHECK, awake ? BST_UNCHECKED : BST_CHECKED, 0);
|
||||
SendMessageW(settings, WM_COMMAND, 310, 0);
|
||||
SetDlgItemTextW(settings, 302, L";inline-fixture");
|
||||
SetDlgItemTextW(settings, 303, L"inline snippet");
|
||||
selectPage(0);
|
||||
selectPage(4);
|
||||
SendMessageW(settings, WM_COMMAND, 318, 0);
|
||||
require(app.settings.awake == awake && IsWindowEnabled(GetDlgItem(settings, IDOK)) &&
|
||||
std::any_of(app.settings.snippets.begin(), app.settings.snippets.end(), [](const ks::Snippet &snippet) {
|
||||
return snippet.trigger == L";inline-fixture" && snippet.text == L"inline snippet";
|
||||
}), "saving inline snippets preserves the unsaved settings draft across tab changes");
|
||||
SendDlgItemMessageW(settings, 205, BM_SETCHECK, awake ? BST_CHECKED : BST_UNCHECKED, 0);
|
||||
SendMessageW(settings, WM_COMMAND, 311, 0);
|
||||
SendMessageW(settings, WM_COMMAND, 318, 0);
|
||||
selectPage(0);
|
||||
require(!(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE),
|
||||
"mouse detection is hidden on General");
|
||||
TabCtrl_SetCurSel(tabs, 5);
|
||||
|
||||
@@ -97,6 +97,10 @@ int main(int argc,char **argv)
|
||||
LanService restored(root);auto state=request(restored,{{"action","status"}});
|
||||
check(state["peers"].size()==1 && state["peers"][0].at("paired").get<bool>() && !state["peers"][0].at("online").get<bool>(),"saved pair remains visible offline");
|
||||
check(state.dump().find(left.derived)==std::string::npos,"status never exposes stored pair keys");
|
||||
const auto filesPeers=request(restored,{{"action","filesPeers"}});
|
||||
check(filesPeers.at("peers").size()==1 && filesPeers.at("peers")[0].at("id")==b &&
|
||||
filesPeers.at("peers")[0].at("address")=="",
|
||||
"Files refresh returns the saved pair before discovery finds an address");
|
||||
rejects([&]{request(restored,{{"action","input"},{"enabled",true},{"peer",b},{"edge",1}});},"input cannot be enabled without discovery");
|
||||
rejects([&]{request(restored,{{"action","input"},{"enabled",false},{"edge",4}});},"invalid screen edge rejected");
|
||||
auto position=request(restored,{{"action","input"},{"enabled",false},{"peer",b},{"edge",2}});
|
||||
|
||||
@@ -48,6 +48,37 @@ int main(){try{
|
||||
check(restarted.deliveries.size()==1&&restarted.deliveries[0].bytes==Bytes({1,2,3}),"clipboard works after peer restart");
|
||||
bool limited=false;try{a.queue({bid},Bytes(32*1024*1024+1),"large",12000);}catch(...){limited=true;}
|
||||
check(limited,"oversized clipboard bounded");
|
||||
{
|
||||
ClipboardExchange sender(aid,"file-transfer"),receiver(bid,"file-transfer"),clipboard(bid);
|
||||
sender.peerSession(bid,receiver.session());receiver.peerSession(aid,sender.session());
|
||||
clipboard.peerSession(aid,sender.session());
|
||||
sender.queue({bid},payload,"file",1);
|
||||
Json first;
|
||||
for(uint64_t now=10;now<1000&&receiver.deliveries.empty();now+=10)
|
||||
{
|
||||
sender.poll(trusted,now,[&](const auto &to,const Json &packet){
|
||||
check(to==bid && packet.at("type")=="file-transfer" && packet.dump().size()<=2048,
|
||||
"file transfer targets a single peer on its own channel");
|
||||
if(first.is_null())first=packet;
|
||||
packets.push_back({to,packet});
|
||||
});
|
||||
while(!packets.empty())
|
||||
{
|
||||
auto packet=std::move(packets.front().second);packets.pop_front();
|
||||
receiver.receive(aid,key,packet,now,0,ack);
|
||||
}
|
||||
while(!acks.empty())
|
||||
{
|
||||
auto packet=std::move(acks.front().second);acks.pop_front();
|
||||
sender.receive(bid,key,packet,now,0,output);
|
||||
}
|
||||
}
|
||||
check(receiver.deliveries.size()==1&&receiver.deliveries.front().bytes==payload,"file payload arrives intact");
|
||||
first["session"]=clipboard.session();
|
||||
bool isolated=false;
|
||||
try{clipboard.receive(aid,key,first,1001,0,ack);}catch(...){isolated=true;}
|
||||
check(isolated&&acks.empty(),"file ciphertext cannot be injected into the clipboard channel");
|
||||
}
|
||||
{
|
||||
WSADATA startup{};check(WSAStartup(MAKEWORD(2,2),&startup)==0,"UDP benchmark starts");
|
||||
SOCKET sa=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP),sb=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#include <winsock2.h>
|
||||
#include <sddl.h>
|
||||
#include "lan_files.h"
|
||||
#include <bit>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
using namespace ks;
|
||||
using namespace ks::lan;
|
||||
namespace fs=std::filesystem;
|
||||
|
||||
namespace
|
||||
{
|
||||
void require(bool condition,const char *message)
|
||||
{
|
||||
if(!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
void save(const fs::path &path,const std::string &text)
|
||||
{
|
||||
std::ofstream file(path,std::ios::binary|std::ios::trunc);
|
||||
file.write(text.data(),text.size());
|
||||
require(bool(file),"write test fixture");
|
||||
}
|
||||
|
||||
std::string load(const fs::path &path)
|
||||
{
|
||||
std::ifstream file(path,std::ios::binary);
|
||||
return {std::istreambuf_iterator<char>(file),{}};
|
||||
}
|
||||
|
||||
void verifyDirection(const FilesPeer *sourcePeer,const FilesPeer *targetPeer,
|
||||
const std::string &id,const std::string &key,const fs::path &root)
|
||||
{
|
||||
auto connect=[&](const FilesPeer *peer)
|
||||
{
|
||||
return peer?std::make_unique<FilesConnection>(*peer,id,key):std::make_unique<FilesConnection>();
|
||||
};
|
||||
const auto sourcePath=root/L"source.bin";
|
||||
const auto targetPath=root/L"target.bin";
|
||||
std::string content(FileChunk*3+5,'a');
|
||||
for(size_t i=0;i<content.size();++i) content[i]=static_cast<char>((i/257)%251);
|
||||
save(sourcePath,content);
|
||||
save(targetPath,"original");
|
||||
auto source=connect(sourcePeer);
|
||||
const auto info=source->call(fileRequest("stat",sourcePath));
|
||||
checkFileReply(info);
|
||||
const auto size=info.at("size").get<uint64_t>(),modified=info.at("modified").get<uint64_t>();
|
||||
auto target=connect(targetPeer);
|
||||
try
|
||||
{
|
||||
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
|
||||
[](uint64_t done,uint64_t)
|
||||
{
|
||||
if(done==FileChunk) throw std::system_error(WSAECONNRESET,std::system_category());
|
||||
return true;
|
||||
});
|
||||
require(false,"interruption expected");
|
||||
}
|
||||
catch(const std::system_error &e){require(e.code().value()==WSAECONNRESET,"unexpected transfer failure");}
|
||||
target.reset();
|
||||
source.reset();
|
||||
require(load(targetPath)=="original","interruption overwrote target");
|
||||
require(load(sourcePath)==content,"interruption changed source");
|
||||
fs::path partial;
|
||||
for(const auto &entry:fs::directory_iterator(root))
|
||||
if(entry.path().extension()==L".partial") partial=entry.path();
|
||||
require(!partial.empty() && fs::file_size(partial)==FileChunk,"confirmed chunk was lost");
|
||||
|
||||
source=connect(sourcePeer);
|
||||
target=connect(targetPeer);
|
||||
uint64_t offset=UINT64_MAX;
|
||||
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
|
||||
[&](uint64_t done,uint64_t){offset=std::min(offset,done);return true;});
|
||||
require(offset==FileChunk,"resumed at the wrong offset");
|
||||
require(load(targetPath)==content,"resumed file differs");
|
||||
require(!fs::exists(partial),"committed partial remains");
|
||||
require(load(sourcePath)==content,"copy removed source");
|
||||
|
||||
save(targetPath,"original");
|
||||
try
|
||||
{
|
||||
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
|
||||
[](uint64_t done,uint64_t){return done<FileChunk;});
|
||||
require(false,"cancellation expected");
|
||||
}
|
||||
catch(const std::system_error &e){require(e.code().value()==ERROR_CANCELLED,"unexpected cancellation error");}
|
||||
require(load(targetPath)=="original" && !fs::exists(partial),"cancel touched original or retained partial");
|
||||
|
||||
save(partial,std::string(FileChunk,'z'));
|
||||
uint64_t minimum=UINT64_MAX;
|
||||
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
|
||||
[&](uint64_t done,uint64_t){minimum=std::min(minimum,done);return true;});
|
||||
require(minimum==0 && load(targetPath)==content,"corrupt partial was reused");
|
||||
|
||||
save(targetPath,"original");
|
||||
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,true,{});
|
||||
require(load(targetPath)=="original"+content,"append did not preserve original prefix");
|
||||
checkFileReply(source->call(fileRequest("remove",sourcePath)));
|
||||
require(!fs::exists(sourcePath),"move source removal failed");
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
WSADATA network{};
|
||||
if(WSAStartup(MAKEWORD(2,2),&network)) return 1;
|
||||
const auto root=fs::temp_directory_path()/wide("kswitcher-lan-files-"+randomHex(16));
|
||||
int result=0;
|
||||
try
|
||||
{
|
||||
fs::create_directory(root);
|
||||
const auto client=randomHex(16),first=randomHex(16),second=randomHex(16),key=randomHex(32);
|
||||
auto lookup=[&](const std::string &id){return id==client?key:std::string{};};
|
||||
FilesListener a(first,lookup,0),b(second,lookup,0);
|
||||
require(a.port() && b.port() && a.port()!=b.port(),"test listeners unavailable");
|
||||
std::jthread polling([&](std::stop_token stop)
|
||||
{
|
||||
while(!stop.stop_requested()){a.poll();b.poll();Sleep(5);}
|
||||
});
|
||||
const FilesPeer one{first,"127.0.0.1",L"One",a.port()},two{second,"127.0.0.1",L"Two",b.port()};
|
||||
for(const auto &direction:{std::pair<const FilesPeer*,const FilesPeer*>{nullptr,&one},
|
||||
{&one,nullptr},{&one,&two}})
|
||||
verifyDirection(direction.first,direction.second,client,key,root);
|
||||
|
||||
try
|
||||
{
|
||||
FilesConnection unpaired(one,randomHex(16),key);
|
||||
require(false,"unpaired client authenticated");
|
||||
}
|
||||
catch(const std::system_error &){}
|
||||
FilesConnection host(one,client,key);
|
||||
const auto drives=host.call({{"action","drives"}});
|
||||
checkFileReply(drives);
|
||||
require(drives.at("drives").size()==static_cast<size_t>(std::popcount(GetLogicalDrives())),"drive mask differs");
|
||||
const auto denied=host.call(fileRequest("stat",root/L"NUL"));
|
||||
require(denied.at("error")==ERROR_INVALID_NAME,"reserved device accepted");
|
||||
// A deny ACE on file data applies to the resident's Windows token.
|
||||
const auto protectedFile=root/L"protected.bin";
|
||||
save(protectedFile,"restricted");
|
||||
const auto protectedInfo=host.call(fileRequest("stat",protectedFile));
|
||||
checkFileReply(protectedInfo);
|
||||
PSECURITY_DESCRIPTOR descriptor{};
|
||||
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
L"D:(D;;0x1;;;WD)(A;;GA;;;WD)",SDDL_REVISION_1,&descriptor,nullptr)!=FALSE,
|
||||
"create test DACL");
|
||||
const auto applied=SetFileSecurityW(protectedFile.c_str(),DACL_SECURITY_INFORMATION,descriptor);
|
||||
LocalFree(descriptor);
|
||||
require(applied!=FALSE,"apply test DACL");
|
||||
auto protectedRead=fileRequest("openRead",protectedFile);
|
||||
protectedRead["size"]=protectedInfo.at("size");
|
||||
protectedRead["modified"]=protectedInfo.at("modified");
|
||||
require(host.call(protectedRead).at("error")==ERROR_ACCESS_DENIED,"Windows DACL did not deny remote read");
|
||||
checkFileReply(host.call(fileRequest("remove",protectedFile)));
|
||||
const auto tree=root/L"tree",file=tree/L"original.bin",copy=tree/L"copy.bin",renamed=tree/L"renamed.bin";
|
||||
checkFileReply(host.call(fileRequest("mkdir",tree)));
|
||||
save(file,"same host");
|
||||
auto operation=fileRequest("copy",file);
|
||||
operation["target"]=utf8(copy.native());
|
||||
operation["replace"]=false;
|
||||
operation["append"]=false;
|
||||
save(file,std::string(FileChunk*2,'s'));
|
||||
const auto largeInfo=host.call(fileRequest("stat",file));
|
||||
operation["size"]=largeInfo.at("size");
|
||||
operation["modified"]=largeInfo.at("modified");
|
||||
{
|
||||
FilesConnection interrupted(one,client,key);
|
||||
try
|
||||
{
|
||||
interrupted.call(operation,[](uint64_t done,uint64_t)
|
||||
{
|
||||
if(done==FileChunk) throw std::system_error(WSAECONNRESET,std::system_category());
|
||||
return true;
|
||||
});
|
||||
require(false,"same-host interruption expected");
|
||||
}
|
||||
catch(const std::system_error &e){require(e.code().value()==WSAECONNRESET,"same-host interruption error");}
|
||||
}
|
||||
require(!fs::exists(copy),"same-host interruption published a partial file");
|
||||
uint64_t firstOffset=UINT64_MAX;
|
||||
for(int i=0;i<100;++i)
|
||||
{
|
||||
const auto reply=host.call(operation,[&](uint64_t done,uint64_t)
|
||||
{
|
||||
firstOffset=std::min(firstOffset,done);
|
||||
return true;
|
||||
});
|
||||
if(reply.at("error")==ERROR_SHARING_VIOLATION){Sleep(10);continue;}
|
||||
checkFileReply(reply);
|
||||
break;
|
||||
}
|
||||
require(firstOffset==FileChunk && load(copy)==load(file),"same-host copy did not resume");
|
||||
operation=fileRequest("move",copy);
|
||||
operation["target"]=utf8(renamed.native());
|
||||
operation["replace"]=false;
|
||||
checkFileReply(host.call(operation));
|
||||
require(!fs::exists(copy) && load(renamed)==load(file),"same-host rename failed");
|
||||
checkFileReply(host.call(fileRequest("remove",renamed)));
|
||||
checkFileReply(host.call(fileRequest("remove",file)));
|
||||
checkFileReply(host.call(fileRequest("rmdir",tree)));
|
||||
require(!fs::exists(tree),"same-host directory remains");
|
||||
}
|
||||
catch(const std::exception &e){std::cerr<<e.what()<<'\n';result=1;}
|
||||
std::error_code ignored;
|
||||
fs::remove_all(root,ignored);
|
||||
WSACleanup();
|
||||
if(!result) std::cout<<"LAN file transfer regressions passed.\n";
|
||||
return result;
|
||||
}
|
||||
@@ -14,7 +14,18 @@ int main(int argc, char **argv)
|
||||
std::filesystem::create_directories(root);
|
||||
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
app.smoothingPreference=root/L"screenshot-smoothing";
|
||||
ks::Capture c(app);
|
||||
require(c.smoothing==25,"missing preference uses default smoothing");
|
||||
c.smoothing=61;
|
||||
c.smoothingPreferenceDirty=true;
|
||||
c.saveSmoothingPreference();
|
||||
ks::Capture reopened(app);
|
||||
require(reopened.smoothing==61,"new editor restores saved smoothing");
|
||||
ks::atomicWrite(app.smoothingPreference,ks::Bytes{101});
|
||||
ks::Capture invalidPreference(app);
|
||||
require(invalidPreference.smoothing==25,"invalid preference uses default smoothing");
|
||||
std::filesystem::remove(app.smoothingPreference);
|
||||
c.image=std::make_unique<ks::Surface>(320,240);
|
||||
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
|
||||
c.marks={ks::Mark{{40,40},{180,140},0}};
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
using namespace ks;
|
||||
int main(){try{
|
||||
Settings base;base.autoCorrect=false;base.toggle=L"Ctrl+Alt+F11";
|
||||
if(decodeSettings(encodeSettings(base),{}).filesHotkey)
|
||||
throw std::runtime_error("missing Files shortcut did not survive round-trip");
|
||||
auto cleared=base;
|
||||
cleared.filesHotkey=L"";
|
||||
const auto clearedRoundTrip=decodeSettings(encodeSettings(cleared),{});
|
||||
if(!clearedRoundTrip.filesHotkey || !clearedRoundTrip.filesHotkey->empty())
|
||||
throw std::runtime_error("explicitly cleared Files shortcut was lost");
|
||||
auto duplicate=base;
|
||||
duplicate.filesHotkey=base.toggle;
|
||||
bool duplicateRejected=false;
|
||||
try {validateSettings(duplicate);} catch(const std::exception &e)
|
||||
{
|
||||
duplicateRejected=std::string(e.what())=="Keyboard shortcuts must be unique";
|
||||
}
|
||||
if(!duplicateRejected) throw std::runtime_error("duplicate Files shortcut accepted");
|
||||
auto draft=base,current=base;draft.toggle=L"Ctrl+Alt+F12";current.autoCorrect=true;
|
||||
auto merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
|
||||
if(merged.toggle!=draft.toggle||!merged.autoCorrect)throw std::runtime_error("independent changes lost");
|
||||
|
||||
@@ -592,6 +592,10 @@ int main(int argc, char **argv)
|
||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||
options.autoNever.clear();
|
||||
options.autoReplacements[L"мамамила"] = L"мама мила";
|
||||
options.autoReplacements[L"нав1язав"] = L"нав’язав";
|
||||
ks::validateSettings(options);
|
||||
correction = edited.suggest(L"Нав1язав ", 2, layouts, options);
|
||||
require(correction && correction->text == L"Нав’язав ", "explicit replacements accept digits in the typo");
|
||||
for (const auto *text : {L"рев'ю", L"рев’ю", L"ревʼю", L"моє рев'ю"})
|
||||
{
|
||||
options.autoReplacements[L"ревю"] = text;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
param([Parameter(Mandatory)][string]$Destination)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot
|
||||
$deps = New-Item -ItemType Directory -Force (Join-Path $root 'build/terminal-deps')
|
||||
$version = '1.0.4314.35'
|
||||
if (!(Test-Path "$deps/sdk/build/native/include/WebView2.h")) {
|
||||
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$version/microsoft.web.webview2.$version.nupkg" -OutFile "$deps/sdk.zip"
|
||||
Expand-Archive "$deps/sdk.zip" "$deps/sdk" -Force
|
||||
}
|
||||
$assets = New-Item -ItemType Directory -Force (Join-Path $Destination 'terminal')
|
||||
foreach ($package in @(
|
||||
@{ Name='xterm'; Spec='@xterm/[email protected]'; Archive='xterm-xterm-6.0.0.tgz'; Files=@{'lib/xterm.js'='xterm.js'; 'css/xterm.css'='xterm.css'; 'LICENSE'='xterm-LICENSE.txt'} },
|
||||
@{ Name='fit'; Spec='@xterm/[email protected]'; Archive='xterm-addon-fit-0.11.0.tgz'; Files=@{'lib/addon-fit.js'='addon-fit.js'; 'LICENSE'='addon-fit-LICENSE.txt'} }
|
||||
)) {
|
||||
if (!(Test-Path "$deps/$($package.Archive)")) {
|
||||
& npm.cmd pack $package.Spec --pack-destination $deps.FullName --silent | Out-Host
|
||||
if ($LASTEXITCODE) { throw "Could not download $($package.Spec)" }
|
||||
}
|
||||
$unpacked = New-Item -ItemType Directory -Force "$deps/$($package.Name)"
|
||||
& tar -xf "$deps/$($package.Archive)" -C $unpacked.FullName
|
||||
if ($LASTEXITCODE) { throw "Could not unpack $($package.Spec)" }
|
||||
foreach ($file in $package.Files.Keys) {
|
||||
Copy-Item "$unpacked/package/$file" "$assets/$($package.Files[$file])" -Force
|
||||
}
|
||||
}
|
||||
Copy-Item "$root/resources/terminal/*" $assets -Force
|
||||
Copy-Item "$deps/sdk/build/native/x64/WebView2Loader.dll" $Destination -Force
|
||||
"$deps/sdk/build/native/include"
|
||||
Reference in new issue
Block a user