Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5137887b9 | ||
|
|
c75cc989f4 | ||
|
|
f580892ca9 | ||
|
|
372c4cb8bd | ||
|
|
37158c1ff0 | ||
|
|
02f81327fd | ||
|
|
2834510680 |
No files matched your search
+5
-1
@@ -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)
|
||||
|
||||
+28
@@ -2,8 +2,35 @@
|
||||
|
||||
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
|
||||
|
||||
## Скорочення
|
||||
|
||||
- Короткий Alt перемикає головне меню Files при відпусканні. F9 також перемикає меню. Після закриття фокус повертається до активної панелі. Alt з літерою верхнього рівня спрацьовує одразу при натисканні літери. Alt+O відкриває Options, а відпускання Alt після комбінації не перемикає меню.
|
||||
- Коли головне меню відкрите, L, F, C, O та R відкривають Left, Files, Commands, Options та Right. У підменю підсвічена літера викликає відповідну команду. Літери унікальні в межах поточного рівня. Усталені мнемоніки мають пріоритет, зокрема X для Exit.
|
||||
- Глобальне скорочення **Open KSwitcher Files** можна змінити або очистити в налаштуваннях. Для профілю без цього поля пропонується Ctrl+Alt+F, якщо Windows реєструє комбінацію і вона вільна серед прив’язок KSwitcher.
|
||||
|
||||
## Налаштування Files
|
||||
|
||||
- Options у головному меню одразу відкриває стандартний діалог Windows. Ліворуч є пошук за назвою категорії чи параметра й дерево результатів. Вибір дочірнього вузла відкриває лише його редактор праворуч. Пошук за назвою окремої властивості відкриває вузол, що її містить. Перемикання вузлів зберігає незастосовані зміни.
|
||||
- File panel modes має вузли Panel layout, Wide panel columns і Status line columns. У Colors кожен колір має власний вузол. В Appearance шрифт редагується у вузлі Font. Діалог змінює розмір від розміру, який вміщує всі параметри. Ширина лівої навігації стала, дерево росте по висоті, а права область використовує додатковий простір. OK, Cancel та Apply лишаються внизу праворуч.
|
||||
- Reset selected to defaults скидає вибраний рівень дерева. Корінь All settings скидає всі параметри, навіть коли пошук приховує частину дерева. У редакторах із кількома параметрами Reset property скидає лише вибрану властивість. Скидання змінює чернетку до Apply або OK. Стандартний Panel background — `#000080`.
|
||||
- Apply доступна, коли чернетка відрізняється від застосованих налаштувань. Після Apply або повернення всіх значень до застосованих вона вимикається. Apply одразу зберігає зміни й перемальовує панелі за відкритим діалогом. OK робить те саме й закриває діалог. Cancel і Escape відкидають зміни після останнього Apply та повертають фокус до панелі.
|
||||
|
||||
- Colors → File type styles налаштовує текст, фон, підкреслення й закреслення для правил за розширенням і групи System and hidden. Розширення можна вибрати зі списку Windows або додати вручну з крапкою чи без неї. Вони зберігаються в KSwitcher, незалежно від асоціацій Windows. Курсор, позначки й результат передачі зберігають свої сигнальні кольори. Кольори правила можна перевизначити окремо для звичайного рядка, позначки Insert, курсора у фокусі та курсора без фокуса. Позначка має пріоритет над курсором, а підсвічування передачі має пріоритет над кольорами курсора. Без перевизначення стан зберігає звичайний вигляд панелі. Мініпанель унизу редактора одразу показує всі чотири стани на синтетичних рядках до Apply.
|
||||
|
||||
- Нижня область показує живе прев’ю для кольорів, шрифту, режимів панелей, широких колонок і рядка стану. Для Popup background, Menu selection і Command bar зразок показує відповідний елемент. Прев’ю читає чернетку, зокрема після Reset, і не потребує реальних файлів. Воно заповнює простір від останнього видимого елемента редактора до нижніх кнопок зі сталими відступами. Зі зміною розміру росте область зразка, а рядки зберігають масштаб шрифту.
|
||||
|
||||
- Підписи File type, Windows extension or custom extension і Row state стоять над відповідними полями зі спільним лівим краєм. Ширина списків враховує найдовші значення, шрифт, DPI й доступне місце. Add і Remove стоять одразу після своїх полів.
|
||||
|
||||
- Початковий набір правил охоплює EXE, Markdown, системні й приховані елементи, архіви, зображення, аудіо й відео, офісні документи й PDF, код і скрипти. Збережений набір правил завантажується як є, включно з видаленими правилами. Reset у File type styles відновлює весь початковий набір у чернетці.
|
||||
|
||||
## Панелі
|
||||
|
||||
- Меню Change drive показує літеру, тип, загальний обсяг і вільне місце. Відкриття меню запускає нове LAN-виявлення. Секція KSwitcher LAN показує збережені пари, зокрема офлайн. Після кожного відкриття меню асинхронно запитує диски й показує їх під назвою відповідного ПК. Поки триває запит, видно стан завантаження. Якщо запит не вдався, видно помилку. Повторне відкриття запускає новий запит. Кожна панель має власний диск і шлях, а в заголовку віддаленої панелі перед шляхом показано назву ПК.
|
||||
|
||||
- Список дисків і файлові операції використовують права облікового запису Windows, під яким працює віддалений KSwitcher. Достатньо спарювання й доступного TCP 45885, спільні папки налаштовувати не потрібно.
|
||||
- F5, F6, F8 і Shift+Delete у віддалених панелях використовують спільні підтвердження, попередження й прогрес. F5/F6 працюють із файлами та деревами папок між локальними й віддаленими панелями, зокрема між двома різними ПК. Між шляхами одного віддаленого ПК вміст копіюється на ньому. F8 використовує кошик того ПК, на якому лежить файл.
|
||||
- Після розриву з’єднання повтори F5 або F6 до того самого призначення. Віддалене копіювання відновлюється з підтвердженої позиції, якщо збережені байти відповідають початку вихідного файла. Підтверджене скасування видаляє частковий запис поточного файла. Завершені файли лишаються на місці. Наявний файл призначення замінюється після повного запису, а при переміщенні джерело видаляється після підтвердження запису призначення.
|
||||
|
||||
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
|
||||
|
||||
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
|
||||
@@ -48,6 +75,7 @@ Far Manager є зразком для оформлення файлового м
|
||||
## Видалення
|
||||
|
||||
- F8 показує сіре підтвердження з дією Move. Shift+Delete і підтвердження непорожньої папки мають темно-червоне тло. Вікно прогресу видалення має сіре тло, як у Copy.
|
||||
- Escape у початковому підтвердженні повертає фокус до панелі, з якої викликано видалення. Поточний рядок зберігається, а стрілки одразу працюють.
|
||||
- F8 переміщує підтверджені елементи в кошик Windows. Shift+Delete видаляє підтверджене дерево від вкладених елементів до батьківської папки. Прогрес і підсумок окремо показують успішні, пропущені й невдалі дії.
|
||||
- Cancel і Esc у вікні прогресу відкривають спільне червоне підтвердження скасування. Yes зупиняє операцію, а No або Esc у підтвердженні повертає до прогресу. Початковий фокус стоїть на No.
|
||||
|
||||
|
||||
@@ -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' }
|
||||
|
||||
+46
-16
@@ -1,26 +1,56 @@
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
+57
-16
@@ -141,6 +141,8 @@ ULONG_PTR snippetReplayMarker()
|
||||
static const char marker{};
|
||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||
}
|
||||
constexpr int OpenFiles = 25, FilesHotkeyFirst = 0x4B10, FilesHotkeySecond = 0x4B11;
|
||||
|
||||
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||
{
|
||||
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
||||
@@ -681,6 +683,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
|
||||
if (snippetPopup) DestroyWindow(snippetPopup);
|
||||
lanService.reset();
|
||||
stopBrowserChatMonitor();
|
||||
@@ -854,6 +857,19 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
||||
registrationWarning = L"Keyboard interception could not start.";
|
||||
return false;
|
||||
}
|
||||
const auto files = Hotkey::parse(s.filesHotkey.value_or(L"")).value_or(Hotkey{});
|
||||
if (files != registeredFilesHotkey)
|
||||
{
|
||||
const int next = filesHotkeyId == FilesHotkeyFirst ? FilesHotkeySecond : FilesHotkeyFirst;
|
||||
if (files.key && !RegisterHotKey(window, next, files.modifiers | MOD_NOREPEAT, files.key))
|
||||
{
|
||||
if (!rollback) registrationWarning = L"The KSwitcher Files shortcut is unavailable. Choose another shortcut.";
|
||||
return false;
|
||||
}
|
||||
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
|
||||
filesHotkeyId = files.key ? next : 0;
|
||||
registeredFilesHotkey = files;
|
||||
}
|
||||
hookBindings = std::move(bindings);
|
||||
return true;
|
||||
}
|
||||
@@ -1022,6 +1038,7 @@ void App::setHotkeyCapture(HWND field)
|
||||
}
|
||||
bool App::isHotkey(DWORD key, UINT mods)
|
||||
{
|
||||
if (registeredFilesHotkey.key && registeredFilesHotkey.key == key && registeredFilesHotkey.modifiers == mods) return true;
|
||||
for (auto &[id, text] : shortcuts(settings))
|
||||
{
|
||||
auto h = Hotkey::parse(text);
|
||||
@@ -1964,6 +1981,20 @@ void App::dispatch(int command)
|
||||
try
|
||||
{
|
||||
if (command == 23) { checkUpdate(true); return; }
|
||||
if (command == OpenFiles)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
|
||||
auto args = L"\"" + path.wstring() + L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), args.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
return;
|
||||
}
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
@@ -2769,7 +2800,7 @@ void App::trayMenu()
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
|
||||
AppendMenuW(menu, MF_STRING, 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 +2853,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 +3035,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return 0;
|
||||
}
|
||||
case WM_HOTKEY:
|
||||
app->dispatch((int)wp);
|
||||
if (app->filesHotkeyId && wp == static_cast<WPARAM>(app->filesHotkeyId))
|
||||
{
|
||||
app->dispatch(OpenFiles);
|
||||
}
|
||||
else if (wp != FilesHotkeyFirst && wp != FilesHotkeySecond) app->dispatch((int)wp);
|
||||
return 0;
|
||||
case MouseActionMessage:
|
||||
app->dispatchMouseActions();
|
||||
@@ -3341,7 +3365,24 @@ int App::execute()
|
||||
}
|
||||
keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyProc, instance, 0);
|
||||
mouseHook = SetWindowsHookExW(WH_MOUSE_LL, mouseProc, instance, 0);
|
||||
registerHotkeys(settings);
|
||||
const bool useDefaultFilesHotkey = !settings.filesHotkey;
|
||||
if (useDefaultFilesHotkey)
|
||||
{
|
||||
const auto fallback = *Hotkey::parse(L"Ctrl+Alt+F");
|
||||
const auto bindings = shortcuts(settings);
|
||||
const bool conflict = std::any_of(bindings.begin(), bindings.end(), [&](const auto &binding)
|
||||
{
|
||||
return Hotkey::parse(binding.second) == fallback;
|
||||
});
|
||||
settings.filesHotkey = conflict ? L"" : L"Ctrl+Alt+F";
|
||||
}
|
||||
if (!registerHotkeys(settings) && settings.filesHotkey && !settings.filesHotkey->empty())
|
||||
{
|
||||
if (useDefaultFilesHotkey) settings.filesHotkey = L"";
|
||||
auto fallback = settings;
|
||||
fallback.filesHotkey = L"";
|
||||
registerHotkeys(fallback, true);
|
||||
}
|
||||
if (!keyboardHook || !mouseHook)
|
||||
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
||||
AddClipboardFormatListener(window);
|
||||
|
||||
@@ -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,6 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
|
||||
inline constexpr ULONG_PTR FilesLanPeers = 0x4b53010d;
|
||||
inline constexpr ULONG_PTR FilesLanPeersReply = 0x4b53010e;
|
||||
inline constexpr DWORD FilesLanPeersLimit = 64 * 1024;
|
||||
+1093
-557
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,537 @@
|
||||
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286;
|
||||
constexpr int allOptions=3;
|
||||
enum OptionsPage { PanelModes, PanelColors, Appearance };
|
||||
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance"};
|
||||
constexpr const wchar_t *colorNames[]{L"Panel background",L"Text",L"Frame",L"Popup background",
|
||||
L"Menu selection",L"Command bar",L"File cursor"};
|
||||
enum PanelSection { PanelLayout, WideColumns, StatusColumns };
|
||||
constexpr const wchar_t *panelSectionNames[]{L"Panel layout",L"Wide panel columns",L"Status line columns"};
|
||||
constexpr int panelLayoutControls[]{273,274};
|
||||
constexpr int wideColumnControls[]{275,276,201,202,204};
|
||||
constexpr int statusColumnControls[]{277,203,205,206,207,208};
|
||||
constexpr int colorOptionControls[]{279,280};
|
||||
constexpr int appearanceControls[]{281,282};
|
||||
|
||||
enum class OptionsAnchor { StretchWidth, StretchHeight, BottomRight };
|
||||
struct OptionsControl
|
||||
{
|
||||
int id;
|
||||
OptionsAnchor anchor;
|
||||
RECT bounds{};
|
||||
};
|
||||
|
||||
struct PanelProperty
|
||||
{
|
||||
int section, control;
|
||||
const wchar_t *name;
|
||||
DWORD defaultValue;
|
||||
};
|
||||
constexpr PanelProperty panelProperties[]{
|
||||
{PanelLayout,273,L"Left panel: Wide",defaultWide},{PanelLayout,274,L"Right panel: Wide",defaultWide},
|
||||
{WideColumns,201,L"Size column width",defaultSizeWidth},
|
||||
{WideColumns,202,L"Align file extensions",defaultAlignExtensions},
|
||||
{WideColumns,204,L"Align folder extensions",defaultAlignFolders},
|
||||
{StatusColumns,203,L"Show details",defaultShowDetails},
|
||||
{StatusColumns,205,L"Name",defaultStatusFields&1},{StatusColumns,206,L"Size",(defaultStatusFields>>1)&1},
|
||||
{StatusColumns,207,L"Date",(defaultStatusFields>>2)&1},{StatusColumns,208,L"Time",(defaultStatusFields>>3)&1}};
|
||||
void resetPanelProperty(HWND dialog,const PanelProperty &property)
|
||||
{
|
||||
if(property.control==201) SetDlgItemInt(dialog,property.control,property.defaultValue,FALSE);
|
||||
else CheckDlgButton(dialog,property.control,property.defaultValue);
|
||||
}
|
||||
|
||||
struct OptionsDraft
|
||||
{
|
||||
LOGFONTW font=displayFont;
|
||||
int points=fontPoints;
|
||||
std::array<COLORREF,ColorCount> palette{};
|
||||
std::array<COLORREF,16> custom{};
|
||||
std::vector<FileStyle> styles=fileStyles;
|
||||
int styleIndex=0;
|
||||
bool styleSuggestionsLoaded{};
|
||||
int page=PanelModes, section=PanelLayout;
|
||||
HTREEITEM wideColumnsNode{};
|
||||
RECT initialClient{};
|
||||
UINT initialDpi=96;
|
||||
OptionsControl controls[8]{
|
||||
{optionsTree,OptionsAnchor::StretchHeight},
|
||||
{optionsTitle,OptionsAnchor::StretchWidth},
|
||||
{275,OptionsAnchor::StretchWidth},{277,OptionsAnchor::StretchWidth},
|
||||
{281,OptionsAnchor::StretchWidth},
|
||||
{IDOK,OptionsAnchor::BottomRight},{IDCANCEL,OptionsAnchor::BottomRight},
|
||||
{optionsApply,OptionsAnchor::BottomRight}};
|
||||
|
||||
};
|
||||
|
||||
#include "files_style_options.inl"
|
||||
#include "files_options_preview.inl"
|
||||
|
||||
bool optionsFontChanged(const OptionsDraft &draft)
|
||||
{
|
||||
auto pending=draft.font, applied=displayFont;
|
||||
// Height follows the selected point size and DPI. applyFont always uses natural width.
|
||||
pending.lfHeight=applied.lfHeight=0;
|
||||
pending.lfWidth=applied.lfWidth=0;
|
||||
return draft.points!=fontPoints ||
|
||||
memcmp(&pending,&applied,offsetof(LOGFONTW,lfFaceName))!=0 ||
|
||||
_wcsicmp(pending.lfFaceName,applied.lfFaceName)!=0;
|
||||
}
|
||||
|
||||
void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
|
||||
{
|
||||
BOOL valid{};
|
||||
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
DWORD fields=0;
|
||||
for(int i=0;i<4;++i) if(checked(205+i)) fields|=1u<<i;
|
||||
const bool dirty=!valid || width!=static_cast<UINT>(sizeWidth) ||
|
||||
checked(273)!=panels[0].wide || checked(274)!=panels[1].wide ||
|
||||
checked(202)!=alignExtensions || checked(204)!=alignFolders || checked(203)!=showDetails ||
|
||||
fields!=statusFields || draft.palette!=colors || draft.styles!=fileStyles || optionsFontChanged(draft);
|
||||
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
|
||||
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK optionsGroupBackground(HWND group,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||
{
|
||||
if(message==WM_ERASEBKGND)
|
||||
{
|
||||
const auto dc=reinterpret_cast<HDC>(wp);
|
||||
RECT client{};
|
||||
GetClientRect(group,&client);
|
||||
FillRect(dc,&client,GetSysColorBrush(COLOR_3DFACE));
|
||||
return TRUE;
|
||||
}
|
||||
return DefSubclassProc(group,message,wp,lp);
|
||||
}
|
||||
|
||||
void layoutOptionsPreview(HWND dialog)
|
||||
{
|
||||
const auto preview=GetDlgItem(dialog,fileStylePreview);
|
||||
if(!(GetWindowLongPtrW(preview,GWL_STYLE)&WS_VISIBLE)) return;
|
||||
RECT gap{0,0,6,5};
|
||||
MapDialogRect(dialog,&gap);
|
||||
const auto title=dialogControlBounds(dialog,optionsTitle);
|
||||
const auto buttons=dialogControlBounds(dialog,IDOK);
|
||||
LONG top=title.bottom;
|
||||
for(auto child=GetWindow(dialog,GW_CHILD);child;child=GetWindow(child,GW_HWNDNEXT))
|
||||
{
|
||||
const auto id=GetDlgCtrlID(child);
|
||||
if(child==preview || id==IDOK || id==IDCANCEL || id==optionsApply ||
|
||||
!(GetWindowLongPtrW(child,GWL_STYLE)&WS_VISIBLE)) continue;
|
||||
const auto bounds=dialogControlBounds(dialog,id);
|
||||
if(id!=-1 && bounds.left>=title.left-4) top=std::max(top,bounds.bottom);
|
||||
}
|
||||
top+=gap.bottom;
|
||||
SetWindowPos(preview,nullptr,title.left,top,title.right-title.left,
|
||||
std::max(1L,buttons.top-gap.bottom-top),SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
|
||||
}
|
||||
|
||||
void layoutOptions(HWND dialog,const OptionsDraft &draft)
|
||||
{
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
const auto scale=[&](LONG value){return MulDiv(value,dpi,draft.initialDpi);};
|
||||
RECT client{};
|
||||
GetClientRect(dialog,&client);
|
||||
const auto dx=std::max(0L,client.right-scale(draft.initialClient.right));
|
||||
const auto dy=std::max(0L,client.bottom-scale(draft.initialClient.bottom));
|
||||
for(const auto &control:draft.controls)
|
||||
{
|
||||
const auto &bounds=control.bounds;
|
||||
auto x=scale(bounds.left),y=scale(bounds.top);
|
||||
auto width=scale(bounds.right)-x,height=scale(bounds.bottom)-y;
|
||||
switch(control.anchor)
|
||||
{
|
||||
case OptionsAnchor::StretchWidth: width+=dx;break;
|
||||
case OptionsAnchor::StretchHeight: height+=dy;break;
|
||||
case OptionsAnchor::BottomRight: x+=dx;y+=dy;break;
|
||||
}
|
||||
SetWindowPos(GetDlgItem(dialog,control.id),nullptr,x,y,width,height,
|
||||
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
|
||||
}
|
||||
if(draft.page==PanelColors && draft.section==fileStyleSection) layoutFileStyleFields(dialog,false);
|
||||
layoutOptionsPreview(dialog);
|
||||
// Paint the final layout together, including the areas vacated by moved controls.
|
||||
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
}
|
||||
|
||||
void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
|
||||
{
|
||||
draft.page=page;
|
||||
draft.section=section;
|
||||
const wchar_t *title=L"No matching settings";
|
||||
if(page==allOptions) title=L"All settings";
|
||||
else if(page>=0)
|
||||
{
|
||||
title=optionsTitles[page];
|
||||
if(section>=0)
|
||||
title=page==PanelModes?panelSectionNames[section]:page==PanelColors?(section==fileStyleSection?L"File type styles":colorNames[section]):L"Font";
|
||||
}
|
||||
SetDlgItemTextW(dialog,optionsTitle,title);
|
||||
const auto show=[&](auto &controls,bool visible)
|
||||
{
|
||||
for(const auto id:controls) ShowWindow(GetDlgItem(dialog,id),visible?SW_SHOW:SW_HIDE);
|
||||
};
|
||||
EnableWindow(GetDlgItem(dialog,optionsReset),page>=0);
|
||||
const bool individual=page==PanelModes && section>=0;
|
||||
ShowWindow(GetDlgItem(dialog,optionsProperty),individual?SW_SHOW:SW_HIDE);
|
||||
ShowWindow(GetDlgItem(dialog,optionsResetProperty),individual?SW_SHOW:SW_HIDE);
|
||||
if(individual)
|
||||
{
|
||||
SendDlgItemMessageW(dialog,optionsProperty,CB_RESETCONTENT,0,0);
|
||||
for(size_t i=0;i<std::size(panelProperties);++i)
|
||||
{
|
||||
const auto &property=panelProperties[i];
|
||||
if(property.section!=section) continue;
|
||||
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_ADDSTRING,0,
|
||||
reinterpret_cast<LPARAM>(property.name));
|
||||
if(row>=0) SendDlgItemMessageW(dialog,optionsProperty,CB_SETITEMDATA,row,i);
|
||||
}
|
||||
SendDlgItemMessageW(dialog,optionsProperty,CB_SETCURSEL,0,0);
|
||||
}
|
||||
show(panelLayoutControls,page==PanelModes && section==PanelLayout);
|
||||
show(wideColumnControls,page==PanelModes && section==WideColumns);
|
||||
show(statusColumnControls,page==PanelModes && section==StatusColumns);
|
||||
const bool fileTypes=page==PanelColors && section==fileStyleSection;
|
||||
constexpr int fileStyleControls[]{287,288,289,290,291,292,293,294,295,296,297,298,300,fileStyleState};
|
||||
show(fileStyleControls,fileTypes);
|
||||
const auto preview=GetDlgItem(dialog,fileStylePreview);
|
||||
ShowWindow(preview,page>=0 && page<allOptions && section>=0?SW_SHOW:SW_HIDE);
|
||||
layoutOptionsPreview(dialog);
|
||||
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
|
||||
show(colorOptionControls,page==PanelColors && section>=0 && !fileTypes);
|
||||
if(fileTypes)
|
||||
{
|
||||
if(!draft.styleSuggestionsLoaded)
|
||||
{
|
||||
fillFileStyleSuggestions(dialog);
|
||||
draft.styleSuggestionsLoaded=true;
|
||||
}
|
||||
updateFileStyleEditor(dialog,draft,true);
|
||||
}
|
||||
show(appearanceControls,page==Appearance && section>=0);
|
||||
if(page==PanelColors && section>=0 && !fileTypes)
|
||||
{
|
||||
const auto color=draft.palette[section];
|
||||
wchar_t hex[8]{};
|
||||
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(color)),unsigned(GetGValue(color)),unsigned(GetBValue(color)));
|
||||
SetDlgItemTextW(dialog,280,hex);
|
||||
}
|
||||
if(page==Appearance)
|
||||
{
|
||||
const auto description=std::wstring(draft.font.lfFaceName)+L" · "+std::to_wstring(draft.points/10)+
|
||||
(draft.points%10?L"."+std::to_wstring(draft.points%10):L"")+L" pt";
|
||||
SetDlgItemTextW(dialog,281,description.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void filterOptions(HWND dialog,OptionsDraft &draft)
|
||||
{
|
||||
wchar_t text[256]{};
|
||||
GetDlgItemTextW(dialog,optionsSearch,text,std::size(text));
|
||||
std::wstring query=text;
|
||||
if(!query.empty()) CharLowerBuffW(query.data(),static_cast<DWORD>(query.size()));
|
||||
const auto matches=[&](const wchar_t *label)
|
||||
{
|
||||
std::wstring lower=label;
|
||||
CharLowerBuffW(lower.data(),static_cast<DWORD>(lower.size()));
|
||||
return lower.find(query)!=std::wstring::npos;
|
||||
};
|
||||
const HWND tree=GetDlgItem(dialog,optionsTree);
|
||||
const auto add=[&](const wchar_t *label,int page,int option,HTREEITEM parent)
|
||||
{
|
||||
TVINSERTSTRUCTW insert{};
|
||||
insert.hParent=parent;
|
||||
insert.hInsertAfter=TVI_LAST;
|
||||
insert.item.mask=TVIF_TEXT|TVIF_PARAM;
|
||||
insert.item.pszText=const_cast<wchar_t*>(label);
|
||||
insert.item.lParam=(page<<8)|(option+1);
|
||||
return TreeView_InsertItem(tree,&insert);
|
||||
};
|
||||
const int previousPage=draft.page,previousSection=draft.section;
|
||||
TreeView_DeleteAllItems(tree);
|
||||
draft.wideColumnsNode=nullptr;
|
||||
const auto root=add(L"All settings",allOptions,-1,TVI_ROOT);
|
||||
HTREEITEM first{},selected{};
|
||||
for(int page=PanelModes;page<=Appearance;++page)
|
||||
{
|
||||
HTREEITEM category{};
|
||||
const bool wholeCategory=matches(optionsTitles[page]) || (!query.empty() && matches(L"All settings"));
|
||||
const auto addSection=[&](const wchar_t *label,int section,bool aliasMatches=false)
|
||||
{
|
||||
if(!wholeCategory && !aliasMatches && !matches(label)) return;
|
||||
if(!category) category=add(optionsTitles[page],page,-1,root);
|
||||
const auto node=add(label,page,section,category);
|
||||
if(!first) first=node;
|
||||
if(page==previousPage && section==previousSection) selected=node;
|
||||
if(page==PanelModes && section==WideColumns) draft.wideColumnsNode=node;
|
||||
};
|
||||
if(page==PanelModes)
|
||||
{
|
||||
for(int section=PanelLayout;section<=StatusColumns;++section)
|
||||
{
|
||||
const bool propertyMatches=std::any_of(std::begin(panelProperties),std::end(panelProperties),
|
||||
[&](const auto &property){return property.section==section && matches(property.name);});
|
||||
addSection(panelSectionNames[section],section,propertyMatches);
|
||||
}
|
||||
}
|
||||
else if(page==PanelColors)
|
||||
{
|
||||
for(int color=0;color<ColorCount;++color) addSection(colorNames[color],color);
|
||||
const bool extensionMatches=std::any_of(draft.styles.begin(),draft.styles.end(),
|
||||
[&](const auto &style){return matches(style.extension.c_str());});
|
||||
addSection(L"File type styles",fileStyleSection,extensionMatches || matches(L"System and hidden") ||
|
||||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
|
||||
}
|
||||
else addSection(L"Font",0,matches(L"Display font"));
|
||||
if(category) TreeView_Expand(tree,category,TVE_EXPAND);
|
||||
}
|
||||
TreeView_Expand(tree,root,TVE_EXPAND);
|
||||
TreeView_SelectItem(tree,query.empty() && previousPage==allOptions?root:selected?selected:first?first:root);
|
||||
if(!first) SetDlgItemTextW(dialog,optionsTitle,L"No matching settings");
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
{
|
||||
auto &draft=*reinterpret_cast<OptionsDraft*>(lp);
|
||||
SetWindowLongPtrW(dialog,DWLP_USER,lp);
|
||||
draft.initialDpi=GetDpiForWindow(dialog);
|
||||
GetClientRect(dialog,&draft.initialClient);
|
||||
for(auto &control:draft.controls) control.bounds=dialogControlBounds(dialog,control.id);
|
||||
// Group boxes are behind their sibling controls. Erase their full client
|
||||
// area on resize while clipping out the controls inside the frame.
|
||||
for(const auto id:{275,277})
|
||||
{
|
||||
const auto group=GetDlgItem(dialog,id);
|
||||
SetWindowLongPtrW(group,GWL_STYLE,GetWindowLongPtrW(group,GWL_STYLE)|WS_CLIPSIBLINGS);
|
||||
SetWindowPos(group,HWND_BOTTOM,0,0,0,0,SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE);
|
||||
SetWindowSubclass(group,optionsGroupBackground,1,0);
|
||||
}
|
||||
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
|
||||
SendDlgItemMessageW(dialog,201,EM_SETLIMITTEXT,2,0);
|
||||
SendDlgItemMessageW(dialog,optionsSearch,EM_SETLIMITTEXT,255,0);
|
||||
CheckDlgButton(dialog,202,alignExtensions);
|
||||
CheckDlgButton(dialog,203,showDetails);
|
||||
CheckDlgButton(dialog,204,alignFolders);
|
||||
CheckDlgButton(dialog,273,panels[0].wide);
|
||||
CheckDlgButton(dialog,274,panels[1].wide);
|
||||
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
|
||||
filterOptions(dialog,draft);
|
||||
updateOptionsApply(dialog,draft);
|
||||
return TRUE;
|
||||
}
|
||||
auto *draft=reinterpret_cast<OptionsDraft*>(GetWindowLongPtrW(dialog,DWLP_USER));
|
||||
if(!draft) return FALSE;
|
||||
if(message==WM_DRAWITEM && wp==fileStylePreview)
|
||||
{
|
||||
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
|
||||
if(draft->page==PanelColors && draft->section==fileStyleSection) paintFileStylePreview(item,*draft);
|
||||
else paintOptionsPreview(dialog,item,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_GETMINMAXINFO)
|
||||
{
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
RECT minimum{0,0,MulDiv(draft->initialClient.right,dpi,draft->initialDpi),
|
||||
MulDiv(draft->initialClient.bottom,dpi,draft->initialDpi)};
|
||||
AdjustWindowRectExForDpi(&minimum,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_STYLE)),
|
||||
FALSE,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_EXSTYLE)),dpi);
|
||||
auto &limits=*reinterpret_cast<MINMAXINFO*>(lp);
|
||||
limits.ptMinTrackSize.x=minimum.right-minimum.left;
|
||||
limits.ptMinTrackSize.y=minimum.bottom-minimum.top;
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_SIZE && wp!=SIZE_MINIMIZED)
|
||||
{
|
||||
layoutOptions(dialog,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
if(message==WM_NOTIFY && wp==optionsTree)
|
||||
{
|
||||
const auto &change=*reinterpret_cast<const NMTREEVIEWW*>(lp);
|
||||
if(change.hdr.code==TVN_SELCHANGEDW && change.itemNew.hItem)
|
||||
showOptionsPage(dialog,*draft,static_cast<int>(change.itemNew.lParam>>8),static_cast<int>(change.itemNew.lParam&255)-1);
|
||||
return FALSE;
|
||||
}
|
||||
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
|
||||
{
|
||||
EndDialog(dialog,IDCANCEL);
|
||||
return TRUE;
|
||||
}
|
||||
if(message!=WM_COMMAND) return FALSE;
|
||||
switch(LOWORD(wp))
|
||||
{
|
||||
case optionsSearch:
|
||||
if(HIWORD(wp)==EN_CHANGE) filterOptions(dialog,*draft);
|
||||
return TRUE;
|
||||
case 279:
|
||||
if(draft->page==PanelColors && draft->section>=0 && draft->section<ColorCount)
|
||||
{
|
||||
choosePanelColor(dialog,draft->palette[draft->section],draft->custom.data());
|
||||
showOptionsPage(dialog,*draft,PanelColors,draft->section);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
case 282:
|
||||
chooseDisplayFont(dialog,draft->font,draft->points);
|
||||
showOptionsPage(dialog,*draft,Appearance,0);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case fileStyleState:
|
||||
if(HIWORD(wp)==CBN_SELCHANGE) updateFileStyleEditor(dialog,*draft,false);
|
||||
return TRUE;
|
||||
case fileStyleRules:
|
||||
if(HIWORD(wp)==CBN_SELCHANGE)
|
||||
{
|
||||
draft->styleIndex=static_cast<int>(SendDlgItemMessageW(dialog,fileStyleRules,CB_GETCURSEL,0,0));
|
||||
updateFileStyleEditor(dialog,*draft,false);
|
||||
}
|
||||
return TRUE;
|
||||
case 292:
|
||||
if(addFileStyle(dialog,*draft)) updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case 289: case 293: case 294: case 295: case 297: case 298:
|
||||
if(draft->styleIndex>=0)
|
||||
{
|
||||
auto &style=draft->styles[draft->styleIndex];
|
||||
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
|
||||
auto &foreground=state>0?style.states[state-1].foreground:style.foreground;
|
||||
auto &background=state>0?style.states[state-1].background:style.background;
|
||||
switch(LOWORD(wp))
|
||||
{
|
||||
case 289:
|
||||
draft->styles.erase(draft->styles.begin()+draft->styleIndex);
|
||||
break;
|
||||
case 293: case 294:
|
||||
{
|
||||
auto &field=LOWORD(wp)==293?foreground:background;
|
||||
auto color=field.value_or(draft->palette[LOWORD(wp)==293?Text:PanelBlue]);
|
||||
if(choosePanelColor(dialog,color,draft->custom.data())) field=color;
|
||||
break;
|
||||
}
|
||||
case 295: foreground.reset();background.reset();break;
|
||||
case 297: style.underline=IsDlgButtonChecked(dialog,297)==BST_CHECKED;break;
|
||||
case 298: style.strikeout=IsDlgButtonChecked(dialog,298)==BST_CHECKED;break;
|
||||
}
|
||||
updateFileStyleEditor(dialog,*draft,LOWORD(wp)==289);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
case optionsReset:
|
||||
if(draft->page==allOptions || draft->page==PanelModes)
|
||||
for(const auto &property:panelProperties)
|
||||
if(draft->section<0 || property.section==draft->section) resetPanelProperty(dialog,property);
|
||||
if(draft->page==allOptions || (draft->page==PanelColors && draft->section<0))
|
||||
draft->palette=defaultColors;
|
||||
else if(draft->page==PanelColors && draft->section< ColorCount)
|
||||
draft->palette[draft->section]=defaultColors[draft->section];
|
||||
if(draft->page==allOptions || (draft->page==PanelColors &&
|
||||
(draft->section<0 || draft->section==fileStyleSection)))
|
||||
draft->styles=defaultFileStyles;
|
||||
if(draft->page==allOptions || draft->page==Appearance)
|
||||
{
|
||||
draft->font=defaultFilesFont();
|
||||
draft->points=defaultFontPoints;
|
||||
}
|
||||
showOptionsPage(dialog,*draft,draft->page,draft->section);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case optionsResetProperty:
|
||||
{
|
||||
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_GETCURSEL,0,0);
|
||||
if(row>=0)
|
||||
{
|
||||
const auto property=SendDlgItemMessageW(dialog,optionsProperty,CB_GETITEMDATA,row,0);
|
||||
if(property>=0 && static_cast<size_t>(property)<std::size(panelProperties))
|
||||
resetPanelProperty(dialog,panelProperties[property]);
|
||||
updateOptionsApply(dialog,*draft);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
case 201:
|
||||
case 202: case 203: case 204: case 205: case 206: case 207: case 208:
|
||||
case 273: case 274:
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case optionsApply:
|
||||
case IDOK:
|
||||
{
|
||||
BOOL valid{};
|
||||
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
|
||||
if(!valid || width<6 || width>16)
|
||||
{
|
||||
SetDlgItemTextW(dialog,optionsSearch,L"");
|
||||
TreeView_SelectItem(GetDlgItem(dialog,optionsTree),draft->wideColumnsNode);
|
||||
MessageBoxW(dialog,L"Enter a width from 6 to 16 characters.",optionsTitles[PanelModes],MB_OK|MB_ICONWARNING);
|
||||
SetFocus(GetDlgItem(dialog,201));
|
||||
SendDlgItemMessageW(dialog,201,EM_SETSEL,0,-1);
|
||||
return TRUE;
|
||||
}
|
||||
const auto owner=GetWindow(dialog,GW_OWNER);
|
||||
const bool stylesChanged=draft->styles!=fileStyles;
|
||||
if(stylesChanged && !saveFileStyles(draft->styles))
|
||||
{
|
||||
MessageBoxW(dialog,L"Could not save the file type styles.",L"KSwitcher Files Options",MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
const bool fontChanged=optionsFontChanged(*draft);
|
||||
if(fontChanged && !applyFont(owner,draft->font,draft->points))
|
||||
{
|
||||
if(stylesChanged) saveFileStyles(fileStyles);
|
||||
MessageBoxW(dialog,L"Could not apply the selected font.",optionsTitles[Appearance],MB_OK|MB_ICONWARNING);
|
||||
return TRUE;
|
||||
}
|
||||
if(stylesChanged) fileStyles=draft->styles;
|
||||
sizeWidth=static_cast<int>(width);
|
||||
alignExtensions=IsDlgButtonChecked(dialog,202)==BST_CHECKED;
|
||||
showDetails=IsDlgButtonChecked(dialog,203)==BST_CHECKED;
|
||||
alignFolders=IsDlgButtonChecked(dialog,204)==BST_CHECKED;
|
||||
statusFields=0;
|
||||
for(int i=0;i<4;++i)
|
||||
if(IsDlgButtonChecked(dialog,205+i)==BST_CHECKED) statusFields|=1u<<i;
|
||||
savePreference(L"SizeWidth",sizeWidth);
|
||||
savePreference(L"AlignExtensions",alignExtensions);
|
||||
savePreference(L"ShowDetails",showDetails);
|
||||
savePreference(L"AlignFolders",alignFolders);
|
||||
savePreference(L"StatusFields",statusFields);
|
||||
for(int i=0;i<ColorCount;++i)
|
||||
if(colors[i]!=draft->palette[i])
|
||||
{
|
||||
colors[i]=draft->palette[i];
|
||||
savePreference(std::to_wstring(i).c_str(),colors[i]);
|
||||
}
|
||||
refreshColors();
|
||||
if(fontChanged)
|
||||
{
|
||||
savePreference(L"FontPoints",fontPoints);
|
||||
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"DisplayFont",REG_BINARY,&displayFont,sizeof(displayFont));
|
||||
draft->font=displayFont;
|
||||
}
|
||||
for(int i=0;i<2;++i)
|
||||
{
|
||||
const bool wide=IsDlgButtonChecked(dialog,273+i)==BST_CHECKED;
|
||||
if(panels[i].wide!=wide) setMode(i,wide);
|
||||
}
|
||||
layout(owner);
|
||||
showFileDetails(panels[active]);
|
||||
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
|
||||
if(LOWORD(wp)==IDOK) EndDialog(dialog,IDOK);
|
||||
else updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void editOptions(HWND owner)
|
||||
{
|
||||
const auto panel=panels[active].list;
|
||||
OptionsDraft draft;
|
||||
std::copy(std::begin(colors),std::end(colors),draft.palette.begin());
|
||||
const auto result=DialogBoxParamW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
|
||||
reinterpret_cast<LPARAM>(&draft));
|
||||
SetFocus(panel);
|
||||
if(result==-1) setStatus(L"Could not open Files Options.");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
void paintOptionsPreview(HWND dialog,const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
|
||||
{
|
||||
const auto dc=item.hDC;
|
||||
const auto saved=SaveDC(dc);
|
||||
if(!saved) return;
|
||||
IntersectClipRect(dc,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
|
||||
const auto dpi=GetDpiForWindow(item.hwndItem);
|
||||
const auto px=[&](int value){return MulDiv(value,dpi,96);};
|
||||
auto font=draft.font;
|
||||
font.lfHeight=-MulDiv(draft.points,dpi,720);
|
||||
font.lfWidth=0;
|
||||
const auto previewFont=CreateFontIndirectW(&font);
|
||||
SelectObject(dc,previewFont?previewFont:mono);
|
||||
TEXTMETRICW metrics{};
|
||||
GetTextMetricsW(dc,&metrics);
|
||||
const auto rowHeight=std::max<LONG>(px(18),metrics.tmHeight+px(4));
|
||||
const auto &palette=draft.palette;
|
||||
const auto fill=[&](RECT bounds,COLORREF color)
|
||||
{
|
||||
SetBkColor(dc,color);
|
||||
ExtTextOutW(dc,0,0,ETO_OPAQUE|ETO_CLIPPED,&bounds,nullptr,0,nullptr);
|
||||
};
|
||||
const auto row=[&](RECT bounds,std::wstring_view label,COLORREF fg,COLORREF bg,bool right=false)
|
||||
{
|
||||
fill(bounds,bg);
|
||||
bounds.left+=px(5);
|
||||
bounds.right-=px(5);
|
||||
SetBkMode(dc,TRANSPARENT);
|
||||
SetTextColor(dc,fg);
|
||||
DrawTextW(dc,label.data(),static_cast<int>(label.size()),&bounds,
|
||||
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS|(right?DT_RIGHT:DT_LEFT));
|
||||
};
|
||||
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
|
||||
auto bounds=item.rcItem;
|
||||
fill(bounds,palette[PanelBlue]);
|
||||
|
||||
const auto frame=[&](RECT rect)
|
||||
{
|
||||
SetDCBrushColor(dc,palette[Frame]);
|
||||
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
|
||||
InflateRect(&rect,-px(2),-px(2));
|
||||
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
|
||||
};
|
||||
const auto panel=[&](RECT panelBounds,bool wide,bool cursor,std::wstring_view path)
|
||||
{
|
||||
frame(panelBounds);
|
||||
InflateRect(&panelBounds,-px(4),-px(4));
|
||||
auto line=panelBounds;
|
||||
line.bottom=line.top+rowHeight;
|
||||
row(line,path,palette[Frame],palette[PanelBlue]);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
BOOL valid{};
|
||||
const auto input=GetDlgItemInt(dialog,201,&valid,FALSE);
|
||||
const auto columns=valid?std::clamp(input,6u,16u):static_cast<UINT>(sizeWidth);
|
||||
const auto entry=[&](std::wstring_view stem,std::wstring_view ext,bool folder,bool highlight)
|
||||
{
|
||||
const auto bg=highlight?palette[PanelCursor]:palette[PanelBlue];
|
||||
auto name=line;
|
||||
if(wide)
|
||||
{
|
||||
name.right=std::max(name.left+px(20),name.right-static_cast<LONG>(columns+2)*metrics.tmAveCharWidth);
|
||||
name.right=std::min(name.right,line.right);
|
||||
auto amount=line;
|
||||
amount.left=name.right;
|
||||
row(amount,folder?L"Folder":formatSize(48382,columns),palette[Text],bg,true);
|
||||
fill({amount.left,amount.top,amount.left+px(1),amount.bottom},palette[Frame]);
|
||||
}
|
||||
if(wide && checked(folder?204:202) && name.right-name.left>px(70))
|
||||
{
|
||||
auto extension=name;
|
||||
extension.left=extension.right-std::min<LONG>(px(60),(extension.right-extension.left)/2);
|
||||
name.right=extension.left;
|
||||
row(extension,ext,palette[Text],bg,true);
|
||||
row(name,stem,palette[Text],bg);
|
||||
}
|
||||
else row(name,std::wstring(stem)+L"."+std::wstring(ext),palette[Text],bg);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
};
|
||||
entry(L"report",L"txt",false,cursor);
|
||||
entry(L"examples",L"old",true,false);
|
||||
};
|
||||
|
||||
if(draft.page==PanelColors && (draft.section==Dialog || draft.section==Selection))
|
||||
{
|
||||
InflateRect(&bounds,-px(12),-px(10));
|
||||
const auto maxWidth=px(340);
|
||||
bounds.right=std::min(bounds.right,bounds.left+maxWidth);
|
||||
fill(bounds,palette[Dialog]);
|
||||
frame(bounds);
|
||||
InflateRect(&bounds,-px(5),-px(5));
|
||||
auto line=bounds;
|
||||
line.bottom=line.top+rowHeight;
|
||||
row(line,L"Change drive",palette[Text],palette[Dialog]);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
row(line,L"C: fixed",palette[Text],palette[Selection]);
|
||||
OffsetRect(&line,0,rowHeight);
|
||||
row(line,L"D: fixed",palette[Text],palette[Dialog]);
|
||||
}
|
||||
else if(draft.page==PanelColors && draft.section==Footer)
|
||||
{
|
||||
auto bar=bounds;
|
||||
bar.top=bar.bottom-rowHeight;
|
||||
row(bar,L"F2 Refresh F3 View F5 Copy F6 Move",palette[Text],palette[Footer]);
|
||||
bounds.bottom=bar.top;
|
||||
panel(bounds,false,false,L"C:\\Example");
|
||||
}
|
||||
else if(draft.page==PanelModes && draft.section==PanelLayout)
|
||||
{
|
||||
auto left=bounds,right=bounds;
|
||||
left.right=(bounds.left+bounds.right)/2;
|
||||
right.left=left.right+px(4);
|
||||
panel(left,checked(273),true,L"C:\\Example");
|
||||
panel(right,checked(274),false,L"D:\\Example");
|
||||
}
|
||||
else if(draft.page==PanelModes && draft.section==StatusColumns)
|
||||
{
|
||||
auto statusRow=bounds;
|
||||
statusRow.top=statusRow.bottom-rowHeight;
|
||||
bounds.bottom=statusRow.top;
|
||||
panel(bounds,true,true,L"C:\\Example");
|
||||
std::wstring label;
|
||||
if(checked(203))
|
||||
{
|
||||
constexpr const wchar_t *values[]{L"report.txt",L"47 KiB",L"2026-11-14",L"10:30"};
|
||||
for(int i=0;i<4;++i)
|
||||
if(checked(205+i))
|
||||
{
|
||||
if(!label.empty()) label+=L" ";
|
||||
label+=values[i];
|
||||
}
|
||||
}
|
||||
row(statusRow,label,palette[Text],palette[PanelBlue]);
|
||||
}
|
||||
else panel(bounds,draft.page==PanelModes || checked(273),
|
||||
draft.page==PanelColors && draft.section==PanelCursor,L"C:\\Example");
|
||||
|
||||
RestoreDC(dc,saved);
|
||||
if(previewFont) DeleteObject(previewFont);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
constexpr int fileStyleSection=ColorCount;
|
||||
constexpr int fileStyleRules=288,fileStyleSuggestion=291,fileStylePreview=299,fileStyleState=301;
|
||||
constexpr const wchar_t *fileStyleStateNames[]{L"Normal",L"Marked (Insert)",L"Focused cursor",L"Unfocused cursor"};
|
||||
|
||||
void layoutFileStyleFields(HWND dialog,bool redraw)
|
||||
{
|
||||
RECT client{};
|
||||
GetClientRect(dialog,&client);
|
||||
RECT spacing{0,0,8,0};
|
||||
MapDialogRect(dialog,&spacing);
|
||||
const auto gap=spacing.right;
|
||||
const auto dpi=GetDpiForWindow(dialog);
|
||||
const auto arrow=GetSystemMetricsForDpi(SM_CXVSCROLL,dpi);
|
||||
const auto border=GetSystemMetricsForDpi(SM_CXEDGE,dpi);
|
||||
const auto flags=SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW;
|
||||
const auto fit=[&](int id,int buttonId)
|
||||
{
|
||||
const auto combo=GetDlgItem(dialog,id);
|
||||
const auto bounds=dialogControlBounds(dialog,id);
|
||||
const auto dc=GetDC(combo);
|
||||
if(!dc) return;
|
||||
const auto previous=SelectObject(dc,reinterpret_cast<HFONT>(SendMessageW(combo,WM_GETFONT,0,0)));
|
||||
SIZE extent{};
|
||||
const std::wstring_view example=L".example";
|
||||
GetTextExtentPoint32W(dc,example.data(),static_cast<int>(example.size()),&extent);
|
||||
LONG longest=extent.cx;
|
||||
const auto count=SendMessageW(combo,CB_GETCOUNT,0,0);
|
||||
for(LRESULT row=0;row<count;++row)
|
||||
{
|
||||
const auto length=SendMessageW(combo,CB_GETLBTEXTLEN,row,0);
|
||||
if(length<=0) continue;
|
||||
std::wstring item(static_cast<size_t>(length)+1,L'\0');
|
||||
if(SendMessageW(combo,CB_GETLBTEXT,row,reinterpret_cast<LPARAM>(item.data()))!=CB_ERR &&
|
||||
GetTextExtentPoint32W(dc,item.data(),static_cast<int>(length),&extent))
|
||||
longest=std::max(longest,extent.cx);
|
||||
}
|
||||
if(previous) SelectObject(dc,previous);
|
||||
ReleaseDC(combo,dc);
|
||||
|
||||
const auto button=buttonId?dialogControlBounds(dialog,buttonId):RECT{};
|
||||
const auto buttonWidth=button.right-button.left;
|
||||
const auto available=client.right-bounds.left-gap-(buttonId?gap+buttonWidth:0);
|
||||
const auto width=std::max(1L,std::min(available,longest+arrow+2*border+gap));
|
||||
RECT dropped{};
|
||||
SendMessageW(combo,CB_GETDROPPEDCONTROLRECT,0,reinterpret_cast<LPARAM>(&dropped));
|
||||
SetWindowPos(combo,nullptr,bounds.left,bounds.top,width,dropped.bottom-dropped.top,flags);
|
||||
if(buttonId)
|
||||
SetWindowPos(GetDlgItem(dialog,buttonId),nullptr,bounds.left+width+gap,button.top,
|
||||
buttonWidth,button.bottom-button.top,flags);
|
||||
};
|
||||
fit(fileStyleRules,289);
|
||||
fit(fileStyleSuggestion,292);
|
||||
fit(fileStyleState,0);
|
||||
if(redraw)
|
||||
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
|
||||
}
|
||||
|
||||
void fillFileStyleSuggestions(HWND dialog)
|
||||
{
|
||||
std::set<std::wstring> extensions;
|
||||
for(DWORD i=0;;++i)
|
||||
{
|
||||
wchar_t name[256]{};
|
||||
DWORD length=std::size(name);
|
||||
const auto result=RegEnumKeyExW(HKEY_CLASSES_ROOT,i,name,&length,nullptr,nullptr,nullptr,nullptr);
|
||||
if(result==ERROR_MORE_DATA) continue;
|
||||
if(result!=ERROR_SUCCESS) break;
|
||||
if(name[0]!=L'.') continue;
|
||||
auto extension=fileStyleExtension(name);
|
||||
if(!extension.empty()) extensions.insert(std::move(extension));
|
||||
}
|
||||
for(const auto &extension:extensions)
|
||||
SendDlgItemMessageW(dialog,fileStyleSuggestion,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(extension.c_str()));
|
||||
SendDlgItemMessageW(dialog,fileStyleSuggestion,CB_LIMITTEXT,255,0);
|
||||
for(const auto label:fileStyleStateNames)
|
||||
SendDlgItemMessageW(dialog,fileStyleState,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
|
||||
SendDlgItemMessageW(dialog,fileStyleState,CB_SETCURSEL,0,0);
|
||||
}
|
||||
|
||||
void updateFileStyleEditor(HWND dialog,OptionsDraft &draft,bool rebuild)
|
||||
{
|
||||
const auto combo=GetDlgItem(dialog,fileStyleRules);
|
||||
if(rebuild)
|
||||
{
|
||||
SendMessageW(combo,CB_RESETCONTENT,0,0);
|
||||
for(const auto &style:draft.styles)
|
||||
{
|
||||
const auto label=style.extension==L"@system-hidden"?L"System and hidden":style.extension.c_str();
|
||||
SendMessageW(combo,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
|
||||
}
|
||||
draft.styleIndex=draft.styles.empty()?-1:std::clamp(draft.styleIndex,0,static_cast<int>(draft.styles.size())-1);
|
||||
SendMessageW(combo,CB_SETCURSEL,draft.styleIndex,0);
|
||||
layoutFileStyleFields(dialog,true);
|
||||
}
|
||||
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
|
||||
const bool selected=draft.styleIndex>=0;
|
||||
for(const int id:{289,293,294,295,297,298})
|
||||
EnableWindow(GetDlgItem(dialog,id),selected);
|
||||
if(!selected)
|
||||
{
|
||||
SetDlgItemTextW(dialog,296,L"");
|
||||
CheckDlgButton(dialog,297,BST_UNCHECKED);
|
||||
CheckDlgButton(dialog,298,BST_UNCHECKED);
|
||||
return;
|
||||
}
|
||||
const auto &style=draft.styles[draft.styleIndex];
|
||||
const auto colorLabel=[](std::optional<COLORREF> color)->std::wstring
|
||||
{
|
||||
if(!color) return L"Default";
|
||||
wchar_t hex[8]{};
|
||||
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(*color)),unsigned(GetGValue(*color)),unsigned(GetBValue(*color)));
|
||||
return hex;
|
||||
};
|
||||
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
|
||||
const auto fg=state>0?style.states[state-1].foreground:style.foreground;
|
||||
const auto bg=state>0?style.states[state-1].background:style.background;
|
||||
const auto label=L"Foreground: "+colorLabel(fg)+L" Background: "+colorLabel(bg);
|
||||
SetDlgItemTextW(dialog,296,label.c_str());
|
||||
CheckDlgButton(dialog,297,style.underline);
|
||||
CheckDlgButton(dialog,298,style.strikeout);
|
||||
}
|
||||
|
||||
bool addFileStyle(HWND dialog,OptionsDraft &draft)
|
||||
{
|
||||
wchar_t input[256]{};
|
||||
GetDlgItemTextW(dialog,fileStyleSuggestion,input,std::size(input));
|
||||
const auto extension=fileStyleExtension(input);
|
||||
if(extension.empty())
|
||||
{
|
||||
MessageBoxW(dialog,L"Enter a file extension such as .txt.",L"File type styles",MB_OK|MB_ICONWARNING);
|
||||
SetFocus(GetDlgItem(dialog,fileStyleSuggestion));
|
||||
return false;
|
||||
}
|
||||
const auto existing=std::find_if(draft.styles.begin(),draft.styles.end(),
|
||||
[&](const auto &style){return sameFileExtension(style.extension,extension);});
|
||||
if(existing!=draft.styles.end()) draft.styleIndex=static_cast<int>(existing-draft.styles.begin());
|
||||
else
|
||||
{
|
||||
if(draft.styles.size()>=64)
|
||||
{
|
||||
MessageBoxW(dialog,L"The profile can contain up to 64 file type rules.",L"File type styles",MB_OK|MB_ICONWARNING);
|
||||
return false;
|
||||
}
|
||||
draft.styles.push_back({extension});
|
||||
draft.styleIndex=static_cast<int>(draft.styles.size())-1;
|
||||
}
|
||||
SetDlgItemTextW(dialog,fileStyleSuggestion,L"");
|
||||
updateFileStyleEditor(dialog,draft,true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void paintFileStylePreview(const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
|
||||
{
|
||||
const auto saved=SaveDC(item.hDC);
|
||||
const bool selected=draft.styleIndex>=0 && static_cast<size_t>(draft.styleIndex)<draft.styles.size();
|
||||
const auto extension=selected?std::wstring_view(draft.styles[draft.styleIndex].extension):std::wstring_view{};
|
||||
const bool systemHidden=extension==L"@system-hidden";
|
||||
SetBkColor(item.hDC,draft.palette[PanelBlue]);
|
||||
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&item.rcItem,nullptr,0,nullptr);
|
||||
if(selected)
|
||||
{
|
||||
auto font=draft.font;
|
||||
font.lfHeight=-MulDiv(draft.points,GetDpiForWindow(item.hwndItem),720);
|
||||
font.lfWidth=0;
|
||||
const auto fontHandle=CreateFontIndirectW(&font);
|
||||
if(fontHandle) SelectObject(item.hDC,fontHandle);
|
||||
TEXTMETRICW metrics{};
|
||||
GetTextMetricsW(item.hDC,&metrics);
|
||||
const int height=metrics.tmHeight+MulDiv(4,GetDpiForWindow(item.hwndItem),96);
|
||||
if(fontHandle)
|
||||
{
|
||||
SelectObject(item.hDC,mono?mono:GetStockObject(SYSTEM_FONT));
|
||||
DeleteObject(fontHandle);
|
||||
}
|
||||
IntersectClipRect(item.hDC,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
|
||||
for(int i=0;i<4;++i)
|
||||
{
|
||||
const auto state=static_cast<FileRowState>(i);
|
||||
const auto background=draft.palette[state==FileRowState::Focused?PanelCursor:PanelBlue];
|
||||
const auto style=resolveFileStyle(extension,!systemHidden,systemHidden,
|
||||
state==FileRowState::Focused || state==FileRowState::Marked,
|
||||
draft.palette[Text],background,draft.styles,state);
|
||||
auto decorated=font;
|
||||
if(style.decorations)
|
||||
{
|
||||
decorated.lfUnderline=(style.decorations&1)!=0;
|
||||
decorated.lfStrikeOut=(style.decorations&2)!=0;
|
||||
}
|
||||
const auto previewFont=CreateFontIndirectW(&decorated);
|
||||
const auto previous=SelectObject(item.hDC,previewFont?previewFont:mono);
|
||||
auto row=item.rcItem;
|
||||
row.top+=i*height;
|
||||
row.bottom=row.top+height;
|
||||
SetBkColor(item.hDC,style.background);
|
||||
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&row,nullptr,0,nullptr);
|
||||
constexpr const wchar_t *samples[]{L"normal-sample",L"marked-sample",L"focused-cursor",L"unfocused-cursor"};
|
||||
const auto label=std::wstring(samples[i])+(systemHidden?L" (hidden)":std::wstring(extension));
|
||||
row.left+=5;
|
||||
row.right-=5;
|
||||
SetBkMode(item.hDC,TRANSPARENT);
|
||||
SetTextColor(item.hDC,style.foreground);
|
||||
DrawTextW(item.hDC,label.c_str(),static_cast<int>(label.size()),&row,
|
||||
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS);
|
||||
SelectObject(item.hDC,previous);
|
||||
if(previewFont) DeleteObject(previewFont);
|
||||
}
|
||||
}
|
||||
RestoreDC(item.hDC,saved);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
enum class FileRowState { Normal, Marked, Focused, Unfocused };
|
||||
struct FileStyleColors
|
||||
{
|
||||
std::optional<COLORREF> foreground,background;
|
||||
bool operator==(const FileStyleColors &) const = default;
|
||||
};
|
||||
struct FileStyle
|
||||
{
|
||||
std::wstring extension;
|
||||
std::optional<COLORREF> foreground,background;
|
||||
bool underline{},strikeout{};
|
||||
std::array<FileStyleColors,3> states{};
|
||||
bool operator==(const FileStyle &) const = default;
|
||||
};
|
||||
|
||||
const std::vector<FileStyle> defaultFileStyles{
|
||||
{L"@system-hidden"},{L".exe",RGB(85,255,85)},{L".md",RGB(231,192,247)},
|
||||
{L".7z",RGB(255,210,128)},{L".zip",RGB(255,210,128)},{L".rar",RGB(255,210,128)},
|
||||
{L".tar",RGB(255,210,128)},{L".gz",RGB(255,210,128)},
|
||||
{L".png",RGB(165,230,255)},{L".jpg",RGB(165,230,255)},{L".jpeg",RGB(165,230,255)},
|
||||
{L".gif",RGB(165,230,255)},{L".webp",RGB(165,230,255)},{L".svg",RGB(165,230,255)},
|
||||
{L".mp3",RGB(255,185,216)},{L".wav",RGB(255,185,216)},{L".flac",RGB(255,185,216)},
|
||||
{L".mp4",RGB(255,185,216)},{L".mkv",RGB(255,185,216)},{L".mov",RGB(255,185,216)},
|
||||
{L".pdf",RGB(255,229,168)},{L".docx",RGB(255,229,168)},{L".xlsx",RGB(255,229,168)},
|
||||
{L".pptx",RGB(255,229,168)},{L".odt",RGB(255,229,168)},
|
||||
{L".cpp",RGB(168,247,190)},{L".h",RGB(168,247,190)},{L".cs",RGB(168,247,190)},
|
||||
{L".py",RGB(168,247,190)},{L".js",RGB(168,247,190)},{L".ts",RGB(168,247,190)},
|
||||
{L".ps1",RGB(168,247,190)},{L".bat",RGB(168,247,190)},{L".cmd",RGB(168,247,190)}};
|
||||
std::vector<FileStyle> fileStyles=defaultFileStyles;
|
||||
constexpr wchar_t fileStyleKey[]=L"FileStyles";
|
||||
|
||||
std::wstring fileStyleExtension(std::wstring_view text)
|
||||
{
|
||||
const auto first=text.find_first_not_of(L" \t");
|
||||
if(first==text.npos) return {};
|
||||
auto extension=std::wstring(text.substr(first,text.find_last_not_of(L" \t")-first+1));
|
||||
if(extension.front()!=L'.') extension.insert(0,1,L'.');
|
||||
if(extension.size()<2 || extension.size()>255 ||
|
||||
extension.find_first_of(L". *?\\/:<>|\";\r\n\t",1)!=extension.npos) return {};
|
||||
CharLowerBuffW(extension.data(),static_cast<DWORD>(extension.size()));
|
||||
return extension;
|
||||
}
|
||||
|
||||
bool sameFileExtension(std::wstring_view left,std::wstring_view right)
|
||||
{
|
||||
return !left.empty() && left.size()==right.size() &&
|
||||
CompareStringOrdinal(left.data(),static_cast<int>(left.size()),
|
||||
right.data(),static_cast<int>(right.size()),TRUE)==CSTR_EQUAL;
|
||||
}
|
||||
|
||||
void loadFileStyles()
|
||||
{
|
||||
DWORD bytes{};
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
|
||||
!bytes || bytes>256*1024) return;
|
||||
std::string data(bytes,'\0');
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto rows=Json::parse(data.begin(),data.begin()+bytes);
|
||||
if(!rows.is_array() || rows.size()>64) return;
|
||||
std::vector<FileStyle> parsed;
|
||||
for(const auto &row:rows)
|
||||
{
|
||||
FileStyle style{ks::wide(row.at("extension").get<std::string>())};
|
||||
if(style.extension!=L"@system-hidden" && fileStyleExtension(style.extension)!=style.extension) return;
|
||||
if(std::any_of(parsed.begin(),parsed.end(),[&](const auto &existing){return existing.extension==style.extension;}))
|
||||
return;
|
||||
const auto color=[&](const char *key)->std::optional<COLORREF>
|
||||
{
|
||||
if(!row.contains(key)) return {};
|
||||
const auto value=row.at(key).get<DWORD>();
|
||||
if(value>0xffffff) throw std::runtime_error("Invalid file style color");
|
||||
return value;
|
||||
};
|
||||
style.foreground=color("foreground");
|
||||
style.background=color("background");
|
||||
style.underline=row.value("underline",false);
|
||||
style.strikeout=row.value("strikeout",false);
|
||||
if(row.contains("states"))
|
||||
{
|
||||
const auto &states=row.at("states");
|
||||
if(!states.is_array() || states.size()!=style.states.size()) return;
|
||||
for(size_t i=0;i<style.states.size();++i)
|
||||
{
|
||||
const auto read=[&](const char *key)->std::optional<COLORREF>
|
||||
{
|
||||
if(!states[i].contains(key)) return {};
|
||||
const auto value=states[i].at(key).get<DWORD>();
|
||||
if(value>0xffffff) throw std::runtime_error("Invalid state color");
|
||||
return value;
|
||||
};
|
||||
style.states[i]={read("foreground"),read("background")};
|
||||
}
|
||||
}
|
||||
parsed.push_back(std::move(style));
|
||||
}
|
||||
fileStyles=std::move(parsed);
|
||||
}
|
||||
catch(const std::exception &) {}
|
||||
}
|
||||
|
||||
bool saveFileStyles(const std::vector<FileStyle> &styles)
|
||||
{
|
||||
auto rows=Json::array();
|
||||
for(const auto &style:styles)
|
||||
{
|
||||
Json row{{"extension",ks::utf8(style.extension)},{"underline",style.underline},{"strikeout",style.strikeout}};
|
||||
if(style.foreground) row["foreground"]=*style.foreground;
|
||||
if(style.background) row["background"]=*style.background;
|
||||
row["states"]=Json::array();
|
||||
for(const auto &state:style.states)
|
||||
{
|
||||
auto entry=Json::object();
|
||||
if(state.foreground) entry["foreground"]=*state.foreground;
|
||||
if(state.background) entry["background"]=*state.background;
|
||||
row["states"].push_back(std::move(entry));
|
||||
}
|
||||
rows.push_back(std::move(row));
|
||||
}
|
||||
const auto data=rows.dump();
|
||||
return RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,REG_BINARY,data.data(),
|
||||
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
struct FileRowStyle
|
||||
{
|
||||
COLORREF foreground,background;
|
||||
unsigned decorations{};
|
||||
};
|
||||
FileRowStyle resolveFileStyle(std::wstring_view extension,bool file,bool dimmed,bool protectedRow,
|
||||
COLORREF foreground,COLORREF background,const std::vector<FileStyle> &styles=fileStyles,
|
||||
FileRowState state=FileRowState::Normal,bool transferred=false)
|
||||
{
|
||||
FileRowStyle result{foreground,background};
|
||||
const FileStyle *extensionRule=nullptr,*attributeRule=nullptr;
|
||||
for(const auto &style:styles)
|
||||
{
|
||||
if(!file || !sameFileExtension(style.extension,extension)) continue;
|
||||
extensionRule=&style;
|
||||
result.foreground=style.foreground.value_or(result.foreground);
|
||||
if(!protectedRow) result.background=style.background.value_or(result.background);
|
||||
result.decorations=(style.underline?1u:0u)|(style.strikeout?2u:0u);
|
||||
break;
|
||||
}
|
||||
if(dimmed)
|
||||
for(const auto &style:styles)
|
||||
{
|
||||
if(style.extension!=L"@system-hidden") continue;
|
||||
attributeRule=&style;
|
||||
if(style.foreground) result.foreground=*style.foreground;
|
||||
else
|
||||
{
|
||||
const auto fg=result.foreground,bg=result.background;
|
||||
result.foreground=RGB((3*GetRValue(fg)+2*GetRValue(bg))/5,
|
||||
(3*GetGValue(fg)+2*GetGValue(bg))/5,(3*GetBValue(fg)+2*GetBValue(bg))/5);
|
||||
}
|
||||
if(!protectedRow) result.background=style.background.value_or(result.background);
|
||||
result.decorations|=(style.underline?1u:0u)|(style.strikeout?2u:0u);
|
||||
break;
|
||||
}
|
||||
|
||||
if(transferred && state!=FileRowState::Marked)
|
||||
return {RGB(255,223,115),background,result.decorations};
|
||||
if(state==FileRowState::Marked) result.foreground=RGB(255,255,0);
|
||||
if(state!=FileRowState::Normal)
|
||||
{
|
||||
const auto index=static_cast<size_t>(state)-1;
|
||||
for(const auto *rule:{extensionRule,attributeRule})
|
||||
if(rule)
|
||||
{
|
||||
result.foreground=rule->states[index].foreground.value_or(result.foreground);
|
||||
result.background=rule->states[index].background.value_or(result.background);
|
||||
}
|
||||
}
|
||||
if(result.foreground==result.background && state!=FileRowState::Normal)
|
||||
result.foreground=GetRValue(result.background)+GetGValue(result.background)+GetBValue(result.background)>382?RGB(0,0,0):RGB(255,255,255);
|
||||
return result;
|
||||
}
|
||||
+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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+6
-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,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.undo = value(w, 213);
|
||||
s.clipboard = value(w, 214);
|
||||
s.screenshot = value(w, 215);
|
||||
s.filesHotkey = value(w, 216);
|
||||
s.maxItems = integer(w, 240);
|
||||
s.maxMiB = integer(w, 241);
|
||||
s.maxImageMiB = integer(w, 242);
|
||||
@@ -2522,13 +2524,13 @@ void showSettings(App &app, bool visible)
|
||||
page();
|
||||
|
||||
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture"};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture", L"Open KSwitcher Files"};
|
||||
for (int i = 0; i < static_cast<int>(std::size(names)); ++i)
|
||||
{
|
||||
label(w, names[i], 24, 116 + i * 40, 180);
|
||||
hotkeyEdit(app, w, 210 + i, 230, 112 + i * 40, 454);
|
||||
}
|
||||
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 378, 660);
|
||||
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 400, 660);
|
||||
page();
|
||||
|
||||
checkbox(w, L"Record clip&board history", 203, 24, 80, 660);
|
||||
@@ -2623,7 +2625,7 @@ void showShortcuts(App &app)
|
||||
auto &s = app.settings;
|
||||
std::wstring text = L"Next layout: " + s.toggle + L"\nChange case: " + s.casing + L"\nTransliterate: " +
|
||||
s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard +
|
||||
L"\nScreen capture: " + s.screenshot + L"\n";
|
||||
L"\nScreen capture: " + s.screenshot + L"\nOpen KSwitcher Files: " + s.filesHotkey.value_or(L"") + L"\n";
|
||||
for (size_t i = 0; i < std::min(app.layouts.size(), s.direct.size()); ++i)
|
||||
if (!s.direct[i].empty())
|
||||
text += L"\n" + app.layouts[i].name + L": " + s.direct[i];
|
||||
|
||||
@@ -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