Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2fa2c92e | ||
|
|
796d03f8a9 | ||
|
|
be68b706f0 | ||
|
|
23a7c73331 | ||
|
|
82fe8ce3a1 | ||
|
|
00cd1262b4 | ||
|
|
7bc734a9a8 | ||
|
|
a19ccaf035 | ||
|
|
60625103f8 | ||
|
|
aa02f2cacb | ||
|
|
6a1e39efa9 | ||
|
|
7242c3f775 | ||
|
|
a43f6441b4 | ||
|
|
2f5ae373a1 | ||
|
|
b978361180 | ||
|
|
93b8055085 | ||
|
|
70ee338fc3 | ||
|
|
46b4b51cea | ||
|
|
721c3f0f38 | ||
|
|
47851d75ef | ||
|
|
ad01951956 | ||
|
|
bf092d2764 | ||
|
|
d5137887b9 | ||
|
|
c75cc989f4 | ||
|
|
f580892ca9 | ||
|
|
372c4cb8bd | ||
|
|
37158c1ff0 | ||
|
|
02f81327fd | ||
|
|
2834510680 |
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Project instructions
|
||||
|
||||
## Reuse shared code
|
||||
|
||||
Reuse existing shared code wherever possible. When the same behavior,
|
||||
component, or logic is needed in two or more places, extract it into a
|
||||
shared component or function and use it at every applicable call site.
|
||||
|
||||
## Mandatory Wiki updates for every release
|
||||
|
||||
A release is not complete until the published project Wiki reflects that release.
|
||||
@@ -34,8 +40,31 @@ Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
|
||||
Updating only the release notes or changing only the Wiki version number does
|
||||
not satisfy this rule; the current functionality must also be reviewed.
|
||||
|
||||
## Gitea issue authentication
|
||||
|
||||
For Gitea issue API requests from this checkout, use the project token stored
|
||||
with Windows DPAPI in `kswitcher-gitea-token.dpapi` under the directory returned
|
||||
by `git rev-parse --absolute-git-dir`. Read it with PowerShell
|
||||
`Get-Content -LiteralPath $tokenFile | ConvertTo-SecureString` and use it only
|
||||
for the Gitea authorization header. Keep the token out of tracked files and
|
||||
command output.
|
||||
|
||||
## KSwitcher Files UI
|
||||
|
||||
Before changing the file manager UI, read [FILES-UI.md](FILES-UI.md).
|
||||
Reuse the shared dialog rendering and layout helpers. Update the document
|
||||
when an agreed UI requirement changes.
|
||||
|
||||
## Link changes to Gitea issues
|
||||
|
||||
For commits that implement a Gitea issue, include `Refs #<number>` in the
|
||||
commit message body. In a pull request, use `Fixes #<number>` when merging
|
||||
fully resolves the issue, or `Refs #<number>` when the work is partial.
|
||||
Use the existing issue for follow-up commits on the same change.
|
||||
|
||||
Assign each new issue to the [KSwitcher project board](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/projects/8)
|
||||
using its default column, and verify that the issue appears on the board.
|
||||
|
||||
Use Markdown headings, lists, and bold emphasis to make issue descriptions
|
||||
easy to scan. Embed relevant screenshots as Markdown images using their
|
||||
Gitea attachment URLs, and verify that the images render in the description.
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.77 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.81 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -35,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)
|
||||
@@ -113,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)
|
||||
|
||||
+108
-3
@@ -2,25 +2,126 @@
|
||||
|
||||
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
|
||||
|
||||
## Скорочення
|
||||
|
||||
- У командному рядку Ctrl+V і Shift+Insert вставляють текст із буфера обміну
|
||||
в позицію курсора або замінюють виділення. Якщо фокус на файловій панелі,
|
||||
ці клавіші переводять його до командного рядка й вставляють текст там.
|
||||
Enter запускає команду. Escape очищає поле й залишає фокус у командному рядку.
|
||||
Промпт і текст команди вирівняні по спільній базовій лінії.
|
||||
|
||||
- Ctrl+E підставляє останню команду та переводить фокус із файлової панелі
|
||||
до командного рядка. Повторне натискання
|
||||
підставляє старішу, доки не досягне початку історії. Alt+F8 відкриває історію
|
||||
з групуванням за датами. Enter або подвійний клік у списку підставляє вибрану
|
||||
команду в поле. Для запуску натисни Enter у самому полі.
|
||||
- Історія зберігає 1000 останніх непорожніх команд, надісланих із поля KSwitcher
|
||||
Files, включно з повторами. Вона зберігається локально в профілі Windows між
|
||||
запусками. Options → Command line → Command history змінює обидві комбінації,
|
||||
перевіряє конфлікти та дозволяє відновити типові значення.
|
||||
- Ширина історії підлаштовується під найдовшу команду в межах робочої області
|
||||
монітора. Рядок, який не вміщується, закінчується трьома крапками. Вибір
|
||||
підставляє до командного рядка повний текст.
|
||||
- Пошук в історії фільтрує команди за всіма введеними словами, незалежно від
|
||||
регістру. Збіги за текстом або датою йдуть перед тематичними. Тематичні
|
||||
ключові слова визначаються локально й використовуються лише для пошуку,
|
||||
зокрема в раніше збережених записах. Кожна команда відображається один
|
||||
раз, за датою останнього запуску.
|
||||
- Історія використовує оформлення діалогів Delete і Copy та спільне поле Far.
|
||||
Підпис, поле й список вирівнюються за відступами Dialog frame. Рядки команд
|
||||
і роздільники дат мають власний внутрішній відступ.
|
||||
Вікно історії бірюзове, поле пошуку сіро-бірюзове. Поки історія відкрита,
|
||||
дії в панелях, меню й командному рядку заблоковано. Заголовок і рамка
|
||||
головного вікна доступні для переміщення, зміни розміру та керування вікном.
|
||||
Escape або клік поза діалогом закриває історію. Заголовок і рамка головного
|
||||
вікна залишають історію відкритою під час керування вікном.
|
||||
Історія центрується відносно головного вікна й рухається разом із ним.
|
||||
Tab циклічно переводить фокус між пошуком і списком.
|
||||
Стрілка вгору або вниз із пошуку одразу пересуває вибір у списку.
|
||||
Висота списку залежить від кількості результатів. Коли місця бракує,
|
||||
прокручується список.
|
||||
|
||||
- Короткий Alt перемикає головне меню Files при відпусканні. F9 також перемикає меню. Після закриття фокус повертається до активної панелі. Alt з літерою верхнього рівня спрацьовує одразу при натисканні літери. Alt+O відкриває Options, а відпускання Alt після комбінації не перемикає меню.
|
||||
- Коли головне меню відкрите, L, F, C, O та R відкривають Left, Files, Commands, Options та Right. У підменю підсвічена літера викликає відповідну команду. Літери унікальні в межах поточного рівня. Усталені мнемоніки мають пріоритет, зокрема X для Exit.
|
||||
- Глобальне скорочення **Open KSwitcher Files** можна змінити або очистити в налаштуваннях. Для профілю без цього поля пропонується Ctrl+Alt+F, якщо Windows реєструє комбінацію і вона вільна серед прив’язок KSwitcher.
|
||||
- Глобальне скорочення повертає до відкритого вікна Files у поточній сесії Windows або запускає Files, якщо такого вікна немає. Пункт **Files** у треї відкриває нове вікно.
|
||||
|
||||
- Ctrl+Shift+0–9 відкриває діалог Title і Path із поточною папкою. OK додає запис без дублювання, Escape скасовує. Title необов’язковий, порожнє значення показує шлях. Записи LAN зберігають ID ПК, а редагування Path залишає запис на тому самому ПК.
|
||||
- Ctrl+0–9 одразу відкриває єдину папку. Для кількох папок відкривається компактний попап Far з одним заголовком Folder shortcuts у подвійній рамці та коротким рядом клавіш у нижній рамці. Ширина залежить від найдовшого запису й обмежується розміром вікна, а висота — кількістю видимих рядків. Нижня рамка показує Ins Del F4 Ctrl+Up Ctrl+Down. Insert додає поточну папку через Title і Path, Delete відв’язує, F4 редагує, Ctrl+Up і Ctrl+Down змінюють і зберігають порядок. Enter відкриває папку. Escape або клік поза попапом закриває його й повертає фокус до панелі. Commands → Folder shortcuts дає вибрати зайняту цифру й відкрити той самий попап навіть для єдиного запису. Brief і Wide доступні в меню Left/Right та Options.
|
||||
|
||||
## Налаштування Files
|
||||
|
||||
- Options у головному меню одразу відкриває стандартний діалог Windows. Ліворуч є пошук за назвою категорії чи параметра й дерево результатів. Вибір дочірнього вузла відкриває лише його редактор праворуч. Пошук за назвою окремої властивості відкриває вузол, що її містить. Перемикання вузлів зберігає незастосовані зміни.
|
||||
- File panel modes має вузли Panel layout, Wide panel columns і Status line columns. У Colors кожен колір має власний вузол. В Appearance шрифт редагується у вузлі Font. Діалог змінює розмір від розміру, який вміщує всі параметри. Ширина лівої навігації стала, дерево росте по висоті, а права область використовує додатковий простір. OK, Cancel та Apply лишаються внизу праворуч.
|
||||
- Reset selected to defaults скидає вибраний рівень дерева. Корінь All settings скидає всі параметри, навіть коли пошук приховує частину дерева. У редакторах із кількома параметрами Reset property скидає лише вибрану властивість. Скидання змінює чернетку до Apply або OK. Стандартний Panel background — `#000080`.
|
||||
- Apply доступна, коли чернетка відрізняється від застосованих налаштувань. Після Apply або повернення всіх значень до застосованих вона вимикається. Apply одразу зберігає зміни й перемальовує панелі за відкритим діалогом. OK робить те саме й закриває діалог. Cancel і Escape відкидають зміни після останнього Apply та повертають фокус до панелі.
|
||||
|
||||
- Colors → File type styles налаштовує текст, фон, підкреслення й закреслення для правил за розширенням і групи System and hidden. Розширення можна вибрати зі списку Windows або додати вручну з крапкою чи без неї. Вони зберігаються в KSwitcher, незалежно від асоціацій Windows. Курсор, позначки й результат передачі зберігають свої сигнальні кольори. Кольори правила можна перевизначити окремо для звичайного рядка, позначки Insert, курсора у фокусі та курсора без фокуса. Позначка має пріоритет над курсором, а підсвічування передачі має пріоритет над кольорами курсора. Без перевизначення стан зберігає звичайний вигляд панелі. Мініпанель унизу редактора одразу показує всі чотири стани на синтетичних рядках до Apply.
|
||||
|
||||
- Нижня область показує живе прев’ю для кольорів, шрифту, режимів панелей, широких колонок і рядка стану. Для Popup background, Menu selection і Command bar зразок показує відповідний елемент. Прев’ю читає чернетку, зокрема після Reset, і не потребує реальних файлів. Воно заповнює простір від останнього видимого елемента редактора до нижніх кнопок зі сталими відступами. Зі зміною розміру росте область зразка, а рядки зберігають масштаб шрифту.
|
||||
|
||||
- Підписи File type, Windows extension or custom extension і Row state стоять над відповідними полями зі спільним лівим краєм. Ширина списків враховує найдовші значення, шрифт, DPI й доступне місце. Add і Remove стоять одразу після своїх полів.
|
||||
|
||||
- Початковий набір правил охоплює EXE, Markdown, системні й приховані елементи, архіви, зображення, аудіо й відео, офісні документи й PDF, код і скрипти. Збережений набір правил завантажується як є, включно з видаленими правилами. Reset у File type styles відновлює весь початковий набір у чернетці.
|
||||
|
||||
## Панелі
|
||||
|
||||
- Ctrl+8 відкриває список усіх ярликів папок. Кожен рядок показує групу
|
||||
й назву або шлях. Enter відкриває папку в активній панелі, F4 редагує
|
||||
ярлик, а Delete прибирає його зі списку.
|
||||
|
||||
- У Change drive повідомлення No paired computers з’являється після
|
||||
успішної відповіді з порожнім списком. Збої локального запиту до
|
||||
KSwitcher відображаються окремо.
|
||||
|
||||
- Якщо команда у вбудованому PowerShell змінює поточну папку,
|
||||
панель, з якої надіслано команду, переходить до цієї папки.
|
||||
|
||||
- Коли список LAN-дисків змінює розмір меню Change drive, рамка й список
|
||||
змінюють позицію разом. Рамка повністю перемальовується після зміни
|
||||
розміру, а список має однаковий внутрішній відступ зліва і справа.
|
||||
- Тайм-аут запиту дисків відображається як Connection timed out. Якщо
|
||||
ввімкнено Write log, Files записує помилки запитів пар і дисків в окремий
|
||||
журнал `data/kswitcher-files.log`. Системні помилки містять код Windows.
|
||||
|
||||
- Застосовані налаштування Files одразу підхоплюють інші відкриті вікна.
|
||||
Apply зберігає поля, змінені в поточній чернетці. Якщо два вікна одночасно
|
||||
редагували набір стилів файлів, відкрий Options заново, щоб побачити свіжі правила.
|
||||
|
||||
- Типовий колір тексту для ZIP-архівів — світлий коралово-рожевий.
|
||||
|
||||
- Меню Change drive показує літеру, тип, загальний обсяг і вільне місце. Відкриття меню запускає нове LAN-виявлення. Секція KSwitcher LAN показує збережені пари, зокрема офлайн. Після кожного відкриття меню асинхронно запитує диски й показує їх під назвою відповідного ПК. Поки триває запит, видно стан завантаження. Якщо запит не вдався, видно помилку. Повторне відкриття запускає новий запит. Кожна панель має власний диск і шлях, а в заголовку віддаленої панелі перед шляхом показано назву ПК.
|
||||
|
||||
- Список дисків і файлові операції використовують права облікового запису Windows, під яким працює віддалений KSwitcher. Достатньо спарювання й доступного TCP 45885, спільні папки налаштовувати не потрібно.
|
||||
- F5, F6, F8 і Shift+Delete у віддалених панелях використовують спільні підтвердження, попередження й прогрес. F5/F6 працюють із файлами та деревами папок між локальними й віддаленими панелями, зокрема між двома різними ПК. Між шляхами одного віддаленого ПК вміст копіюється на ньому. F8 використовує кошик того ПК, на якому лежить файл.
|
||||
- Після розриву з’єднання повтори F5 або F6 до того самого призначення. Віддалене копіювання відновлюється з підтвердженої позиції, якщо збережені байти відповідають початку вихідного файла. Підтверджене скасування видаляє частковий запис поточного файла. Завершені файли лишаються на місці. Наявний файл призначення замінюється після повного запису, а при переміщенні джерело видаляється після підтвердження запису призначення.
|
||||
|
||||
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
|
||||
|
||||
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
|
||||
|
||||
- Бірюзовий курсор поточного рядка видно лише в активній панелі. Кожна панель зберігає свою позицію курсора.
|
||||
- Бірюзовий курсор поточного рядка видно лише в активній панелі, зокрема коли клавіатурний фокус переходить у командний рядок. Кожна панель зберігає свою позицію курсора. Коли панелі видимі, F5 і F6 з командного рядка діють на активну панель.
|
||||
- Клік у панелі нижче списку ставить курсор на останній рядок, а клік вище списку — на перший.
|
||||
|
||||
- Поточний шлях відображається по центру верхньої подвійної лінії панелі. Лінію видно з обох боків напису. Бірюзове тло активної панелі охоплює лише напис шляху з невеликими відступами. Довгий шлях скорочується в межах панелі.
|
||||
|
||||
## Спільні елементи діалогів
|
||||
|
||||
- Власні діалоги Files блокують дії панелей і командного рядка, залишаючи
|
||||
системний заголовок і рамку головного вікна доступними. Клік поза діалогом
|
||||
діє як Cancel. Для прогресу копіювання, переміщення й видалення це відкриває
|
||||
підтвердження скасування.
|
||||
|
||||
- Enter активує кнопку у фокусі. Для кнопок із власним малюванням використовуй спільний обробник `dialogButtonEnter`. У підтвердженні скасування початковий фокус стоїть на No.
|
||||
|
||||
- При перенесенні вікна між моніторами з різним DPI оновлюй шрифт, висоту рядків панелей і компонування відкритого прогресу копіювання. Перевір перенесення під час активного копіювання.
|
||||
- Для власних діалогів використовуй подвійну рамку з одним заголовком у верхній лінії. Звичайний діалог має сіре тло, попередження — темно-червоне тло та білий текст.
|
||||
- Для власних діалогів і списків Far використовуй спільні метрики та малювання подвійної рамки. Еталон Delete задає 8 px від краю тла до першої лінії та 14 px від внутрішньої лінії до бічної межі вмісту при 96 DPI. Обидва відступи масштабуються за DPI. Вертикальний відступ враховує висоту заголовка та нижніх підказок. Діалоги й списки зберігають свою палітру та поведінку.
|
||||
- Appearance → Dialog frame налаштовує зовнішній і внутрішній відступи від 0 до 24 px при 96 DPI. Row text padding додає від 0 до 4 ширин символу з кожного боку тексту в рядках Folder shortcuts. Inner padding задає відстань від рамки до краю списку. Ширина попапа враховує обидва текстові відступи, а довгі шляхи скорочуються в межах доступної ширини. Живий зразок показує чернетку. Apply зберігає значення, Cancel відкидає незастосовані зміни, Reset selected to defaults повертає 8 px, 14 px і 0 символів. Профілі без цих параметрів використовують ті самі значення.
|
||||
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` центрує текст, підсвічує фокус і додає фігурні дужки для дії за замовчуванням та квадратні для решти кнопок.
|
||||
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
|
||||
- Поля вводу й випадаючі списки використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
|
||||
- Для текстових полів Far використовуй ресурс `FAR_EDIT` і спільну підготовку та палітру діалогів. Поля призначення Copy/Rename і Title/Path використовують нативний Win32 Edit без рамки, моноширинний шрифт і вертикальне центрування. Кольори Text і Popup background діють на всі ці поля, зокрема на поле Copy лише для читання. У відповідних розділах Options → Colors зміни видно на зразку поля. Existing files у Copy є списком без редагування. Поля та редагований список Windows Options мають системне оформлення.
|
||||
- Для позиціонування роздільника використовуй невидимий маркер, якщо межу не задає інший елемент секції. Спільний обробник малює лінію.
|
||||
- Випадаючі списки Far використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
|
||||
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
|
||||
|
||||
## Діалог Copy
|
||||
@@ -40,6 +141,9 @@ Far Manager є зразком для оформлення файлового м
|
||||
|
||||
## Попередження про конфлікт
|
||||
|
||||
- Для однойменних файлів Warning показує точний розмір у байтах із розділенням тисяч і місцевий
|
||||
час останньої зміни окремо для вхідного файла та файла в папці призначення.
|
||||
|
||||
- Після розрахунку остаточної висоти центр Warning збігається з центром вікна прогресу.
|
||||
- Remember choice стоїть по вертикальному центру власної секції. Кнопки Overwrite, Skip і Cancel стоять по центру нижньої секції.
|
||||
- Tab проходить Remember choice, Overwrite, Skip і Cancel. Стрілки вліво та вправо циклічно перемикають три кнопки в порядку їх розташування.
|
||||
@@ -48,6 +152,7 @@ Far Manager є зразком для оформлення файлового м
|
||||
## Видалення
|
||||
|
||||
- F8 показує сіре підтвердження з дією Move. Shift+Delete і підтвердження непорожньої папки мають темно-червоне тло. Вікно прогресу видалення має сіре тло, як у Copy.
|
||||
- Escape у початковому підтвердженні повертає фокус до панелі, з якої викликано видалення. Поточний рядок зберігається, а стрілки одразу працюють.
|
||||
- F8 переміщує підтверджені елементи в кошик Windows. Shift+Delete видаляє підтверджене дерево від вкладених елементів до батьківської папки. Прогрес і підсумок окремо показують успішні, пропущені й невдалі дії.
|
||||
- Cancel і Esc у вікні прогресу відкривають спільне червоне підтвердження скасування. Yes зупиняє операцію, а No або Esc у підтвердженні повертає до прогресу. Початковий фокус стоїть на No.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,41 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.81 — 2026-11-14
|
||||
|
||||
Ctrl+8 lists all folder shortcuts. Press Enter to open a folder, F4 to edit the shortcut, or Delete to remove it. Ctrl+Alt+F brings an open Files window forward.
|
||||
|
||||
**Українською.** Ctrl+8 показує всі ярлики папок. Enter відкриває папку, F4 редагує ярлик, Delete видаляє його. Ctrl+Alt+F повертає до відкритого вікна Files.
|
||||
|
||||
## v0.6.80 — 2026-11-14
|
||||
|
||||
Includes the Files updates from v0.6.79. Change drive now explains why the local peer lookup failed and shows No paired computers only when the response is empty.
|
||||
|
||||
**Українською.** Випуск включає зміни Files із v0.6.79. Change drive показує конкретну причину збою запиту пар. No paired computers з’являється лише після успішної порожньої відповіді.
|
||||
|
||||
## v0.6.79 — 2026-11-14
|
||||
|
||||
In KSwitcher Files, changing directory in PowerShell updates the panel
|
||||
that sent the command. The global Files shortcut brings an open window to
|
||||
the foreground. Change drive redraws its frame when LAN drives arrive and
|
||||
shows a short timeout message. Files writes LAN lookup errors to its own log.
|
||||
|
||||
**Українською.** Зміна каталогу в PowerShell оновлює панель, з якої
|
||||
надіслано команду. Глобальна клавіша Files повертає до відкритого вікна.
|
||||
Change drive перемальовує рамку після появи LAN-дисків і показує коротке
|
||||
повідомлення про тайм-аут. Files записує помилки LAN у власний журнал.
|
||||
|
||||
## v0.6.78 — 2026-11-14
|
||||
|
||||
The Windows package includes KSwitcher Files, a two-panel file manager with
|
||||
local and paired LAN drives, folder shortcuts, an embedded PowerShell terminal,
|
||||
and searchable command history. Applied Files settings update other open
|
||||
instances immediately.
|
||||
|
||||
**Українською.** До пакета для Windows входить KSwitcher Files, двопанельний
|
||||
файловий менеджер із локальними дисками й дисками спарених ПК, ярликами папок,
|
||||
вбудованим PowerShell та пошуком в історії команд. Застосовані налаштування
|
||||
Files одразу підхоплюють інші відкриті вікна.
|
||||
|
||||
## v0.6.77 — 2026-11-13
|
||||
|
||||
The screenshot editor remembers the local pencil smoothing default across
|
||||
|
||||
@@ -1 +1 @@
|
||||
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
|
||||
d471919e04961b9fb323ef83e8b5e6ed41ce4667e881b5133574d6f4915c53e7 KSwitcher-win-x64.zip
|
||||
+85
-40
@@ -1,86 +1,131 @@
|
||||
{
|
||||
"version": "0.6.77",
|
||||
"files": [
|
||||
{
|
||||
"path": "KSwitcher.exe",
|
||||
"size": 2249216,
|
||||
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
|
||||
"size": 2341376,
|
||||
"sha256": "ac5858551e4c487fe75954ea0e01d12ca021ada8e785b901f425dea9c5dedea1",
|
||||
"path": "KSwitcher.exe"
|
||||
},
|
||||
{
|
||||
"path": "KSwitcher.Config.exe",
|
||||
"size": 2251776,
|
||||
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
|
||||
"size": 2341888,
|
||||
"sha256": "bb20f7dc5feb3e5657e94f18088aac26d818cb8e35f4e1248f67dedabf7139dd",
|
||||
"path": "KSwitcher.Config.exe"
|
||||
},
|
||||
{
|
||||
"path": "KSwitcher.Screenshot.exe",
|
||||
"size": 3247616,
|
||||
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
|
||||
"size": 3319808,
|
||||
"sha256": "3b71f51aee26e0a146678b7278a81f6a9dd827f9b8e650c33ede997eb813b97f",
|
||||
"path": "KSwitcher.Screenshot.exe"
|
||||
},
|
||||
{
|
||||
"size": 1827328,
|
||||
"sha256": "f4c5d3359b601b753906d6c4ac3b1fef66ae95f341a30bb66ba5229cc3ea5645",
|
||||
"path": "KSwitcher.Files.exe"
|
||||
},
|
||||
{
|
||||
"size": 194912,
|
||||
"sha256": "d3fce90bdeecb08596e377dea6a316354dd8949e32ff09b89d51be00c14e21b6",
|
||||
"path": "WebView2Loader.dll"
|
||||
},
|
||||
{
|
||||
"size": 1103,
|
||||
"sha256": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f",
|
||||
"path": "terminal/addon-fit-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"size": 1521,
|
||||
"sha256": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527",
|
||||
"path": "terminal/addon-fit.js"
|
||||
},
|
||||
{
|
||||
"size": 527,
|
||||
"sha256": "084d97a727b778fb10d31adcd557ee7840a38377ed60e0ef0f3ece93b349b1ef",
|
||||
"path": "terminal/index.html"
|
||||
},
|
||||
{
|
||||
"size": 3481,
|
||||
"sha256": "f677e61d7d90d3023839ee131ddabed7a5c3510b18d47cc9a3c19353913ca5cc",
|
||||
"path": "terminal/terminal.js"
|
||||
},
|
||||
{
|
||||
"size": 1261,
|
||||
"sha256": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd",
|
||||
"path": "terminal/xterm-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"size": 7112,
|
||||
"sha256": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6",
|
||||
"path": "terminal/xterm.css"
|
||||
},
|
||||
{
|
||||
"size": 488663,
|
||||
"sha256": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972",
|
||||
"path": "terminal/xterm.js"
|
||||
},
|
||||
{
|
||||
"path": "LICENSE",
|
||||
"size": 4401,
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
|
||||
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
|
||||
"path": "LICENSE"
|
||||
},
|
||||
{
|
||||
"path": "json-LICENSE.txt",
|
||||
"size": 1075,
|
||||
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
|
||||
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
|
||||
"path": "json-LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"size": 93885,
|
||||
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
|
||||
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b",
|
||||
"path": "README.md"
|
||||
},
|
||||
{
|
||||
"path": "LAN-PREVIEW.md",
|
||||
"size": 11418,
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
|
||||
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada",
|
||||
"path": "LAN-PREVIEW.md"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/en.txt",
|
||||
"size": 682867,
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
|
||||
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
|
||||
"path": "dictionaries/en.txt"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
|
||||
"size": 20138,
|
||||
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
|
||||
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
|
||||
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/README.md",
|
||||
"size": 2230,
|
||||
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
|
||||
"size": 2265,
|
||||
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
|
||||
"path": "dictionaries/README.md"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/ru.txt",
|
||||
"size": 1060364,
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
|
||||
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
|
||||
"path": "dictionaries/ru.txt"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/sources.json",
|
||||
"size": 1279,
|
||||
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
|
||||
"size": 1280,
|
||||
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
|
||||
"path": "dictionaries/sources.json"
|
||||
},
|
||||
{
|
||||
"path": "dictionaries/uk.txt",
|
||||
"size": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
|
||||
"size": 940799,
|
||||
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
|
||||
"path": "dictionaries/uk.txt"
|
||||
},
|
||||
{
|
||||
"path": "tools/README.txt",
|
||||
"size": 993,
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
|
||||
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
|
||||
"path": "tools/README.txt"
|
||||
},
|
||||
{
|
||||
"path": "tools/StayHere.exe",
|
||||
"size": 134656,
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
|
||||
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376",
|
||||
"path": "tools/StayHere.exe"
|
||||
},
|
||||
{
|
||||
"path": "tools/StayHere-README.md",
|
||||
"size": 2796,
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
|
||||
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
|
||||
"path": "tools/StayHere-README.md"
|
||||
}
|
||||
],
|
||||
"schema": 1
|
||||
"schema": 1,
|
||||
"version": "0.6.81"
|
||||
}
|
||||
@@ -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,15 +35,15 @@ 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'
|
||||
@@ -51,7 +51,7 @@ try {
|
||||
& "$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 -luser32 -lgdi32 -lcomctl32 -lshell32 -lcomdlg32 -ladvapi32 -lole32 -luuid -o $filesPath
|
||||
& $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) {
|
||||
@@ -67,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')
|
||||
@@ -79,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' }
|
||||
@@ -91,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' }
|
||||
@@ -111,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' }
|
||||
@@ -123,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')
|
||||
@@ -135,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' }
|
||||
|
||||
+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,77,0
|
||||
PRODUCTVERSION 0,6,77,0
|
||||
FILEVERSION 0,6,81,0
|
||||
PRODUCTVERSION 0,6,81,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.77"
|
||||
VALUE "FileVersion", "0.6.81"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+108
-29
@@ -1,40 +1,84 @@
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
|
||||
#define FAR_EDIT(id, mode, x, y, width, height) \
|
||||
CONTROL "", id, "Edit", ES_AUTOHSCROLL | WS_TABSTOP | mode, x, y, width, height
|
||||
|
||||
1 ICON "kswitcher-files.ico"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
|
||||
101 DIALOGEX 0, 0, 256, 141
|
||||
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
|
||||
CAPTION "Wide panel mode"
|
||||
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
|
||||
GROUPBOX "Panel columns — Name, Size", -1, 8, 6, 240, 59
|
||||
LTEXT "Size column &width (6-16 characters)", -1, 17, 22, 166, 10
|
||||
EDITTEXT 201, 196, 18, 41, 14, ES_NUMBER | ES_AUTOHSCROLL
|
||||
AUTOCHECKBOX "&Align file extensions", 202, 17, 42, 104, 12
|
||||
AUTOCHECKBOX "Align &folder extensions", 204, 130, 42, 108, 12
|
||||
GROUPBOX "Status line columns", -1, 8, 72, 240, 39
|
||||
AUTOCHECKBOX "Show &details", 203, 17, 83, 94, 11
|
||||
AUTOCHECKBOX "Name", 205, 17, 96, 47, 11
|
||||
AUTOCHECKBOX "Size", 206, 72, 96, 42, 11
|
||||
AUTOCHECKBOX "Date", 207, 128, 96, 45, 11
|
||||
AUTOCHECKBOX "Time", 208, 185, 96, 45, 11
|
||||
DEFPUSHBUTTON "OK", IDOK, 139, 119, 50, 14
|
||||
PUSHBUTTON "Cancel", IDCANCEL, 196, 119, 50, 14
|
||||
LTEXT "&Search settings", -1, 8, 8, 113, 10
|
||||
EDITTEXT 270, 8, 21, 113, 14, ES_AUTOHSCROLL
|
||||
CONTROL "", 271, "SysTreeView32", WS_TABSTOP | WS_BORDER | TVS_HASLINES | TVS_LINESATROOT | TVS_HASBUTTONS | TVS_SHOWSELALWAYS, 8, 40, 113, 240
|
||||
LTEXT "", 272, 131, 8, 253, 12
|
||||
AUTOCHECKBOX "Left panel: &Wide", 273, 132, 27, 115, 12
|
||||
AUTOCHECKBOX "Right panel: W&ide", 274, 251, 27, 125, 12
|
||||
GROUPBOX "Wide panel columns", 275, 129, 27, 259, 59
|
||||
LTEXT "Size column &width (6–16 characters)", 276, 138, 44, 175, 10
|
||||
EDITTEXT 201, 335, 41, 43, 14, ES_NUMBER | ES_AUTOHSCROLL
|
||||
AUTOCHECKBOX "&Align file extensions", 202, 138, 64, 108, 12
|
||||
AUTOCHECKBOX "Align &folder extensions", 204, 252, 64, 125, 12
|
||||
GROUPBOX "Status line columns", 277, 129, 27, 259, 46
|
||||
AUTOCHECKBOX "Show &details", 203, 138, 40, 100, 12
|
||||
AUTOCHECKBOX "Name", 205, 138, 55, 47, 12
|
||||
AUTOCHECKBOX "Size", 206, 196, 55, 42, 12
|
||||
AUTOCHECKBOX "Date", 207, 254, 55, 45, 12
|
||||
AUTOCHECKBOX "Time", 208, 312, 55, 45, 12
|
||||
PUSHBUTTON "Change &color…", 279, 131, 48, 82, 15
|
||||
LTEXT "", 280, 222, 50, 155, 12
|
||||
LTEXT "", 281, 131, 30, 254, 14
|
||||
PUSHBUTTON "Change &font…", 282, 131, 52, 82, 15
|
||||
LTEXT "&Outer padding (px at 96 DPI)", 302, 131, 31, 173, 12
|
||||
COMBOBOX 303, 311, 28, 56, 125, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
LTEXT "&Inner padding (px at 96 DPI)", 304, 131, 54, 173, 12
|
||||
COMBOBOX 305, 311, 51, 56, 125, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
LTEXT "&Row text padding (characters)", 306, 131, 77, 173, 12
|
||||
COMBOBOX 307, 311, 74, 56, 76, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
LTEXT "Previous command", 320, 131, 30, 120, 12
|
||||
CONTROL "", 321, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 27, 110, 15
|
||||
LTEXT "Open command history", 322, 131, 53, 120, 12
|
||||
CONTROL "", 323, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 50, 110, 15
|
||||
PUSHBUTTON "Reset selected to defaults", 284, 131, 181, 125, 15
|
||||
COMBOBOX 285, 131, 183, 139, 95, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
PUSHBUTTON "Reset property", 286, 278, 183, 108, 15
|
||||
LTEXT "File type", 287, 131, 26, 75, 10
|
||||
COMBOBOX 288, 131, 38, 151, 100, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
|
||||
PUSHBUTTON "&Remove", 289, 290, 38, 77, 14
|
||||
LTEXT "Windows extension or custom extension", 290, 131, 63, 246, 10
|
||||
COMBOBOX 291, 131, 75, 151, 108, CBS_DROPDOWN | CBS_AUTOHSCROLL | WS_TABSTOP | WS_VSCROLL
|
||||
PUSHBUTTON "A&dd", 292, 290, 75, 77, 14
|
||||
LTEXT "Row state", 300, 131, 100, 72, 10
|
||||
COMBOBOX 301, 131, 112, 174, 80, CBS_DROPDOWNLIST | WS_TABSTOP
|
||||
PUSHBUTTON "&Foreground…", 293, 131, 135, 78, 14
|
||||
PUSHBUTTON "&Background…", 294, 216, 135, 78, 14
|
||||
PUSHBUTTON "&Inherit colors", 295, 301, 135, 83, 14
|
||||
LTEXT "", 296, 131, 152, 253, 13
|
||||
AUTOCHECKBOX "&Underline", 297, 131, 166, 80, 12
|
||||
AUTOCHECKBOX "Stri&kethrough", 298, 218, 166, 100, 12
|
||||
CONTROL "Settings preview", 299, "Static", SS_OWNERDRAW | WS_BORDER, 131, 202, 253, 81
|
||||
DEFPUSHBUTTON "OK", IDOK, 222, 289, 50, 14
|
||||
PUSHBUTTON "Cancel", IDCANCEL, 280, 289, 50, 14
|
||||
PUSHBUTTON "&Apply", 283, 338, 289, 50, 14
|
||||
END
|
||||
|
||||
102 DIALOGEX 0, 0, 350, 114
|
||||
102 DIALOGEX 0, 0, 350, 145
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
CAPTION "Copy"
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
|
||||
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
|
||||
FAR_EDIT(211, ES_MULTILINE, 14, 29, 322, 18)
|
||||
LTEXT "", 328, 14, 49, 322, 35, SS_NOPREFIX
|
||||
LTEXT "Existing files:", 212, 14, 92, 122, 14, SS_CENTERIMAGE
|
||||
COMBOBOX 213, 140, 92, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
|
||||
CONTROL "Also ask on read-only files", 214, "Button", BS_OWNERDRAW | WS_TABSTOP, 14, 112, 322, 14
|
||||
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 127, 86, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 127, 86, 18
|
||||
END
|
||||
|
||||
103 DIALOGEX 0, 0, 460, 126
|
||||
@@ -53,7 +97,7 @@ BEGIN
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 106, 86, 18
|
||||
END
|
||||
|
||||
104 DIALOGEX 0, 0, 360, 106
|
||||
104 DIALOGEX 0, 0, 360, 139
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
CAPTION "Warning"
|
||||
FONT 10, "Consolas"
|
||||
@@ -61,11 +105,16 @@ 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
|
||||
LTEXT "New", 329, 12, 58, 65, 13
|
||||
RTEXT "", 330, 78, 58, 270, 13
|
||||
LTEXT "Existing", 331, 12, 72, 65, 13
|
||||
RTEXT "", 332, 78, 72, 270, 13
|
||||
LTEXT "", 333, 12, 88, 1, 1
|
||||
LTEXT "", 234, 12, 114, 1, 1
|
||||
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 90, 336, 18
|
||||
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 122, 110, 18
|
||||
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 122, 68, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 122, 86, 18
|
||||
END
|
||||
|
||||
105 DIALOGEX 0, 0, 310, 65
|
||||
@@ -105,3 +154,33 @@ BEGIN
|
||||
LTEXT "", 248, 14, 73, 1, 1
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 157, 74, 86, 18
|
||||
END
|
||||
109 DIALOGEX 0, 0, 250, 85
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
|
||||
CAPTION "Folder shortcuts"
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
CONTROL "", 310, "ListBox", LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP, 8, 14, 234, 50
|
||||
END
|
||||
|
||||
110 DIALOGEX 0, 0, 370, 116
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
CAPTION "Folder shortcuts"
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "&Title", 316, 16, 18, 338, 12
|
||||
FAR_EDIT(314, ES_LEFT, 16, 31, 338, 15)
|
||||
LTEXT "&Path", 317, 16, 49, 338, 12
|
||||
FAR_EDIT(211, ES_LEFT, 16, 62, 338, 15)
|
||||
LTEXT "", 315, 10, 82, 1, 1, NOT WS_VISIBLE
|
||||
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 97, 86, 86, 18
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 86, 86, 18
|
||||
END
|
||||
|
||||
111 DIALOGEX 0, 0, 385, 220
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
FONT 9, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "&Search commands or topics", 326, 12, 14, 361, 12
|
||||
FAR_EDIT(325, ES_MULTILINE, 12, 28, 361, 16)
|
||||
LISTBOX 324, 12, 49, 361, 155, LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP
|
||||
END
|
||||
@@ -7,6 +7,16 @@ const terminal = new Terminal({
|
||||
theme: { background: '#181818', foreground: '#e8e8e8' },
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
terminal.parser.registerOscHandler(633, data => {
|
||||
const prefix = 'ks-directory:';
|
||||
if (!data.startsWith(prefix)) return false;
|
||||
try {
|
||||
bridge.postMessage('directory:' + decodeURIComponent(data.slice(prefix.length)));
|
||||
} catch {
|
||||
bridge.postMessage('directory:');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
terminal.loadAddon(fit);
|
||||
const host = document.getElementById('terminal');
|
||||
terminal.open(host);
|
||||
|
||||
+79
-16
@@ -141,6 +141,30 @@ ULONG_PTR snippetReplayMarker()
|
||||
static const char marker{};
|
||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||
}
|
||||
constexpr int OpenFiles = 25, FilesHotkeyFirst = 0x4B10, FilesHotkeySecond = 0x4B11;
|
||||
|
||||
bool activateFilesWindow()
|
||||
{
|
||||
HWND files{};
|
||||
EnumWindows([](HWND candidate, LPARAM context) -> BOOL
|
||||
{
|
||||
wchar_t name[32]{};
|
||||
if(!IsWindowVisible(candidate) || !GetClassNameW(candidate,name,std::size(name)) ||
|
||||
wcscmp(name,L"KSwitcher.Files"))
|
||||
return TRUE;
|
||||
*reinterpret_cast<HWND *>(context)=candidate;
|
||||
return FALSE;
|
||||
},reinterpret_cast<LPARAM>(&files));
|
||||
if(!files) return false;
|
||||
if(IsIconic(files)) ShowWindowAsync(files,SW_RESTORE);
|
||||
if(!SetForegroundWindow(files))
|
||||
{
|
||||
FLASHWINFO flash{sizeof(flash),files,FLASHW_TRAY,3,0};
|
||||
FlashWindowEx(&flash);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||
{
|
||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||
@@ -681,6 +705,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
|
||||
if (snippetPopup) DestroyWindow(snippetPopup);
|
||||
lanService.reset();
|
||||
stopBrowserChatMonitor();
|
||||
@@ -854,6 +879,19 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
||||
registrationWarning = L"Keyboard interception could not start.";
|
||||
return false;
|
||||
}
|
||||
const auto files = Hotkey::parse(s.filesHotkey.value_or(L"")).value_or(Hotkey{});
|
||||
if (files != registeredFilesHotkey)
|
||||
{
|
||||
const int next = filesHotkeyId == FilesHotkeyFirst ? FilesHotkeySecond : FilesHotkeyFirst;
|
||||
if (files.key && !RegisterHotKey(window, next, files.modifiers | MOD_NOREPEAT, files.key))
|
||||
{
|
||||
if (!rollback) registrationWarning = L"The KSwitcher Files shortcut is unavailable. Choose another shortcut.";
|
||||
return false;
|
||||
}
|
||||
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
|
||||
filesHotkeyId = files.key ? next : 0;
|
||||
registeredFilesHotkey = files;
|
||||
}
|
||||
hookBindings = std::move(bindings);
|
||||
return true;
|
||||
}
|
||||
@@ -1022,6 +1060,7 @@ void App::setHotkeyCapture(HWND field)
|
||||
}
|
||||
bool App::isHotkey(DWORD key, UINT mods)
|
||||
{
|
||||
if (registeredFilesHotkey.key && registeredFilesHotkey.key == key && registeredFilesHotkey.modifiers == mods) return true;
|
||||
for (auto &[id, text] : shortcuts(settings))
|
||||
{
|
||||
auto h = Hotkey::parse(text);
|
||||
@@ -1964,6 +2003,20 @@ void App::dispatch(int command)
|
||||
try
|
||||
{
|
||||
if (command == 23) { checkUpdate(true); return; }
|
||||
if (command == OpenFiles)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
|
||||
auto args = L"\"" + path.wstring() + L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), args.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
return;
|
||||
}
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
@@ -2769,7 +2822,7 @@ void App::trayMenu()
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
|
||||
AppendMenuW(menu, MF_STRING, 25, L"&Files…");
|
||||
AppendMenuW(menu, MF_STRING, OpenFiles, L"&Files…");
|
||||
SetMenuDefaultItem(menu, 11, FALSE);
|
||||
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||
@@ -2822,20 +2875,9 @@ void App::trayMenu()
|
||||
}
|
||||
switch (cmd)
|
||||
{
|
||||
case 25:
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
|
||||
auto command = L"\"" + path.wstring() + L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
case OpenFiles:
|
||||
dispatch(OpenFiles);
|
||||
break;
|
||||
}
|
||||
case 24:
|
||||
dispatch(Screenshot);
|
||||
break;
|
||||
@@ -3015,7 +3057,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
}
|
||||
case WM_HOTKEY:
|
||||
app->dispatch((int)wp);
|
||||
if (app->filesHotkeyId && wp == static_cast<WPARAM>(app->filesHotkeyId))
|
||||
{
|
||||
if(!activateFilesWindow()) app->dispatch(OpenFiles);
|
||||
}
|
||||
else if (wp != FilesHotkeyFirst && wp != FilesHotkeySecond) app->dispatch((int)wp);
|
||||
return 0;
|
||||
case MouseActionMessage:
|
||||
app->dispatchMouseActions();
|
||||
@@ -3341,7 +3387,24 @@ int App::execute()
|
||||
}
|
||||
keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyProc, instance, 0);
|
||||
mouseHook = SetWindowsHookExW(WH_MOUSE_LL, mouseProc, instance, 0);
|
||||
registerHotkeys(settings);
|
||||
const bool useDefaultFilesHotkey = !settings.filesHotkey;
|
||||
if (useDefaultFilesHotkey)
|
||||
{
|
||||
const auto fallback = *Hotkey::parse(L"Ctrl+Alt+F");
|
||||
const auto bindings = shortcuts(settings);
|
||||
const bool conflict = std::any_of(bindings.begin(), bindings.end(), [&](const auto &binding)
|
||||
{
|
||||
return Hotkey::parse(binding.second) == fallback;
|
||||
});
|
||||
settings.filesHotkey = conflict ? L"" : L"Ctrl+Alt+F";
|
||||
}
|
||||
if (!registerHotkeys(settings) && settings.filesHotkey && !settings.filesHotkey->empty())
|
||||
{
|
||||
if (useDefaultFilesHotkey) settings.filesHotkey = L"";
|
||||
auto fallback = settings;
|
||||
fallback.filesHotkey = L"";
|
||||
registerHotkeys(fallback, true);
|
||||
}
|
||||
if (!keyboardHook || !mouseHook)
|
||||
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
||||
AddClipboardFormatListener(window);
|
||||
|
||||
@@ -169,6 +169,8 @@ class App
|
||||
HWND previousAttemptWindow{};
|
||||
int previousAttemptCommand{}, repeatAttempts{};
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
Hotkey registeredFilesHotkey{};
|
||||
int filesHotkeyId{};
|
||||
HotkeyHook hotkeyHook;
|
||||
ChatSwitchHook chatSwitchHook;
|
||||
HWND chatWindow{};
|
||||
|
||||
+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);
|
||||
|
||||
@@ -97,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,107 @@
|
||||
constexpr std::array<WORD,2> defaultHistoryKeys{MAKEWORD('E',HOTKEYF_CONTROL),MAKEWORD(VK_F8,HOTKEYF_ALT)};
|
||||
auto commandHistoryKeys=defaultHistoryKeys;
|
||||
constexpr size_t commandHistoryLimit=1000;
|
||||
|
||||
bool validHistoryKeys(const std::array<WORD,2> &keys)
|
||||
{
|
||||
if(keys[0]==keys[1]) return false;
|
||||
return std::all_of(keys.begin(),keys.end(),[](WORD binding)
|
||||
{
|
||||
const auto key=LOBYTE(binding), modifiers=HIBYTE(binding);
|
||||
const bool letter=key>='A' && key<='Z';
|
||||
const bool function=key>=VK_F1 && key<=VK_F12;
|
||||
if(!letter && !function) return false;
|
||||
if(modifiers==HOTKEYF_CONTROL) return (letter && key!='C' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
|
||||
if(modifiers==HOTKEYF_ALT) return function && key!=VK_F1 && key!=VK_F2 && key!=VK_F4 && key!=VK_F10;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
std::array<WORD,2> optionHistoryKeys(HWND dialog)
|
||||
{
|
||||
return {static_cast<WORD>(SendDlgItemMessageW(dialog,321,HKM_GETHOTKEY,0,0)),
|
||||
static_cast<WORD>(SendDlgItemMessageW(dialog,323,HKM_GETHOTKEY,0,0))};
|
||||
}
|
||||
|
||||
void setOptionHistoryKeys(HWND dialog,const std::array<WORD,2> &keys)
|
||||
{
|
||||
SendDlgItemMessageW(dialog,321,HKM_SETHOTKEY,keys[0],0);
|
||||
SendDlgItemMessageW(dialog,323,HKM_SETHOTKEY,keys[1],0);
|
||||
}
|
||||
|
||||
void loadCommandHistoryKeys()
|
||||
{
|
||||
const std::array<WORD,2> keys{static_cast<WORD>(preference(L"PreviousCommandKey",defaultHistoryKeys[0])),
|
||||
static_cast<WORD>(preference(L"CommandHistoryKey",defaultHistoryKeys[1]))};
|
||||
commandHistoryKeys=validHistoryKeys(keys)?keys:defaultHistoryKeys;
|
||||
}
|
||||
|
||||
void loadCommandHistory()
|
||||
{
|
||||
|
||||
DWORD bytes{};
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)
|
||||
!=ERROR_SUCCESS || bytes>16*1024*1024 || bytes%sizeof(wchar_t)) return;
|
||||
std::wstring data(bytes/sizeof(wchar_t),L'\0');
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)
|
||||
!=ERROR_SUCCESS) return;
|
||||
size_t start{};
|
||||
while(start<data.size())
|
||||
{
|
||||
const auto dateEnd=data.find(L'\0',start);
|
||||
if(dateEnd==std::wstring::npos) break;
|
||||
const auto commandEnd=data.find(L'\0',dateEnd+1);
|
||||
if(commandEnd==std::wstring::npos) break;
|
||||
terminalHistory.push_back({data.substr(start,dateEnd-start),data.substr(dateEnd+1,commandEnd-dateEnd-1)});
|
||||
start=commandEnd+1;
|
||||
}
|
||||
terminalHistoryIndex=terminalHistory.size();
|
||||
}
|
||||
|
||||
void rememberCommand(const std::wstring &command)
|
||||
{
|
||||
SYSTEMTIME now{};
|
||||
GetLocalTime(&now);
|
||||
wchar_t date[24]{};
|
||||
GetDateFormatEx(LOCALE_NAME_USER_DEFAULT,0,&now,L"dd.MM.yyyy",date,std::size(date),nullptr);
|
||||
terminalHistory.push_back({date,command});
|
||||
if(terminalHistory.size()>commandHistoryLimit) terminalHistory.erase(terminalHistory.begin());
|
||||
std::wstring data;
|
||||
for(const auto &entry:terminalHistory)
|
||||
{
|
||||
data.append(entry.date).push_back(L'\0');
|
||||
data.append(entry.command).push_back(L'\0');
|
||||
}
|
||||
if(RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",REG_BINARY,data.data(),
|
||||
static_cast<DWORD>(data.size()*sizeof(wchar_t)))!=ERROR_SUCCESS)
|
||||
setStatus(L"Could not save command history.");
|
||||
refreshCommandHistory();
|
||||
}
|
||||
|
||||
void recallCommand(size_t index)
|
||||
{
|
||||
terminalHistoryIndex=index;
|
||||
SetWindowTextW(terminalInput,index<terminalHistory.size()?terminalHistory[index].command.c_str():L"");
|
||||
SetFocus(terminalInput);
|
||||
SendMessageW(terminalInput,EM_SETSEL,-2,-1);
|
||||
}
|
||||
|
||||
void showCommandHistory(HWND owner);
|
||||
|
||||
bool handleCommandHistoryKey(HWND window,const MSG &message)
|
||||
{
|
||||
if(message.message!=WM_KEYDOWN && message.message!=WM_SYSKEYDOWN) return false;
|
||||
if((GetKeyState(VK_LWIN)&0x8000) || (GetKeyState(VK_RWIN)&0x8000)) return false;
|
||||
const BYTE modifiers=((GetKeyState(VK_CONTROL)&0x8000)?HOTKEYF_CONTROL:0) |
|
||||
((GetKeyState(VK_MENU)&0x8000)?HOTKEYF_ALT:0) | ((GetKeyState(VK_SHIFT)&0x8000)?HOTKEYF_SHIFT:0);
|
||||
const auto binding=MAKEWORD(message.wParam,modifiers);
|
||||
if(binding==commandHistoryKeys[0] && (message.hwnd==terminalInput ||
|
||||
message.hwnd==panels[0].list || message.hwnd==panels[1].list))
|
||||
{
|
||||
if(terminalHistoryIndex) recallCommand(terminalHistoryIndex-1);
|
||||
return true;
|
||||
}
|
||||
if(binding!=commandHistoryKeys[1]) return false;
|
||||
if(!(message.lParam&(1LL<<30))) showCommandHistory(window);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
constexpr int historyList=324;
|
||||
|
||||
constexpr int historySearch=325;
|
||||
constexpr COLORREF historySearchSurface=RGB(132,177,177);
|
||||
constexpr int historySearchLabel=326;
|
||||
HWND commandHistoryWindow{};
|
||||
HHOOK historyMouseHook{};
|
||||
|
||||
LRESULT CALLBACK dismissHistoryOnOutsideClick(int code,WPARAM message,LPARAM data)
|
||||
{
|
||||
const auto dialog=commandHistoryWindow;
|
||||
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) &&
|
||||
(message==WM_LBUTTONDOWN || message==WM_RBUTTONDOWN ||
|
||||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN))
|
||||
{
|
||||
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
|
||||
const auto clicked=WindowFromPoint(point);
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
if(clicked!=dialog && !IsChild(dialog,clicked))
|
||||
{
|
||||
POINT local=point;
|
||||
RECT client{};
|
||||
GetClientRect(owner,&client);
|
||||
ScreenToClient(owner,&local);
|
||||
const bool ownerClient=clicked==owner || IsChild(owner,clicked);
|
||||
if(!ownerClient || PtInRect(&client,local))
|
||||
{
|
||||
const auto focusPanel=clicked==panels[0].list?panels[0].list:
|
||||
clicked==panels[1].list?panels[1].list:panels[active].list;
|
||||
if(PostMessageW(dialog,WM_CLOSE,reinterpret_cast<WPARAM>(ownerClient?focusPanel:nullptr),0)
|
||||
&& ownerClient) return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr,code,message,data);
|
||||
}
|
||||
|
||||
std::wstring historyLower(std::wstring value)
|
||||
{
|
||||
if(!value.empty()) CharLowerBuffW(value.data(),static_cast<DWORD>(value.size()));
|
||||
return value;
|
||||
}
|
||||
|
||||
std::wstring_view commandTopicKeywords(const std::wstring &command)
|
||||
{
|
||||
const auto start=command.find_first_not_of(L" \t");
|
||||
if(start==command.npos) return L"";
|
||||
const auto end=command.find_first_of(L" \t",start);
|
||||
auto tool=command.substr(start,end-start);
|
||||
if(tool.ends_with(L".exe")) tool.resize(tool.size()-4);
|
||||
const auto args=end==command.npos?L"":command.substr(end);
|
||||
if(tool==L"ipconfig" && args.find(L"/flushdns")!=args.npos)
|
||||
return L"network tools мережа dns flush clear cache очистити кеш";
|
||||
if(tool==L"nslookup" || tool==L"resolve-dnsname")
|
||||
return L"network tools мережа dns lookup запит";
|
||||
if(tool==L"ping" || tool==L"tracert" || tool==L"ipconfig" || tool==L"test-netconnection" ||
|
||||
tool==L"netstat" || tool==L"curl" || tool==L"invoke-webrequest")
|
||||
return L"network tools мережа з’єднання connectivity address адреса";
|
||||
if(tool==L"cls" || tool==L"clear" || tool==L"clear-host")
|
||||
return L"terminal clear screen очистити екран консоль";
|
||||
if(tool==L"git")
|
||||
{
|
||||
const auto first=args.find_first_not_of(L" \t");
|
||||
const auto action=first==args.npos?L"":args.substr(first,args.find_first_of(L" \t",first)-first);
|
||||
if(action==L"pull" || action==L"fetch" || action==L"update")
|
||||
return L"git version control repository update fetch pull оновити репозиторій синхронізація";
|
||||
return L"git version control repository репозиторій контроль версій";
|
||||
}
|
||||
return L"";
|
||||
}
|
||||
|
||||
void populateCommandHistory(HWND dialog)
|
||||
{
|
||||
const auto search=GetDlgItem(dialog,historySearch);
|
||||
std::wstring query(GetWindowTextLengthW(search)+1,L'\0');
|
||||
query.resize(GetWindowTextW(search,query.data(),static_cast<int>(query.size())));
|
||||
query=historyLower(std::move(query));
|
||||
std::wistringstream stream(query);
|
||||
std::vector<std::wstring> words;
|
||||
for(std::wstring word;stream>>word;) words.push_back(word);
|
||||
|
||||
const auto list=GetDlgItem(dialog,historyList);
|
||||
SendMessageW(list,WM_SETREDRAW,FALSE,0);
|
||||
SendMessageW(list,LB_RESETCONTENT,0,0);
|
||||
const auto add=[&](const std::wstring &label,LPARAM index)
|
||||
{
|
||||
const auto row=SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
|
||||
if(row>=0) SendMessageW(list,LB_SETITEMDATA,row,index);
|
||||
return row;
|
||||
};
|
||||
std::array<std::vector<size_t>,2> matches;
|
||||
std::set<std::wstring_view> seen;
|
||||
for(size_t index=terminalHistory.size();index-- > 0;)
|
||||
{
|
||||
const auto &entry=terminalHistory[index];
|
||||
if(!seen.insert(entry.command).second) continue;
|
||||
const auto command=historyLower(entry.command);
|
||||
const auto containsAll=[&](const std::wstring &text)
|
||||
{
|
||||
return std::all_of(words.begin(),words.end(),[&](const auto &word){return text.find(word)!=text.npos;});
|
||||
};
|
||||
if(containsAll(command+L" "+entry.date))
|
||||
{
|
||||
matches[0].push_back(index);
|
||||
continue;
|
||||
}
|
||||
const auto keywords=commandTopicKeywords(command);
|
||||
if(!keywords.empty() && containsAll(command+L" "+entry.date+L" "+std::wstring(keywords)))
|
||||
matches[1].push_back(index);
|
||||
}
|
||||
LRESULT selected=LB_ERR;
|
||||
for(const auto &group:matches)
|
||||
{
|
||||
std::wstring lastDate;
|
||||
bool first=true;
|
||||
for(auto match=group.rbegin();match!=group.rend();++match)
|
||||
{
|
||||
const auto index=*match;
|
||||
const auto &entry=terminalHistory[index];
|
||||
const auto &date=entry.date;
|
||||
if(first || date!=lastDate) add(date,-1);
|
||||
first=false;
|
||||
lastDate=date;
|
||||
const auto row=add(entry.command,index);
|
||||
if(row>=0 && (words.empty() || selected==LB_ERR)) selected=row;
|
||||
}
|
||||
}
|
||||
if(selected==LB_ERR) add(words.empty()?L"No commands yet":L"No matching commands",-2);
|
||||
else SendMessageW(list,LB_SETCURSEL,selected,0);
|
||||
SendMessageW(list,WM_SETREDRAW,TRUE,0);
|
||||
InvalidateRect(list,nullptr,TRUE);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK historySearchKeys(HWND search,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(message==WM_GETDLGCODE)
|
||||
{
|
||||
const auto code=DefSubclassProc(search,message,key,flags);
|
||||
if(key==VK_RETURN || key==VK_TAB || key==VK_ESCAPE) return code&~DLGC_WANTALLKEYS;
|
||||
return code|DLGC_WANTARROWS;
|
||||
}
|
||||
if(message==WM_KEYDOWN && (key==VK_UP || key==VK_DOWN))
|
||||
{
|
||||
const auto list=GetDlgItem(GetParent(search),historyList);
|
||||
SetFocus(list);
|
||||
SendMessageW(list,message,key,flags);
|
||||
return 0;
|
||||
}
|
||||
return DefSubclassProc(search,message,key,flags);
|
||||
}
|
||||
|
||||
|
||||
int historyRowInset(UINT dpi)
|
||||
{
|
||||
return std::max(MulDiv(4,dpi,96),dialogFrameMetrics(dpi).inner)+dialogRowTextInset(cellWidth);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK historyListScroll(HWND list,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(message==LB_GETTOPINDEX || message==WM_NCDESTROY)
|
||||
return DefSubclassProc(list,message,wp,lp);
|
||||
const auto top=SendMessageW(list,LB_GETTOPINDEX,0,0);
|
||||
const auto result=scrollList(list,message,wp,lp);
|
||||
if(top!=SendMessageW(list,LB_GETTOPINDEX,0,0))
|
||||
InvalidateRect(GetParent(list),nullptr,FALSE);
|
||||
return result;
|
||||
}
|
||||
|
||||
void sizeCommandHistory(HWND dialog,HWND list,bool movingOwner=false)
|
||||
{
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
RECT ownerBounds{};
|
||||
if(!GetWindowRect(owner,&ownerBounds) || IsIconic(owner)) return;
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
if(!GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor)) return;
|
||||
RECT area{};
|
||||
if(!IntersectRect(&area,&ownerBounds,&monitor.rcWork)) area=monitor.rcWork;
|
||||
|
||||
const auto dc=GetDC(list);
|
||||
if(!dc) return;
|
||||
const auto previous=SelectObject(dc,mono);
|
||||
LONG longest{};
|
||||
for(const auto &entry:terminalHistory)
|
||||
{
|
||||
SIZE size{};
|
||||
if(GetTextExtentPoint32W(dc,entry.command.c_str(),static_cast<int>(entry.command.size()),&size))
|
||||
longest=std::max(longest,size.cx);
|
||||
}
|
||||
SelectObject(dc,previous);
|
||||
ReleaseDC(list,dc);
|
||||
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
const auto frame=dialogFrameMetrics(dpi);
|
||||
const int inset=frame.content();
|
||||
const int top=dialogListInset(frame,textHeight);
|
||||
const int gap=MulDiv(4,dpi,96);
|
||||
const int listTop=top+2*textHeight+2*gap;
|
||||
const LONG desired=std::max<LONG>(MulDiv(320,dpi,96),
|
||||
longest+2*cellWidth+2*historyRowInset(dpi)+GetSystemMetricsForDpi(SM_CXVSCROLL,dpi))+2*inset;
|
||||
const auto target=std::min(desired,area.right-area.left);
|
||||
const auto rows=std::max<LRESULT>(1,SendMessageW(list,LB_GETCOUNT,0,0));
|
||||
RECT listWindow{},listClient{};
|
||||
GetWindowRect(list,&listWindow);
|
||||
GetClientRect(list,&listClient);
|
||||
const auto listBorder=listWindow.bottom-listWindow.top-listClient.bottom;
|
||||
const auto height=std::min<LONG>(listTop+rows*textHeight+listBorder+inset,
|
||||
area.bottom-area.top);
|
||||
const auto left=std::clamp((ownerBounds.left+ownerBounds.right-target)/2,area.left,area.right-target);
|
||||
const auto y=std::clamp((ownerBounds.top+ownerBounds.bottom-height)/2,area.top,area.bottom-height);
|
||||
RECT current{};
|
||||
if(movingOwner && GetWindowRect(dialog,¤t) &&
|
||||
current.right-current.left==target && current.bottom-current.top==height)
|
||||
{
|
||||
if(current.left!=left || current.top!=y)
|
||||
SetWindowPos(dialog,nullptr,left,y,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
return;
|
||||
}
|
||||
SetWindowPos(dialog,nullptr,left,y,target,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
const auto place=[&](HWND control,int rowTop,int rowHeight)
|
||||
{
|
||||
SetWindowPos(control,nullptr,inset,rowTop,std::max(1L,target-2*inset),rowHeight,
|
||||
SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
};
|
||||
place(GetDlgItem(dialog,historySearchLabel),top,textHeight);
|
||||
place(GetDlgItem(dialog,historySearch),top+textHeight+gap,textHeight);
|
||||
SetWindowPos(list,nullptr,inset,listTop,std::max(1L,target-2*inset-5),
|
||||
std::max(1L,height-listTop-inset),SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
GetClientRect(list,&listClient);
|
||||
if(rows*textHeight<=listClient.bottom) SendMessageW(list,LB_SETTOPINDEX,0,0);
|
||||
InvalidateRect(dialog,nullptr,FALSE);
|
||||
}
|
||||
|
||||
void layoutOpenCommandHistory(bool movingOwner=false)
|
||||
{
|
||||
if(commandHistoryWindow)
|
||||
sizeCommandHistory(commandHistoryWindow,GetDlgItem(commandHistoryWindow,historyList),movingOwner);
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
prepareCopyDialog(dialog);
|
||||
const auto list=GetDlgItem(dialog,historyList);
|
||||
SetWindowSubclass(list,historyListScroll,1,0);
|
||||
SendMessageW(list,WM_SETFONT,reinterpret_cast<WPARAM>(mono),TRUE);
|
||||
SendMessageW(list,LB_SETITEMHEIGHT,0,std::max(1,textHeight));
|
||||
const auto search=GetDlgItem(dialog,historySearch);
|
||||
SendDlgItemMessageW(dialog,historySearchLabel,WM_SETFONT,reinterpret_cast<WPARAM>(mono),TRUE);
|
||||
SetWindowSubclass(search,historySearchKeys,1,0);
|
||||
populateCommandHistory(dialog);
|
||||
sizeCommandHistory(dialog,list);
|
||||
SetFocus(search);
|
||||
return FALSE;
|
||||
}
|
||||
if(message==WM_COMMAND && LOWORD(wp)==historySearch && HIWORD(wp)==EN_CHANGE)
|
||||
{
|
||||
populateCommandHistory(dialog);
|
||||
sizeCommandHistory(dialog,GetDlgItem(dialog,historyList));
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_NCDESTROY && commandHistoryWindow==dialog)
|
||||
{
|
||||
if(historyMouseHook) UnhookWindowsHookEx(historyMouseHook);
|
||||
historyMouseHook=nullptr;
|
||||
commandHistoryWindow=nullptr;
|
||||
}
|
||||
if(message==WM_CTLCOLORLISTBOX || message==WM_CTLCOLOREDIT)
|
||||
{
|
||||
const auto dc=reinterpret_cast<HDC>(wp);
|
||||
const bool search=message==WM_CTLCOLOREDIT;
|
||||
SetTextColor(dc,search?RGB(0,0,0):colors[Text]);
|
||||
const auto background=search?historySearchSurface:colors[Dialog];
|
||||
SetBkColor(dc,background);
|
||||
if(search) SetDCBrushColor(dc,background);
|
||||
return reinterpret_cast<INT_PTR>(search?GetStockObject(DC_BRUSH):teal);
|
||||
}
|
||||
if(message!=WM_DRAWITEM || wp!=historyList)
|
||||
if(const auto painted=paintCopyDialog(dialog,message,wp,lp,L" History ",false,{},true,
|
||||
GetDlgItem(dialog,historyList)))
|
||||
return painted;
|
||||
if(message==WM_DRAWITEM && wp==historyList)
|
||||
{
|
||||
const auto &item=*reinterpret_cast<DRAWITEMSTRUCT*>(lp);
|
||||
if(item.itemID==UINT(-1)) return TRUE;
|
||||
const auto count=SendMessageW(item.hwndItem,LB_GETTEXTLEN,item.itemID,0);
|
||||
if(count<0) return TRUE;
|
||||
std::wstring label(static_cast<size_t>(count)+1,L'\0');
|
||||
SendMessageW(item.hwndItem,LB_GETTEXT,item.itemID,reinterpret_cast<LPARAM>(label.data()));
|
||||
label.resize(count);
|
||||
const bool selected=(item.itemState&ODS_SELECTED)!=0 && item.itemData<terminalHistory.size();
|
||||
const auto background=colors[selected?Selection:Dialog];
|
||||
const auto ink=colors[Text];
|
||||
if(item.itemData==static_cast<ULONG_PTR>(-1))
|
||||
{
|
||||
auto bounds=item.rcItem;
|
||||
InflateRect(&bounds,-historyRowInset(GetDpiForWindow(dialog)),0);
|
||||
const auto saved=SaveDC(item.hDC);
|
||||
SelectObject(item.hDC,mono);
|
||||
SetBkColor(item.hDC,background);
|
||||
SetTextColor(item.hDC,ink);
|
||||
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&bounds,nullptr,0,nullptr);
|
||||
const auto middle=(bounds.top+bounds.bottom)/2;
|
||||
RECT rule{bounds.left,middle,bounds.right,middle+1};
|
||||
SetBkColor(item.hDC,ink);
|
||||
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&rule,nullptr,0,nullptr);
|
||||
SIZE size{};
|
||||
GetTextExtentPoint32W(item.hDC,label.c_str(),static_cast<int>(label.size()),&size);
|
||||
const auto left=(bounds.left+bounds.right-size.cx)/2;
|
||||
RECT caption{left-cellWidth,bounds.top,left+size.cx+cellWidth,bounds.bottom};
|
||||
SetBkColor(item.hDC,background);
|
||||
ExtTextOutW(item.hDC,left,middle-size.cy/2,ETO_OPAQUE,&caption,label.c_str(),
|
||||
static_cast<UINT>(label.size()),nullptr);
|
||||
RestoreDC(item.hDC,saved);
|
||||
}
|
||||
else paintDialogListRow(item.hDC,item.rcItem,label,mono,ink,background,
|
||||
historyRowInset(GetDpiForWindow(dialog)),DT_END_ELLIPSIS);
|
||||
if(selected && (item.itemState&ODS_FOCUS)) DrawFocusRect(item.hDC,&item.rcItem);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const auto focusPanel=message==WM_CLOSE?reinterpret_cast<HWND>(wp):panels[active].list;
|
||||
DestroyWindow(dialog);
|
||||
if(focusPanel && IsWindow(owner) && !IsIconic(owner))
|
||||
{
|
||||
SetActiveWindow(owner);
|
||||
SetFocus(focusPanel);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_COMMAND && (LOWORD(wp)==IDOK || (LOWORD(wp)==historyList && HIWORD(wp)==LBN_DBLCLK)))
|
||||
{
|
||||
const auto row=SendDlgItemMessageW(dialog,historyList,LB_GETCURSEL,0,0);
|
||||
const auto index=SendDlgItemMessageW(dialog,historyList,LB_GETITEMDATA,row,0);
|
||||
if(index>=0 && static_cast<size_t>(index)<terminalHistory.size())
|
||||
{
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
DestroyWindow(dialog);
|
||||
SetActiveWindow(owner);
|
||||
recallCommand(static_cast<size_t>(index));
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void refreshCommandHistory()
|
||||
{
|
||||
if(commandHistoryWindow) populateCommandHistory(commandHistoryWindow);
|
||||
layoutOpenCommandHistory();
|
||||
}
|
||||
|
||||
void showCommandHistory(HWND owner)
|
||||
{
|
||||
if(!commandHistoryWindow)
|
||||
commandHistoryWindow=CreateDialogParamW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(111),owner,commandHistoryDialog,0);
|
||||
if(!commandHistoryWindow) return;
|
||||
if(!historyMouseHook)
|
||||
historyMouseHook=SetWindowsHookExW(WH_MOUSE_LL,dismissHistoryOnOutsideClick,GetModuleHandleW(nullptr),0);
|
||||
ShowWindow(commandHistoryWindow,SW_SHOW);
|
||||
SetActiveWindow(commandHistoryWindow);
|
||||
SetFocus(GetDlgItem(commandHistoryWindow,historySearch));
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
HWND activeFilesDialog{};
|
||||
HHOOK filesDialogMouseHook{};
|
||||
|
||||
bool filesMouseDown(UINT message)
|
||||
{
|
||||
return message==WM_LBUTTONDOWN || message==WM_RBUTTONDOWN ||
|
||||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK filesDialogMouse(int code,WPARAM message,LPARAM data)
|
||||
{
|
||||
const auto dialog=activeFilesDialog;
|
||||
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) && filesMouseDown(static_cast<UINT>(message)))
|
||||
{
|
||||
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
|
||||
const auto clicked=WindowFromPoint(point);
|
||||
const auto root=GetAncestor(dialog,GA_ROOTOWNER);
|
||||
if(clicked!=dialog && !IsChild(dialog,clicked))
|
||||
{
|
||||
RECT client{};
|
||||
POINT local=point;
|
||||
GetClientRect(root,&client);
|
||||
ScreenToClient(root,&local);
|
||||
if(clicked!=root || PtInRect(&client,local))
|
||||
{
|
||||
const bool internal=clicked==root || IsChild(root,clicked) ||
|
||||
(clicked && GetAncestor(clicked,GA_ROOTOWNER)==root);
|
||||
if(PostMessageW(dialog,WM_COMMAND,IDCANCEL,0) && internal) return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr,code,message,data);
|
||||
}
|
||||
|
||||
struct FilesDialogInput
|
||||
{
|
||||
HWND previous=activeFilesDialog;
|
||||
explicit FilesDialogInput(HWND dialog)
|
||||
{
|
||||
activeFilesDialog=dialog;
|
||||
if(!filesDialogMouseHook)
|
||||
filesDialogMouseHook=SetWindowsHookExW(WH_MOUSE_LL,filesDialogMouse,GetModuleHandleW(nullptr),0);
|
||||
}
|
||||
|
||||
~FilesDialogInput()
|
||||
{
|
||||
activeFilesDialog=previous;
|
||||
if(!previous && filesDialogMouseHook)
|
||||
{
|
||||
UnhookWindowsHookEx(filesDialogMouseHook);
|
||||
filesDialogMouseHook=nullptr;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
bool filesDialogHandlesMessage(HWND dialog,MSG &message)
|
||||
{
|
||||
if(message.hwnd==dialog || IsChild(dialog,message.hwnd)) return IsDialogMessageW(dialog,&message);
|
||||
if(!message.hwnd || GetAncestor(message.hwnd,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER)) return false;
|
||||
const bool input=(message.message>=WM_KEYFIRST && message.message<=WM_KEYLAST) ||
|
||||
(message.message>=WM_MOUSEFIRST && message.message<=WM_MOUSELAST) ||
|
||||
message.message==WM_CONTEXTMENU;
|
||||
if(input && message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)
|
||||
PostMessageW(dialog,WM_COMMAND,IDCANCEL,0);
|
||||
return input;
|
||||
}
|
||||
|
||||
struct FilesDialogResult
|
||||
{
|
||||
DLGPROC procedure;
|
||||
LPARAM argument;
|
||||
INT_PTR result=IDCANCEL;
|
||||
bool finished=false;
|
||||
};
|
||||
|
||||
constexpr wchar_t filesDialogResultProperty[]=L"KSwitcher.Files.DialogResult";
|
||||
|
||||
INT_PTR CALLBACK filesDialogProcedure(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
auto *result=reinterpret_cast<FilesDialogResult*>(GetPropW(dialog,filesDialogResultProperty));
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
result=reinterpret_cast<FilesDialogResult*>(lp);
|
||||
if(!SetPropW(dialog,filesDialogResultProperty,result)) return FALSE;
|
||||
lp=result->argument;
|
||||
}
|
||||
if(!result) return FALSE;
|
||||
const auto handled=result->procedure(dialog,message,wp,lp);
|
||||
if(message==WM_NCDESTROY) RemovePropW(dialog,filesDialogResultProperty);
|
||||
return handled;
|
||||
}
|
||||
|
||||
void finishFilesDialog(HWND dialog,INT_PTR answer)
|
||||
{
|
||||
auto *result=reinterpret_cast<FilesDialogResult*>(GetPropW(dialog,filesDialogResultProperty));
|
||||
if(!result) return;
|
||||
result->result=answer;
|
||||
result->finished=true;
|
||||
ShowWindow(dialog,SW_HIDE);
|
||||
}
|
||||
|
||||
INT_PTR runFilesDialog(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
|
||||
{
|
||||
FilesDialogResult result{procedure,argument};
|
||||
const auto dialog=CreateDialogParamW(instance,resource,owner,filesDialogProcedure,
|
||||
reinterpret_cast<LPARAM>(&result));
|
||||
if(!dialog) return -1;
|
||||
GetWindowRect(GetAncestor(owner,GA_ROOTOWNER),&previousOwnerBounds);
|
||||
FilesDialogInput input(dialog);
|
||||
ShowWindow(dialog,SW_SHOW);
|
||||
SetActiveWindow(dialog);
|
||||
MSG message{};
|
||||
while(!result.finished && IsWindow(dialog))
|
||||
{
|
||||
const auto status=GetMessageW(&message,nullptr,0,0);
|
||||
if(status<=0)
|
||||
{
|
||||
if(status==0) PostQuitMessage(static_cast<int>(message.wParam));
|
||||
break;
|
||||
}
|
||||
if(filesDialogHandlesMessage(dialog,message)) continue;
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
if(IsWindow(dialog)) DestroyWindow(dialog);
|
||||
if(IsWindow(owner) && !IsIconic(GetAncestor(owner,GA_ROOTOWNER))) SetActiveWindow(owner);
|
||||
return result.result;
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
struct FolderShortcut
|
||||
{
|
||||
fs::path path;
|
||||
std::string peerId;
|
||||
std::wstring peerName,title;
|
||||
};
|
||||
using FolderShortcuts=std::array<std::vector<FolderShortcut>,10>;
|
||||
FolderShortcuts folderShortcuts;
|
||||
constexpr wchar_t folderShortcutsKey[]=L"FolderShortcuts";
|
||||
|
||||
bool sameFolderShortcut(const FolderShortcut &left,const FolderShortcut &right)
|
||||
{
|
||||
return left.peerId==right.peerId &&
|
||||
CompareStringOrdinal(left.path.c_str(),-1,right.path.c_str(),-1,TRUE)==CSTR_EQUAL;
|
||||
}
|
||||
|
||||
bool saveFolderShortcuts(const FolderShortcuts &shortcuts)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto groups=Json::array();
|
||||
for(const auto &group:shortcuts)
|
||||
{
|
||||
auto rows=Json::array();
|
||||
for(const auto &entry:group)
|
||||
rows.push_back({{"path",ks::utf8(entry.path.native())},{"peer",entry.peerId},
|
||||
{"name",ks::utf8(entry.peerName)},{"title",ks::utf8(entry.title)}});
|
||||
groups.push_back(std::move(rows));
|
||||
}
|
||||
const auto data=groups.dump();
|
||||
return data.size()<=1024*1024 &&
|
||||
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,REG_BINARY,data.data(),
|
||||
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
|
||||
}
|
||||
catch(const std::exception &){return false;}
|
||||
}
|
||||
|
||||
void loadFolderShortcuts()
|
||||
{
|
||||
DWORD bytes{};
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
|
||||
!bytes || bytes>1024*1024) return;
|
||||
std::string data(bytes,'\0');
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto groups=Json::parse(data.begin(),data.begin()+bytes);
|
||||
if(!groups.is_array() || groups.size()!=folderShortcuts.size()) return;
|
||||
FolderShortcuts loaded;
|
||||
for(size_t digit=0;digit<loaded.size();++digit)
|
||||
{
|
||||
if(!groups[digit].is_array()) return;
|
||||
for(const auto &row:groups[digit])
|
||||
{
|
||||
FolderShortcut entry{filePath(row.at("path")),row.at("peer"),ks::wide(row.at("name")),ks::wide(row.value("title",""))};
|
||||
if(!entry.path.is_absolute() || entry.path.native().find(L'\0')!=std::wstring::npos) continue;
|
||||
loaded[digit].push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
folderShortcuts=std::move(loaded);
|
||||
}
|
||||
catch(const std::exception &) {}
|
||||
}
|
||||
|
||||
constexpr int shortcutTitle=314,shortcutPath=211;
|
||||
|
||||
struct FolderShortcutEdit
|
||||
{
|
||||
FolderShortcut entry;
|
||||
int digit;
|
||||
std::optional<size_t> index;
|
||||
};
|
||||
|
||||
std::wstring shortcutField(HWND dialog,int id)
|
||||
{
|
||||
const auto field=GetDlgItem(dialog,id);
|
||||
const auto length=GetWindowTextLengthW(field);
|
||||
std::wstring result(static_cast<size_t>(length)+1,L'\0');
|
||||
result.resize(GetWindowTextW(field,result.data(),length+1));
|
||||
return result;
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(const auto painted=paintCopyDialog(dialog,message,wp,lp,L" Folder shortcuts ",false,{315}))
|
||||
return painted;
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
auto &edit=*reinterpret_cast<FolderShortcutEdit*>(lp);
|
||||
SetWindowLongPtrW(dialog,DWLP_USER,lp);
|
||||
prepareCopyDialog(dialog);
|
||||
layoutDialogContent(dialog,316);
|
||||
for(const auto id:{316,317,IDOK,IDCANCEL})
|
||||
SendDlgItemMessageW(dialog,id,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
|
||||
SendDlgItemMessageW(dialog,shortcutTitle,EM_SETLIMITTEXT,255,0);
|
||||
SendDlgItemMessageW(dialog,shortcutPath,EM_SETLIMITTEXT,32767,0);
|
||||
SetDlgItemTextW(dialog,shortcutTitle,edit.entry.title.c_str());
|
||||
SetDlgItemTextW(dialog,shortcutPath,edit.entry.path.c_str());
|
||||
if(!edit.entry.peerId.empty())
|
||||
SetDlgItemTextW(dialog,317,(L"Path on "+edit.entry.peerName).c_str());
|
||||
layoutDialogButtons(dialog,315,{IDOK,IDCANCEL});
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
finishFilesDialog(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND || LOWORD(wp)!=IDOK) return FALSE;
|
||||
auto &edit=*reinterpret_cast<FolderShortcutEdit*>(GetWindowLongPtrW(dialog,DWLP_USER));
|
||||
auto path=fs::path(shortcutField(dialog,shortcutPath)).lexically_normal();
|
||||
if(path.has_relative_path() && !path.has_filename()) path=path.parent_path();
|
||||
const auto root=path.root_name().native();
|
||||
const bool lanDrive=root.size()==2 && root[1]==L':' &&
|
||||
((root[0]>=L'A' && root[0]<=L'Z') || (root[0]>=L'a' && root[0]<=L'z'));
|
||||
if(!path.is_absolute() || (!edit.entry.peerId.empty() && !lanDrive))
|
||||
{
|
||||
MessageBoxW(dialog,L"Enter an absolute folder path on the original computer.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
SetFocus(GetDlgItem(dialog,shortcutPath));
|
||||
return TRUE;
|
||||
}
|
||||
auto entry=edit.entry;
|
||||
entry.path=std::move(path);
|
||||
entry.title=shortcutField(dialog,shortcutTitle);
|
||||
const auto &group=folderShortcuts[edit.digit];
|
||||
for(size_t i=0;i<group.size();++i)
|
||||
if((!edit.index || i!=*edit.index) && sameFolderShortcut(group[i],entry))
|
||||
{
|
||||
MessageBoxW(dialog,L"This folder is already assigned to this digit.",L"Folder shortcuts",MB_OK|MB_ICONINFORMATION);
|
||||
return TRUE;
|
||||
}
|
||||
auto updated=folderShortcuts;
|
||||
if(edit.index) updated[edit.digit][*edit.index]=std::move(entry);
|
||||
else updated[edit.digit].push_back(std::move(entry));
|
||||
if(!saveFolderShortcuts(updated))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
folderShortcuts=std::move(updated);
|
||||
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
|
||||
finishFilesDialog(dialog,IDOK);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
bool editFolderShortcut(HWND owner,FolderShortcutEdit &edit)
|
||||
{
|
||||
return runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(110),owner,
|
||||
folderShortcutEditDialog,reinterpret_cast<LPARAM>(&edit))==IDOK;
|
||||
}
|
||||
|
||||
void addFolderShortcut(const Panel &panel,int digit)
|
||||
{
|
||||
if(panel.directory.empty()) return;
|
||||
FolderShortcutEdit edit{{panel.directory,panel.peer?panel.peer->id:"",
|
||||
panel.peer?panel.peer->name:L""},digit,{}};
|
||||
if(editFolderShortcut(GetParent(panel.list),edit))
|
||||
setStatus(L"Folder added to Ctrl+"+std::to_wstring(digit)+L".");
|
||||
SetFocus(panel.list);
|
||||
}
|
||||
|
||||
void openFolderShortcut(Panel &panel,const FolderShortcut &entry)
|
||||
{
|
||||
if(entry.peerId.empty())
|
||||
{
|
||||
openOn(panel,entry.path,true,{});
|
||||
return;
|
||||
}
|
||||
queryDrivePeers();
|
||||
const auto peer=std::find_if(drivePeers.begin(),drivePeers.end(),
|
||||
[&](const auto &candidate){return candidate.id==entry.peerId && !candidate.address.empty();});
|
||||
if(peer==drivePeers.end())
|
||||
{
|
||||
setStatus(L"Paired computer unavailable: "+entry.peerName);
|
||||
return;
|
||||
}
|
||||
openOn(panel,entry.path,false,*peer);
|
||||
}
|
||||
|
||||
constexpr int shortcutList=310,quickRemove=313,quickEdit=318,quickInsert=319,quickMoveUp=320,quickMoveDown=321;
|
||||
constexpr wchar_t quickFolderHints[]=L"Ins Del F4 Ctrl+Up Ctrl+Down";
|
||||
struct FolderShortcutView
|
||||
{
|
||||
int digit;
|
||||
std::optional<FolderShortcut> chosen;
|
||||
std::vector<int> digits;
|
||||
int rowHeight{};
|
||||
Panel *panel{};
|
||||
bool all{};
|
||||
std::vector<std::pair<int,size_t>> entries;
|
||||
};
|
||||
|
||||
std::wstring shortcutLabel(const FolderShortcut &entry)
|
||||
{
|
||||
return entry.title.empty()?(entry.peerId.empty()?L"":entry.peerName+L" · ")+entry.path.native():entry.title;
|
||||
}
|
||||
|
||||
void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
|
||||
{
|
||||
const auto list=GetDlgItem(dialog,shortcutList);
|
||||
SendMessageW(list,LB_RESETCONTENT,0,0);
|
||||
view.digits.clear();
|
||||
view.entries.clear();
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const auto dc=GetDC(dialog);
|
||||
const auto font=SelectObject(dc,mono);
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
const auto px=[&](int value){return MulDiv(value,dpi,96);};
|
||||
TEXTMETRICW metrics{};
|
||||
GetTextMetricsW(dc,&metrics);
|
||||
view.rowHeight=std::max(1L,metrics.tmHeight);
|
||||
const auto frame=dialogFrameMetrics(dpi);
|
||||
const int inset=frame.content();
|
||||
const int listTop=dialogListInset(frame,view.rowHeight);
|
||||
const auto measure=[&](std::wstring_view label)
|
||||
{
|
||||
SIZE size{};
|
||||
GetTextExtentPoint32W(dc,label.data(),static_cast<int>(label.size()),&size);
|
||||
return static_cast<int>(size.cx);
|
||||
};
|
||||
int width=std::max(measure(L"Folder shortcuts"),measure(view.all?L"Del F4":view.digit<0?L"Enter Esc":quickFolderHints))+3*cellWidth;
|
||||
const int textInset=dialogRowTextInset(cellWidth);
|
||||
const auto add=[&](const std::wstring &label)
|
||||
{
|
||||
width=std::max(width,measure(label)+2*textInset);
|
||||
SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
|
||||
};
|
||||
if(view.all)
|
||||
{
|
||||
for(int digit=0;digit<static_cast<int>(folderShortcuts.size());++digit)
|
||||
for(size_t index=0;index<folderShortcuts[digit].size();++index)
|
||||
{
|
||||
view.entries.emplace_back(digit,index);
|
||||
add(std::to_wstring(digit)+L" "+shortcutLabel(folderShortcuts[digit][index]));
|
||||
}
|
||||
}
|
||||
else if(view.digit<0)
|
||||
{
|
||||
for(int digit=0;digit<10;++digit)
|
||||
if(!folderShortcuts[digit].empty())
|
||||
{
|
||||
view.digits.push_back(digit);
|
||||
add(L"Ctrl+"+std::to_wstring(digit));
|
||||
}
|
||||
}
|
||||
else for(const auto &entry:folderShortcuts[view.digit])
|
||||
add(shortcutLabel(entry));
|
||||
SelectObject(dc,font);
|
||||
ReleaseDC(dialog,dc);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||
const auto &work=monitor.rcWork;
|
||||
RECT parent{};
|
||||
GetWindowRect(owner,&parent);
|
||||
const auto windowWidth=std::max(px(260),static_cast<int>(parent.right-parent.left));
|
||||
const auto windowHeight=std::max(px(140),static_cast<int>(parent.bottom-parent.top));
|
||||
width=std::min(width,std::max(1,std::min(windowWidth,static_cast<int>(work.right-work.left))-2*inset));
|
||||
const auto maxRows=std::max(1,(std::min(windowHeight,static_cast<int>(work.bottom-work.top))-2*listTop)/view.rowHeight);
|
||||
const auto count=static_cast<int>(SendMessageW(list,LB_GETCOUNT,0,0));
|
||||
const int rows=std::min(count,maxRows);
|
||||
SendMessageW(list,LB_SETITEMHEIGHT,0,view.rowHeight);
|
||||
const int listHeight=rows*view.rowHeight;
|
||||
const int w=width+2*inset,h=listHeight+2*listTop;
|
||||
const int x=std::clamp(static_cast<int>((parent.left+parent.right-w)/2),
|
||||
static_cast<int>(work.left),std::max(static_cast<int>(work.left),static_cast<int>(work.right)-w));
|
||||
const int y=std::clamp(static_cast<int>((parent.top+parent.bottom-h)/2),
|
||||
static_cast<int>(work.top),std::max(static_cast<int>(work.top),static_cast<int>(work.bottom)-h));
|
||||
SetWindowPos(dialog,nullptr,x,y,w,h,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
SetWindowPos(list,nullptr,inset,listTop,width,listHeight,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
SendMessageW(list,LB_SETCURSEL,std::clamp(selection,0,std::max(0,count-1)),0);
|
||||
InvalidateRect(dialog,nullptr,TRUE);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK quickFolderKeys(HWND list,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(message==WM_KEYDOWN)
|
||||
{
|
||||
int command=0;
|
||||
if(key==VK_RETURN) command=IDOK;
|
||||
if(key==VK_DELETE) command=quickRemove;
|
||||
if(key==VK_F4) command=quickEdit;
|
||||
if(key==VK_INSERT) command=quickInsert;
|
||||
if((key==VK_UP || key==VK_DOWN) && (GetKeyState(VK_CONTROL)&0x8000))
|
||||
command=key==VK_UP?quickMoveUp:quickMoveDown;
|
||||
if(command)
|
||||
{
|
||||
if(!(flags & (1LL<<30))) SendMessageW(GetParent(list),WM_COMMAND,command,0);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return scrollList(list,message,key,flags);
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
auto &view=*reinterpret_cast<FolderShortcutView*>(lp);
|
||||
SetWindowLongPtrW(dialog,DWLP_USER,lp);
|
||||
prepareCopyDialog(dialog);
|
||||
layoutQuickFolders(dialog,view,0);
|
||||
const auto list=GetDlgItem(dialog,shortcutList);
|
||||
SetWindowSubclass(list,quickFolderKeys,1,0);
|
||||
SetFocus(list);
|
||||
return FALSE;
|
||||
}
|
||||
auto *view=reinterpret_cast<FolderShortcutView*>(GetWindowLongPtrW(dialog,DWLP_USER));
|
||||
if(!view) return FALSE;
|
||||
if(message==WM_DESTROY)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
if(message==WM_PAINT)
|
||||
{
|
||||
PAINTSTRUCT ps{};
|
||||
const auto dc=BeginPaint(dialog,&ps);
|
||||
RECT client{};
|
||||
GetClientRect(dialog,&client);
|
||||
const auto frame=CreateSolidBrush(colors[Text]);
|
||||
const auto bounds=paintDialogFrame(dc,client,dialogFrameMetrics(GetDpiForWindow(dialog)),teal,frame);
|
||||
DeleteObject(frame);
|
||||
paintDialogFrameCaptions(dc,client,bounds,L"Folder shortcuts",teal,colors[Text],mono,
|
||||
view->all?L"Del F4":view->digit<0?L"Enter Esc":quickFolderHints);
|
||||
EndPaint(dialog,&ps);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_DRAWITEM && wp==shortcutList)
|
||||
{
|
||||
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
|
||||
if(item.itemID==UINT(-1)) return TRUE;
|
||||
const auto length=SendMessageW(item.hwndItem,LB_GETTEXTLEN,item.itemID,0);
|
||||
if(length<0) return TRUE;
|
||||
std::wstring label(static_cast<size_t>(length)+1,L'\0');
|
||||
SendMessageW(item.hwndItem,LB_GETTEXT,item.itemID,reinterpret_cast<LPARAM>(label.data()));
|
||||
const auto background=colors[item.itemState&ODS_SELECTED?Selection:Dialog];
|
||||
paintDialogListRow(item.hDC,item.rcItem,std::wstring_view(label.data(),static_cast<size_t>(length)),
|
||||
mono,colors[Text],background,dialogRowTextInset(cellWidth));
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
finishFilesDialog(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND) return FALSE;
|
||||
const auto action=LOWORD(wp);
|
||||
if(action!=quickRemove && action!=quickEdit && action!=quickInsert && action!=quickMoveUp &&
|
||||
action!=quickMoveDown && action!=IDOK && !(action==shortcutList && HIWORD(wp)==LBN_DBLCLK)) return FALSE;
|
||||
const auto row=SendDlgItemMessageW(dialog,shortcutList,LB_GETCURSEL,0,0);
|
||||
if(!view->all && view->digit<0)
|
||||
{
|
||||
if((action==IDOK || action==shortcutList) && row>=0 && static_cast<size_t>(row)<view->digits.size())
|
||||
{
|
||||
view->digit=view->digits[row];
|
||||
layoutQuickFolders(dialog,*view,0);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
int digit=view->digit;
|
||||
size_t index=static_cast<size_t>(row);
|
||||
if(view->all)
|
||||
{
|
||||
if(index>=view->entries.size() || action==quickInsert ||
|
||||
action==quickMoveUp || action==quickMoveDown) return TRUE;
|
||||
const auto &entry=view->entries[index];
|
||||
digit=entry.first;
|
||||
index=entry.second;
|
||||
}
|
||||
const auto &group=folderShortcuts[digit];
|
||||
if(action==quickInsert)
|
||||
{
|
||||
const auto &panel=*view->panel;
|
||||
FolderShortcutEdit edit{{panel.directory,panel.peer?panel.peer->id:"",
|
||||
panel.peer?panel.peer->name:L""},view->digit,{}};
|
||||
if(!panel.directory.empty() && editFolderShortcut(dialog,edit))
|
||||
layoutQuickFolders(dialog,*view,static_cast<int>(group.size())-1);
|
||||
SetFocus(GetDlgItem(dialog,shortcutList));
|
||||
return TRUE;
|
||||
}
|
||||
if(index>=group.size()) return TRUE;
|
||||
if(action==quickMoveUp || action==quickMoveDown)
|
||||
{
|
||||
const auto target=row+(action==quickMoveUp?-1:1);
|
||||
if(target<0 || static_cast<size_t>(target)>=group.size()) return TRUE;
|
||||
auto updated=folderShortcuts;
|
||||
std::swap(updated[view->digit][row],updated[view->digit][target]);
|
||||
if(!saveFolderShortcuts(updated))
|
||||
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
else
|
||||
{
|
||||
folderShortcuts=std::move(updated);
|
||||
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
|
||||
layoutQuickFolders(dialog,*view,static_cast<int>(target));
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(action==quickEdit)
|
||||
{
|
||||
FolderShortcutEdit edit{group[index],digit,index};
|
||||
if(editFolderShortcut(dialog,edit)) layoutQuickFolders(dialog,*view,static_cast<int>(row));
|
||||
SetFocus(GetDlgItem(dialog,shortcutList));
|
||||
return TRUE;
|
||||
}
|
||||
if(action==quickRemove)
|
||||
{
|
||||
auto updated=folderShortcuts;
|
||||
updated[digit].erase(updated[digit].begin()+index);
|
||||
if(!saveFolderShortcuts(updated))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts",MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
folderShortcuts=std::move(updated);
|
||||
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
|
||||
if(view->all?view->entries.size()==1:folderShortcuts[digit].empty()) finishFilesDialog(dialog,IDCANCEL);
|
||||
else layoutQuickFolders(dialog,*view,static_cast<int>(row));
|
||||
return TRUE;
|
||||
}
|
||||
view->chosen=group[index];
|
||||
finishFilesDialog(dialog,IDOK);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
void showFolderShortcuts(Panel &panel,int digit,bool all)
|
||||
{
|
||||
if(digit>=0)
|
||||
{
|
||||
const auto &group=folderShortcuts[digit];
|
||||
if(group.empty())
|
||||
{
|
||||
setStatus(L"No folders assigned to Ctrl+"+std::to_wstring(digit)+L".");
|
||||
return;
|
||||
}
|
||||
if(group.size()==1)
|
||||
{
|
||||
openFolderShortcut(panel,group.front());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(digit<0 && std::all_of(folderShortcuts.begin(),folderShortcuts.end(),[](const auto &group){return group.empty();}))
|
||||
{
|
||||
setStatus(L"No folder shortcuts.");
|
||||
return;
|
||||
}
|
||||
FolderShortcutView view{digit};
|
||||
view.panel=&panel;
|
||||
view.all=all;
|
||||
const auto result=runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(109),GetParent(panel.list),
|
||||
quickFolderDialog,reinterpret_cast<LPARAM>(&view));
|
||||
SetFocus(panel.list);
|
||||
if(result==IDOK && view.chosen) openFolderShortcut(panel,*view.chosen);
|
||||
else if(result==-1) setStatus(L"Could not open Folder shortcuts.");
|
||||
}
|
||||
@@ -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;
|
||||
+1587
-659
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,660 @@
|
||||
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286;
|
||||
constexpr int allOptions=4;
|
||||
enum OptionsPage { PanelModes, PanelColors, Appearance, CommandLine };
|
||||
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance",L"Command line"};
|
||||
constexpr const wchar_t *colorNames[]{L"Panel background",L"Text",L"Frame",L"Popup background",
|
||||
L"Menu selection",L"Command bar",L"File cursor"};
|
||||
enum PanelSection { PanelLayout, WideColumns, StatusColumns };
|
||||
constexpr const wchar_t *panelSectionNames[]{L"Panel layout",L"Wide panel columns",L"Status line columns"};
|
||||
constexpr int panelLayoutControls[]{273,274};
|
||||
constexpr int wideColumnControls[]{275,276,201,202,204};
|
||||
constexpr int statusColumnControls[]{277,203,205,206,207,208};
|
||||
constexpr int colorOptionControls[]{279,280};
|
||||
constexpr int appearanceControls[]{281,282};
|
||||
enum AppearanceSection { Font, DialogFrame };
|
||||
constexpr int outerFramePadding=303,innerFramePadding=305,rowTextPadding=307;
|
||||
constexpr int dialogFrameControls[]{302,outerFramePadding,304,innerFramePadding,306,rowTextPadding};
|
||||
constexpr struct
|
||||
{
|
||||
int id;
|
||||
DWORD DialogFramePadding::*value;
|
||||
DWORD maximum;
|
||||
} frameOptions[]{
|
||||
{outerFramePadding,&DialogFramePadding::outer,maxDialogFramePadding},
|
||||
{innerFramePadding,&DialogFramePadding::inner,maxDialogFramePadding},
|
||||
{rowTextPadding,&DialogFramePadding::rowText,maxDialogRowTextPadding}};
|
||||
|
||||
enum class OptionsAnchor { StretchWidth, StretchHeight, BottomRight };
|
||||
struct OptionsControl
|
||||
{
|
||||
int id;
|
||||
OptionsAnchor anchor;
|
||||
RECT bounds{};
|
||||
};
|
||||
|
||||
struct PanelProperty
|
||||
{
|
||||
int section, control;
|
||||
const wchar_t *name;
|
||||
DWORD defaultValue;
|
||||
};
|
||||
constexpr PanelProperty panelProperties[]{
|
||||
{PanelLayout,273,L"Left panel: Wide",defaultWide},{PanelLayout,274,L"Right panel: Wide",defaultWide},
|
||||
{WideColumns,201,L"Size column width",defaultSizeWidth},
|
||||
{WideColumns,202,L"Align file extensions",defaultAlignExtensions},
|
||||
{WideColumns,204,L"Align folder extensions",defaultAlignFolders},
|
||||
{StatusColumns,203,L"Show details",defaultShowDetails},
|
||||
{StatusColumns,205,L"Name",defaultStatusFields&1},{StatusColumns,206,L"Size",(defaultStatusFields>>1)&1},
|
||||
{StatusColumns,207,L"Date",(defaultStatusFields>>2)&1},{StatusColumns,208,L"Time",(defaultStatusFields>>3)&1}};
|
||||
void resetPanelProperty(HWND dialog,const PanelProperty &property)
|
||||
{
|
||||
if(property.control==201) SetDlgItemInt(dialog,property.control,property.defaultValue,FALSE);
|
||||
else CheckDlgButton(dialog,property.control,property.defaultValue);
|
||||
}
|
||||
|
||||
struct OptionsBaseline
|
||||
{
|
||||
LOGFONTW font=displayFont;
|
||||
int points=fontPoints;
|
||||
DialogFramePadding padding=dialogFramePadding;
|
||||
std::array<COLORREF,ColorCount> palette=colors;
|
||||
std::vector<FileStyle> styles=fileStyles;
|
||||
std::array<WORD,2> keys=commandHistoryKeys;
|
||||
DWORD width=static_cast<DWORD>(sizeWidth), fields=statusFields;
|
||||
bool extensions=alignExtensions, folders=alignFolders, details=showDetails;
|
||||
std::array<bool,2> wide{panels[0].wide,panels[1].wide};
|
||||
};
|
||||
|
||||
struct OptionsDraft
|
||||
{
|
||||
OptionsBaseline baseline;
|
||||
LOGFONTW font=displayFont;
|
||||
int points=fontPoints;
|
||||
DialogFramePadding framePadding=dialogFramePadding;
|
||||
std::array<COLORREF,ColorCount> palette{};
|
||||
std::array<COLORREF,16> custom{};
|
||||
std::vector<FileStyle> styles=fileStyles;
|
||||
int styleIndex=0;
|
||||
bool styleSuggestionsLoaded{};
|
||||
int page=PanelModes, section=PanelLayout;
|
||||
HTREEITEM wideColumnsNode{};
|
||||
RECT initialClient{};
|
||||
UINT initialDpi=96;
|
||||
OptionsControl controls[8]{
|
||||
{optionsTree,OptionsAnchor::StretchHeight},
|
||||
{optionsTitle,OptionsAnchor::StretchWidth},
|
||||
{275,OptionsAnchor::StretchWidth},{277,OptionsAnchor::StretchWidth},
|
||||
{281,OptionsAnchor::StretchWidth},
|
||||
{IDOK,OptionsAnchor::BottomRight},{IDCANCEL,OptionsAnchor::BottomRight},
|
||||
{optionsApply,OptionsAnchor::BottomRight}};
|
||||
|
||||
};
|
||||
|
||||
#include "files_style_options.inl"
|
||||
#include "files_options_preview.inl"
|
||||
|
||||
bool optionsFontChanged(const OptionsDraft &draft)
|
||||
{
|
||||
auto pending=draft.font, applied=draft.baseline.font;
|
||||
// Height follows the selected point size and DPI. applyFont always uses natural width.
|
||||
pending.lfHeight=applied.lfHeight=0;
|
||||
pending.lfWidth=applied.lfWidth=0;
|
||||
return draft.points!=draft.baseline.points ||
|
||||
memcmp(&pending,&applied,offsetof(LOGFONTW,lfFaceName))!=0 ||
|
||||
_wcsicmp(pending.lfFaceName,applied.lfFaceName)!=0;
|
||||
}
|
||||
|
||||
struct OptionsWriteLock
|
||||
{
|
||||
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Files.OptionsWrite");
|
||||
DWORD result=mutex?WaitForSingleObject(mutex,2000):WAIT_FAILED;
|
||||
|
||||
bool acquired() const { return result==WAIT_OBJECT_0 || result==WAIT_ABANDONED; }
|
||||
~OptionsWriteLock()
|
||||
{
|
||||
if(acquired()) ReleaseMutex(mutex);
|
||||
if(mutex) CloseHandle(mutex);
|
||||
}
|
||||
};
|
||||
|
||||
void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
|
||||
{
|
||||
BOOL valid{};
|
||||
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
DWORD fields=0;
|
||||
for(int i=0;i<4;++i) if(checked(205+i)) fields|=1u<<i;
|
||||
const auto &base=draft.baseline;
|
||||
const bool dirty=!valid || width!=base.width ||
|
||||
checked(273)!=base.wide[0] || checked(274)!=base.wide[1] ||
|
||||
checked(202)!=base.extensions || checked(204)!=base.folders || checked(203)!=base.details ||
|
||||
fields!=base.fields || draft.palette!=base.palette || draft.styles!=base.styles ||
|
||||
draft.framePadding!=base.padding || optionsFontChanged(draft) ||
|
||||
optionHistoryKeys(dialog)!=base.keys;
|
||||
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
|
||||
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK optionsGroupBackground(HWND group,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(message==WM_ERASEBKGND)
|
||||
{
|
||||
const auto dc=reinterpret_cast<HDC>(wp);
|
||||
RECT client{};
|
||||
GetClientRect(group,&client);
|
||||
FillRect(dc,&client,GetSysColorBrush(COLOR_3DFACE));
|
||||
return TRUE;
|
||||
}
|
||||
return DefSubclassProc(group,message,wp,lp);
|
||||
}
|
||||
|
||||
void layoutOptionsPreview(HWND dialog)
|
||||
{
|
||||
const auto preview=GetDlgItem(dialog,fileStylePreview);
|
||||
if(!(GetWindowLongPtrW(preview,GWL_STYLE)&WS_VISIBLE)) return;
|
||||
RECT gap{0,0,6,5};
|
||||
MapDialogRect(dialog,&gap);
|
||||
const auto title=dialogControlBounds(dialog,optionsTitle);
|
||||
const auto buttons=dialogControlBounds(dialog,IDOK);
|
||||
LONG top=title.bottom;
|
||||
for(auto child=GetWindow(dialog,GW_CHILD);child;child=GetWindow(child,GW_HWNDNEXT))
|
||||
{
|
||||
const auto id=GetDlgCtrlID(child);
|
||||
if(child==preview || id==IDOK || id==IDCANCEL || id==optionsApply ||
|
||||
!(GetWindowLongPtrW(child,GWL_STYLE)&WS_VISIBLE)) continue;
|
||||
const auto bounds=dialogControlBounds(dialog,id);
|
||||
if(id!=-1 && bounds.left>=title.left-4) top=std::max(top,bounds.bottom);
|
||||
}
|
||||
top+=gap.bottom;
|
||||
SetWindowPos(preview,nullptr,title.left,top,title.right-title.left,
|
||||
std::max(1L,buttons.top-gap.bottom-top),SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
|
||||
}
|
||||
|
||||
void layoutOptions(HWND dialog,const OptionsDraft &draft)
|
||||
{
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
const auto scale=[&](LONG value){return MulDiv(value,dpi,draft.initialDpi);};
|
||||
RECT client{};
|
||||
GetClientRect(dialog,&client);
|
||||
const auto dx=std::max(0L,client.right-scale(draft.initialClient.right));
|
||||
const auto dy=std::max(0L,client.bottom-scale(draft.initialClient.bottom));
|
||||
for(const auto &control:draft.controls)
|
||||
{
|
||||
const auto &bounds=control.bounds;
|
||||
auto x=scale(bounds.left),y=scale(bounds.top);
|
||||
auto width=scale(bounds.right)-x,height=scale(bounds.bottom)-y;
|
||||
switch(control.anchor)
|
||||
{
|
||||
case OptionsAnchor::StretchWidth: width+=dx;break;
|
||||
case OptionsAnchor::StretchHeight: height+=dy;break;
|
||||
case OptionsAnchor::BottomRight: x+=dx;y+=dy;break;
|
||||
}
|
||||
SetWindowPos(GetDlgItem(dialog,control.id),nullptr,x,y,width,height,
|
||||
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
|
||||
}
|
||||
if(draft.page==PanelColors && draft.section==fileStyleSection) layoutFileStyleFields(dialog,false);
|
||||
layoutOptionsPreview(dialog);
|
||||
// Paint the final layout together, including the areas vacated by moved controls.
|
||||
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
}
|
||||
|
||||
void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
|
||||
{
|
||||
draft.page=page;
|
||||
draft.section=section;
|
||||
const wchar_t *title=L"No matching settings";
|
||||
if(page==allOptions) title=L"All settings";
|
||||
else if(page>=0)
|
||||
{
|
||||
title=optionsTitles[page];
|
||||
if(section>=0)
|
||||
title=page==PanelModes?panelSectionNames[section]:page==PanelColors?
|
||||
(section==fileStyleSection?L"File type styles":colorNames[section]):
|
||||
page==CommandLine?L"Command history":section==DialogFrame?L"Dialog frame":L"Font";
|
||||
}
|
||||
SetDlgItemTextW(dialog,optionsTitle,title);
|
||||
const auto show=[&](auto &controls,bool visible)
|
||||
{
|
||||
for(const auto id:controls) ShowWindow(GetDlgItem(dialog,id),visible?SW_SHOW:SW_HIDE);
|
||||
};
|
||||
EnableWindow(GetDlgItem(dialog,optionsReset),page>=0);
|
||||
const bool individual=page==PanelModes && section>=0;
|
||||
ShowWindow(GetDlgItem(dialog,optionsProperty),individual?SW_SHOW:SW_HIDE);
|
||||
ShowWindow(GetDlgItem(dialog,optionsResetProperty),individual?SW_SHOW:SW_HIDE);
|
||||
if(individual)
|
||||
{
|
||||
SendDlgItemMessageW(dialog,optionsProperty,CB_RESETCONTENT,0,0);
|
||||
for(size_t i=0;i<std::size(panelProperties);++i)
|
||||
{
|
||||
const auto &property=panelProperties[i];
|
||||
if(property.section!=section) continue;
|
||||
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_ADDSTRING,0,
|
||||
reinterpret_cast<LPARAM>(property.name));
|
||||
if(row>=0) SendDlgItemMessageW(dialog,optionsProperty,CB_SETITEMDATA,row,i);
|
||||
}
|
||||
SendDlgItemMessageW(dialog,optionsProperty,CB_SETCURSEL,0,0);
|
||||
}
|
||||
show(panelLayoutControls,page==PanelModes && section==PanelLayout);
|
||||
show(wideColumnControls,page==PanelModes && section==WideColumns);
|
||||
show(statusColumnControls,page==PanelModes && section==StatusColumns);
|
||||
show(appearanceControls,page==Appearance && section==Font);
|
||||
show(dialogFrameControls,page==Appearance && section==DialogFrame);
|
||||
if(page==Appearance && section==DialogFrame)
|
||||
{
|
||||
for(const auto &option:frameOptions)
|
||||
SendDlgItemMessageW(dialog,option.id,CB_SETCURSEL,draft.framePadding.*option.value,0);
|
||||
}
|
||||
constexpr int commandLineControls[]{320,321,322,323};
|
||||
show(commandLineControls,page==CommandLine && section==0);
|
||||
const bool fileTypes=page==PanelColors && section==fileStyleSection;
|
||||
constexpr int fileStyleControls[]{287,288,289,290,291,292,293,294,295,296,297,298,300,fileStyleState};
|
||||
show(fileStyleControls,fileTypes);
|
||||
const auto preview=GetDlgItem(dialog,fileStylePreview);
|
||||
ShowWindow(preview,page>=0 && page<CommandLine && section>=0?SW_SHOW:SW_HIDE);
|
||||
layoutOptionsPreview(dialog);
|
||||
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
|
||||
show(colorOptionControls,page==PanelColors && section>=0 && !fileTypes);
|
||||
if(fileTypes)
|
||||
{
|
||||
if(!draft.styleSuggestionsLoaded)
|
||||
{
|
||||
fillFileStyleSuggestions(dialog);
|
||||
draft.styleSuggestionsLoaded=true;
|
||||
}
|
||||
updateFileStyleEditor(dialog,draft,true);
|
||||
}
|
||||
if(page==PanelColors && section>=0 && !fileTypes)
|
||||
{
|
||||
const auto color=draft.palette[section];
|
||||
wchar_t hex[8]{};
|
||||
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(color)),unsigned(GetGValue(color)),unsigned(GetBValue(color)));
|
||||
SetDlgItemTextW(dialog,280,hex);
|
||||
}
|
||||
if(page==Appearance)
|
||||
{
|
||||
const auto description=std::wstring(draft.font.lfFaceName)+L" · "+std::to_wstring(draft.points/10)+
|
||||
(draft.points%10?L"."+std::to_wstring(draft.points%10):L"")+L" pt";
|
||||
SetDlgItemTextW(dialog,281,description.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void filterOptions(HWND dialog,OptionsDraft &draft)
|
||||
{
|
||||
wchar_t text[256]{};
|
||||
GetDlgItemTextW(dialog,optionsSearch,text,std::size(text));
|
||||
std::wstring query=text;
|
||||
if(!query.empty()) CharLowerBuffW(query.data(),static_cast<DWORD>(query.size()));
|
||||
const auto matches=[&](const wchar_t *label)
|
||||
{
|
||||
std::wstring lower=label;
|
||||
CharLowerBuffW(lower.data(),static_cast<DWORD>(lower.size()));
|
||||
return lower.find(query)!=std::wstring::npos;
|
||||
};
|
||||
const HWND tree=GetDlgItem(dialog,optionsTree);
|
||||
const auto add=[&](const wchar_t *label,int page,int option,HTREEITEM parent)
|
||||
{
|
||||
TVINSERTSTRUCTW insert{};
|
||||
insert.hParent=parent;
|
||||
insert.hInsertAfter=TVI_LAST;
|
||||
insert.item.mask=TVIF_TEXT|TVIF_PARAM;
|
||||
insert.item.pszText=const_cast<wchar_t*>(label);
|
||||
insert.item.lParam=(page<<8)|(option+1);
|
||||
return TreeView_InsertItem(tree,&insert);
|
||||
};
|
||||
const int previousPage=draft.page,previousSection=draft.section;
|
||||
TreeView_DeleteAllItems(tree);
|
||||
draft.wideColumnsNode=nullptr;
|
||||
const auto root=add(L"All settings",allOptions,-1,TVI_ROOT);
|
||||
HTREEITEM first{},selected{};
|
||||
for(int page=PanelModes;page<=CommandLine;++page)
|
||||
{
|
||||
HTREEITEM category{};
|
||||
const bool wholeCategory=matches(optionsTitles[page]) || (!query.empty() && matches(L"All settings"));
|
||||
const auto addSection=[&](const wchar_t *label,int section,bool aliasMatches=false)
|
||||
{
|
||||
if(!wholeCategory && !aliasMatches && !matches(label)) return;
|
||||
if(!category) category=add(optionsTitles[page],page,-1,root);
|
||||
const auto node=add(label,page,section,category);
|
||||
if(!first) first=node;
|
||||
if(page==previousPage && section==previousSection) selected=node;
|
||||
if(page==PanelModes && section==WideColumns) draft.wideColumnsNode=node;
|
||||
};
|
||||
if(page==PanelModes)
|
||||
{
|
||||
for(int section=PanelLayout;section<=StatusColumns;++section)
|
||||
{
|
||||
const bool propertyMatches=std::any_of(std::begin(panelProperties),std::end(panelProperties),
|
||||
[&](const auto &property){return property.section==section && matches(property.name);});
|
||||
addSection(panelSectionNames[section],section,propertyMatches);
|
||||
}
|
||||
}
|
||||
else if(page==PanelColors)
|
||||
{
|
||||
for(int color=0;color<ColorCount;++color) addSection(colorNames[color],color);
|
||||
const bool extensionMatches=std::any_of(draft.styles.begin(),draft.styles.end(),
|
||||
[&](const auto &style){return matches(style.extension.c_str());});
|
||||
addSection(L"File type styles",fileStyleSection,extensionMatches || matches(L"System and hidden") ||
|
||||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
|
||||
}
|
||||
else if(page==CommandLine) addSection(L"Command history",0,matches(L"Previous command"));
|
||||
else
|
||||
{
|
||||
addSection(L"Font",Font,matches(L"Display font"));
|
||||
addSection(L"Dialog frame",DialogFrame,
|
||||
matches(L"Outer padding") || matches(L"Inner padding") || matches(L"Row text padding"));
|
||||
}
|
||||
if(category) TreeView_Expand(tree,category,TVE_EXPAND);
|
||||
}
|
||||
TreeView_Expand(tree,root,TVE_EXPAND);
|
||||
TreeView_SelectItem(tree,query.empty() && previousPage==allOptions?root:selected?selected:first?first:root);
|
||||
if(!first) SetDlgItemTextW(dialog,optionsTitle,L"No matching settings");
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
auto &draft=*reinterpret_cast<OptionsDraft*>(lp);
|
||||
SetWindowLongPtrW(dialog,DWLP_USER,lp);
|
||||
draft.initialDpi=GetDpiForWindow(dialog);
|
||||
GetClientRect(dialog,&draft.initialClient);
|
||||
for(auto &control:draft.controls) control.bounds=dialogControlBounds(dialog,control.id);
|
||||
// Group boxes are behind their sibling controls. Erase their full client
|
||||
// area on resize while clipping out the controls inside the frame.
|
||||
for(const auto id:{275,277})
|
||||
{
|
||||
const auto group=GetDlgItem(dialog,id);
|
||||
SetWindowLongPtrW(group,GWL_STYLE,GetWindowLongPtrW(group,GWL_STYLE)|WS_CLIPSIBLINGS);
|
||||
SetWindowPos(group,HWND_BOTTOM,0,0,0,0,SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE);
|
||||
SetWindowSubclass(group,optionsGroupBackground,1,0);
|
||||
}
|
||||
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
|
||||
SendDlgItemMessageW(dialog,201,EM_SETLIMITTEXT,2,0);
|
||||
SendDlgItemMessageW(dialog,optionsSearch,EM_SETLIMITTEXT,255,0);
|
||||
for(const auto &option:frameOptions)
|
||||
for(DWORD value=0;value<=option.maximum;++value)
|
||||
SendDlgItemMessageW(dialog,option.id,CB_ADDSTRING,0,
|
||||
reinterpret_cast<LPARAM>(std::to_wstring(value).c_str()));
|
||||
CheckDlgButton(dialog,202,alignExtensions);
|
||||
CheckDlgButton(dialog,203,showDetails);
|
||||
CheckDlgButton(dialog,204,alignFolders);
|
||||
CheckDlgButton(dialog,273,panels[0].wide);
|
||||
CheckDlgButton(dialog,274,panels[1].wide);
|
||||
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
|
||||
setOptionHistoryKeys(dialog,commandHistoryKeys);
|
||||
filterOptions(dialog,draft);
|
||||
updateOptionsApply(dialog,draft);
|
||||
return TRUE;
|
||||
}
|
||||
auto *draft=reinterpret_cast<OptionsDraft*>(GetWindowLongPtrW(dialog,DWLP_USER));
|
||||
if(!draft) return FALSE;
|
||||
if(message==WM_DRAWITEM && wp==fileStylePreview)
|
||||
{
|
||||
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
|
||||
if(draft->page==PanelColors && draft->section==fileStyleSection) paintFileStylePreview(item,*draft);
|
||||
else paintOptionsPreview(dialog,item,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_GETMINMAXINFO)
|
||||
{
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
RECT minimum{0,0,MulDiv(draft->initialClient.right,dpi,draft->initialDpi),
|
||||
MulDiv(draft->initialClient.bottom,dpi,draft->initialDpi)};
|
||||
AdjustWindowRectExForDpi(&minimum,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_STYLE)),
|
||||
FALSE,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_EXSTYLE)),dpi);
|
||||
auto &limits=*reinterpret_cast<MINMAXINFO*>(lp);
|
||||
limits.ptMinTrackSize.x=minimum.right-minimum.left;
|
||||
limits.ptMinTrackSize.y=minimum.bottom-minimum.top;
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_SIZE && wp!=SIZE_MINIMIZED)
|
||||
{
|
||||
layoutOptions(dialog,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_NOTIFY && wp==optionsTree)
|
||||
{
|
||||
const auto &change=*reinterpret_cast<const NMTREEVIEWW*>(lp);
|
||||
if(change.hdr.code==TVN_SELCHANGEDW && change.itemNew.hItem)
|
||||
showOptionsPage(dialog,*draft,static_cast<int>(change.itemNew.lParam>>8),static_cast<int>(change.itemNew.lParam&255)-1);
|
||||
return FALSE;
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
finishFilesDialog(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND) return FALSE;
|
||||
switch(LOWORD(wp))
|
||||
{
|
||||
case optionsSearch:
|
||||
if(HIWORD(wp)==EN_CHANGE) filterOptions(dialog,*draft);
|
||||
return TRUE;
|
||||
case 279:
|
||||
if(draft->page==PanelColors && draft->section>=0 && draft->section<ColorCount)
|
||||
{
|
||||
choosePanelColor(dialog,draft->palette[draft->section],draft->custom.data());
|
||||
showOptionsPage(dialog,*draft,PanelColors,draft->section);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
case 282:
|
||||
chooseDisplayFont(dialog,draft->font,draft->points);
|
||||
showOptionsPage(dialog,*draft,Appearance,Font);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case outerFramePadding: case innerFramePadding: case rowTextPadding:
|
||||
if(HIWORD(wp)==CBN_SELCHANGE)
|
||||
{
|
||||
const auto value=SendMessageW(reinterpret_cast<HWND>(lp),CB_GETCURSEL,0,0);
|
||||
if(value>=0)
|
||||
for(const auto &option:frameOptions)
|
||||
if(option.id==LOWORD(wp)) draft->framePadding.*option.value=static_cast<DWORD>(value);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
case fileStyleState:
|
||||
if(HIWORD(wp)==CBN_SELCHANGE) updateFileStyleEditor(dialog,*draft,false);
|
||||
return TRUE;
|
||||
case fileStyleRules:
|
||||
if(HIWORD(wp)==CBN_SELCHANGE)
|
||||
{
|
||||
draft->styleIndex=static_cast<int>(SendDlgItemMessageW(dialog,fileStyleRules,CB_GETCURSEL,0,0));
|
||||
updateFileStyleEditor(dialog,*draft,false);
|
||||
}
|
||||
return TRUE;
|
||||
case 292:
|
||||
if(addFileStyle(dialog,*draft)) updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case 289: case 293: case 294: case 295: case 297: case 298:
|
||||
if(draft->styleIndex>=0)
|
||||
{
|
||||
auto &style=draft->styles[draft->styleIndex];
|
||||
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
|
||||
auto &foreground=state>0?style.states[state-1].foreground:style.foreground;
|
||||
auto &background=state>0?style.states[state-1].background:style.background;
|
||||
switch(LOWORD(wp))
|
||||
{
|
||||
case 289:
|
||||
draft->styles.erase(draft->styles.begin()+draft->styleIndex);
|
||||
break;
|
||||
case 293: case 294:
|
||||
{
|
||||
auto &field=LOWORD(wp)==293?foreground:background;
|
||||
auto color=field.value_or(draft->palette[LOWORD(wp)==293?Text:PanelBlue]);
|
||||
if(choosePanelColor(dialog,color,draft->custom.data())) field=color;
|
||||
break;
|
||||
}
|
||||
case 295: foreground.reset();background.reset();break;
|
||||
case 297: style.underline=IsDlgButtonChecked(dialog,297)==BST_CHECKED;break;
|
||||
case 298: style.strikeout=IsDlgButtonChecked(dialog,298)==BST_CHECKED;break;
|
||||
}
|
||||
updateFileStyleEditor(dialog,*draft,LOWORD(wp)==289);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
case optionsReset:
|
||||
if(draft->page==allOptions || draft->page==CommandLine) setOptionHistoryKeys(dialog,defaultHistoryKeys);
|
||||
if(draft->page==allOptions || draft->page==PanelModes)
|
||||
for(const auto &property:panelProperties)
|
||||
if(draft->section<0 || property.section==draft->section) resetPanelProperty(dialog,property);
|
||||
if(draft->page==allOptions || (draft->page==PanelColors && draft->section<0))
|
||||
draft->palette=defaultColors;
|
||||
else if(draft->page==PanelColors && draft->section< ColorCount)
|
||||
draft->palette[draft->section]=defaultColors[draft->section];
|
||||
if(draft->page==allOptions || (draft->page==PanelColors &&
|
||||
(draft->section<0 || draft->section==fileStyleSection)))
|
||||
draft->styles=defaultFileStyles;
|
||||
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=DialogFrame))
|
||||
{
|
||||
draft->font=defaultFilesFont();
|
||||
draft->points=defaultFontPoints;
|
||||
}
|
||||
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=Font))
|
||||
draft->framePadding=defaultDialogFramePadding;
|
||||
showOptionsPage(dialog,*draft,draft->page,draft->section);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case optionsResetProperty:
|
||||
{
|
||||
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_GETCURSEL,0,0);
|
||||
if(row>=0)
|
||||
{
|
||||
const auto property=SendDlgItemMessageW(dialog,optionsProperty,CB_GETITEMDATA,row,0);
|
||||
if(property>=0 && static_cast<size_t>(property)<std::size(panelProperties))
|
||||
resetPanelProperty(dialog,panelProperties[property]);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
case 201:
|
||||
case 202: case 203: case 204: case 205: case 206: case 207: case 208:
|
||||
case 273: case 274:
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case 321: case 323:
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case optionsApply:
|
||||
case IDOK:
|
||||
{
|
||||
BOOL valid{};
|
||||
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
|
||||
if(!valid || width<6 || width>16)
|
||||
{
|
||||
SetDlgItemTextW(dialog,optionsSearch,L"");
|
||||
TreeView_SelectItem(GetDlgItem(dialog,optionsTree),draft->wideColumnsNode);
|
||||
MessageBoxW(dialog,L"Enter a width from 6 to 16 characters.",optionsTitles[PanelModes],MB_OK|MB_ICONWARNING);
|
||||
SetFocus(GetDlgItem(dialog,201));
|
||||
SendDlgItemMessageW(dialog,201,EM_SETSEL,0,-1);
|
||||
return TRUE;
|
||||
}
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const OptionsWriteLock writeLock;
|
||||
if(!writeLock.acquired())
|
||||
{
|
||||
setStatus(L"Another window is saving Files settings. Try Apply again.");
|
||||
return TRUE;
|
||||
}
|
||||
const auto &base=draft->baseline;
|
||||
auto keys=optionHistoryKeys(dialog);
|
||||
loadCommandHistoryKeys();
|
||||
for(size_t i=0;i<keys.size();++i)
|
||||
if(keys[i]==base.keys[i]) keys[i]=commandHistoryKeys[i];
|
||||
if(!validHistoryKeys(keys))
|
||||
{
|
||||
MessageBoxW(dialog,L"Choose distinct shortcuts using Ctrl or Alt that are free in Files.",L"Command history",MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
const bool stylesChanged=draft->styles!=base.styles;
|
||||
loadFileStyles();
|
||||
if(stylesChanged && fileStyles!=base.styles && fileStyles!=draft->styles)
|
||||
{
|
||||
MessageBoxW(dialog,L"File type styles changed in another window. Reopen Options before editing them again.",
|
||||
L"KSwitcher Files Options",MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
if(stylesChanged && !saveFileStyles(draft->styles))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save the file type styles.",L"KSwitcher Files Options",MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
const bool fontChanged=optionsFontChanged(*draft);
|
||||
if(fontChanged && !applyFont(owner,draft->font,draft->points))
|
||||
{
|
||||
if(stylesChanged) saveFileStyles(fileStyles);
|
||||
MessageBoxW(dialog,L"Could not apply the selected font.",optionsTitles[Appearance],MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
const auto saveChanged=[](const wchar_t *name,DWORD value,DWORD initial)
|
||||
{
|
||||
if(value!=initial) savePreference(name,value);
|
||||
};
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
const auto inputKeys=optionHistoryKeys(dialog);
|
||||
saveChanged(L"PreviousCommandKey",inputKeys[0],base.keys[0]);
|
||||
saveChanged(L"CommandHistoryKey",inputKeys[1],base.keys[1]);
|
||||
saveChanged(L"SizeWidth",width,base.width);
|
||||
saveChanged(L"AlignExtensions",checked(202),base.extensions);
|
||||
saveChanged(L"AlignFolders",checked(204),base.folders);
|
||||
saveChanged(L"ShowDetails",checked(203),base.details);
|
||||
DWORD fields=preference(L"StatusFields",defaultStatusFields)&15;
|
||||
const auto initialFields=fields;
|
||||
for(int i=0;i<4;++i)
|
||||
{
|
||||
const DWORD bit=1u<<i;
|
||||
if(checked(205+i)!=((base.fields&bit)!=0)) fields=checked(205+i)?fields|bit:fields&~bit;
|
||||
}
|
||||
saveChanged(L"StatusFields",fields,initialFields);
|
||||
for(int i=0;i<ColorCount;++i)
|
||||
saveChanged(std::to_wstring(i).c_str(),draft->palette[i],base.palette[i]);
|
||||
saveChanged(dialogFrameOuterKey,draft->framePadding.outer,base.padding.outer);
|
||||
saveChanged(dialogFrameInnerKey,draft->framePadding.inner,base.padding.inner);
|
||||
saveChanged(dialogRowTextKey,draft->framePadding.rowText,base.padding.rowText);
|
||||
if(fontChanged)
|
||||
{
|
||||
savePreference(L"FontPoints",fontPoints);
|
||||
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"DisplayFont",REG_BINARY,&displayFont,sizeof(displayFont));
|
||||
}
|
||||
saveChanged(L"LeftWide",checked(273),base.wide[0]);
|
||||
saveChanged(L"RightWide",checked(274),base.wide[1]);
|
||||
loadFilesPreferences(owner);
|
||||
notifyFilesPreferences(owner);
|
||||
draft->baseline=OptionsBaseline{};
|
||||
draft->font=displayFont;
|
||||
draft->points=fontPoints;
|
||||
draft->styles=fileStyles;
|
||||
draft->palette=colors;
|
||||
draft->framePadding=dialogFramePadding;
|
||||
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
|
||||
CheckDlgButton(dialog,202,alignExtensions);
|
||||
CheckDlgButton(dialog,203,showDetails);
|
||||
CheckDlgButton(dialog,204,alignFolders);
|
||||
CheckDlgButton(dialog,273,panels[0].wide);
|
||||
CheckDlgButton(dialog,274,panels[1].wide);
|
||||
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
|
||||
setOptionHistoryKeys(dialog,commandHistoryKeys);
|
||||
if(draft->page==PanelColors && draft->section==fileStyleSection)
|
||||
updateFileStyleEditor(dialog,*draft,true);
|
||||
showOptionsPage(dialog,*draft,draft->page,draft->section);
|
||||
layout(owner);
|
||||
showFileDetails(panels[active]);
|
||||
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
if(LOWORD(wp)==IDOK) finishFilesDialog(dialog,IDOK);
|
||||
else updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void editOptions(HWND owner)
|
||||
{
|
||||
const auto panel=panels[active].list;
|
||||
OptionsDraft draft;
|
||||
std::copy(std::begin(colors),std::end(colors),draft.palette.begin());
|
||||
const auto result=runFilesDialog(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
|
||||
reinterpret_cast<LPARAM>(&draft));
|
||||
SetFocus(panel);
|
||||
if(result==-1) setStatus(L"Could not open Files Options.");
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
void paintOptionsPreview(HWND dialog,const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
|
||||
{
|
||||
const auto dc=item.hDC;
|
||||
const auto saved=SaveDC(dc);
|
||||
if(!saved) return;
|
||||
IntersectClipRect(dc,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
|
||||
const auto dpi=GetDpiForWindow(item.hwndItem);
|
||||
const auto px=[&](int value){return MulDiv(value,dpi,96);};
|
||||
auto font=draft.font;
|
||||
font.lfHeight=-MulDiv(draft.points,dpi,720);
|
||||
font.lfWidth=0;
|
||||
const auto previewFont=CreateFontIndirectW(&font);
|
||||
SelectObject(dc,previewFont?previewFont:mono);
|
||||
TEXTMETRICW metrics{};
|
||||
GetTextMetricsW(dc,&metrics);
|
||||
const auto rowHeight=std::max<LONG>(px(18),metrics.tmHeight+px(4));
|
||||
const auto &palette=draft.palette;
|
||||
const auto fill=[&](RECT bounds,COLORREF color)
|
||||
{
|
||||
SetBkColor(dc,color);
|
||||
ExtTextOutW(dc,0,0,ETO_OPAQUE|ETO_CLIPPED,&bounds,nullptr,0,nullptr);
|
||||
};
|
||||
const auto row=[&](RECT bounds,std::wstring_view label,COLORREF fg,COLORREF bg,bool right=false)
|
||||
{
|
||||
fill(bounds,bg);
|
||||
bounds.left+=px(5);
|
||||
bounds.right-=px(5);
|
||||
SetBkMode(dc,TRANSPARENT);
|
||||
SetTextColor(dc,fg);
|
||||
DrawTextW(dc,label.data(),static_cast<int>(label.size()),&bounds,
|
||||
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS|(right?DT_RIGHT:DT_LEFT));
|
||||
};
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
auto bounds=item.rcItem;
|
||||
fill(bounds,palette[PanelBlue]);
|
||||
|
||||
const auto frame=[&](RECT rect)
|
||||
{
|
||||
SetDCBrushColor(dc,palette[Frame]);
|
||||
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
|
||||
InflateRect(&rect,-px(2),-px(2));
|
||||
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
|
||||
};
|
||||
const auto panel=[&](RECT panelBounds,bool wide,bool cursor,std::wstring_view path)
|
||||
{
|
||||
frame(panelBounds);
|
||||
InflateRect(&panelBounds,-px(4),-px(4));
|
||||
auto line=panelBounds;
|
||||
line.bottom=line.top+rowHeight;
|
||||
row(line,path,palette[Frame],palette[PanelBlue]);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
BOOL valid{};
|
||||
const auto input=GetDlgItemInt(dialog,201,&valid,FALSE);
|
||||
const auto columns=valid?std::clamp(input,6u,16u):static_cast<UINT>(sizeWidth);
|
||||
const auto entry=[&](std::wstring_view stem,std::wstring_view ext,bool folder,bool highlight)
|
||||
{
|
||||
const auto bg=highlight?palette[PanelCursor]:palette[PanelBlue];
|
||||
auto name=line;
|
||||
if(wide)
|
||||
{
|
||||
name.right=std::max(name.left+px(20),name.right-static_cast<LONG>(columns+2)*metrics.tmAveCharWidth);
|
||||
name.right=std::min(name.right,line.right);
|
||||
auto amount=line;
|
||||
amount.left=name.right;
|
||||
row(amount,folder?L"Folder":formatSize(48382,columns),palette[Text],bg,true);
|
||||
fill({amount.left,amount.top,amount.left+px(1),amount.bottom},palette[Frame]);
|
||||
}
|
||||
if(wide && checked(folder?204:202) && name.right-name.left>px(70))
|
||||
{
|
||||
auto extension=name;
|
||||
extension.left=extension.right-std::min<LONG>(px(60),(extension.right-extension.left)/2);
|
||||
name.right=extension.left;
|
||||
row(extension,ext,palette[Text],bg,true);
|
||||
row(name,stem,palette[Text],bg);
|
||||
}
|
||||
else row(name,std::wstring(stem)+L"."+std::wstring(ext),palette[Text],bg);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
};
|
||||
entry(L"report",L"txt",false,cursor);
|
||||
entry(L"examples",L"old",true,false);
|
||||
};
|
||||
|
||||
if(draft.page==Appearance && draft.section==DialogFrame)
|
||||
{
|
||||
InflateRect(&bounds,-px(8),-px(8));
|
||||
const auto framePadding=dialogFrameMetrics(dpi,draft.framePadding);
|
||||
const auto surface=CreateSolidBrush(palette[Dialog]);
|
||||
const auto ink=CreateSolidBrush(palette[Text]);
|
||||
const auto frame=paintDialogFrame(dc,bounds,framePadding,surface,ink);
|
||||
const auto titleFont=previewFont?previewFont:mono;
|
||||
paintDialogFrameCaptions(dc,bounds,frame,L"Folder shortcuts",surface,palette[Text],titleFont);
|
||||
DeleteObject(ink);
|
||||
DeleteObject(surface);
|
||||
auto content=bounds;
|
||||
InflateRect(&content,-framePadding.content(),-dialogListInset(framePadding,metrics.tmHeight));
|
||||
content.bottom=std::min(content.bottom,content.top+rowHeight);
|
||||
if(content.bottom>content.top)
|
||||
paintDialogListRow(dc,content,LR"(C:\Projects\Example\Documents\Archive)",titleFont,
|
||||
palette[Text],palette[Selection],dialogRowTextInset(metrics.tmAveCharWidth,draft.framePadding));
|
||||
}
|
||||
else if(draft.page==PanelColors && (draft.section==Dialog || draft.section==Selection))
|
||||
{
|
||||
InflateRect(&bounds,-px(12),-px(10));
|
||||
const auto maxWidth=px(340);
|
||||
bounds.right=std::min(bounds.right,bounds.left+maxWidth);
|
||||
fill(bounds,palette[Dialog]);
|
||||
frame(bounds);
|
||||
InflateRect(&bounds,-px(5),-px(5));
|
||||
auto line=bounds;
|
||||
line.bottom=line.top+rowHeight;
|
||||
row(line,L"Change drive",palette[Text],palette[Dialog]);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
row(line,L"C: fixed",palette[Text],palette[Selection]);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
row(line,L"D: fixed",palette[Text],palette[Dialog]);
|
||||
}
|
||||
else if(draft.page==PanelColors && draft.section==Footer)
|
||||
{
|
||||
auto bar=bounds;
|
||||
bar.top=bar.bottom-rowHeight;
|
||||
row(bar,L"F2 Refresh F3 View F5 Copy F6 Move",palette[Text],palette[Footer]);
|
||||
bounds.bottom=bar.top;
|
||||
panel(bounds,false,false,L"C:\\Example");
|
||||
}
|
||||
else if(draft.page==PanelModes && draft.section==PanelLayout)
|
||||
{
|
||||
auto left=bounds,right=bounds;
|
||||
left.right=(bounds.left+bounds.right)/2;
|
||||
right.left=left.right+px(4);
|
||||
panel(left,checked(273),true,L"C:\\Example");
|
||||
panel(right,checked(274),false,L"D:\\Example");
|
||||
}
|
||||
else if(draft.page==PanelModes && draft.section==StatusColumns)
|
||||
{
|
||||
auto statusRow=bounds;
|
||||
statusRow.top=statusRow.bottom-rowHeight;
|
||||
bounds.bottom=statusRow.top;
|
||||
panel(bounds,true,true,L"C:\\Example");
|
||||
std::wstring label;
|
||||
if(checked(203))
|
||||
{
|
||||
constexpr const wchar_t *values[]{L"report.txt",L"47 KiB",L"2026-11-14",L"10:30"};
|
||||
for(int i=0;i<4;++i)
|
||||
if(checked(205+i))
|
||||
{
|
||||
if(!label.empty()) label+=L" ";
|
||||
label+=values[i];
|
||||
}
|
||||
}
|
||||
row(statusRow,label,palette[Text],palette[PanelBlue]);
|
||||
}
|
||||
else panel(bounds,draft.page==PanelModes || checked(273),
|
||||
draft.page==PanelColors && draft.section==PanelCursor,L"C:\\Example");
|
||||
|
||||
if(draft.page==PanelColors && (draft.section==Text || draft.section==Dialog))
|
||||
{
|
||||
auto field=item.rcItem;
|
||||
InflateRect(&field,-px(12),-px(10));
|
||||
field.right=std::min(field.right,field.left+px(340));
|
||||
field.top+=px(5)+4*rowHeight;
|
||||
field.bottom=field.top+rowHeight+px(6);
|
||||
fill(field,RGB(192,192,192));
|
||||
InflateRect(&field,-px(4),-px(3));
|
||||
row(field,L"C:\\Example",palette[Text],palette[Dialog]);
|
||||
}
|
||||
|
||||
RestoreDC(dc,saved);
|
||||
if(previewFont) DeleteObject(previewFont);
|
||||
}
|
||||
@@ -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,155,170)},{L".rar",RGB(255,210,128)},
|
||||
{L".tar",RGB(255,210,128)},{L".gz",RGB(255,210,128)},
|
||||
{L".png",RGB(165,230,255)},{L".jpg",RGB(165,230,255)},{L".jpeg",RGB(165,230,255)},
|
||||
{L".gif",RGB(165,230,255)},{L".webp",RGB(165,230,255)},{L".svg",RGB(165,230,255)},
|
||||
{L".mp3",RGB(255,185,216)},{L".wav",RGB(255,185,216)},{L".flac",RGB(255,185,216)},
|
||||
{L".mp4",RGB(255,185,216)},{L".mkv",RGB(255,185,216)},{L".mov",RGB(255,185,216)},
|
||||
{L".pdf",RGB(255,229,168)},{L".docx",RGB(255,229,168)},{L".xlsx",RGB(255,229,168)},
|
||||
{L".pptx",RGB(255,229,168)},{L".odt",RGB(255,229,168)},
|
||||
{L".cpp",RGB(168,247,190)},{L".h",RGB(168,247,190)},{L".cs",RGB(168,247,190)},
|
||||
{L".py",RGB(168,247,190)},{L".js",RGB(168,247,190)},{L".ts",RGB(168,247,190)},
|
||||
{L".ps1",RGB(168,247,190)},{L".bat",RGB(168,247,190)},{L".cmd",RGB(168,247,190)}};
|
||||
std::vector<FileStyle> fileStyles=defaultFileStyles;
|
||||
constexpr wchar_t fileStyleKey[]=L"FileStyles";
|
||||
|
||||
std::wstring fileStyleExtension(std::wstring_view text)
|
||||
{
|
||||
const auto first=text.find_first_not_of(L" \t");
|
||||
if(first==text.npos) return {};
|
||||
auto extension=std::wstring(text.substr(first,text.find_last_not_of(L" \t")-first+1));
|
||||
if(extension.front()!=L'.') extension.insert(0,1,L'.');
|
||||
if(extension.size()<2 || extension.size()>255 ||
|
||||
extension.find_first_of(L". *?\\/:<>|\";\r\n\t",1)!=extension.npos) return {};
|
||||
CharLowerBuffW(extension.data(),static_cast<DWORD>(extension.size()));
|
||||
return extension;
|
||||
}
|
||||
|
||||
bool sameFileExtension(std::wstring_view left,std::wstring_view right)
|
||||
{
|
||||
return !left.empty() && left.size()==right.size() &&
|
||||
CompareStringOrdinal(left.data(),static_cast<int>(left.size()),
|
||||
right.data(),static_cast<int>(right.size()),TRUE)==CSTR_EQUAL;
|
||||
}
|
||||
|
||||
void loadFileStyles()
|
||||
{
|
||||
DWORD bytes{};
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
|
||||
!bytes || bytes>256*1024) return;
|
||||
std::string data(bytes,'\0');
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto rows=Json::parse(data.begin(),data.begin()+bytes);
|
||||
if(!rows.is_array() || rows.size()>64) return;
|
||||
std::vector<FileStyle> parsed;
|
||||
for(const auto &row:rows)
|
||||
{
|
||||
FileStyle style{ks::wide(row.at("extension").get<std::string>())};
|
||||
if(style.extension!=L"@system-hidden" && fileStyleExtension(style.extension)!=style.extension) return;
|
||||
if(std::any_of(parsed.begin(),parsed.end(),[&](const auto &existing){return existing.extension==style.extension;}))
|
||||
return;
|
||||
const auto color=[&](const char *key)->std::optional<COLORREF>
|
||||
{
|
||||
if(!row.contains(key)) return {};
|
||||
const auto value=row.at(key).get<DWORD>();
|
||||
if(value>0xffffff) throw std::runtime_error("Invalid file style color");
|
||||
return value;
|
||||
};
|
||||
style.foreground=color("foreground");
|
||||
style.background=color("background");
|
||||
style.underline=row.value("underline",false);
|
||||
style.strikeout=row.value("strikeout",false);
|
||||
if(row.contains("states"))
|
||||
{
|
||||
const auto &states=row.at("states");
|
||||
if(!states.is_array() || states.size()!=style.states.size()) return;
|
||||
for(size_t i=0;i<style.states.size();++i)
|
||||
{
|
||||
const auto read=[&](const char *key)->std::optional<COLORREF>
|
||||
{
|
||||
if(!states[i].contains(key)) return {};
|
||||
const auto value=states[i].at(key).get<DWORD>();
|
||||
if(value>0xffffff) throw std::runtime_error("Invalid state color");
|
||||
return value;
|
||||
};
|
||||
style.states[i]={read("foreground"),read("background")};
|
||||
}
|
||||
}
|
||||
parsed.push_back(std::move(style));
|
||||
}
|
||||
fileStyles=std::move(parsed);
|
||||
}
|
||||
catch(const std::exception &) {}
|
||||
}
|
||||
|
||||
bool saveFileStyles(const std::vector<FileStyle> &styles)
|
||||
{
|
||||
auto rows=Json::array();
|
||||
for(const auto &style:styles)
|
||||
{
|
||||
Json row{{"extension",ks::utf8(style.extension)},{"underline",style.underline},{"strikeout",style.strikeout}};
|
||||
if(style.foreground) row["foreground"]=*style.foreground;
|
||||
if(style.background) row["background"]=*style.background;
|
||||
row["states"]=Json::array();
|
||||
for(const auto &state:style.states)
|
||||
{
|
||||
auto entry=Json::object();
|
||||
if(state.foreground) entry["foreground"]=*state.foreground;
|
||||
if(state.background) entry["background"]=*state.background;
|
||||
row["states"].push_back(std::move(entry));
|
||||
}
|
||||
rows.push_back(std::move(row));
|
||||
}
|
||||
const auto data=rows.dump();
|
||||
return RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,REG_BINARY,data.data(),
|
||||
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
struct FileRowStyle
|
||||
{
|
||||
COLORREF foreground,background;
|
||||
unsigned decorations{};
|
||||
};
|
||||
FileRowStyle resolveFileStyle(std::wstring_view extension,bool file,bool dimmed,bool protectedRow,
|
||||
COLORREF foreground,COLORREF background,const std::vector<FileStyle> &styles=fileStyles,
|
||||
FileRowState state=FileRowState::Normal,bool transferred=false)
|
||||
{
|
||||
FileRowStyle result{foreground,background};
|
||||
const FileStyle *extensionRule=nullptr,*attributeRule=nullptr;
|
||||
for(const auto &style:styles)
|
||||
{
|
||||
if(!file || !sameFileExtension(style.extension,extension)) continue;
|
||||
extensionRule=&style;
|
||||
result.foreground=style.foreground.value_or(result.foreground);
|
||||
if(!protectedRow) result.background=style.background.value_or(result.background);
|
||||
result.decorations=(style.underline?1u:0u)|(style.strikeout?2u:0u);
|
||||
break;
|
||||
}
|
||||
if(dimmed)
|
||||
for(const auto &style:styles)
|
||||
{
|
||||
if(style.extension!=L"@system-hidden") continue;
|
||||
attributeRule=&style;
|
||||
if(style.foreground) result.foreground=*style.foreground;
|
||||
else
|
||||
{
|
||||
const auto fg=result.foreground,bg=result.background;
|
||||
result.foreground=RGB((3*GetRValue(fg)+2*GetRValue(bg))/5,
|
||||
(3*GetGValue(fg)+2*GetGValue(bg))/5,(3*GetBValue(fg)+2*GetBValue(bg))/5);
|
||||
}
|
||||
if(!protectedRow) result.background=style.background.value_or(result.background);
|
||||
result.decorations|=(style.underline?1u:0u)|(style.strikeout?2u:0u);
|
||||
break;
|
||||
}
|
||||
|
||||
if(transferred && state!=FileRowState::Marked)
|
||||
return {RGB(255,223,115),background,result.decorations};
|
||||
if(state==FileRowState::Marked) result.foreground=RGB(255,255,0);
|
||||
if(state!=FileRowState::Normal)
|
||||
{
|
||||
const auto index=static_cast<size_t>(state)-1;
|
||||
for(const auto *rule:{extensionRule,attributeRule})
|
||||
if(rule)
|
||||
{
|
||||
result.foreground=rule->states[index].foreground.value_or(result.foreground);
|
||||
result.background=rule->states[index].background.value_or(result.background);
|
||||
}
|
||||
}
|
||||
if(result.foreground==result.background && state!=FileRowState::Normal)
|
||||
result.foreground=GetRValue(result.background)+GetGValue(result.background)+GetBValue(result.background)>382?RGB(0,0,0):RGB(255,255,255);
|
||||
return result;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public:
|
||||
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 command[]=L"powershell.exe -NoLogo -NoProfile -NoExit -Command \"Remove-Module PSReadLine -ErrorAction SilentlyContinue; function global:prompt { $location = Get-Location; $path = if ($location.Provider.Name -eq 'FileSystem') { [Uri]::EscapeDataString($location.ProviderPath) }; ([string][char]27) + ']633;ks-directory:' + $path + [char]7 + [char]27 + '[?25l' }; Clear-Host\"";
|
||||
wchar_t system[MAX_PATH]{};
|
||||
const auto length=GetSystemDirectoryW(system,MAX_PATH);
|
||||
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||
|
||||
+18
-5
@@ -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,7 +49,7 @@ 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;
|
||||
@@ -61,7 +62,7 @@ struct LanService::Impl
|
||||
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{};
|
||||
@@ -225,6 +226,7 @@ struct LanService::Impl
|
||||
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))
|
||||
{
|
||||
@@ -289,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;
|
||||
@@ -308,11 +317,12 @@ struct LanService::Impl
|
||||
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;
|
||||
}
|
||||
@@ -331,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){
|
||||
@@ -385,6 +396,7 @@ struct LanService::Impl
|
||||
}
|
||||
std::lock_guard lock(mutex);
|
||||
auto now=GetTickCount64();
|
||||
if(action=="filesPeers") refreshFiles=true;
|
||||
if(action=="shareLogs")
|
||||
{
|
||||
bool previous=shareLogs;
|
||||
@@ -480,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}});
|
||||
}
|
||||
@@ -524,10 +536,11 @@ struct LanService::Impl
|
||||
? (lan::PairLifetime-(now-pending->started)+999)/1000 : 0;
|
||||
for(const auto &[peer,p]:peers)
|
||||
{
|
||||
if(action=="filesPeers" && !trusted.contains(peer)) continue;
|
||||
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
|
||||
result["peers"].push_back({{"id",peer},{"name",p.name},{"address",ip},{"paired",trusted.contains(peer)},
|
||||
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true},
|
||||
{"fileTransfer",transfers->hasSession(peer)}});
|
||||
{"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}});
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+7
-1
@@ -737,6 +737,7 @@ namespace
|
||||
{
|
||||
std::recursive_mutex logGate;
|
||||
bool loggingEnabled = false;
|
||||
std::wstring logFilename = L"kswitcher.log";
|
||||
DiagnosticBuffer diagnosticBuffer;
|
||||
}
|
||||
size_t DiagnosticBuffer::expire(ULONGLONG now)
|
||||
@@ -832,6 +833,11 @@ void diagnosticOperation(const std::string &json)
|
||||
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
|
||||
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
|
||||
}
|
||||
void setLogFilename(std::wstring filename)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
logFilename = std::move(filename);
|
||||
}
|
||||
void log(const std::wstring &message)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
@@ -839,7 +845,7 @@ void log(const std::wstring &message)
|
||||
return;
|
||||
try
|
||||
{
|
||||
auto path = dataDirectory() / L"kswitcher.log";
|
||||
auto path = dataDirectory() / logFilename;
|
||||
std::error_code ec;
|
||||
auto size = std::filesystem::file_size(path, ec);
|
||||
if (!ec && size > 256 * 1024)
|
||||
|
||||
@@ -94,6 +94,7 @@ class DiagnosticBuffer
|
||||
void clear() { entries.clear(); }
|
||||
};
|
||||
void setLoggingEnabled(bool enabled);
|
||||
void setLogFilename(std::wstring filename);
|
||||
void expireDiagnostics(ULONGLONG now);
|
||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||
void diagnosticOperation(const std::string &json);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -370,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"))
|
||||
{
|
||||
@@ -529,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);
|
||||
|
||||
+8
-4
@@ -1460,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));
|
||||
@@ -1513,6 +1514,9 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.undo = value(w, 213);
|
||||
s.clipboard = value(w, 214);
|
||||
s.screenshot = value(w, 215);
|
||||
auto filesHotkey = value(w, 216);
|
||||
if (s.filesHotkey || !filesHotkey.empty())
|
||||
s.filesHotkey = std::move(filesHotkey);
|
||||
s.maxItems = integer(w, 240);
|
||||
s.maxMiB = integer(w, 241);
|
||||
s.maxImageMiB = integer(w, 242);
|
||||
@@ -2522,13 +2526,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);
|
||||
@@ -2623,7 +2627,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];
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.77";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.81";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -292,11 +292,16 @@ int main()
|
||||
HWND picker = GetDlgItem(dialog, 728);
|
||||
RECT dialogBounds{};
|
||||
GetWindowRect(dialog, &dialogBounds);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
require(GetMonitorInfoW(MonitorFromWindow(dialog, MONITOR_DEFAULTTONEAREST), &monitor),
|
||||
"monitor work area available");
|
||||
const auto targetX = std::clamp(dialogBounds.left + 32, monitor.rcWork.left, monitor.rcWork.right - 120);
|
||||
const auto targetY = std::clamp(dialogBounds.top + 100, monitor.rcWork.top, monitor.rcWork.bottom - 48);
|
||||
HWND target = CreateWindowExW(WS_EX_TOPMOST | WS_EX_NOACTIVATE, L"EDIT", L"Picker fixture",
|
||||
WS_POPUP | WS_VISIBLE, dialogBounds.left + 32, dialogBounds.top + 100, 120, 48,
|
||||
WS_POPUP | WS_VISIBLE, targetX, targetY, 120, 48,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
require(picker && target, "window picker and target available");
|
||||
POINT drop{dialogBounds.left + 33, dialogBounds.top + 101};
|
||||
POINT drop{targetX + 1, targetY + 1};
|
||||
require(GetAncestor(WindowFromPoint(drop), GA_ROOT) == target, "fixture window is under the drop point");
|
||||
ScreenToClient(picker, &drop);
|
||||
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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}});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user