Compare commits
13
Commits
release-v0.6.87
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea4f6f914 | ||
|
|
36f0b6ef8e | ||
|
|
3aafc1f937 | ||
|
|
6215675cb4 | ||
|
|
56c6257465 | ||
|
|
64b03a2168 | ||
|
|
36baeeb081 | ||
|
|
7698ea040d | ||
|
|
84d911ae9e | ||
|
|
8dedf30ece | ||
|
|
f11bfff784 | ||
|
|
78aea46fb3 | ||
|
|
1549a89dc3 |
No files matched your search
+2
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.87 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.93 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -47,6 +47,7 @@ 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)
|
||||
set_property(SOURCE resources/app.rc APPEND PROPERTY OBJECT_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/resources/update.ps1")
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
|
||||
+39
@@ -13,6 +13,15 @@ Far Manager є зразком для оформлення файлового м
|
||||
Це діє і для вкладених діалогів. Під час файлової операції Cancel спершу
|
||||
просить підтвердити переривання.
|
||||
|
||||
Спільне повідомлення Files підлаштовується під заголовок і кількість рядків.
|
||||
Висота діалогу залежить від видимого вмісту. Приховані та порожні секції
|
||||
не займають місця. Перед завершенням змін перевір коротке повідомлення й
|
||||
повідомлення з кількома рядками, включно з відступами до рамки та кнопок.
|
||||
Смуга прокрутки з’являється, лише коли текст не вміщується в доступну висоту.
|
||||
Оформлення, кнопки й необов’язковий прапорець задаються параметрами шаблону.
|
||||
Підтвердження гібернації та віддаленого оновлення мають червоне оформлення
|
||||
Warning і початковий фокус на No.
|
||||
|
||||
## Скорочення
|
||||
|
||||
- Enter і подвійний клік відкривають папку в поточній панелі, а локальний
|
||||
@@ -39,12 +48,33 @@ Far Manager є зразком для оформлення файлового м
|
||||
Command history → Allow paired PCs to run PowerShell commands here**.
|
||||
Дозвіл за замовчуванням вимкнений і діє лише для спарених ПК. Команди
|
||||
запускаються без підвищених прав. Для цієї функції оновіть обидва ПК.
|
||||
- **F9 → Commands → Hibernate remote PC** доступна для активної LAN-панелі.
|
||||
У підтвердженні показується ім’я цільового ПК. На цільовому ПК ввімкніть
|
||||
**Options → Command line → Command history → Allow paired PCs to hibernate this PC**.
|
||||
Дозвіл за замовчуванням вимкнений і діє лише для спарених ПК. Для цієї дії
|
||||
потрібні оновлення на обох ПК і ввімкнена гібернація Windows.
|
||||
- **F9 → Commands → Update remote KSwitcher** запускає штатне оновлення для
|
||||
ПК активної LAN-панелі. Підтвердження з іменем ПК показується у Files
|
||||
відправника. На цільовому ПК потрібен дозвіл **Options → Command line →
|
||||
Command history → Allow paired PCs to update KSwitcher here**. Після
|
||||
перевірки нового стабільного пакета оновлювач без запитань закриває
|
||||
KSwitcher, Files, Config і редактори скриншотів цієї інсталяції.
|
||||
Незбережені зміни у примусово закритих вікнах втрачаються.
|
||||
Закриття процесів і результат записуються до `kswitcher.log`.
|
||||
|
||||
- Ctrl+F вставляє повний шлях елемента під курсором активної панелі в позицію
|
||||
курсора командного рядка або замінює виділення. Подвійні лапки додаються
|
||||
лише до шляхів із пробілами. Для `..` вставляється шлях батьківської папки.
|
||||
- Ctrl+Enter вставляє лише назву файла або папки під курсором активної панелі
|
||||
за тими ж правилами вставлення й лапок. Рядок `..` пропускається.
|
||||
- Ctrl+R перечитує поточну папку активної панелі. Позиція курсора й позначки
|
||||
зберігаються для наявних елементів, а підраховані через F3 розміри папок
|
||||
скидаються. F2 також виконує цю дію.
|
||||
- Ctrl+C копіює весь введений у командний рядок текст, навіть коли виділено
|
||||
лише його частину.
|
||||
- Ctrl+Insert копіює імена позначених файлів і папок активної панелі в порядку
|
||||
відображення, по одному на рядок. Якщо позначок немає, копіюється ім’я під
|
||||
курсором. Шляхи й рядок `..` пропускаються.
|
||||
|
||||
- Ctrl+E підставляє останню команду. Повторне натискання
|
||||
підставляє старішу, доки не досягне початку історії. Alt+F8 відкриває історію
|
||||
@@ -83,6 +113,10 @@ Far Manager є зразком для оформлення файлового м
|
||||
а Enter відкриває поточний елемент.
|
||||
- F9 перемикає головне меню Files. Після закриття фокус повертається в командний рядок. Коли меню відкрите, Alt з літерою верхнього рівня відкриває відповідний пункт.
|
||||
- Коли головне меню відкрите, L, F, C, O та R відкривають Left, Files, Commands, Options та Right. У підменю підсвічена літера викликає відповідну команду. Літери унікальні в межах поточного рівня. Усталені мнемоніки мають пріоритет, зокрема X для Exit.
|
||||
- У Commands лінії відокремлюють навігацію панелі, перегляд і редагування
|
||||
та керування віддаленим ПК. Остання група з’являється разом зі своїм
|
||||
роздільником лише в активній LAN-панелі. Роздільники пропускаються під
|
||||
час навігації й не реагують на клік.
|
||||
- Глобальне скорочення **Open KSwitcher Files** можна змінити або очистити в налаштуваннях. Для профілю без цього поля пропонується Ctrl+Alt+F, якщо Windows реєструє комбінацію і вона вільна серед прив’язок KSwitcher.
|
||||
- Глобальне скорочення повертає до відкритого вікна Files у поточній сесії Windows або запускає Files, якщо такого вікна немає. Пункт **Files** у треї відкриває нове вікно.
|
||||
|
||||
@@ -101,6 +135,11 @@ Far Manager є зразком для оформлення файлового м
|
||||
відкриваються в окремих немодальних вікнах із власними кнопками на панелі
|
||||
завдань Windows. При повторному відкритті Files пропонує перейти до вже
|
||||
відкритого вікна. Закриття менеджера залишає вікна файлів відкритими.
|
||||
- Коли курсор на папці, F3 у фоні підраховує сумарний розмір файлів у ній
|
||||
та вкладених папках. Якщо позначено папки, розмір рахується для кожної з
|
||||
них окремо. Результат видно в колонці Size у широкому режимі та в рядку
|
||||
стану, коли там увімкнено розмір. Це працює і в LAN-панелі. Посилання й
|
||||
junction не обходяться. Недоступний вміст позначається як частковий результат.
|
||||
- F3 і F4 працюють і на панелі LAN. Файл завантажується у тимчасову копію
|
||||
на диску у фоновому режимі. Збереження передає зміни назад на віддалений ПК.
|
||||
Якщо розмір або час зміни віддаленого файла відрізняється від відкритої
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,42 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.93 — 2026-11-16
|
||||
|
||||
In Files, Ctrl+R refreshes the active pane as in FAR Manager. The menu and key bar show Ctrl+R. F2 also refreshes the pane. Refresh preserves marks and the cursor where possible, and clears folder sizes calculated with F3.
|
||||
|
||||
**Українською.** У Files Ctrl+R перечитує активну панель, як у FAR Manager. Комбінація показана в меню та рядку клавіш. F2 також перечитує панель. Перечитування зберігає позначки й курсор для наявних елементів і скидає розміри папок, підраховані через F3.
|
||||
|
||||
## v0.6.92 — 2026-11-16
|
||||
|
||||
In Files, F3 on a folder measures file sizes throughout the directory tree. If folders are marked, Files measures each marked folder separately. Results appear in the Size column in Wide mode. F3 on a file opens the viewer even when folders are marked. Folder sizing runs in the background and supports LAN panes.
|
||||
|
||||
**Українською.** У Files F3 на папці підраховує сумарний розмір файлів у всьому дереві. Якщо позначено папки, Files рахує розмір кожної окремо. Результат з’являється в колонці Size у широкому режимі. F3 на файлі відкриває переглядач, навіть коли позначено папки. Підрахунок виконується у фоні й підтримує LAN-панелі.
|
||||
|
||||
## v0.6.91 — 2026-11-16
|
||||
|
||||
Ctrl+Insert copies the marked file and folder names from the active pane, one per line. Without marks, it copies the name under the cursor. LAN Files retries opening TCP 45885 if the port is busy at startup, and Change drive retries failed drive queries. A remote update request waits for an in-progress update check. The updater identifies and closes companion processes from its installation, including Files running from an update backup, and records confirmed exits in the journal.
|
||||
|
||||
**Українською.** Ctrl+Insert копіює імена позначених файлів і папок активної панелі, по одному на рядок. Якщо позначок немає, копіюється ім’я під курсором. LAN Files повторює відкриття TCP 45885, якщо порт зайнятий під час старту, а Change drive повторює невдалі запити дисків. Запит віддаленого оновлення чекає завершення поточної перевірки. Оновлювач визначає й закриває процеси своєї інсталяції, зокрема Files із резервної папки, і записує підтверджене завершення в журнал.
|
||||
|
||||
## v0.6.90 — 2026-11-16
|
||||
|
||||
Files groups Commands into navigation, viewing and editing, and remote PC actions. The remote group appears in an active LAN pane. Shared notices fit their contents and show a scrollbar only when needed. Remote hibernation and update confirmations use a compact red warning with No focused by default.
|
||||
|
||||
**Українською.** Меню Commands у Files групує навігацію, перегляд і редагування та дії для віддаленого ПК. Віддалена група доступна в активній LAN-панелі. Спільні повідомлення підлаштовуються під вміст і показують прокрутку лише за потреби. Гібернація та віддалене оновлення використовують компактне червоне підтвердження з фокусом на No.
|
||||
|
||||
## v0.6.89 — 2026-11-16
|
||||
|
||||
In Files, Commands now offers **Hibernate remote PC** and **Update remote KSwitcher** for the active LAN pane. The target PC has separate, default-off permissions for each action. Unattended updates verify the stable package, close the target installation's Files, Config and screenshot windows, and restart KSwitcher. Forced closure discards unsaved changes. The target journal records closed processes and the update result.
|
||||
|
||||
**Українською.** У меню Commands з’явилися **Hibernate remote PC** і **Update remote KSwitcher** для активної LAN-панелі. На цільовому ПК кожна дія має окремий дозвіл, вимкнений за замовчуванням. Оновлювач перевіряє стабільний пакет, без запитань закриває Files, Config і редактори скриншотів цієї інсталяції та перезапускає KSwitcher. Примусове закриття відкидає незбережені зміни. Журнал цільового ПК фіксує закриті процеси й результат оновлення.
|
||||
|
||||
## v0.6.88 — 2026-11-16
|
||||
|
||||
In Files, Ctrl+Enter inserts the name of the file or folder under the active pane cursor into the command line. An open Files window can still request the LAN peer list after KSwitcher updates and restarts.
|
||||
|
||||
**Українською.** У Files Ctrl+Enter вставляє в командний рядок назву файла або папки під курсором активної панелі. Відкрите вікно Files і далі може отримувати список LAN-комп’ютерів після оновлення та автоматичного перезапуску KSwitcher.
|
||||
|
||||
|
||||
## v0.6.87 — 2026-11-15
|
||||
|
||||
Remote PowerShell in Files now displays errors as text and hides progress metadata. Update the PC that runs the commands to remove raw CLIXML from terminal output. This release supersedes v0.6.86.
|
||||
|
||||
@@ -1 +1 @@
|
||||
8e69019173d92de7790917573c9e95bb96104994d518170cad320d294fca2153 KSwitcher-win-x64.zip
|
||||
21b9e32cfbd5e5cc8898d675a3b53e4b510a427dbeb2c26a957d104f1154dc05 KSwitcher-win-x64.zip
|
||||
+13
-13
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"files": [
|
||||
{
|
||||
"size": 2462208,
|
||||
"sha256": "fad74ffb05e9c3ce6ada562c610837539809682cef33d0be1e878dc45e4dea9d",
|
||||
"size": 2467840,
|
||||
"sha256": "3ad872f175c26ec2e9e06f9c5edf54f817fb281174d3be84d3286f9c38796b6e",
|
||||
"path": "KSwitcher.exe"
|
||||
},
|
||||
{
|
||||
"size": 2439168,
|
||||
"sha256": "8c88bd7f4018d0f400e56f2ae21c63b259f2ab482adfd82bf2e067a48e7eb47a",
|
||||
"size": 2446848,
|
||||
"sha256": "e7465f15c46278841ed0764f3ac699b95423c3490c375d5481bbf85c100dbf34",
|
||||
"path": "KSwitcher.Config.exe"
|
||||
},
|
||||
{
|
||||
"size": 3050496,
|
||||
"sha256": "7d2a3d04436989411e1220569fbfef95a9916696ec97b62963c3c04f128a11ed",
|
||||
"size": 3053568,
|
||||
"sha256": "e7b2f32b0138b3a3af7f648c5d6cfb17f952f48440ae69fcf7803a58b0ee32f6",
|
||||
"path": "KSwitcher.Screenshot.exe"
|
||||
},
|
||||
{
|
||||
"size": 3660288,
|
||||
"sha256": "7a5596ee03cce3000f83f13d8de1113be467590abcf03ef4a2bd8d13f1d3b5fe",
|
||||
"size": 3686912,
|
||||
"sha256": "1a570823393e2c3e2198408ab44f83bf4456849b9eaa85a0ca52f63f1cb5326e",
|
||||
"path": "KSwitcher.Files.exe"
|
||||
},
|
||||
{
|
||||
@@ -41,8 +42,8 @@
|
||||
"path": "terminal/index.html"
|
||||
},
|
||||
{
|
||||
"size": 4167,
|
||||
"sha256": "d60e6ff8948b6616695297695b05ef6e74b497a8e32e6031fc2e4fe2cca39ca7",
|
||||
"size": 4478,
|
||||
"sha256": "0f3edde02b30e7b0f76d0cc88daae5791d720a4b74cf8b34e7c0b4dbe0cf9c9d",
|
||||
"path": "terminal/terminal.js"
|
||||
},
|
||||
{
|
||||
@@ -122,7 +123,7 @@
|
||||
},
|
||||
{
|
||||
"size": 133120,
|
||||
"sha256": "759c4ad30bbcf1f99d086dbd88dd2f5c68012d6a793479e8712a6934e17e23f4",
|
||||
"sha256": "db68aff204ab8c69ded43914e397fda151dce4fdc01f8ac1900db07393a97712",
|
||||
"path": "tools/StayHere.exe"
|
||||
},
|
||||
{
|
||||
@@ -131,6 +132,5 @@
|
||||
"path": "tools/StayHere-README.md"
|
||||
}
|
||||
],
|
||||
"version": "0.6.87",
|
||||
"schema": 1
|
||||
"version": "0.6.93"
|
||||
}
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,87,0
|
||||
PRODUCTVERSION 0,6,87,0
|
||||
FILEVERSION 0,6,93,0
|
||||
PRODUCTVERSION 0,6,93,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.87"
|
||||
VALUE "FileVersion", "0.6.93"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+7
-13
@@ -47,24 +47,16 @@ BEGIN
|
||||
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 153, 42, 60, 17
|
||||
END
|
||||
|
||||
607 DIALOGEX 0, 0, 300, 106
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
CONTROL "", 100, "Edit", ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL | WS_TABSTOP, 18, 22, 264, 52
|
||||
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 120, 80, 60, 17
|
||||
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 85, 80, 60, 17
|
||||
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 155, 80, 60, 17
|
||||
AUTOCHECKBOX "&Remember for this session", 101, 18, 63, 264, 13, NOT WS_VISIBLE
|
||||
END
|
||||
|
||||
609 DIALOGEX 0, 0, 185, 57
|
||||
607 DIALOGEX 0, 0, 185, 57
|
||||
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
|
||||
FONT 10, "Consolas"
|
||||
BEGIN
|
||||
LTEXT "", 100, 14, 16, 157, 13, SS_NOPREFIX
|
||||
CONTROL "", 100, "Edit", ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL, 14, 16, 157, 13
|
||||
AUTOCHECKBOX "&Remember for this session", 101, 14, 30, 157, 13, NOT WS_VISIBLE
|
||||
CONTROL "", 102, "Static", NOT WS_VISIBLE, 14, 35, 1, 1
|
||||
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 67, 38, 51, 15
|
||||
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 36, 38, 51, 15
|
||||
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 98, 38, 51, 15
|
||||
END
|
||||
|
||||
608 DIALOGEX 0, 0, 220, 67
|
||||
@@ -115,6 +107,8 @@ BEGIN
|
||||
LTEXT "Open command history", 322, 131, 53, 120, 12
|
||||
CONTROL "", 323, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 50, 110, 15
|
||||
AUTOCHECKBOX "Allow &paired PCs to run PowerShell commands here", 370, 131, 79, 250, 14
|
||||
AUTOCHECKBOX "Allow paired PCs to &hibernate this PC", 371, 131, 95, 250, 14
|
||||
AUTOCHECKBOX "Allow paired PCs to &update KSwitcher here", 372, 131, 111, 250, 14
|
||||
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
|
||||
|
||||
@@ -58,6 +58,15 @@ terminal.attachCustomKeyEventHandler(event => {
|
||||
else if (event.code === 'KeyF' || event.code === 'KeyC') {
|
||||
if (!event.repeat) bridge.postMessage(event.code === 'KeyF' ? 'insert-path' : 'copy-command-line');
|
||||
}
|
||||
else if (event.key === 'Enter') {
|
||||
if (!event.repeat) bridge.postMessage('insert-name');
|
||||
}
|
||||
else if (event.key === 'Insert') {
|
||||
if (!event.repeat) bridge.postMessage('copy-panel-names');
|
||||
}
|
||||
else if (event.code === 'KeyR') {
|
||||
if (!event.repeat) bridge.postMessage('refresh-panel');
|
||||
}
|
||||
else return true;
|
||||
event.preventDefault();
|
||||
return false;
|
||||
|
||||
+68
-5
@@ -4,6 +4,7 @@
|
||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||
[switch]$ShowProgress,
|
||||
[switch]$Unattended, [string]$Journal,
|
||||
[switch]$Library
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -408,12 +409,59 @@ function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[st
|
||||
function Write-Result($Value) {
|
||||
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
|
||||
}
|
||||
function Write-UpdateJournal([string]$Message) {
|
||||
if (!$Journal) { return }
|
||||
[IO.File]::AppendAllText($Journal, "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') LAN update: $Message`n",
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
function Stop-UpdateCompanions([string]$Directory) {
|
||||
if (!('KSwitcherUpdateProcessImage' -as [type])) {
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
public static class KSwitcherUpdateProcessImage {
|
||||
[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)]
|
||||
static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder image, ref int length);
|
||||
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
public static string Read(int pid) {
|
||||
var process = OpenProcess(0x1000, false, pid);
|
||||
if (process == IntPtr.Zero) return null;
|
||||
try {
|
||||
var length = 32768;
|
||||
var image = new StringBuilder(length);
|
||||
return QueryFullProcessImageName(process, 0, image, ref length) ? image.ToString() : null;
|
||||
} finally { CloseHandle(process); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
$root = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
|
||||
$prefix = [regex]::Escape($root + '\')
|
||||
$pattern = '^' + $prefix + '(?:\.kswitcher-update-[0-9a-f]{32}\\backup\\)?KSwitcher\.(?:Config|Screenshot|Files)\.exe$'
|
||||
foreach ($process in @(Get-Process 'KSwitcher.Config','KSwitcher.Screenshot','KSwitcher.Files' -ErrorAction SilentlyContinue)) {
|
||||
$image = [KSwitcherUpdateProcessImage]::Read($process.Id)
|
||||
if (!$image -and !$process.HasExited) {
|
||||
Write-UpdateJournal "Could not identify $($process.ProcessName) (PID $($process.Id))."
|
||||
}
|
||||
if ($image -notmatch $pattern) { continue }
|
||||
Write-UpdateJournal "Closing $($process.ProcessName) (PID $($process.Id))."
|
||||
if (!$process.HasExited) {
|
||||
try { $process.Kill() } catch { if (!$process.HasExited) { throw } }
|
||||
if (!$process.WaitForExit(10000)) { throw "Could not close $($process.ProcessName) (PID $($process.Id))." }
|
||||
}
|
||||
Write-UpdateJournal "Closed $($process.ProcessName) (PID $($process.Id))."
|
||||
}
|
||||
}
|
||||
if ($Library) { return }
|
||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||
$changed = [Collections.Generic.List[string]]::new()
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||
try {
|
||||
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||
if (!$Unattended -and ($ShowProgress -or $Mode -eq 'Install')) { Start-UpdateProgress }
|
||||
$current = Get-StableVersion $CurrentVersion
|
||||
if (!$current) { throw 'Invalid installed version.' }
|
||||
if ($Mode -eq 'Install') {
|
||||
@@ -426,9 +474,12 @@ try {
|
||||
$metadata = Join-Path $WorkDirectory 'releases.json'
|
||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
|
||||
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
|
||||
if (!$release) { Write-Result @{state='current'}; exit 0 }
|
||||
if (!$release) {
|
||||
if ($Unattended) { Write-UpdateJournal "No newer stable release is available ($CurrentVersion)." }
|
||||
Write-Result @{state='current'}; exit 0
|
||||
}
|
||||
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
|
||||
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
|
||||
if (!$Unattended -and $ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
|
||||
Assert-PlainDirectory $InstallDirectory
|
||||
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
|
||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||
@@ -442,12 +493,18 @@ try {
|
||||
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||
if (!$Unattended -and (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath })) {
|
||||
throw 'Close screenshot editors before installing an update.'
|
||||
}
|
||||
if ($Unattended) {
|
||||
Write-UpdateJournal "Verified $($release.version). Closing KSwitcher (PID $ParentProcessId)."
|
||||
}
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
$parentExited = $true
|
||||
if ($Unattended) { Stop-UpdateCompanions $InstallDirectory }
|
||||
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||
Install-Files $stage $InstallDirectory $backup $changed
|
||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||
@@ -456,6 +513,9 @@ try {
|
||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||
$success = $true
|
||||
if ($Unattended) {
|
||||
try { Write-UpdateJournal "Completed $($release.version). KSwitcher restarted." } catch {}
|
||||
}
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||
}
|
||||
@@ -474,7 +534,10 @@ try {
|
||||
}
|
||||
}
|
||||
Write-Result @{state='error';error=$message}
|
||||
if ($ready -and $ready.WaitOne(0)) {
|
||||
if ($Unattended) {
|
||||
try { Write-UpdateJournal "Failed. $($message -replace '[\r\n]+',' ')" } catch {}
|
||||
}
|
||||
if (!$Unattended -and $ready -and $ready.WaitOne(0)) {
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||
} else {
|
||||
|
||||
+25
-2
@@ -4,6 +4,7 @@
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "lan_clipboard_image.h"
|
||||
#include "lan_files.h"
|
||||
#include "configuration.h"
|
||||
#include "diagnostics.h"
|
||||
#include "paste_probe.h"
|
||||
@@ -2941,12 +2942,12 @@ void App::trayMenu()
|
||||
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
|
||||
{
|
||||
if (updateRunning) return;
|
||||
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
|
||||
if (install && interactive && FindWindowW(L"KSwitcher.Config", nullptr))
|
||||
{
|
||||
report(L"Close the configurator before installing an update.");
|
||||
return;
|
||||
}
|
||||
if (install && busy)
|
||||
if (install && interactive && busy)
|
||||
{
|
||||
report(L"Wait for the current text operation to finish, then select Update again.");
|
||||
return;
|
||||
@@ -3006,6 +3007,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
switch (msg)
|
||||
{
|
||||
case LanUpdateRequestedMessage:
|
||||
if (!lan::remoteUpdateAllowed()) return 0;
|
||||
if (app->updateRunning)
|
||||
{
|
||||
if (!app->installingUpdate)
|
||||
{
|
||||
app->lanUpdatePending = true;
|
||||
log(L"LAN update queued behind an update check.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
log(L"Starting unattended KSwitcher update requested over LAN.");
|
||||
app->checkUpdate(false, true);
|
||||
}
|
||||
return 0;
|
||||
case UpdateMessage:
|
||||
{
|
||||
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
|
||||
@@ -3019,6 +3036,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
// The helper has verified and staged the package; it waits for process exit.
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
else if (std::exchange(app->lanUpdatePending, false) && lan::remoteUpdateAllowed() &&
|
||||
result->state != L"current")
|
||||
{
|
||||
log(L"Starting queued unattended KSwitcher update.");
|
||||
app->checkUpdate(false, true);
|
||||
}
|
||||
else if (result->state == L"available")
|
||||
{
|
||||
app->availableUpdate = result->version;
|
||||
|
||||
@@ -155,7 +155,7 @@ class App
|
||||
std::vector<SyncConflict> pendingSyncConflicts;
|
||||
Bytes syncSnapshot;
|
||||
std::wstring syncStatus = L"Not connected.";
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
bool updateRunning{}, installingUpdate{}, lanUpdatePending{};
|
||||
std::wstring availableUpdate, notifiedUpdate;
|
||||
bool (*updateNotifier)(const std::wstring &){};
|
||||
bool stop{};
|
||||
|
||||
+16
-5
@@ -4,17 +4,25 @@
|
||||
#include <cstring>
|
||||
namespace ks
|
||||
{
|
||||
bool configurationPeer(HWND window, const wchar_t *filename)
|
||||
namespace
|
||||
{
|
||||
std::filesystem::path processImage(HWND window)
|
||||
{
|
||||
DWORD pid{};
|
||||
GetWindowThreadProcessId(window, &pid);
|
||||
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if (!process) return false;
|
||||
if (!process) return {};
|
||||
wchar_t path[32768]; DWORD count = std::size(path);
|
||||
bool ok = QueryFullProcessImageNameW(process, 0, path, &count) != 0;
|
||||
CloseHandle(process);
|
||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||
return ok ? std::filesystem::path(path) : std::filesystem::path{};
|
||||
}
|
||||
}
|
||||
|
||||
bool configurationPeer(HWND window, const wchar_t *filename)
|
||||
{
|
||||
const auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||
return _wcsicmp(processImage(window).c_str(), expected.c_str()) == 0;
|
||||
}
|
||||
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||
{
|
||||
@@ -35,7 +43,10 @@ void launchConfiguration(App &app, bool snippets, bool logs)
|
||||
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
|
||||
{
|
||||
const bool filesPeers = message.dwData == FilesLanPeers;
|
||||
if (!configurationPeer(sender, filesPeers ? L"KSwitcher.Files.exe" : L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
|
||||
const bool knownSender = filesPeers
|
||||
? isFilesPeerImage(processImage(sender), std::filesystem::path(executable()).parent_path())
|
||||
: configurationPeer(sender, L"KSwitcher.Config.exe");
|
||||
if (!knownSender || message.cbData > ConfigLimit ||
|
||||
(message.cbData && !message.lpData)) return FALSE;
|
||||
Bytes reply;
|
||||
bool ok = false;
|
||||
|
||||
@@ -12,7 +12,7 @@ bool validHistoryKeys(const std::array<WORD,2> &keys)
|
||||
const bool function=key>=VK_F1 && key<=VK_F12;
|
||||
if(!letter && !function) return false;
|
||||
if(modifiers==HOTKEYF_CONTROL)
|
||||
return (letter && key!='C' && key!='F' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
|
||||
return (letter && key!='C' && key!='F' && key!='R' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
|
||||
if(modifiers==HOTKEYF_ALT) return function && key!=VK_F1 && key!=VK_F2 && key!=VK_F4 && key!=VK_F10;
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <filesystem>
|
||||
#include <string_view>
|
||||
|
||||
inline constexpr ULONG_PTR FilesLanPeers = 0x4b53010d;
|
||||
inline constexpr ULONG_PTR FilesLanPeersReply = 0x4b53010e;
|
||||
inline constexpr DWORD FilesLanPeersLimit = 64 * 1024;
|
||||
|
||||
namespace ks
|
||||
{
|
||||
inline bool isFilesPeerImage(const std::filesystem::path &image, const std::filesystem::path &install)
|
||||
{
|
||||
if (_wcsicmp(image.filename().c_str(), L"KSwitcher.Files.exe") != 0) return false;
|
||||
const auto directory = image.parent_path();
|
||||
if (_wcsicmp(directory.c_str(), install.c_str()) == 0) return true;
|
||||
|
||||
// Updating moves a still-running Files executable into the transaction backup.
|
||||
// Windows then reports the moved path for its process image.
|
||||
if (_wcsicmp(directory.filename().c_str(), L"backup") != 0) return false;
|
||||
const auto transaction = directory.parent_path();
|
||||
if (_wcsicmp(transaction.parent_path().c_str(), install.c_str()) != 0) return false;
|
||||
constexpr std::wstring_view prefix = L".kswitcher-update-";
|
||||
const auto name = transaction.filename().wstring();
|
||||
if (name.size() != prefix.size() + 32 || _wcsnicmp(name.c_str(), prefix.data(), prefix.size()) != 0)
|
||||
return false;
|
||||
return std::wstring_view(name).substr(prefix.size()).find_first_not_of(L"0123456789abcdefABCDEF")
|
||||
== std::wstring_view::npos;
|
||||
}
|
||||
}
|
||||
+454
-104
@@ -247,6 +247,49 @@ DWORD requestElevatedFolder(HWND owner,const fs::path &directory,
|
||||
}
|
||||
return ERROR_TIMEOUT;
|
||||
}
|
||||
struct FolderSize
|
||||
{
|
||||
uint64_t bytes{};
|
||||
bool partial{};
|
||||
};
|
||||
using FolderSizes=std::map<fs::path,FolderSize>;
|
||||
|
||||
FolderSize measureFolder(FilesConnection &host,const fs::path &root,std::stop_token stop)
|
||||
{
|
||||
FolderSize size;
|
||||
std::vector<fs::path> pending{root};
|
||||
while(!pending.empty() && !stop.stop_requested())
|
||||
{
|
||||
const auto directory=std::move(pending.back());
|
||||
pending.pop_back();
|
||||
try
|
||||
{
|
||||
const auto info=statFile(host,directory);
|
||||
if(!info || !(info->attributes&FILE_ATTRIBUTE_DIRECTORY))
|
||||
{
|
||||
size.partial=true;
|
||||
continue;
|
||||
}
|
||||
if(info->attributes&FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
{
|
||||
if(directory==root) size.partial=true;
|
||||
continue;
|
||||
}
|
||||
listFiles(host,directory,[&](const fs::path &path,const FileInfo &entry)
|
||||
{
|
||||
if(stop.stop_requested()) return false;
|
||||
if(entry.attributes&FILE_ATTRIBUTE_REPARSE_POINT) return true;
|
||||
if(entry.attributes&FILE_ATTRIBUTE_DIRECTORY) pending.push_back(path);
|
||||
else if(entry.size>UINT64_MAX-size.bytes) size.partial=true;
|
||||
else size.bytes+=entry.size;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
catch(const std::exception &) {size.partial=true;}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
struct Panel
|
||||
{
|
||||
HWND path{}, list{}, heading{};
|
||||
@@ -274,6 +317,9 @@ struct Panel
|
||||
std::vector<std::wstring> sizes;
|
||||
std::vector<bool> dimmed;
|
||||
std::vector<bool> marked;
|
||||
FolderSizes folderSizes;
|
||||
std::future<FolderSizes> folderSizeQuery;
|
||||
std::stop_source folderSizeStop;
|
||||
std::set<fs::path> transferResults;
|
||||
size_t fileExtensionLength{}, folderExtensionLength{};
|
||||
bool wide{};
|
||||
@@ -316,6 +362,7 @@ HWND picker{}, pickerFrame{}, menuBar{};
|
||||
HWND menuItems[5]{};
|
||||
constexpr std::wstring_view menuAccessKeys=L"LFCOR";
|
||||
std::vector<size_t> commandAccessPositions;
|
||||
std::vector<int> commandActions;
|
||||
int menuIndex{};
|
||||
bool commandPicker{};
|
||||
constexpr int defaultSizeWidth=6, defaultFontPoints=110;
|
||||
@@ -332,6 +379,7 @@ HWND drivePeerService{};
|
||||
bool drivePeerReplyReceived{};
|
||||
std::wstring drivePeerLookupError;
|
||||
std::vector<FilesPeer> drivePeers;
|
||||
std::future<std::wstring> pendingPeerAction;
|
||||
struct DriveQuery
|
||||
{
|
||||
FilesPeer peer;
|
||||
@@ -343,8 +391,7 @@ struct DriveChoice { FilesPeer peer; fs::path path; };
|
||||
std::vector<DriveQuery> driveQueries;
|
||||
std::map<LRESULT,DriveChoice> remoteDriveRows;
|
||||
constexpr UINT_PTR driveQueryTimer=3;
|
||||
bool refreshDriveAddresses{};
|
||||
ULONGLONG driveOpened{};
|
||||
ULONGLONG nextDriveRefresh{},driveRefreshDeadline{};
|
||||
int drivePickerWidth{};
|
||||
int pickerPanel{};
|
||||
std::wstring panelSearchPrefix;
|
||||
@@ -387,8 +434,14 @@ void savePlacement(HWND window)
|
||||
&placement,sizeof(placement));
|
||||
}
|
||||
void chooseDrive(int index, bool fromMenu = false);
|
||||
INT_PTR showFilesNotice(HWND owner,const wchar_t *text,const wchar_t *title,bool confirm=false,
|
||||
bool *remember=nullptr,bool warning=false);
|
||||
enum class FilesNoticeTone { dialog, neutral, warning };
|
||||
struct FilesNoticeOptions
|
||||
{
|
||||
bool confirm{};
|
||||
bool *remember{};
|
||||
FilesNoticeTone tone{FilesNoticeTone::dialog};
|
||||
};
|
||||
INT_PTR showFilesNotice(HWND owner,const wchar_t *text,const wchar_t *title,FilesNoticeOptions options={});
|
||||
void openMenu(HWND window, int index);
|
||||
void showFileDetails(const Panel &panel);
|
||||
void editOptions(HWND owner);
|
||||
@@ -836,10 +889,14 @@ void layout(HWND window)
|
||||
ShowWindow(terminalOutput,showOutput?SW_SHOWNA:SW_HIDE);
|
||||
ShowWindow(status,terminalVisible?SW_HIDE:SW_SHOWNA);
|
||||
terminalView.layout(terminalOutput,showOutput,terminalBottom-4);
|
||||
constexpr int refreshExtra=4;
|
||||
for (int i = 0; i < static_cast<int>(std::size(commands)); ++i)
|
||||
{
|
||||
const int count=static_cast<int>(std::size(commands));
|
||||
place(commands[i],i*width/count,height-textHeight,(i+1)*width/count-i*width/count,textHeight);
|
||||
constexpr int unit=10;
|
||||
const int total=static_cast<int>(std::size(commands))*unit+refreshExtra;
|
||||
const int left=i?(i*unit+refreshExtra)*width/total:0;
|
||||
const int right=((i+1)*unit+refreshExtra)*width/total;
|
||||
place(commands[i],left,height-textHeight,right-left,textHeight);
|
||||
}
|
||||
if(IsWindowVisible(panelSearchPopup)) placePanelSearch(window);
|
||||
RedrawWindow(window,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
|
||||
@@ -897,6 +954,41 @@ LRESULT currentPanelRow(const Panel &panel)
|
||||
return row>=0 && static_cast<size_t>(row)<panel.rows.size()?row:LB_ERR;
|
||||
}
|
||||
|
||||
std::vector<fs::path> selectedPanelPaths(const Panel &panel)
|
||||
{
|
||||
std::vector<fs::path> paths;
|
||||
for(size_t i=0;i<panel.rows.size();++i)
|
||||
if(panel.marked[i] && panel.sizes[i]!=L"Up") paths.push_back(panel.rows[i]);
|
||||
if(paths.empty())
|
||||
{
|
||||
const auto row=currentPanelRow(panel);
|
||||
if(row!=LB_ERR && panel.sizes[row]!=L"Up") paths.push_back(panel.rows[row]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
void copyPanelNames(HWND owner)
|
||||
{
|
||||
const auto paths=selectedPanelPaths(panels[active]);
|
||||
if(paths.empty()) return;
|
||||
std::wstring names;
|
||||
for(const auto &path:paths)
|
||||
{
|
||||
if(!names.empty()) names+=L"\r\n";
|
||||
names+=path.filename().native();
|
||||
}
|
||||
try { ks::writeClipboard(owner,ks::textClip(names)); }
|
||||
catch(const std::exception &) { setStatus(L"Could not copy file names."); }
|
||||
}
|
||||
|
||||
void refreshPanel(Panel &panel)
|
||||
{
|
||||
panel.folderSizeStop.request_stop();
|
||||
panel.folderSizeQuery={};
|
||||
panel.folderSizes.clear();
|
||||
open(panel,panel.directory,false);
|
||||
}
|
||||
|
||||
std::wstring powerShellPath(const fs::path &path)
|
||||
{
|
||||
std::wstring quoted=L"'";
|
||||
@@ -909,12 +1001,14 @@ std::wstring powerShellPath(const fs::path &path)
|
||||
return quoted+L"'";
|
||||
}
|
||||
|
||||
void insertCurrentPanelPath()
|
||||
void insertCurrentPanelPath(bool nameOnly = false)
|
||||
{
|
||||
const auto &panel=panels[active];
|
||||
const auto row=currentPanelRow(panel);
|
||||
if(row==LB_ERR) return;
|
||||
const auto &path=panel.rows[row].native();
|
||||
const auto &item=panel.rows[row];
|
||||
if(nameOnly && row==0 && panel.directory.has_relative_path()) return;
|
||||
const auto path=nameOnly?item.filename().native():item.native();
|
||||
std::wstring quoted;
|
||||
if(path.find(L' ')!=std::wstring::npos)
|
||||
{
|
||||
@@ -974,7 +1068,10 @@ bool ensureTerminalView(HWND owner)
|
||||
if(GetForegroundWindow()==owner) SetFocus(terminalInput);
|
||||
}
|
||||
else if(message==L"insert-path") insertCurrentPanelPath();
|
||||
else if(message==L"insert-name") insertCurrentPanelPath(true);
|
||||
else if(message==L"copy-command-line") SendMessageW(terminalInput,WM_COPY,0,0);
|
||||
else if(message==L"copy-panel-names") copyPanelNames(owner);
|
||||
else if(message==L"refresh-panel") refreshPanel(panels[active]);
|
||||
else if(message==L"toggle") showTerminal(owner,!terminalVisible);
|
||||
else if(!terminalVisible && (message==L"grow" || message==L"shrink"))
|
||||
{
|
||||
@@ -1222,7 +1319,11 @@ void showFileDetails(const Panel &panel)
|
||||
if(statusFields&2)
|
||||
{
|
||||
const auto &size=panel.sizes[row];
|
||||
details+=L" "+(size==L"Folder" || size==L"Up"?size:formatSize(std::stoull(size)));
|
||||
const auto measured=panel.folderSizes.find(path);
|
||||
if(size==L"Folder" && measured!=panel.folderSizes.end())
|
||||
details+=L" "+formatSize(measured->second.bytes)+(measured->second.partial?L" (partial)":L"");
|
||||
else
|
||||
details+=L" "+(size==L"Folder" || size==L"Up"?size:formatSize(std::stoull(size)));
|
||||
}
|
||||
std::wstring timestamp=date;
|
||||
if((statusFields&4) && timestamp.size()>=12) details+=timestamp.substr(0,12);
|
||||
@@ -1275,7 +1376,7 @@ void openOn(Panel &panel,const fs::path &directory,bool offerElevation,std::opti
|
||||
const auto question=L"Windows denied access to this folder.\r\n"+path+
|
||||
L"\r\n\r\nTry opening as administrator? Windows may still ask for UAC.";
|
||||
if(rememberElevationApproval || showFilesNotice(owner,question.c_str(),
|
||||
L"KSwitcher Files",true,&remember)==IDYES)
|
||||
L"KSwitcher Files",{.confirm=true,.remember=&remember})==IDYES)
|
||||
{
|
||||
const auto result=requestElevatedFolder(owner,directory,folders,files,metadata);
|
||||
SetForegroundWindow(owner);
|
||||
@@ -1322,6 +1423,12 @@ void openOn(Panel &panel,const fs::path &directory,bool offerElevation,std::opti
|
||||
}
|
||||
panel.directory = directory;
|
||||
panel.peer=std::move(peer);
|
||||
if(!sameDirectory)
|
||||
{
|
||||
panel.folderSizeStop.request_stop();
|
||||
panel.folderSizeQuery={};
|
||||
panel.folderSizes.clear();
|
||||
}
|
||||
if(!sameDirectory || nextWatch.handle!=INVALID_HANDLE_VALUE)
|
||||
{
|
||||
panel.watch.reset();
|
||||
@@ -1405,6 +1512,61 @@ void refreshChangedPanels()
|
||||
}
|
||||
}
|
||||
|
||||
void calculateFolderSizes(Panel &panel,LRESULT current)
|
||||
{
|
||||
if(panel.folderSizeQuery.valid())
|
||||
{
|
||||
setStatus(L"Calculating folder sizes...");
|
||||
return;
|
||||
}
|
||||
std::vector<fs::path> folders;
|
||||
for(size_t i=0;i<panel.rows.size();++i)
|
||||
if(panel.marked[i] && panel.sizes[i]==L"Folder") folders.push_back(panel.rows[i]);
|
||||
if(folders.empty()) folders.push_back(panel.rows[current]);
|
||||
|
||||
panel.folderSizeStop=std::stop_source{};
|
||||
std::packaged_task<FolderSizes()> task(
|
||||
[folders=std::move(folders),peer=panel.peer,stop=panel.folderSizeStop.get_token()]
|
||||
{
|
||||
FolderSizes results;
|
||||
try
|
||||
{
|
||||
FilesConnection host(peer?&*peer:nullptr);
|
||||
for(const auto &folder:folders)
|
||||
{
|
||||
if(stop.stop_requested()) break;
|
||||
results.emplace(folder,measureFolder(host,folder,stop));
|
||||
}
|
||||
}
|
||||
catch(const std::exception &)
|
||||
{
|
||||
for(const auto &folder:folders) results.try_emplace(folder,FolderSize{0,true});
|
||||
}
|
||||
return results;
|
||||
});
|
||||
panel.folderSizeQuery=task.get_future();
|
||||
try {std::thread(std::move(task)).detach();}
|
||||
catch(const std::system_error &error)
|
||||
{
|
||||
panel.folderSizeQuery={};
|
||||
setStatus(fileError(error));
|
||||
return;
|
||||
}
|
||||
setStatus(L"Calculating folder sizes...");
|
||||
}
|
||||
|
||||
void collectFolderSizes()
|
||||
{
|
||||
for(auto &panel:panels)
|
||||
{
|
||||
if(!panel.folderSizeQuery.valid() ||
|
||||
panel.folderSizeQuery.wait_for(std::chrono::seconds(0))!=std::future_status::ready) continue;
|
||||
for(const auto &[path,size]:panel.folderSizeQuery.get()) panel.folderSizes.insert_or_assign(path,size);
|
||||
InvalidateRect(panel.list,nullptr,FALSE);
|
||||
if(&panel==&panels[active]) showFileDetails(panel);
|
||||
}
|
||||
}
|
||||
|
||||
void openSelectedText(const Panel &panel, bool edit, bool external);
|
||||
INT_PTR confirmTextSave(HWND owner);
|
||||
INT_PTR paintTextDialog(HWND window, UINT message, WPARAM wp, LPARAM lp, const wchar_t *caption);
|
||||
@@ -1415,9 +1577,74 @@ void prepareTextDialog(HWND dialog);
|
||||
void showTextMessage(HWND owner,const wchar_t *text);
|
||||
void editTextOptions(HWND owner,bool edit);
|
||||
|
||||
void runActivePeerAction(HWND owner,bool update)
|
||||
{
|
||||
if(!panels[active].peer) return;
|
||||
if(pendingPeerAction.valid())
|
||||
{
|
||||
setStatus(L"A remote PC request is already in progress.");
|
||||
return;
|
||||
}
|
||||
const auto peer=*panels[active].peer;
|
||||
const auto question=update
|
||||
? L"Update KSwitcher on "+peer.name+L"? Open KSwitcher windows there will close if an update is available."
|
||||
: L"Hibernate "+peer.name+L"?";
|
||||
if(showFilesNotice(owner,question.c_str(),update?L"Update remote KSwitcher":L"Hibernate remote PC",
|
||||
{.confirm=true,.tone=FilesNoticeTone::warning})!=IDYES)
|
||||
return;
|
||||
|
||||
std::packaged_task<std::wstring()> task([peer,update]
|
||||
{
|
||||
const auto prefix=peer.name+L": ";
|
||||
const auto unconfirmed=prefix+(update?L"Could not confirm receipt of the update request."
|
||||
:L"Hibernation could not be confirmed.");
|
||||
try
|
||||
{
|
||||
FilesConnection host(&peer);
|
||||
checkFileReply(host.call({{"action",update?"update":"hibernate"}}));
|
||||
return prefix+(update?L"Update requested. See the remote KSwitcher log for the result."
|
||||
:L"Windows accepted the hibernation request.");
|
||||
}
|
||||
catch(const std::system_error &error)
|
||||
{
|
||||
switch(error.code().value())
|
||||
{
|
||||
case ERROR_ACCESS_DENIED:
|
||||
return prefix+(update?L"Remote updates are disabled. Check Files settings on this PC."
|
||||
:L"Hibernation is not allowed. Check the remote Files settings.");
|
||||
case ERROR_INVALID_FUNCTION:
|
||||
return prefix+(update?L"This version of KSwitcher cannot accept remote update requests."
|
||||
:L"Update KSwitcher to enable remote hibernation.");
|
||||
case ERROR_PRIVILEGE_NOT_HELD:
|
||||
return prefix+L"Windows denied the hibernation privilege.";
|
||||
default:
|
||||
return unconfirmed+L" "+fileError(error);
|
||||
}
|
||||
}
|
||||
catch(const std::exception &)
|
||||
{
|
||||
return unconfirmed;
|
||||
}
|
||||
});
|
||||
pendingPeerAction=task.get_future();
|
||||
try {std::thread(std::move(task)).detach();}
|
||||
catch(const std::system_error &error)
|
||||
{
|
||||
pendingPeerAction={};
|
||||
setStatus(fileError(error));
|
||||
return;
|
||||
}
|
||||
setStatus((update?L"Requesting a KSwitcher update on ":L"Requesting hibernation for ")+peer.name+L"...");
|
||||
}
|
||||
|
||||
void selectDrive()
|
||||
{
|
||||
auto row=SendMessageW(picker,LB_GETCURSEL,0,0);
|
||||
if(commandPicker)
|
||||
{
|
||||
if(row<0 || static_cast<size_t>(row)>=commandActions.size() || commandActions[row]<0) return;
|
||||
row=commandActions[row];
|
||||
}
|
||||
if(!commandPicker && (row<0 || static_cast<size_t>(row)==drivePaths.size())) return;
|
||||
const auto remote=remoteDriveRows.find(row);
|
||||
if(commandPicker || row<static_cast<LRESULT>(drivePaths.size()) || remote!=remoteDriveRows.end())
|
||||
@@ -1437,6 +1664,7 @@ void selectDrive()
|
||||
}
|
||||
else if(menuIndex==2 && row==3) showFolderShortcuts(panels[active],-1);
|
||||
else if(menuIndex==2 && (row==4 || row==5)) openSelectedText(panels[active],row==5,true);
|
||||
else if(menuIndex==2 && (row==6 || row==7)) runActivePeerAction(owner,row==7);
|
||||
else
|
||||
{
|
||||
const int command=menuIndex==1?113+static_cast<int>(row):110+static_cast<int>(row);
|
||||
@@ -1452,6 +1680,26 @@ void selectDrive()
|
||||
|
||||
LRESULT CALLBACK pickerKeys(HWND window, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if(commandPicker)
|
||||
{
|
||||
const auto separator=[](LRESULT row)
|
||||
{
|
||||
return row>=0 && static_cast<size_t>(row)<commandActions.size() && commandActions[row]<0;
|
||||
};
|
||||
if(msg==WM_KEYDOWN && (wp==VK_UP || wp==VK_DOWN || wp==VK_PRIOR || wp==VK_NEXT))
|
||||
{
|
||||
const auto result=DefSubclassProc(window,msg,wp,lp);
|
||||
const auto row=SendMessageW(window,LB_GETCURSEL,0,0);
|
||||
if(separator(row))
|
||||
SendMessageW(window,LB_SETCURSEL,row+((wp==VK_UP || wp==VK_PRIOR)?-1:1),0);
|
||||
return result;
|
||||
}
|
||||
if(msg==WM_LBUTTONDOWN || msg==WM_LBUTTONDBLCLK || (msg==WM_MOUSEMOVE && (wp&MK_LBUTTON)))
|
||||
{
|
||||
const auto hit=SendMessageW(window,LB_ITEMFROMPOINT,0,lp);
|
||||
if(!HIWORD(hit) && separator(LOWORD(hit))) return 0;
|
||||
}
|
||||
}
|
||||
if(!commandPicker && msg==WM_CHAR)
|
||||
{
|
||||
const auto key=towupper(static_cast<wchar_t>(wp));
|
||||
@@ -1615,7 +1863,7 @@ void showRemoteDrives(bool focus=false,int initialRow=0)
|
||||
add(query.peer.name);
|
||||
if(query.pending.valid()) add(L" Loading drives...");
|
||||
else if(!query.error.empty()) add(L" "+query.error);
|
||||
else if(query.peer.address.empty()) add(refreshDriveAddresses?L" Looking for PC...":L" Offline");
|
||||
else if(query.peer.address.empty()) add(nextDriveRefresh?L" Looking for PC...":L" Offline");
|
||||
else if(query.drives.empty()) add(L" No drives");
|
||||
for(const auto &entry:query.drives)
|
||||
{
|
||||
@@ -1642,26 +1890,30 @@ void pollDriveQueries(HWND owner)
|
||||
return;
|
||||
}
|
||||
bool changed=false, pending=false;
|
||||
if(refreshDriveAddresses && GetTickCount64()-driveOpened>=500)
|
||||
const auto now=GetTickCount64();
|
||||
if(nextDriveRefresh && now>=nextDriveRefresh)
|
||||
{
|
||||
refreshDriveAddresses=false;
|
||||
nextDriveRefresh=now<driveRefreshDeadline?now+2000:0;
|
||||
queryDrivePeers();
|
||||
for(const auto &peer:drivePeers)
|
||||
if(std::none_of(driveQueries.begin(),driveQueries.end(),[&](const DriveQuery &query){return query.peer.id==peer.id;}))
|
||||
{
|
||||
auto entry=std::find_if(driveQueries.begin(),driveQueries.end(),[&](const DriveQuery &query)
|
||||
{return query.peer.id==peer.id;});
|
||||
if(entry==driveQueries.end())
|
||||
{
|
||||
auto &query=driveQueries.emplace_back();
|
||||
query.peer=peer;
|
||||
startDriveQuery(query);
|
||||
}
|
||||
for(auto &query:driveQueries)
|
||||
if(query.peer.address.empty())
|
||||
for(const auto &peer:drivePeers)
|
||||
if(peer.id==query.peer.id && !peer.address.empty())
|
||||
{
|
||||
query.peer=peer;
|
||||
startDriveQuery(query);
|
||||
break;
|
||||
}
|
||||
else if(!entry->pending.valid() && (entry->peer.address!=peer.address ||
|
||||
entry->peer.address.empty() || !entry->error.empty()))
|
||||
{
|
||||
entry->peer=peer;
|
||||
entry->drives=Json::array();
|
||||
entry->error.clear();
|
||||
startDriveQuery(*entry);
|
||||
}
|
||||
}
|
||||
changed=true;
|
||||
}
|
||||
for(auto &query:driveQueries)
|
||||
@@ -1675,7 +1927,8 @@ void pollDriveQueries(HWND owner)
|
||||
try {query.drives=query.pending.get();}
|
||||
catch(const std::system_error &e)
|
||||
{
|
||||
ks::log(L"LAN Files drive query failed: Windows error "+std::to_wstring(e.code().value()));
|
||||
ks::log(L"LAN Files drive query for "+query.peer.name+L" ("+ks::wide(query.peer.address)+
|
||||
L") failed: Windows error "+std::to_wstring(e.code().value()));
|
||||
if(e.code()==std::error_code(WSAETIMEDOUT,std::system_category()))
|
||||
query.error=L"Connection timed out";
|
||||
else
|
||||
@@ -1688,8 +1941,12 @@ void pollDriveQueries(HWND owner)
|
||||
}
|
||||
changed=true;
|
||||
}
|
||||
if(!pending && drivePeerLookupError.empty() &&
|
||||
std::none_of(driveQueries.begin(),driveQueries.end(),[](const DriveQuery &query)
|
||||
{return query.peer.address.empty() || !query.error.empty();}))
|
||||
nextDriveRefresh=0;
|
||||
if(changed) showRemoteDrives();
|
||||
if(!pending && !refreshDriveAddresses) KillTimer(owner,driveQueryTimer);
|
||||
if(!pending && !nextDriveRefresh) KillTimer(owner,driveQueryTimer);
|
||||
}
|
||||
|
||||
void chooseDrive(int index,bool fromMenu)
|
||||
@@ -1709,8 +1966,9 @@ void chooseDrive(int index,bool fromMenu)
|
||||
query.peer=peer;
|
||||
startDriveQuery(query);
|
||||
}
|
||||
refreshDriveAddresses=true;
|
||||
driveOpened=GetTickCount64();
|
||||
const auto now=GetTickCount64();
|
||||
nextDriveRefresh=now+500;
|
||||
driveRefreshDeadline=now+20000;
|
||||
const DWORD disks=GetLogicalDrives();
|
||||
int selected=0;
|
||||
int textWidth=0;
|
||||
@@ -1817,7 +2075,8 @@ void openSelectedText(const Panel &panel, bool edit, bool external)
|
||||
farDropDownPalette,FilesModalDialog::run,FilesModalDialog::finish,FilesModalDialog::dismissOwned,
|
||||
[](HWND owner)
|
||||
{
|
||||
return showFilesNotice(owner,L"This file is already open. Switch to its window?",L"Files",true)==IDYES;
|
||||
return showFilesNotice(owner,L"This file is already open. Switch to its window?",L"Files",
|
||||
{.confirm=true})==IDYES;
|
||||
}},panel.peer?&*panel.peer:nullptr);
|
||||
}
|
||||
|
||||
@@ -2450,7 +2709,7 @@ INT_PTR CALLBACK copyDialog(HWND dialog, UINT message, WPARAM wp, LPARAM lp)
|
||||
if(LOWORD(wp)!=IDOK) return FALSE;
|
||||
const auto showError=[&](const wchar_t *message)
|
||||
{
|
||||
showFilesNotice(dialog,message,L"Error",false,nullptr,true);
|
||||
showFilesNotice(dialog,message,L"Error",{.tone=FilesNoticeTone::warning});
|
||||
SetFocus(GetDlgItem(dialog,pathField));
|
||||
};
|
||||
try
|
||||
@@ -2840,11 +3099,55 @@ struct FilesNotice
|
||||
{
|
||||
const wchar_t *text;
|
||||
std::wstring title;
|
||||
bool confirm;
|
||||
bool *remember;
|
||||
bool warning;
|
||||
FilesNoticeOptions options;
|
||||
};
|
||||
|
||||
void layoutFilesNotice(HWND dialog)
|
||||
{
|
||||
const auto ¬ice=*reinterpret_cast<const FilesNotice *>(GetWindowLongPtrW(dialog,DWLP_USER));
|
||||
const auto frame=dialogFrameMetrics(GetDpiForWindow(dialog));
|
||||
const auto inset=frame.content();
|
||||
const auto top=dialogListInset(frame,textHeight);
|
||||
const auto field=GetDlgItem(dialog,100);
|
||||
RECT client{};
|
||||
GetClientRect(dialog,&client);
|
||||
const auto contentWidth=std::max(1L,client.right-2*inset);
|
||||
TEXTMETRICW metrics{};
|
||||
if(const auto dc=GetDC(field))
|
||||
{
|
||||
const auto previous=SelectObject(dc,mono);
|
||||
GetTextMetricsW(dc,&metrics);
|
||||
SelectObject(dc,previous);
|
||||
ReleaseDC(field,dc);
|
||||
}
|
||||
const auto lineHeight=std::max(1L,metrics.tmHeight);
|
||||
const auto place=[&](int id,int y,int height)
|
||||
{
|
||||
SetWindowPos(GetDlgItem(dialog,id),nullptr,inset,y,contentWidth,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
};
|
||||
constexpr LRESULT maxLines=8;
|
||||
ShowScrollBar(field,SB_VERT,FALSE);
|
||||
place(100,top,maxLines*lineHeight);
|
||||
const auto lines=SendMessageW(field,EM_GETLINECOUNT,0,0);
|
||||
const bool overflow=lines>maxLines;
|
||||
const auto height=static_cast<int>(std::clamp<LRESULT>(lines,1,maxLines))*lineHeight;
|
||||
place(100,top,height);
|
||||
ShowScrollBar(field,SB_VERT,overflow);
|
||||
const auto style=GetWindowLongPtrW(field,GWL_STYLE);
|
||||
SetWindowLongPtrW(field,GWL_STYLE,overflow?style|WS_TABSTOP:style&~WS_TABSTOP);
|
||||
if(!overflow) SendMessageW(field,EM_LINESCROLL,0,-SendMessageW(field,EM_GETFIRSTVISIBLELINE,0,0));
|
||||
auto bottom=top+height;
|
||||
if(notice.options.remember)
|
||||
{
|
||||
bottom+=dialogRowGap();
|
||||
place(101,bottom,textHeight);
|
||||
bottom+=textHeight;
|
||||
}
|
||||
place(102,bottom+dialogRowGap()+4,1);
|
||||
if(notice.options.confirm) layoutDialogButtons(dialog,102,{IDYES,IDNO});
|
||||
else layoutDialogButtons(dialog,102,{IDOK});
|
||||
}
|
||||
|
||||
INT_PTR CALLBACK filesNoticeDialog(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
if(message==WM_INITDIALOG)
|
||||
@@ -2852,70 +3155,73 @@ INT_PTR CALLBACK filesNoticeDialog(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||
SetWindowLongPtrW(window,DWLP_USER,lp);
|
||||
const auto ¬ice=*reinterpret_cast<const FilesNotice *>(lp);
|
||||
prepareTextDialog(window);
|
||||
SetDlgItemTextW(window,100,notice.text);
|
||||
if(notice.warning)
|
||||
const auto field=GetDlgItem(window,100);
|
||||
SendMessageW(field,EM_SETMARGINS,EC_LEFTMARGIN|EC_RIGHTMARGIN,0);
|
||||
SetWindowTextW(field,notice.text);
|
||||
if(notice.options.remember)
|
||||
{
|
||||
const auto bounds=dialogControlBounds(window,100);
|
||||
const auto divider=dialogControlBounds(window,102);
|
||||
RECT text{0,0,bounds.right-bounds.left,0};
|
||||
if(const auto dc=GetDC(window))
|
||||
{
|
||||
const auto previous=SelectObject(dc,mono);
|
||||
DrawTextW(dc,notice.text,-1,&text,DT_CALCRECT|DT_WORDBREAK|DT_NOPREFIX);
|
||||
SelectObject(dc,previous);
|
||||
ReleaseDC(window,dc);
|
||||
}
|
||||
const auto height=std::max<LONG>(text.bottom,textHeight);
|
||||
SetWindowPos(GetDlgItem(window,100),nullptr,bounds.left,bounds.top,bounds.right-bounds.left,height,
|
||||
SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
SetWindowPos(GetDlgItem(window,102),nullptr,divider.left,bounds.top+height+dialogRowGap()+4,0,0,
|
||||
SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
layoutDialogButtons(window,102,{IDOK});
|
||||
const auto checkbox=GetDlgItem(window,101);
|
||||
installFarCheckbox(checkbox,notice.options.tone==FilesNoticeTone::warning?FarCheckboxSurface::warning:
|
||||
notice.options.tone==FilesNoticeTone::neutral?FarCheckboxSurface::gray:FarCheckboxSurface::dialog);
|
||||
ShowWindow(checkbox,SW_SHOW);
|
||||
}
|
||||
if(notice.remember)
|
||||
{
|
||||
const auto field=GetDlgItem(window,100);
|
||||
RECT bounds{};
|
||||
GetWindowRect(field,&bounds);
|
||||
SetWindowPos(field,nullptr,0,0,bounds.right-bounds.left,
|
||||
(bounds.bottom-bounds.top)*3/4,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
ShowWindow(GetDlgItem(window,101),SW_SHOW);
|
||||
}
|
||||
if(notice.confirm)
|
||||
if(notice.options.confirm)
|
||||
{
|
||||
ShowWindow(GetDlgItem(window,IDOK),SW_HIDE);
|
||||
ShowWindow(GetDlgItem(window,IDYES),SW_SHOW);
|
||||
ShowWindow(GetDlgItem(window,IDNO),SW_SHOW);
|
||||
groupDialogButtons(window,{IDYES,IDNO});
|
||||
}
|
||||
else if(!notice.warning) groupDialogButtons(window,{IDOK});
|
||||
const auto defaultButton=notice.confirm?IDNO:IDOK;
|
||||
RECT caption{},text{};
|
||||
if(const auto dc=GetDC(window))
|
||||
{
|
||||
const auto previous=SelectObject(dc,mono);
|
||||
DrawTextW(dc,notice.title.c_str(),-1,&caption,DT_CALCRECT|DT_SINGLELINE|DT_NOPREFIX);
|
||||
DrawTextW(dc,notice.text,-1,&text,DT_CALCRECT|DT_NOPREFIX|DT_EXPANDTABS);
|
||||
SelectObject(dc,previous);
|
||||
ReleaseDC(window,dc);
|
||||
}
|
||||
const auto inset=dialogFrameMetrics(GetDpiForWindow(window)).content();
|
||||
const auto minimum=std::max({24L*cellWidth,caption.right+6*cellWidth,
|
||||
notice.options.remember?30L*cellWidth:0L})+2*inset;
|
||||
const auto width=std::clamp(text.right+cellWidth+2*inset,minimum,std::max(minimum,60L*cellWidth+2*inset));
|
||||
SetWindowPos(window,nullptr,0,0,width,1,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
layoutFilesNotice(window);
|
||||
FilesModalDialog::fitWidthToOwner(window,minimum,layoutFilesNotice);
|
||||
const auto defaultButton=notice.options.confirm?IDNO:IDOK;
|
||||
SendMessageW(window,DM_SETDEFID,defaultButton,0);
|
||||
SetFocus(GetDlgItem(window,defaultButton));
|
||||
return FALSE;
|
||||
}
|
||||
const auto *notice=reinterpret_cast<const FilesNotice *>(GetWindowLongPtrW(window,DWLP_USER));
|
||||
if(!notice) return FALSE;
|
||||
if(const auto painted=paintCopyDialog(window,message,wp,lp,notice->title.c_str(),
|
||||
notice->warning,notice->warning?std::initializer_list<int>{102}:std::initializer_list<int>{},
|
||||
!notice->warning)) return painted;
|
||||
const bool warning=notice->options.tone==FilesNoticeTone::warning;
|
||||
const bool palette=notice->options.tone==FilesNoticeTone::dialog;
|
||||
if(message==WM_CTLCOLORSTATIC && reinterpret_cast<HWND>(lp)==GetDlgItem(window,100) && !palette)
|
||||
{
|
||||
const auto dc=reinterpret_cast<HDC>(wp);
|
||||
SetTextColor(dc,warning?RGB(255,255,255):RGB(0,0,0));
|
||||
SetBkColor(dc,warning?RGB(128,0,0):RGB(192,192,192));
|
||||
return reinterpret_cast<INT_PTR>(warning?warningBrush:GetStockObject(LTGRAY_BRUSH));
|
||||
}
|
||||
if(const auto painted=paintCopyDialog(window,message,wp,lp,notice->title.c_str(),warning,{102},palette))
|
||||
return painted;
|
||||
const auto command=LOWORD(wp);
|
||||
if(message==WM_COMMAND && (command==IDOK || command==IDCANCEL || command==IDYES || command==IDNO))
|
||||
{
|
||||
if(command==IDYES && notice->remember)
|
||||
*notice->remember=IsDlgButtonChecked(window,101)==BST_CHECKED;
|
||||
if(command==IDYES && notice->options.remember)
|
||||
*notice->options.remember=IsDlgButtonChecked(window,101)==BST_CHECKED;
|
||||
FilesModalDialog::finish(window,command);
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
INT_PTR showFilesNotice(HWND owner,const wchar_t *text,const wchar_t *title,bool confirm,bool *remember,bool warning)
|
||||
INT_PTR showFilesNotice(HWND owner,const wchar_t *text,const wchar_t *title,FilesNoticeOptions options)
|
||||
{
|
||||
const bool createBrush=warning && !warningBrush;
|
||||
const bool createBrush=options.tone==FilesNoticeTone::warning && !warningBrush;
|
||||
if(createBrush) warningBrush=CreateSolidBrush(RGB(128,0,0));
|
||||
FilesNotice notice{text,std::wstring(L" ")+title+L" ",confirm,remember,warning};
|
||||
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(warning?609:607),owner,filesNoticeDialog,
|
||||
FilesNotice notice{text,std::wstring(L" ")+title+L" ",options};
|
||||
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(607),owner,filesNoticeDialog,
|
||||
reinterpret_cast<LPARAM>(¬ice));
|
||||
if(createBrush)
|
||||
{
|
||||
@@ -3378,14 +3684,8 @@ void copySelection(HWND owner,Panel &source,Panel &destination,bool move)
|
||||
request.otherDirectory=destination.directory;
|
||||
request.sourcePeer=source.peer?&*source.peer:nullptr;
|
||||
request.otherPeer=request.destinationPeer=destination.peer?&*destination.peer:nullptr;
|
||||
for(size_t i=0;i<source.rows.size();++i)
|
||||
if(source.marked[i]) request.sources.push_back(source.rows[i]);
|
||||
if(request.sources.empty())
|
||||
{
|
||||
const auto row=SendMessageW(source.list,LB_GETCURSEL,0,0);
|
||||
if(row<0 || static_cast<size_t>(row)>=source.rows.size() || source.sizes[row]==L"Up") return;
|
||||
request.sources.push_back(source.rows[row]);
|
||||
}
|
||||
request.sources=selectedPanelPaths(source);
|
||||
if(request.sources.empty()) return;
|
||||
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(102),owner,copyDialog,
|
||||
reinterpret_cast<LPARAM>(&request));
|
||||
if(result==-1) setStatus(L"Could not open the Copy dialog.");
|
||||
@@ -3689,15 +3989,8 @@ bool pumpDeletion(HWND window,bool &canceled)
|
||||
void deleteSelection(HWND owner,Panel &panel,bool permanent)
|
||||
{
|
||||
if(copyInProgress) return;
|
||||
std::vector<fs::path> paths;
|
||||
for(size_t i=0;i<panel.rows.size();++i)
|
||||
if(panel.marked[i] && panel.sizes[i]!=L"Up") paths.push_back(panel.rows[i]);
|
||||
if(paths.empty())
|
||||
{
|
||||
const auto row=SendMessageW(panel.list,LB_GETCURSEL,0,0);
|
||||
if(row<0 || static_cast<size_t>(row)>=panel.rows.size() || panel.sizes[row]==L"Up") return;
|
||||
paths.push_back(panel.rows[row]);
|
||||
}
|
||||
auto paths=selectedPanelPaths(panel);
|
||||
if(paths.empty()) return;
|
||||
const auto shown=std::min<size_t>(paths.size(),10);
|
||||
std::wstring entries;
|
||||
for(size_t i=0;i<shown;++i)
|
||||
@@ -4028,7 +4321,7 @@ INT_PTR CALLBACK makeFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
if(command!=IDOK) return FALSE;
|
||||
const auto error=[&](const wchar_t *text,HWND focus)
|
||||
{
|
||||
showFilesNotice(dialog,text,L"Error",false,nullptr,true);
|
||||
showFilesNotice(dialog,text,L"Error",{.tone=FilesNoticeTone::warning});
|
||||
SetFocus(focus);
|
||||
};
|
||||
const auto entered=dialogFieldText(input);
|
||||
@@ -4143,11 +4436,18 @@ LRESULT CALLBACK panelKeys(HWND window, UINT message, WPARAM wp, LPARAM lp, UINT
|
||||
{
|
||||
if(wp==VK_TAB) { activatePanel(static_cast<int>(1-index)); SetFocus(terminalInput); return 0; }
|
||||
if (wp == VK_RETURN) { enter(panels[index]); return 0; }
|
||||
if (wp == VK_F2) { open(panels[index], panels[index].directory); return 0; }
|
||||
if (wp == VK_F2)
|
||||
{
|
||||
refreshPanel(panels[index]);
|
||||
return 0;
|
||||
}
|
||||
if (wp == VK_F10) { SendMessageW(GetParent(window), WM_CLOSE, 0, 0); return 0; }
|
||||
if (wp == VK_F3 || wp == VK_F4)
|
||||
{
|
||||
openSelectedText(panels[index],wp==VK_F4,false);
|
||||
auto &panel=panels[index];
|
||||
const auto row=currentPanelRow(panel);
|
||||
if(wp==VK_F3 && row!=LB_ERR && panel.sizes[row]==L"Folder") calculateFolderSizes(panel,row);
|
||||
else openSelectedText(panel,wp==VK_F4,false);
|
||||
return 0;
|
||||
}
|
||||
if (wp == VK_F5 || wp == VK_F6)
|
||||
@@ -4233,17 +4533,33 @@ void openMenu(HWND window, int index)
|
||||
index==0?L" Change drive\tAlt+F1":L" Change drive\tAlt+F2"};
|
||||
}
|
||||
else if(index==1) rows={L"Copy\tF5",L"Move\tF6",L"Make folder\tF7",L"Exit\tF10"};
|
||||
else if(index==2) rows={L"Refresh\tF2",L"View\tF3",L"Edit\tF4",L"Folder shortcuts",
|
||||
L"View externally\tAlt+F3",L"Edit externally\tAlt+F4"};
|
||||
else if(index==2)
|
||||
{
|
||||
rows={L"Refresh panel\tCtrl+R",L"View\tF3",L"Edit\tF4",L"Folder shortcuts",
|
||||
L"View externally\tAlt+F3",L"Edit externally\tAlt+F4"};
|
||||
if(panels[active].peer)
|
||||
{
|
||||
rows.push_back(L"Hibernate remote PC");
|
||||
rows.push_back(L"Update remote KSwitcher");
|
||||
}
|
||||
}
|
||||
commandActions.clear();
|
||||
if(index==2)
|
||||
{
|
||||
commandActions={0,3,-1,1,2,4,5};
|
||||
if(panels[active].peer) commandActions.insert(commandActions.end(),{-1,6,7});
|
||||
}
|
||||
else
|
||||
for(size_t i=0;i<rows.size();++i) commandActions.push_back(static_cast<int>(i));
|
||||
std::set<wchar_t> used;
|
||||
commandAccessPositions.assign(rows.size(),std::wstring::npos);
|
||||
std::vector<size_t> accessPositions(rows.size(),std::wstring::npos);
|
||||
const auto reserve=[&](size_t row,wchar_t key)
|
||||
{
|
||||
const auto &text=rows[row];
|
||||
for(size_t pos=0;pos<text.size() && text[pos]!=L'\t';++pos)
|
||||
if(towupper(text[pos])==key && used.insert(key).second)
|
||||
{
|
||||
commandAccessPositions[row]=pos;
|
||||
accessPositions[row]=pos;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -4254,14 +4570,19 @@ void openMenu(HWND window, int index)
|
||||
for(size_t i=0;i<rows.size();++i)
|
||||
{
|
||||
const auto &text=rows[i];
|
||||
if(commandAccessPositions[i]==std::wstring::npos)
|
||||
if(accessPositions[i]==std::wstring::npos)
|
||||
for(const auto ch:text)
|
||||
{
|
||||
if(ch==L'\t') break;
|
||||
const auto key=towupper(ch);
|
||||
if(key>=L'A' && key<=L'Z' && reserve(i,static_cast<wchar_t>(key))) break;
|
||||
}
|
||||
SendMessageW(picker,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(text.c_str()));
|
||||
}
|
||||
commandAccessPositions.clear();
|
||||
for(const auto action:commandActions)
|
||||
{
|
||||
commandAccessPositions.push_back(action<0?std::wstring::npos:accessPositions[action]);
|
||||
SendMessageW(picker,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(action<0?L"":rows[action].c_str()));
|
||||
}
|
||||
RECT anchor{}, client{};
|
||||
GetWindowRect(menuItems[index],&anchor);
|
||||
@@ -4272,7 +4593,7 @@ void openMenu(HWND window, int index)
|
||||
const int x=std::clamp(static_cast<int>(anchor.left)+inset,inset,
|
||||
std::max(inset,static_cast<int>(client.right)-width-inset));
|
||||
const int y=anchor.bottom+5;
|
||||
const int height=static_cast<int>(rows.size())*static_cast<int>(SendMessageW(picker,LB_GETITEMHEIGHT,0,0));
|
||||
const int height=static_cast<int>(commandActions.size())*static_cast<int>(SendMessageW(picker,LB_GETITEMHEIGHT,0,0));
|
||||
SetWindowPos(pickerFrame,HWND_TOP,x-inset,y-inset,width+2*inset,height+2*inset,SWP_SHOWWINDOW);
|
||||
SetWindowPos(picker,HWND_TOP,x,y,width,height,SWP_SHOWWINDOW);
|
||||
SendMessageW(picker,LB_SETCURSEL,0,0);
|
||||
@@ -4448,8 +4769,15 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
case WM_TIMER:
|
||||
if(wp==driveQueryTimer) {pollDriveQueries(window);return 0;}
|
||||
if(wp==terminalTimer) { updateTerminalOutput(); return 0; }
|
||||
if(wp==directoryWatchTimer) { refreshChangedPanels(); return 0; }
|
||||
if(wp==terminalTimer)
|
||||
{
|
||||
if(pendingPeerAction.valid() &&
|
||||
pendingPeerAction.wait_for(std::chrono::seconds(0))==std::future_status::ready)
|
||||
setStatus(pendingPeerAction.get());
|
||||
updateTerminalOutput();
|
||||
return 0;
|
||||
}
|
||||
if(wp==directoryWatchTimer) { refreshChangedPanels(); collectFolderSizes(); return 0; }
|
||||
if(wp!=transferHighlightTimer) break;
|
||||
++transferHighlightPhase;
|
||||
for(auto &panel:panels)
|
||||
@@ -4501,7 +4829,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
SetTimer(window,directoryWatchTimer,200,nullptr);
|
||||
if (!mono || !blue || !teal || !menuBar || !picker || !pickerFrame || !status || !panelSearchPopup) return -1;
|
||||
SendMessageW(status, WM_SETFONT, reinterpret_cast<WPARAM>(mono), FALSE);
|
||||
const wchar_t *labels[]{L"F2 Refresh", L"F3 View", L"F4 Edit", L"F5 Copy", L"F6 Move", L"F7 Mkdir", L"F10 Exit"};
|
||||
const wchar_t *labels[]{L"Ctrl+R Refresh", L"F3 View", L"F4 Edit", L"F5 Copy", L"F6 Move", L"F7 Mkdir", L"F10 Exit"};
|
||||
for (int i = 0; i < static_cast<int>(std::size(commands)); ++i)
|
||||
commands[i] = CreateWindowW(L"BUTTON", labels[i], WS_CHILD | WS_VISIBLE | BS_OWNERDRAW,
|
||||
0, 0, 0, 0, window, reinterpret_cast<HMENU>(110 + static_cast<INT_PTR>(i)), instance, nullptr);
|
||||
@@ -4771,6 +5099,15 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
auto background=item.CtlType==ODT_BUTTON?Footer:item.hwndItem==picker?Dialog:PanelBlue;
|
||||
auto foreground=colors[Text];
|
||||
if(item.hwndItem==picker && commandPicker && item.itemID<commandActions.size() && commandActions[item.itemID]<0)
|
||||
{
|
||||
FillRect(item.hDC,&item.rcItem,teal);
|
||||
const auto y=(item.rcItem.top+item.rcItem.bottom)/2;
|
||||
const RECT line{item.rcItem.left,y,item.rcItem.right,y+1};
|
||||
SetDCBrushColor(item.hDC,colors[Frame]);
|
||||
FillRect(item.hDC,&line,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
|
||||
return TRUE;
|
||||
}
|
||||
if(item.hwndItem==picker && !commandPicker && item.itemID==drivePaths.size())
|
||||
{
|
||||
auto row=item.rcItem;
|
||||
@@ -4916,7 +5253,14 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
paintText(item.hDC,name,text.c_str(),foreground,rowBackground,false,rowFont);
|
||||
const auto &amount=panel.sizes[item.itemID];
|
||||
auto visible=amount;
|
||||
if(amount!=L"Folder" && amount!=L"Up")
|
||||
const auto measured=folder?panel.folderSizes.find(panel.rows[item.itemID]):panel.folderSizes.end();
|
||||
if(measured!=panel.folderSizes.end())
|
||||
{
|
||||
const auto partial=measured->second.partial;
|
||||
visible=formatSize(measured->second.bytes,static_cast<size_t>(sizeWidth)-(partial?1:0));
|
||||
if(partial) visible+=L"+";
|
||||
}
|
||||
else if(amount!=L"Folder" && amount!=L"Up")
|
||||
visible=formatSize(std::stoull(amount),static_cast<size_t>(sizeWidth));
|
||||
paintText(item.hDC,size,visible.c_str(),foreground,rowBackground,true);
|
||||
RECT line{size.left,size.top,size.left+1,size.bottom};
|
||||
@@ -4986,6 +5330,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
DestroyWindow(window);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
for(auto &panel:panels) panel.folderSizeStop.request_stop();
|
||||
KillTimer(window,terminalTimer);
|
||||
KillTimer(window,directoryWatchTimer);
|
||||
for(auto &panel:panels) panel.watch.reset();
|
||||
@@ -5147,11 +5492,16 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
|
||||
{
|
||||
const bool ctrl=(GetKeyState(VK_CONTROL)&0x8000)!=0;
|
||||
const bool shift=(GetKeyState(VK_SHIFT)&0x8000)!=0;
|
||||
if(ctrl && !shift && (message.wParam=='F' || message.wParam=='C'))
|
||||
if(ctrl && !shift &&
|
||||
(message.wParam=='F' || message.wParam=='C' || message.wParam=='R' ||
|
||||
message.wParam==VK_RETURN || message.wParam==VK_INSERT))
|
||||
{
|
||||
if(!(message.lParam & (1LL<<30)))
|
||||
{
|
||||
if(message.wParam=='F') insertCurrentPanelPath();
|
||||
else if(message.wParam=='R') refreshPanel(panels[active]);
|
||||
else if(message.wParam==VK_RETURN) insertCurrentPanelPath(true);
|
||||
else if(message.wParam==VK_INSERT) copyPanelNames(window);
|
||||
else SendMessageW(terminalInput,WM_COPY,0,0);
|
||||
}
|
||||
continue;
|
||||
|
||||
+19
-5
@@ -1,5 +1,5 @@
|
||||
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286,
|
||||
optionsRemoteTerminal=370;
|
||||
optionsRemoteTerminal=370,optionsRemoteHibernate=371,optionsRemoteUpdate=372;
|
||||
enum OptionsPage { PanelModes, PanelColors, Appearance, CommandLine, TextViewer, TextEditor, allOptions };
|
||||
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance",L"Command line",L"Viewer",L"Editor"};
|
||||
constexpr const wchar_t *colorNames[]{L"Panel background",L"Text",L"Frame",L"Popup background",
|
||||
@@ -80,6 +80,8 @@ struct OptionsBaseline
|
||||
std::vector<FileStyle> styles=fileStyles;
|
||||
std::array<WORD,2> keys=commandHistoryKeys;
|
||||
bool remoteTerminal=ks::lan::remoteTerminalAllowed();
|
||||
bool remoteHibernate=ks::lan::remoteHibernateAllowed();
|
||||
bool remoteUpdate=ks::lan::remoteUpdateAllowed();
|
||||
DWORD width=static_cast<DWORD>(sizeWidth), fields=statusFields;
|
||||
bool extensions=alignExtensions, folders=alignFolders, details=showDetails;
|
||||
std::array<bool,2> wide{panels[0].wide,panels[1].wide};
|
||||
@@ -154,6 +156,8 @@ void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
|
||||
fields!=base.fields || draft.palette!=base.palette || draft.styles!=base.styles ||
|
||||
draft.framePadding!=base.padding || optionsFontChanged(draft) ||
|
||||
optionHistoryKeys(dialog)!=base.keys || checked(optionsRemoteTerminal)!=base.remoteTerminal ||
|
||||
checked(optionsRemoteHibernate)!=base.remoteHibernate ||
|
||||
checked(optionsRemoteUpdate)!=base.remoteUpdate ||
|
||||
draft.text!=base.text;
|
||||
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
|
||||
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
|
||||
@@ -302,7 +306,8 @@ void saveTextSchemeDraft(HWND dialog,ks::files::TextSettings &settings,ks::files
|
||||
[&](const auto &scheme){return sameSchemeName(name,scheme.name.c_str());});
|
||||
if(existing!=settings.schemes.end())
|
||||
{
|
||||
if(showFilesNotice(dialog,L"Replace the saved scheme with the current colors?",L"Save color scheme",true)!=IDYES) return;
|
||||
if(showFilesNotice(dialog,L"Replace the saved scheme with the current colors?",L"Save color scheme",
|
||||
{.confirm=true,.tone=FilesNoticeTone::neutral})!=IDYES) return;
|
||||
existing->colors=profile.colors;
|
||||
profile.scheme=existing->name;
|
||||
}
|
||||
@@ -423,7 +428,7 @@ void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
|
||||
for(const auto &option:frameOptions)
|
||||
SendDlgItemMessageW(dialog,option.id,CB_SETCURSEL,draft.framePadding.*option.value,0);
|
||||
}
|
||||
constexpr int commandLineControls[]{320,321,322,323,optionsRemoteTerminal};
|
||||
constexpr int commandLineControls[]{320,321,322,323,optionsRemoteTerminal,optionsRemoteHibernate,optionsRemoteUpdate};
|
||||
show(commandLineControls,page==CommandLine && section==0);
|
||||
show(textControls,textOptionsPage(page));
|
||||
show(editingControls,page==TextEditor);
|
||||
@@ -519,7 +524,8 @@ void filterOptions(HWND dialog,OptionsDraft &draft)
|
||||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
|
||||
}
|
||||
else if(page==CommandLine) addSection(L"Command history",0,
|
||||
matches(L"Previous command") || matches(L"Allow paired PCs to run PowerShell commands here"));
|
||||
matches(L"Previous command") || matches(L"Allow paired PCs to run PowerShell commands here") ||
|
||||
matches(L"Allow paired PCs to hibernate this PC") || matches(L"Allow paired PCs to update KSwitcher here"));
|
||||
else if(textOptionsPage(page))
|
||||
{
|
||||
const bool propertyMatches=matches(L"Tab width") ||
|
||||
@@ -584,6 +590,8 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
|
||||
setOptionHistoryKeys(dialog,commandHistoryKeys);
|
||||
CheckDlgButton(dialog,optionsRemoteTerminal,ks::lan::remoteTerminalAllowed());
|
||||
CheckDlgButton(dialog,optionsRemoteHibernate,ks::lan::remoteHibernateAllowed());
|
||||
CheckDlgButton(dialog,optionsRemoteUpdate,ks::lan::remoteUpdateAllowed());
|
||||
filterOptions(dialog,draft);
|
||||
updateOptionsApply(dialog,draft);
|
||||
return TRUE;
|
||||
@@ -701,6 +709,8 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
setOptionHistoryKeys(dialog,defaultHistoryKeys);
|
||||
CheckDlgButton(dialog,optionsRemoteTerminal,BST_UNCHECKED);
|
||||
CheckDlgButton(dialog,optionsRemoteHibernate,BST_UNCHECKED);
|
||||
CheckDlgButton(dialog,optionsRemoteUpdate,BST_UNCHECKED);
|
||||
}
|
||||
if(draft->page==allOptions || draft->page==PanelModes)
|
||||
for(const auto &property:panelProperties)
|
||||
@@ -740,7 +750,7 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
}
|
||||
case 201:
|
||||
case 202: case 203: case 204: case 205: case 206: case 207: case 208:
|
||||
case 273: case 274: case optionsRemoteTerminal:
|
||||
case 273: case 274: case optionsRemoteTerminal: case optionsRemoteHibernate: case optionsRemoteUpdate:
|
||||
updateOptionsApply(dialog,*draft);
|
||||
return TRUE;
|
||||
case 321: case 323:
|
||||
@@ -841,6 +851,8 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
saveChanged(L"PreviousCommandKey",inputKeys[0],base.keys[0]);
|
||||
saveChanged(L"CommandHistoryKey",inputKeys[1],base.keys[1]);
|
||||
saveChanged(ks::lan::remoteTerminalPreference,checked(optionsRemoteTerminal),base.remoteTerminal);
|
||||
saveChanged(ks::lan::remoteHibernatePreference,checked(optionsRemoteHibernate),base.remoteHibernate);
|
||||
saveChanged(ks::lan::remoteUpdatePreference,checked(optionsRemoteUpdate),base.remoteUpdate);
|
||||
saveChanged(L"SizeWidth",width,base.width);
|
||||
saveChanged(L"AlignExtensions",checked(202),base.extensions);
|
||||
saveChanged(L"AlignFolders",checked(204),base.folders);
|
||||
@@ -887,6 +899,8 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
|
||||
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
|
||||
setOptionHistoryKeys(dialog,commandHistoryKeys);
|
||||
CheckDlgButton(dialog,optionsRemoteTerminal,ks::lan::remoteTerminalAllowed());
|
||||
CheckDlgButton(dialog,optionsRemoteHibernate,ks::lan::remoteHibernateAllowed());
|
||||
CheckDlgButton(dialog,optionsRemoteUpdate,ks::lan::remoteUpdateAllowed());
|
||||
if(draft->page==PanelColors && draft->section==fileStyleSection)
|
||||
updateFileStyleEditor(dialog,*draft,true);
|
||||
showOptionsPage(dialog,*draft,draft->page,draft->section);
|
||||
|
||||
+13
-4
@@ -295,15 +295,25 @@ struct LanService::Impl
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
return !stopping && trusted.contains(peer)?trusted.at(peer).at("key").get<std::string>():std::string{};
|
||||
},lan::FilesPort,lan::remoteTerminalAllowed);
|
||||
},lan::FilesPort,lan::remoteTerminalAllowed,lan::hibernateFromPairedPC,[this]() -> DWORD
|
||||
{
|
||||
if(!lan::remoteUpdateAllowed()) return ERROR_ACCESS_DENIED;
|
||||
if(!inputWindow) return ERROR_INVALID_WINDOW_HANDLE;
|
||||
return PostMessageW(inputWindow,LanUpdateRequestedMessage,0,0)?ERROR_SUCCESS:GetLastError();
|
||||
});
|
||||
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.");
|
||||
bool filesListening=files.port()!=0;
|
||||
while(!stopping)
|
||||
{
|
||||
files.poll();
|
||||
if(filesListening!=(files.port()!=0))
|
||||
{
|
||||
filesListening=files.port()!=0;
|
||||
nextHello=0;
|
||||
}
|
||||
auto now=GetTickCount64();bool clipboardActive=false;
|
||||
std::vector<lan::InputEndpoint> endpoints;
|
||||
{
|
||||
@@ -341,7 +351,6 @@ 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){
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ks
|
||||
{
|
||||
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
|
||||
inline constexpr UINT LanFilesChangedMessage=WM_APP+70;
|
||||
inline constexpr UINT LanUpdateRequestedMessage=WM_APP+90;
|
||||
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
|
||||
// Discovery, pairing and optional encrypted input within the resident process.
|
||||
class LanService
|
||||
|
||||
+82
-21
@@ -33,13 +33,17 @@ void checkFileReply(const Json &reply)
|
||||
if(error) throw std::system_error(static_cast<int>(error),std::system_category());
|
||||
}
|
||||
|
||||
bool remoteTerminalAllowed()
|
||||
static bool filesPermissionEnabled(const wchar_t *name)
|
||||
{
|
||||
DWORD allowed{},size=sizeof(allowed);
|
||||
return RegGetValueW(HKEY_CURRENT_USER,L"Software\\KSwitcher\\Files",remoteTerminalPreference,
|
||||
return RegGetValueW(HKEY_CURRENT_USER,L"Software\\KSwitcher\\Files",name,
|
||||
RRF_RT_REG_DWORD,nullptr,&allowed,&size)==ERROR_SUCCESS && allowed==1;
|
||||
}
|
||||
|
||||
bool remoteTerminalAllowed() {return filesPermissionEnabled(remoteTerminalPreference);}
|
||||
bool remoteHibernateAllowed() {return filesPermissionEnabled(remoteHibernatePreference);}
|
||||
bool remoteUpdateAllowed() {return filesPermissionEnabled(remoteUpdatePreference);}
|
||||
|
||||
bool recycleFile(HWND owner,const fs::path &path)
|
||||
{
|
||||
const auto initialized=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
@@ -232,6 +236,27 @@ struct FileHandle
|
||||
~FileHandle(){close();}
|
||||
};
|
||||
|
||||
DWORD requestSystemHibernate()
|
||||
{
|
||||
wchar_t system[MAX_PATH]{};
|
||||
const auto length=GetSystemDirectoryW(system,MAX_PATH);
|
||||
if(!length) return GetLastError();
|
||||
if(length>=MAX_PATH) return ERROR_INSUFFICIENT_BUFFER;
|
||||
const auto executable=fs::path(system)/L"shutdown.exe";
|
||||
wchar_t command[]=L"shutdown.exe /h";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION child{};
|
||||
if(!CreateProcessW(executable.c_str(),command,nullptr,nullptr,FALSE,CREATE_NO_WINDOW,
|
||||
nullptr,system,&startup,&child)) return GetLastError();
|
||||
CloseHandle(child.hThread);
|
||||
FileHandle process{child.hProcess};
|
||||
const auto wait=WaitForSingleObject(process.value,3000);
|
||||
if(wait==WAIT_TIMEOUT) return ERROR_TIMEOUT;
|
||||
if(wait!=WAIT_OBJECT_0) return GetLastError();
|
||||
DWORD exitCode{};
|
||||
return GetExitCodeProcess(process.value,&exitCode)?exitCode:GetLastError();
|
||||
}
|
||||
|
||||
struct RemoteCommand
|
||||
{
|
||||
FileHandle job,process,output;
|
||||
@@ -403,6 +428,7 @@ std::string hashPrefix(HANDLE file,uint64_t size,const FileProgress &progress,ui
|
||||
class FileOperations
|
||||
{
|
||||
FilesListener::TerminalPermission terminalPermission;
|
||||
FilesListener::HostAction hibernate,update;
|
||||
RemoteCommand terminal;
|
||||
bool remote;
|
||||
HANDLE search=INVALID_HANDLE_VALUE;
|
||||
@@ -558,8 +584,10 @@ class FileOperations
|
||||
return reply;
|
||||
}
|
||||
public:
|
||||
explicit FileOperations(bool remote,FilesListener::TerminalPermission permission={}):
|
||||
terminalPermission(std::move(permission)),remote(remote){}
|
||||
explicit FileOperations(bool remote,FilesListener::TerminalPermission permission={},
|
||||
FilesListener::HostAction hibernateAction={},FilesListener::HostAction updateAction={}):
|
||||
terminalPermission(std::move(permission)),hibernate(std::move(hibernateAction)),
|
||||
update(std::move(updateAction)),remote(remote){}
|
||||
~FileOperations(){closeSearch();discard();}
|
||||
Json call(const Json &request,const FileProgress &progress)
|
||||
{
|
||||
@@ -573,6 +601,14 @@ public:
|
||||
Json perform(const Json &request,const FileProgress &progress)
|
||||
{
|
||||
const auto action=request.at("action").get<std::string>();
|
||||
if(action=="hibernate" || action=="update")
|
||||
{
|
||||
if(request.size()!=1) return failure(ERROR_INVALID_PARAMETER);
|
||||
const auto &hostAction=action=="hibernate"?hibernate:update;
|
||||
if(!remote || !hostAction) return failure(ERROR_ACCESS_DENIED);
|
||||
const auto error=hostAction();
|
||||
return error?failure(error):ok();
|
||||
}
|
||||
if(action=="terminalRun" || action=="terminalPoll" || action=="terminalCancel")
|
||||
{
|
||||
if(!remote || !terminalPermission || !terminalPermission())
|
||||
@@ -865,6 +901,12 @@ struct FilesConnection::Impl
|
||||
}
|
||||
};
|
||||
|
||||
DWORD hibernateFromPairedPC()
|
||||
{
|
||||
if(!remoteHibernateAllowed()) return ERROR_ACCESS_DENIED;
|
||||
return requestSystemHibernate();
|
||||
}
|
||||
|
||||
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>())
|
||||
@@ -986,30 +1028,46 @@ struct FilesListener::Impl
|
||||
std::string local;
|
||||
PairKey key;
|
||||
TerminalPermission terminalPermission;
|
||||
HostAction hibernate,update;
|
||||
WinSocket listener;
|
||||
std::vector<std::unique_ptr<Client>> clients;
|
||||
uint16_t boundPort{};
|
||||
Impl(std::string id,PairKey lookup,uint16_t port,TerminalPermission permission):
|
||||
local(std::move(id)),key(std::move(lookup)),terminalPermission(std::move(permission))
|
||||
uint16_t requestedPort{},boundPort{};
|
||||
ULONGLONG nextBind{};
|
||||
DWORD lastBindError{};
|
||||
Impl(std::string id,PairKey lookup,uint16_t port,TerminalPermission permission,HostAction hibernateAction,
|
||||
HostAction updateAction):
|
||||
local(std::move(id)),key(std::move(lookup)),terminalPermission(std::move(permission)),
|
||||
hibernate(std::move(hibernateAction)),update(std::move(updateAction)),requestedPort(port)
|
||||
{
|
||||
listener.value=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
|
||||
if(listener.value==INVALID_SOCKET) return;
|
||||
bindListener();
|
||||
}
|
||||
void bindListener()
|
||||
{
|
||||
nextBind=GetTickCount64()+1000;
|
||||
WinSocket candidate{socket(AF_INET,SOCK_STREAM,IPPROTO_TCP)};
|
||||
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))
|
||||
address.sin_port=htons(requestedPort);
|
||||
int length=sizeof(address);
|
||||
if(candidate.value==INVALID_SOCKET ||
|
||||
setsockopt(candidate.value,SOL_SOCKET,SO_EXCLUSIVEADDRUSE,reinterpret_cast<const char*>(&exclusive),sizeof(exclusive)) ||
|
||||
ioctlsocket(candidate.value,FIONBIO,&mode) ||
|
||||
bind(candidate.value,reinterpret_cast<sockaddr*>(&address),sizeof(address)) ||
|
||||
listen(candidate.value,8) ||
|
||||
getsockname(candidate.value,reinterpret_cast<sockaddr*>(&address),&length))
|
||||
{
|
||||
closesocket(std::exchange(listener.value,INVALID_SOCKET));
|
||||
const auto error=static_cast<DWORD>(WSAGetLastError());
|
||||
if(requestedPort==FilesPort && lastBindError!=error)
|
||||
log(L"LAN Files TCP 45885 unavailable; retrying. Windows error "+std::to_wstring(error));
|
||||
lastBindError=error;
|
||||
return;
|
||||
}
|
||||
int length=sizeof(address);
|
||||
if(!getsockname(listener.value,reinterpret_cast<sockaddr*>(&address),&length))
|
||||
boundPort=ntohs(address.sin_port);
|
||||
listener.value=std::exchange(candidate.value,INVALID_SOCKET);
|
||||
boundPort=ntohs(address.sin_port);
|
||||
if(requestedPort==FilesPort) log(L"LAN Files listener ready on TCP 45885.");
|
||||
lastBindError=0;
|
||||
}
|
||||
void serve(SOCKET socket)
|
||||
{
|
||||
@@ -1025,7 +1083,7 @@ struct FilesListener::Impl
|
||||
wire.authenticate(secret,peer,local,challenge,nonce,false);
|
||||
if(wire.read().at("action")!="authenticate") return;
|
||||
wire.write(ok());
|
||||
FileOperations operations(true,terminalPermission);
|
||||
FileOperations operations(true,terminalPermission,hibernate,update);
|
||||
for(;;)
|
||||
{
|
||||
const auto request=wire.read(true);
|
||||
@@ -1044,6 +1102,7 @@ struct FilesListener::Impl
|
||||
void poll()
|
||||
{
|
||||
std::erase_if(clients,[](const auto &client){return client->done.load();});
|
||||
if(listener.value==INVALID_SOCKET && GetTickCount64()>=nextBind) bindListener();
|
||||
if(listener.value==INVALID_SOCKET || clients.size()>=16) return;
|
||||
sockaddr_in address{};
|
||||
int size=sizeof(address);
|
||||
@@ -1066,8 +1125,10 @@ struct FilesListener::Impl
|
||||
}
|
||||
};
|
||||
|
||||
FilesListener::FilesListener(std::string local,PairKey key,uint16_t port,TerminalPermission terminalPermission):
|
||||
impl(std::make_unique<Impl>(std::move(local),std::move(key),port,std::move(terminalPermission))){}
|
||||
FilesListener::FilesListener(std::string local,PairKey key,uint16_t port,TerminalPermission terminalPermission,
|
||||
HostAction hibernate,HostAction update):
|
||||
impl(std::make_unique<Impl>(std::move(local),std::move(key),port,std::move(terminalPermission),
|
||||
std::move(hibernate),std::move(update))){}
|
||||
FilesListener::~FilesListener()=default;
|
||||
uint16_t FilesListener::port()const{return impl->boundPort;}
|
||||
void FilesListener::poll(){impl->poll();}
|
||||
|
||||
+8
-1
@@ -42,7 +42,9 @@ class FilesListener
|
||||
public:
|
||||
using PairKey = std::function<std::string(const std::string &)>;
|
||||
using TerminalPermission = std::function<bool()>;
|
||||
FilesListener(std::string local, PairKey key,uint16_t port=FilesPort,TerminalPermission terminalPermission={});
|
||||
using HostAction = std::function<DWORD()>;
|
||||
FilesListener(std::string local, PairKey key,uint16_t port=FilesPort,TerminalPermission terminalPermission={},
|
||||
HostAction hibernate={},HostAction update={});
|
||||
uint16_t port() const;
|
||||
~FilesListener();
|
||||
void poll();
|
||||
@@ -54,4 +56,9 @@ void checkFileReply(const Json &reply);
|
||||
bool recycleFile(HWND owner, const std::filesystem::path &path);
|
||||
inline constexpr wchar_t remoteTerminalPreference[]=L"AllowRemoteTerminal";
|
||||
bool remoteTerminalAllowed();
|
||||
inline constexpr wchar_t remoteHibernatePreference[]=L"AllowRemoteHibernate";
|
||||
bool remoteHibernateAllowed();
|
||||
DWORD hibernateFromPairedPC();
|
||||
inline constexpr wchar_t remoteUpdatePreference[]=L"AllowRemoteUpdate";
|
||||
bool remoteUpdateAllowed();
|
||||
}
|
||||
+6
-1
@@ -55,7 +55,12 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool s
|
||||
L" -CurrentVersion " + quoteArgument(AppVersion) + L" -WorkDirectory " + quoteArgument(work.wstring()) +
|
||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||
if (install && expectedVersion.empty())
|
||||
{
|
||||
command += L" -Unattended -Journal " + quoteArgument((dataDirectory() / L"kswitcher.log").wstring());
|
||||
}
|
||||
else if (install)
|
||||
command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||
if (showProgress) command += L" -ShowProgress";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.87";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.93";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -20,6 +20,9 @@ public static class FilesActivationUi {
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string type, string title);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window);
|
||||
[DllImport("user32.dll")] static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr window, uint msg, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr process);
|
||||
[DllImport("user32.dll")] static extern bool GetGUIThreadInfo(uint thread, ref GuiInfo info);
|
||||
@@ -36,6 +39,14 @@ public static class FilesActivationUi {
|
||||
return info.Focus;
|
||||
}
|
||||
|
||||
public static bool Activate(IntPtr window) {
|
||||
if (GetForegroundWindow() == window) return true;
|
||||
keybd_event(0x12, 0, 0, UIntPtr.Zero);
|
||||
keybd_event(0x12, 0, 2, UIntPtr.Zero);
|
||||
SetForegroundWindow(window);
|
||||
return GetForegroundWindow() == window;
|
||||
}
|
||||
|
||||
public static void ClickRow(IntPtr list, int row, bool doubleClick) {
|
||||
Rect rect;
|
||||
if (SendRect(list, 0x198, new IntPtr(row), out rect).ToInt32() == -1)
|
||||
@@ -179,6 +190,21 @@ try {
|
||||
[FilesActivationUi]::Send($left, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq (Find-Row $left '..')
|
||||
}
|
||||
if ((Read-Text $inputBox) -cne 'draft command') { throw 'Pane navigation changed the command line.' }
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$fileName = [IO.Path]::GetFileName($document)
|
||||
foreach ($name in $fileName, 'Test folder') {
|
||||
$label = if ($name -eq $fileName) { $name } else { "[$name]" }
|
||||
Select-Row $right $label
|
||||
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, 'draft command')
|
||||
[void][FilesActivationUi]::Send($inputBox, 0xB1, [IntPtr]6, [IntPtr]13)
|
||||
Wait-Until 'Files activation for Ctrl+Enter' {
|
||||
[FilesActivationUi]::Activate($window) -and
|
||||
[FilesActivationUi]::Focus($window) -eq $inputBox
|
||||
}
|
||||
$shell.SendKeys('^{ENTER}')
|
||||
$expected = 'draft "{0}"' -f $name
|
||||
Wait-Until "Ctrl+Enter inserting $name" { (Read-Text $inputBox) -ceq $expected }
|
||||
}
|
||||
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, '')
|
||||
Write-Output 'Tab and Down navigated the panes while preserving command-line focus and text.'
|
||||
|
||||
@@ -244,6 +270,79 @@ try {
|
||||
}
|
||||
if ([FilesActivationUi]::Focus($window) -ne $inputBox) { throw 'Deletion moved focus.' }
|
||||
Write-Output 'Both panels refreshed external creation, modification and deletion while preserving focus.'
|
||||
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$savedClipboard=[Windows.Forms.Clipboard]::GetDataObject()
|
||||
$programName=[IO.Path]::GetFileName($program)
|
||||
$expectedNames="Test folder`r`n$programName"
|
||||
try {
|
||||
Select-Row $left '[Test folder]'
|
||||
[void][FilesActivationUi]::Send($left,0x100,[IntPtr]45,[IntPtr]::Zero)
|
||||
Select-Row $left $programName
|
||||
[void][FilesActivationUi]::Send($left,0x100,[IntPtr]45,[IntPtr]::Zero)
|
||||
Wait-Until 'Files activation for Ctrl+Insert' {
|
||||
[FilesActivationUi]::Activate($window) -and [FilesActivationUi]::Focus($window) -eq $inputBox
|
||||
}
|
||||
$shell.SendKeys('^{INSERT}')
|
||||
Wait-Until 'Ctrl+Insert copying marked names in panel order' {
|
||||
[Windows.Forms.Clipboard]::GetText() -ceq $expectedNames
|
||||
}
|
||||
Select-Row $right $fileName
|
||||
$shell.SendKeys('^{INSERT}')
|
||||
Wait-Until 'Ctrl+Insert copying the current item in the other pane' {
|
||||
[Windows.Forms.Clipboard]::GetText() -ceq $fileName
|
||||
}
|
||||
Write-Output 'Ctrl+Insert copied marked names in order and used the cursor in the other pane.'
|
||||
} finally {
|
||||
if ([Windows.Forms.Clipboard]::GetText() -cin @($expectedNames,$fileName)) {
|
||||
if ($savedClipboard) { [Windows.Forms.Clipboard]::SetDataObject($savedClipboard,$true) }
|
||||
else { [Windows.Forms.Clipboard]::Clear() }
|
||||
}
|
||||
}
|
||||
|
||||
$sizeA=Join-Path $rightDir 'Size A'
|
||||
$sizeB=Join-Path $rightDir 'Size B'
|
||||
$sizeC=Join-Path $rightDir 'Size C'
|
||||
New-Item -ItemType Directory -Path "$sizeA\nested", $sizeB, $sizeC | Out-Null
|
||||
[IO.File]::WriteAllBytes("$sizeA\file.bin",[byte[]]::new(4))
|
||||
[IO.File]::WriteAllBytes("$sizeA\nested\file.bin",[byte[]]::new(7))
|
||||
[IO.File]::WriteAllBytes("$sizeB\file.bin",[byte[]]::new(23))
|
||||
[IO.File]::WriteAllBytes("$sizeC\file.bin",[byte[]]::new(31))
|
||||
Select-Row $right '[Size A]'
|
||||
[void][FilesActivationUi]::PostMessage($right,0x100,[IntPtr]0x72,[IntPtr]::Zero)
|
||||
Wait-Until 'F3 recursive size for the current folder' { (Read-Text $status) -ceq 'Size A 11 B' }
|
||||
foreach($name in 'Size A','Size B'){
|
||||
Select-Row $right "[$name]"
|
||||
[void][FilesActivationUi]::Send($right,0x100,[IntPtr]45,[IntPtr]::Zero)
|
||||
}
|
||||
Select-Row $right '[Size C]'
|
||||
[void][FilesActivationUi]::PostMessage($right,0x100,[IntPtr]0x72,[IntPtr]::Zero)
|
||||
Wait-Until 'completion of the marked size batch' { (Read-Text $status) -ceq 'Size C Folder' }
|
||||
Select-Row $right '[Size B]'
|
||||
Wait-Until 'F3 size for a marked folder' { (Read-Text $status) -ceq 'Size B 23 B' }
|
||||
Select-Row $right '[Size C]'
|
||||
if ((Read-Text $status) -cne 'Size C Folder') { throw 'F3 measured the unmarked cursor folder.' }
|
||||
Select-Row $right $fileName
|
||||
[void][FilesActivationUi]::PostMessage($right,0x100,[IntPtr]0x72,[IntPtr]::Zero)
|
||||
$viewerTitle='Viewer '+[char]0x2014+" $document"
|
||||
Wait-Until 'F3 opening a file when folders are marked' {
|
||||
[FilesActivationUi]::FindWindowEx([IntPtr]::Zero,[IntPtr]::Zero,'KSwitcher.Files.Text',$viewerTitle) -ne [IntPtr]::Zero
|
||||
}
|
||||
$viewer=[FilesActivationUi]::FindWindowEx([IntPtr]::Zero,[IntPtr]::Zero,'KSwitcher.Files.Text',$viewerTitle)
|
||||
[void][FilesActivationUi]::PostMessage($viewer,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
Write-Output 'F3 measured the current folder recursively and measured marked folders without measuring the unmarked cursor folder.'
|
||||
|
||||
Select-Row $right '[Size B]'
|
||||
Wait-Until 'Files activation for Ctrl+R' {
|
||||
[FilesActivationUi]::Activate($window) -and [FilesActivationUi]::Focus($window) -eq $inputBox
|
||||
}
|
||||
$shell.SendKeys('^r')
|
||||
Wait-Until 'Ctrl+R clearing the measured size and retaining the current folder' {
|
||||
(Read-Text $status) -ceq 'Size B Folder' -and
|
||||
[FilesActivationUi]::Send($right,0x188,[IntPtr]::Zero,[IntPtr]::Zero).ToInt32() -eq
|
||||
(Find-Row $right '[Size B]')
|
||||
}
|
||||
Write-Output 'Ctrl+R refreshed the active pane and kept its cursor.'
|
||||
} finally {
|
||||
if ($process -and !$process.HasExited) {
|
||||
[void]$process.CloseMainWindow()
|
||||
|
||||
@@ -113,6 +113,28 @@ function Read-Bounds($Window) {
|
||||
if (![SearchArrowTest]::GetWindowRect($Window,[ref]$bounds)) { throw 'Missing window bounds' }
|
||||
$bounds
|
||||
}
|
||||
function Assert-CompactNotice($Dialog, [int]$DefaultButton) {
|
||||
$text=[SearchArrowTest]::GetDlgItem($Dialog,100)
|
||||
if (([SearchArrowTest]::GetWindowStyle($text,-16).ToInt64() -band 0x00200000) -ne 0) {
|
||||
throw 'A short Files notice has a vertical scrollbar.'
|
||||
}
|
||||
$lines=[SearchArrowTest]::Send($text,0xBA,0,0).ToInt32()
|
||||
$body=Read-Bounds $text
|
||||
$button=[SearchArrowTest]::GetDlgItem($Dialog,$DefaultButton)
|
||||
$footer=Read-Bounds $button
|
||||
$lineHeight=($body.Bottom-$body.Top)/$lines
|
||||
$frame=Read-Bounds $Dialog
|
||||
if (($body.Top-$frame.Top) -gt 2*$lineHeight -or
|
||||
($frame.Bottom-$footer.Bottom) -gt $lineHeight) {
|
||||
throw 'A short Files notice has excess space between its content and frame.'
|
||||
}
|
||||
if (($footer.Top-$body.Bottom) -gt $lineHeight -or $footer.Top -le $body.Bottom) {
|
||||
throw 'A short Files notice has incorrect spacing between the text and the buttons.'
|
||||
}
|
||||
if ([SearchArrowTest]::FocusedControl($Dialog) -ne $button) {
|
||||
throw 'A Files notice has the wrong starting focus.'
|
||||
}
|
||||
}
|
||||
function Assert-FarFieldHeight($Dialog, [int[]]$Ids) {
|
||||
$heights=@(foreach ($id in $Ids) {
|
||||
$bounds=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,$id))
|
||||
@@ -197,6 +219,35 @@ $process = Start-Process -FilePath $copy -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$previousDpi=[SearchArrowTest]::SetThreadDpiAwarenessContext([IntPtr](-4))
|
||||
$main = Wait-Until { Find-Window 'KSwitcher.Files' $process.Id }
|
||||
Post $main 0x111 132
|
||||
$commandMenu=Wait-Until {
|
||||
$list=[SearchArrowTest]::GetDlgItem($main,121)
|
||||
if ([SearchArrowTest]::IsWindowVisible($list)) { $list }
|
||||
}
|
||||
$count=[SearchArrowTest]::Send($commandMenu,0x18B,0,0).ToInt32()
|
||||
$rows=@(for ($i=0; $i -lt $count; $i++) {
|
||||
$label=[Text.StringBuilder]::new(128)
|
||||
[void][SearchArrowTest]::GetText($commandMenu,0x189,$i,$label)
|
||||
$label.ToString()
|
||||
})
|
||||
if (($rows -join '|') -cne "Refresh panel`tCtrl+R|Folder shortcuts||View`tF3|Edit`tF4|View externally`tAlt+F3|Edit externally`tAlt+F4") {
|
||||
throw 'Commands did not group local actions or showed a LAN-only section.'
|
||||
}
|
||||
Post $commandMenu 0x100 40
|
||||
Post $commandMenu 0x100 40
|
||||
[void](Wait-Until { [SearchArrowTest]::Send($commandMenu,0x188,0,0).ToInt32() -eq 3 })
|
||||
Post $commandMenu 0x100 38
|
||||
[void](Wait-Until { [SearchArrowTest]::Send($commandMenu,0x188,0,0).ToInt32() -eq 1 })
|
||||
$rowHeight=[SearchArrowTest]::Send($commandMenu,0x1A1,0,0).ToInt32()
|
||||
$separatorPoint=((2*$rowHeight+1) -shl 16) -bor 4
|
||||
[void][SearchArrowTest]::Send($commandMenu,0x201,1,$separatorPoint)
|
||||
[void][SearchArrowTest]::Send($commandMenu,0x202,0,$separatorPoint)
|
||||
if ([SearchArrowTest]::Send($commandMenu,0x188,0,0).ToInt32() -ne 1) {
|
||||
throw 'A click selected the Commands separator.'
|
||||
}
|
||||
Post $commandMenu 0x100 27
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindowVisible($commandMenu) })
|
||||
Write-Output 'Verified Commands grouping, arrow navigation, and inert separators.'
|
||||
$panel = Wait-Until { $handle = [SearchArrowTest]::GetDlgItem($main,102); if ($handle -ne [IntPtr]::Zero) { $handle } }
|
||||
$pathControl = [SearchArrowTest]::GetDlgItem($main,142)
|
||||
$pathText = [Text.StringBuilder]::new(1024)
|
||||
@@ -244,6 +295,7 @@ try {
|
||||
[void][SearchArrowTest]::SendText($nameField,0xC,0,$newFolder)
|
||||
Post $makeDialog 0x111 1
|
||||
$folderError=Wait-Until { Find-Window '#32770' $process.Id 100 }
|
||||
Assert-CompactNotice $folderError 1
|
||||
Post $folderError 0x111 1
|
||||
[void](Wait-Until {
|
||||
![SearchArrowTest]::IsWindow($folderError) -and
|
||||
@@ -477,6 +529,7 @@ try {
|
||||
$notice=[Text.StringBuilder]::new(256)
|
||||
[void][SearchArrowTest]::GetText([SearchArrowTest]::GetDlgItem($existing,100),0xD,$notice.Capacity,$notice)
|
||||
if (!$notice.ToString().Contains('already open')) { throw 'Reopening did not offer to switch to the editor.' }
|
||||
Assert-CompactNotice $existing 7
|
||||
Post $existing 0x111 6
|
||||
[void](Wait-Until { ![SearchArrowTest]::IsWindow($existing) })
|
||||
if (Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor) { throw 'Duplicate editor opened.' }
|
||||
|
||||
+65
-1
@@ -1,6 +1,7 @@
|
||||
#include <winsock2.h>
|
||||
#include <sddl.h>
|
||||
#include "lan_files.h"
|
||||
#include "files_lan_ipc.h"
|
||||
#include <bit>
|
||||
#include <atomic>
|
||||
#include <fstream>
|
||||
@@ -18,6 +19,23 @@ void require(bool condition,const char *message)
|
||||
if(!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
void verifyFilesPeerImage()
|
||||
{
|
||||
const fs::path install = LR"(C:\Tools\KSwitcher)";
|
||||
const auto transaction = install / L".kswitcher-update-0123456789abcdef0123456789abcdef";
|
||||
constexpr auto files = L"KSwitcher.Files.exe";
|
||||
require(isFilesPeerImage(install / files, install), "installed Files can request LAN peers");
|
||||
require(isFilesPeerImage(transaction / L"backup" / files, install),
|
||||
"Files open during an update can still request LAN peers");
|
||||
require(!isFilesPeerImage(transaction / L"stage" / files, install), "staged Files image is rejected");
|
||||
require(!isFilesPeerImage(transaction / L"backup" / L"KSwitcher.Config.exe", install),
|
||||
"another executable in the backup is rejected");
|
||||
require(!isFilesPeerImage(install / L".kswitcher-update-invalid" / L"backup" / files, install),
|
||||
"malformed transaction directory is rejected");
|
||||
require(!isFilesPeerImage(install.parent_path() / transaction.filename() / L"backup" / files, install),
|
||||
"backup outside the active installation is rejected");
|
||||
}
|
||||
|
||||
void save(const fs::path &path,const std::string &text)
|
||||
{
|
||||
std::ofstream file(path,std::ios::binary|std::ios::trunc);
|
||||
@@ -111,11 +129,37 @@ int main()
|
||||
int result=0;
|
||||
try
|
||||
{
|
||||
verifyFilesPeerImage();
|
||||
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{};};
|
||||
{
|
||||
auto previous=std::make_unique<FilesListener>(first,lookup,0);
|
||||
const auto port=previous->port();
|
||||
require(port!=0,"old listener bound before restart");
|
||||
FilesListener restarting(first,lookup,port);
|
||||
require(restarting.port()==0,"new listener must not take over an occupied port");
|
||||
previous.reset();
|
||||
const auto deadline=GetTickCount64()+4000;
|
||||
while(!restarting.port() && GetTickCount64()<deadline)
|
||||
{
|
||||
restarting.poll();
|
||||
Sleep(20);
|
||||
}
|
||||
require(restarting.port()==port,"listener automatically recovers after its port becomes available");
|
||||
std::jthread pollingRestarted([&](std::stop_token stop)
|
||||
{
|
||||
while(!stop.stop_requested()){restarting.poll();Sleep(5);}
|
||||
});
|
||||
FilesConnection recovered(FilesPeer{first,"127.0.0.1",L"Recovered",port},client,key);
|
||||
checkFileReply(recovered.call({{"action","drives"}}));
|
||||
}
|
||||
std::atomic<bool> allowTerminal{};
|
||||
FilesListener a(first,lookup,0,[&]{return allowTerminal.load();}),b(second,lookup,0);
|
||||
std::atomic<unsigned> hibernateRequests{};
|
||||
std::atomic<unsigned> updateRequests{};
|
||||
FilesListener a(first,lookup,0,[&]{return allowTerminal.load();}),
|
||||
b(second,lookup,0,{},[&]{++hibernateRequests;return ERROR_SUCCESS;},
|
||||
[&]{++updateRequests;return ERROR_SUCCESS;});
|
||||
require(a.port() && b.port() && a.port()!=b.port(),"test listeners unavailable");
|
||||
std::jthread polling([&](std::stop_token stop)
|
||||
{
|
||||
@@ -133,6 +177,26 @@ int main()
|
||||
}
|
||||
catch(const std::system_error &){}
|
||||
FilesConnection host(one,client,key);
|
||||
{
|
||||
const Json hibernate={{"action","hibernate"}};
|
||||
FilesConnection target(two,client,key);
|
||||
FilesConnection local;
|
||||
require(local.call(hibernate).at("error")==ERROR_ACCESS_DENIED,
|
||||
"local file operations cannot dispatch the LAN power action");
|
||||
require(host.call(hibernate).at("error")==ERROR_ACCESS_DENIED && hibernateRequests==0,
|
||||
"hibernate requires a separately enabled handler on the target");
|
||||
require(target.call({{"action","hibernate"},{"command","anything"}}).at("error")
|
||||
==ERROR_INVALID_PARAMETER && hibernateRequests==0,"hibernate accepts no extra parameters");
|
||||
require(target.call(hibernate).at("error")==ERROR_SUCCESS && hibernateRequests==1,
|
||||
"hibernate dispatches only to the addressed paired PC");
|
||||
const Json update={{"action","update"}};
|
||||
require(host.call(update).at("error")==ERROR_ACCESS_DENIED && updateRequests==0,
|
||||
"update requires a separate handler on the target");
|
||||
require(target.call({{"action","update"},{"url","https://example.invalid"}}).at("error")
|
||||
==ERROR_INVALID_PARAMETER && updateRequests==0,"update cannot supply an external package");
|
||||
require(target.call(update).at("error")==ERROR_SUCCESS && updateRequests==1 && hibernateRequests==1,
|
||||
"update dispatches to the addressed paired PC without invoking hibernate");
|
||||
}
|
||||
auto deniedCommand=fileRequest("terminalRun",root);
|
||||
deniedCommand["command"]="Write-Output denied";
|
||||
require(host.call(deniedCommand).at("error")==ERROR_ACCESS_DENIED,"remote command allowed without consent");
|
||||
|
||||
@@ -7,6 +7,38 @@ function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { &
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
try {
|
||||
$companionRoot=Join-Path $root 'companion-install'
|
||||
$otherRoot=Join-Path $root 'other-install'
|
||||
[IO.Directory]::CreateDirectory($companionRoot) | Out-Null
|
||||
[IO.Directory]::CreateDirectory($otherRoot) | Out-Null
|
||||
$companions=@()
|
||||
$Journal=Join-Path $root 'update.log'
|
||||
try {
|
||||
foreach ($fixture in @(
|
||||
@{Root=$companionRoot; Name='KSwitcher.Files.exe'},
|
||||
@{Root=$companionRoot; Name='KSwitcher.Screenshot.exe'},
|
||||
@{Root=$companionRoot; Name='KSwitcher.Config.exe'},
|
||||
@{Root=$otherRoot; Name='KSwitcher.Files.exe'})) {
|
||||
$path=Join-Path $fixture.Root $fixture.Name
|
||||
Copy-Item -LiteralPath (Join-Path ([Environment]::SystemDirectory) 'ping.exe') -Destination $path
|
||||
$companions+=Start-Process -FilePath $path -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru
|
||||
}
|
||||
$oldFiles=Join-Path $companionRoot 'KSwitcher.Files.exe'
|
||||
$backup=Join-Path $companionRoot '.kswitcher-update-0123456789abcdef0123456789abcdef/backup'
|
||||
[IO.Directory]::CreateDirectory($backup) | Out-Null
|
||||
Move-Item -LiteralPath $oldFiles -Destination $backup
|
||||
Stop-UpdateCompanions $companionRoot
|
||||
Assert (@($companions[0..2] | Where-Object HasExited).Count -eq 3) 'unattended update closes all companions in its installation'
|
||||
Assert (!$companions[3].HasExited) 'unattended update leaves a companion from another installation running'
|
||||
Assert (@(Get-Content -LiteralPath $Journal | Where-Object { $_ -match 'LAN update: Closing KSwitcher\.' }).Count -eq 3) 'unattended update journals every companion closure'
|
||||
Assert (@(Get-Content -LiteralPath $Journal | Where-Object { $_ -match 'LAN update: Closed KSwitcher\.' }).Count -eq 3) 'unattended update confirms each companion exited'
|
||||
} finally {
|
||||
foreach ($companion in $companions) {
|
||||
if (!$companion.HasExited) { $companion.Kill(); $companion.WaitForExit() }
|
||||
$companion.Dispose()
|
||||
}
|
||||
$Journal=$null
|
||||
}
|
||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
||||
|
||||
Reference in new issue
Block a user