Compare commits
26
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 | ||
|
|
3cb2eee637 | ||
|
|
41a1426a16 | ||
|
|
601a7288c4 | ||
|
|
7eca94ea59 |
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.68 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.
|
||||
|
||||
@@ -443,6 +456,32 @@ Recognition uses Chrome accessibility: the address bar and active document must
|
||||
|
||||
**Українською.** З v0.6.51 підтримано Telegram Web K у Chrome. Відкрий чат, потім **Settings → Languages → Languages for a chat...**, вибери **Telegram Web: ім’я**, мови та **Use languages → Save**. Набір діє для Alt+Shift, Win+Space, Win+Shift+Space і Next layout. Вебпрофілі окремі від Telegram Desktop; однакові назви в різних акаунтах/профілях браузера мають спільний вебпрофіль. Адреса й назва беруться через accessibility, без OCR, таймера, розширення та читання історії повідомлень. В адресному рядку, на головній сторінці й для непідтримуваних адрес профіль не діє. Web A та інші браузери не перевірені й не заявлені як підтримувані.
|
||||
|
||||
### Snippet autocomplete — v0.6.69
|
||||
|
||||
Starting with v0.6.71, **Enter** accepts a suggestion after you navigate
|
||||
the list with **↑ / ↓**. Without arrow navigation, **Enter** goes to the
|
||||
editor. З v0.6.71 **Enter** після вибору стрілками вставляє сніпет. Без
|
||||
переміщення списком **Enter** надходить до редактора.
|
||||
|
||||
Starting with v0.6.70, suggestions open at the text caret. If the editor
|
||||
provides no caret position, you can still type the complete trigger to
|
||||
expand the snippet.
|
||||
|
||||
Type `;` at the start of a trigger to open suggestions near the caret, or
|
||||
type the first three characters of another trigger. The list filters as you
|
||||
type and fits the widest matching trigger. Use **↑ / ↓** and **Tab** or click
|
||||
an entry. **Esc** dismisses the list. Full triggers still expand
|
||||
automatically. See the [snippet guide](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Snippets).
|
||||
|
||||
**Українською.** Введіть `;` на початку тригера або три перші символи іншого
|
||||
тригера, щоб відкрити підказки біля курсора. Список фільтрується під час набору
|
||||
й підлаштовує ширину під найдовший збіг. Для вибору натисніть **↑ / ↓** і
|
||||
**Tab** або клацніть рядок. **Esc** закриває список. Повні тригери й далі
|
||||
спрацьовують автоматично. Дивіться [посібник зі сніпетів](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Snippets-uk).
|
||||
|
||||
З v0.6.70 підказки відкриваються біля текстового курсора. Якщо редактор не
|
||||
надає його позицію, введіть повний тригер для розгортання сніпета.
|
||||
|
||||
### Desktop chat snippet selection — v0.6.55
|
||||
|
||||
ChatGPT and Codex desktop editors use paced selection for snippet triggers. If only a verified suffix is selected, KSwitcher extends the selection and verifies the full trigger before replacement. Unrelated text or changed focus cancels the operation. Triggers remain recognized across keyboard layouts. One snippet can target multiple applications: add each with **Add app**, or enter comma-separated process names. These existing application scopes also synchronize.
|
||||
@@ -451,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
|
||||
@@ -570,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,
|
||||
@@ -794,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,130 @@
|
||||
# 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
|
||||
insert the selected snippet and close the list. The editor does not receive
|
||||
that keypress. If you have not navigated the list, **Enter** still goes to
|
||||
the editor. **Tab** and mouse selection also work.
|
||||
|
||||
KSwitcher skips suggestions when VS Code's EditContext supplies the bounds
|
||||
of the whole line in place of the text caret. See the [FAQ](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/FAQ)
|
||||
for the VS Code workaround.
|
||||
|
||||
**Українською.** Після вибору підказки клавішами **↑ / ↓** натисніть
|
||||
**Enter**, щоб вставити сніпет і закрити список. Редактор не отримує це
|
||||
натискання. Якщо ви не переміщалися списком, **Enter** і далі надходить
|
||||
до редактора. Також можна вибрати сніпет через **Tab** або мишею.
|
||||
|
||||
KSwitcher приховує підказки, коли EditContext у VS Code повертає координати
|
||||
всього рядка замість текстового курсора. Обхідний шлях описано у [FAQ українською](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/FAQ-uk).
|
||||
|
||||
## v0.6.70 — 2026-11-12
|
||||
|
||||
Snippet suggestions are anchored to the text caret. KSwitcher uses the
|
||||
editor's caret location or text accessibility information, including for
|
||||
thin carets and wrapped text. If the editor provides no caret position,
|
||||
the list stays hidden. Full triggers continue to expand automatically.
|
||||
|
||||
**Українською.** Підказки сніпетів прив’язані до текстового курсора.
|
||||
KSwitcher використовує координати курсора або дані доступності редактора,
|
||||
зокрема для тонкого курсора й перенесених рядків. Якщо редактор не надає
|
||||
позицію текстового курсора, список не відкривається. Повні тригери й далі
|
||||
спрацьовують автоматично.
|
||||
|
||||
## v0.6.69 — 2026-11-12
|
||||
|
||||
Snippet autocomplete opens beside the text caret as soon as you type `;` at
|
||||
the start of a trigger. Other triggers offer suggestions after three
|
||||
characters. The list stays open while you type, resizes to fit the matching
|
||||
triggers, and respects application restrictions. Use **↑ / ↓** and **Tab** or
|
||||
click a suggestion to insert it. Press **Esc** to dismiss the list. Full
|
||||
triggers still expand automatically.
|
||||
|
||||
**Українською.** Автодоповнення сніпетів відкривається біля текстового курсора,
|
||||
щойно ви вводите `;` на початку тригера. Для інших тригерів підказки
|
||||
з’являються після трьох символів. Список залишається відкритим під час набору,
|
||||
підлаштовує ширину під відповідні тригери й враховує обмеження за програмами.
|
||||
Виберіть сніпет клавішами **↑ / ↓** і **Tab** або мишею. **Esc** закриває
|
||||
список. Повні тригери й далі спрацьовують автоматично.
|
||||
|
||||
## v0.6.68 — 2026-11-12
|
||||
|
||||
With **Start with Windows** and **Start with Windows as administrator** enabled,
|
||||
|
||||
@@ -1 +1 @@
|
||||
c371776d24706525ae4b654f9f121994b672582355d47489324a409ef9345c75 KSwitcher-win-x64.zip
|
||||
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
|
||||
+11
-11
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"version": "0.6.77",
|
||||
"files": [
|
||||
{
|
||||
"path": "KSwitcher.exe",
|
||||
"size": 2193408,
|
||||
"sha256": "ad2b1437d21d0985ef5172c9ca566c4444d4f96ad4e4435608c050f214a22726"
|
||||
"size": 2249216,
|
||||
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
|
||||
},
|
||||
{
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"size": 2129408,
|
||||
"sha256": "2b485a5806168906ed7800461b18e8f4d37d39e38e7bfdce2631c205b9f888c9"
|
||||
"size": 2251776,
|
||||
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
|
||||
},
|
||||
{
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"size": 3210240,
|
||||
"sha256": "a3d761a085f56b64528bb6ada317c66178a1ebf25f1b82eedfe3e87e0b9fd8a1"
|
||||
"size": 3247616,
|
||||
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
|
||||
},
|
||||
{
|
||||
"path": "LICENSE",
|
||||
@@ -27,13 +28,13 @@
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"size": 90760,
|
||||
"sha256": "ddf065b0ff663ef62cf879464c906da965e764a8a0e0b065a3864a6894adb4d1"
|
||||
"size": 93885,
|
||||
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
|
||||
},
|
||||
{
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"size": 9900,
|
||||
"sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e"
|
||||
"size": 11418,
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/en.txt",
|
||||
@@ -81,6 +82,5 @@
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
}
|
||||
],
|
||||
"version": "0.6.68",
|
||||
"schema": 1
|
||||
}
|
||||
@@ -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,68,0
|
||||
PRODUCTVERSION 0,6,68,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.68"
|
||||
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();
|
||||
+605
-43
File diff suppressed because it is too large.
Load diff
@@ -69,7 +69,7 @@ class App
|
||||
~App();
|
||||
int execute();
|
||||
void dispatch(int);
|
||||
void transform(int, int snippet = -1);
|
||||
void transform(int, int snippet = -1, size_t snippetLength = 0, HWND snippetEditor = nullptr);
|
||||
void pasteEntry(std::wstring, bool plain);
|
||||
void recognizeEntry(std::wstring);
|
||||
void captureHistory();
|
||||
@@ -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{};
|
||||
@@ -173,7 +180,29 @@ class App
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
int snippetPending = -1;
|
||||
size_t snippetPendingLength{};
|
||||
HWND snippetPendingEditor{};
|
||||
bool snippetTyping{};
|
||||
HWND snippetPopup{}, snippetTarget{}, snippetFocus{};
|
||||
uint64_t snippetPopupVersion{};
|
||||
size_t snippetPrefixLength{}, snippetSelection{};
|
||||
bool snippetNavigated{};
|
||||
int snippetRowHeight{};
|
||||
UINT snippetDpi{96};
|
||||
RECT snippetPopupAnchor{};
|
||||
bool snippetCaretPending{};
|
||||
std::optional<RECT> snippetCaret;
|
||||
std::vector<size_t> snippetMatches;
|
||||
std::array<bool, 256> snippetKeys{};
|
||||
void matchSnippets(HWND, bool expandExact);
|
||||
bool queueSnippet(HWND, size_t index, size_t length, HWND editor = nullptr);
|
||||
void showSnippetPopup();
|
||||
void placeSnippetPopup(RECT);
|
||||
void hideSnippetPopup();
|
||||
bool snippetPopupCurrent() const;
|
||||
bool handleSnippetKey(DWORD, UINT);
|
||||
void selectSnippet(size_t);
|
||||
static LRESULT CALLBACK snippetPopupProc(HWND, UINT, WPARAM, LPARAM);
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||
void replayTyping();
|
||||
|
||||
+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.68";
|
||||
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);
|
||||
|
||||
@@ -10,6 +10,41 @@ namespace ks
|
||||
struct InputRegressionAccess
|
||||
{
|
||||
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||
static bool suggestionsVisible(const App &app)
|
||||
{
|
||||
return app.snippetPopup && IsWindowVisible(app.snippetPopup);
|
||||
}
|
||||
static bool suggestionsNearCaret(const App &app)
|
||||
{
|
||||
GUITHREADINFO info{sizeof(info)};
|
||||
RECT popup{};
|
||||
if (!GetGUIThreadInfo(GetCurrentThreadId(), &info) || !info.hwndCaret ||
|
||||
!GetWindowRect(app.snippetPopup, &popup))
|
||||
return false;
|
||||
POINT caret{info.rcCaret.left, info.rcCaret.bottom};
|
||||
return ClientToScreen(info.hwndCaret, &caret) &&
|
||||
caret.x >= popup.left && caret.x <= popup.right &&
|
||||
std::min(std::abs(caret.y - popup.top), std::abs(caret.y - popup.bottom)) <= 32;
|
||||
}
|
||||
static LONG suggestionsWidth(const App &app)
|
||||
{
|
||||
RECT popup{};
|
||||
GetWindowRect(app.snippetPopup, &popup);
|
||||
return popup.right - popup.left;
|
||||
}
|
||||
static bool findingSuggestionCaret(const App &app) { return app.snippetCaretPending; }
|
||||
static void watchSuggestions(App &app, int &hideCount)
|
||||
{
|
||||
SetWindowSubclass(app.snippetPopup, [](HWND window, UINT message, WPARAM wp, LPARAM lp,
|
||||
UINT_PTR, DWORD_PTR context) -> LRESULT {
|
||||
if (message == WM_WINDOWPOSCHANGING &&
|
||||
(reinterpret_cast<const WINDOWPOS *>(lp)->flags & SWP_HIDEWINDOW))
|
||||
++*reinterpret_cast<int *>(context);
|
||||
return DefSubclassProc(window, message, wp, lp);
|
||||
}, 1, reinterpret_cast<DWORD_PTR>(&hideCount));
|
||||
std::lock_guard lock(app.stateMutex);
|
||||
app.run.at = GetTickCount64() - 2500;
|
||||
}
|
||||
static bool deferring(App &app)
|
||||
{
|
||||
std::lock_guard lock(app.stateMutex);
|
||||
@@ -23,6 +58,8 @@ ks::App *app{};
|
||||
HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
bool snippetRecoveryOnly = false;
|
||||
LONG unfilteredSnippetWidth{};
|
||||
int snippetPopupHides{};
|
||||
bool captured = false, failed = false;
|
||||
bool conversionOnly = false;
|
||||
bool typosOnly = false;
|
||||
@@ -185,6 +222,19 @@ void record(const std::wstring &keys)
|
||||
for (wchar_t c : keys)
|
||||
app->run.record(c, host, app->layouts[en].handle, GetTickCount64());
|
||||
}
|
||||
void typeKeys(std::initializer_list<WORD> keys)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
for (WORD key : keys)
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key, flags);
|
||||
input.ki.dwExtraInfo = 123; // Traverse the real keyboard hook.
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"snippet autocomplete keyboard input submitted");
|
||||
}
|
||||
void finish()
|
||||
{
|
||||
KillTimer(host, 1);
|
||||
@@ -589,6 +639,75 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
|
||||
finish();
|
||||
break;
|
||||
case 66:
|
||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||
captured = true;
|
||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||
app->settings.snippets = {{L";qz-first", L"first"}, {L";qz-second", L"second"},
|
||||
{L";other-much-longer-trigger", L"unrelated"},
|
||||
{L";qz-unavailable", L"unavailable", L"", {L"kswitcher-no-such-process"}}};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"prefix ", false);
|
||||
typeKeys({VK_OEM_1});
|
||||
break;
|
||||
case 67:
|
||||
require(ks::InputRegressionAccess::suggestionsVisible(*app) && GetFocus() == field,
|
||||
"semicolon alone shows suggestions without moving focus");
|
||||
require(ks::InputRegressionAccess::suggestionsNearCaret(*app),
|
||||
"suggestions open beside the editor caret");
|
||||
unfilteredSnippetWidth = ks::InputRegressionAccess::suggestionsWidth(*app);
|
||||
ks::InputRegressionAccess::watchSuggestions(*app, snippetPopupHides);
|
||||
typeKeys({'Q', 'Z'});
|
||||
break;
|
||||
case 68:
|
||||
require(ks::InputRegressionAccess::suggestionsVisible(*app) && snippetPopupHides == 0,
|
||||
"typing after a pause filters suggestions without hiding the popup");
|
||||
require(ks::InputRegressionAccess::suggestionsNearCaret(*app),
|
||||
"filtering keeps suggestions beside the updated editor caret");
|
||||
require(ks::InputRegressionAccess::suggestionsWidth(*app) < unfilteredSnippetWidth,
|
||||
"suggestions narrow when the longest trigger is filtered out");
|
||||
typeKeys({VK_ESCAPE});
|
||||
break;
|
||||
case 69:
|
||||
require(!ks::InputRegressionAccess::suggestionsVisible(*app) && text() == L"prefix ;qz",
|
||||
"Escape dismisses suggestions and preserves typed text");
|
||||
typeKeys({VK_BACK, 'Z'});
|
||||
break;
|
||||
case 70:
|
||||
require(ks::InputRegressionAccess::suggestionsVisible(*app), "editing the prefix reopens suggestions");
|
||||
enterPresses = 0;
|
||||
typeKeys({VK_UP, VK_RETURN});
|
||||
break;
|
||||
case 71:
|
||||
require(text() == L"prefix second", "Up wraps to the last eligible snippet and Enter replaces only the prefix");
|
||||
require(enterPresses == 0 && !ks::InputRegressionAccess::suggestionsVisible(*app),
|
||||
"confirming with Enter closes suggestions without sending Enter to the editor");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "autocomplete restores the clipboard");
|
||||
prepare(L"prefix ", false);
|
||||
typeKeys({VK_OEM_1, 'Q', 'Z'});
|
||||
break;
|
||||
case 72:
|
||||
require(ks::InputRegressionAccess::suggestionsVisible(*app), "suggestions reopen for a new prefix");
|
||||
typeKeys({VK_RETURN});
|
||||
break;
|
||||
case 73:
|
||||
require(enterPresses == 1 && text() == L"prefix ;qz\r\n" &&
|
||||
!ks::InputRegressionAccess::suggestionsVisible(*app),
|
||||
"Enter without arrow navigation reaches the editor");
|
||||
app->resetInput();
|
||||
SetFocus(host);
|
||||
typeKeys({VK_OEM_1});
|
||||
break;
|
||||
case 74:
|
||||
if (ks::InputRegressionAccess::findingSuggestionCaret(*app))
|
||||
{
|
||||
stage = 74;
|
||||
break;
|
||||
}
|
||||
require(!ks::InputRegressionAccess::suggestionsVisible(*app),
|
||||
"suggestions stay hidden when the focused window has no text caret");
|
||||
finish();
|
||||
break;
|
||||
case 64:
|
||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||
captured = true;
|
||||
@@ -1061,6 +1180,8 @@ int main(int argc, char **argv)
|
||||
snippetRecoveryOnly = argc > 1 && std::string(argv[1]) == "--snippet-recovery-only";
|
||||
if (snippetRecoveryOnly || (argc > 1 && std::string(argv[1]) == "--snippets-only"))
|
||||
stage = 64;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippet-autocomplete")
|
||||
stage = 66;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
auto forwarding = SetWindowsHookExW(WH_KEYBOARD_LL,forwardingHook,GetModuleHandleW(nullptr),0);
|
||||
require(forwarding!=nullptr,"downstream forwarding fixture installed before application hook");
|
||||
|
||||
@@ -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