Compare commits

...
6 Commits
19 changed files with 873 additions and 174 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.84 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.86 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+18 -2
View File
@@ -29,6 +29,15 @@ Far Manager є зразком для оформлення файлового м
Enter запускає введену команду або відкриває поточний елемент, якщо рядок
порожній. Escape очищає рядок.
Промпт і текст команди вирівняні по спільній базовій лінії.
- У віддаленій панелі Enter запускає введену команду PowerShell на
спареному ПК в поточному каталозі цієї панелі. Вивід і код завершення
з’являються в терміналі Files. Кожна команда запускається окремо,
тому змінні середовища й `cd` між командами не зберігаються. Escape
в порожньому командному рядку скасовує поточну віддалену команду.
- На ПК, який приймає команди, увімкніть **Options → Command line →
Command history → Allow paired PCs to run PowerShell commands here**.
Дозвіл за замовчуванням вимкнений і діє лише для спарених ПК. Команди
запускаються без підвищених прав. Для цієї функції оновіть обидва ПК.
- Ctrl+F вставляє повний шлях елемента під курсором активної панелі в позицію
курсора командного рядка або замінює виділення. Подвійні лапки додаються
@@ -87,8 +96,15 @@ Far Manager є зразком для оформлення файлового м
для читання. F4 відкриває спільне вікно в режимі редагування. Alt+F3
відкриває файл у програмі, призначеній Windows для цього типу, а Alt+F4
викликає зареєстровану дію Edit. Ці команди також є в меню Commands.
F10 або × закривають головне вікно Files. Файл із панелі LAN треба
спочатку скопіювати локально.
F10 або × закривають головне вікно Files. Вбудовані Viewer та Editor
відкриваються в окремих немодальних вікнах із власними кнопками на панелі
завдань Windows. При повторному відкритті Files пропонує перейти до вже
відкритого вікна. Закриття менеджера залишає вікна файлів відкритими.
- F3 і F4 працюють і на панелі LAN. Файл завантажується у тимчасову копію
на диску у фоновому режимі. Збереження передає зміни назад на віддалений ПК.
Якщо розмір або час зміни віддаленого файла відрізняється від відкритої
копії, Files відхиляє запис і залишає зміни в редакторі. Для F4 оновіть
KSwitcher також на віддаленому ПК. Alt+F3 і Alt+F4 потребують локальної копії.
- Вікно використовує той самий шрифт і розмір, що й файлові панелі,
синю палітру Far за замовчуванням,
рядок стану згори й підказки клавіш унизу. Інформація про файл
Binary file not shown.
+12
View File
@@ -1,5 +1,17 @@
# Latest Windows release
## v0.6.86 — 2026-11-15
The Files editor applies its palette before the text area appears. In a LAN pane, enter a PowerShell command to run it in that pane’s directory on the paired PC. The Files terminal displays output and the exit code. On the remote PC, enable **Allow paired PCs to run PowerShell commands here** in **Files → Options → Command line → Command history**. Both PCs need v0.6.86 or later.
**Українською.** Редактор Files застосовує палітру до появи текстового поля. У панелі LAN введена команда PowerShell виконується в каталозі цієї панелі на спареному ПК. Термінал Files показує вивід і код завершення. На віддаленому ПК увімкніть **Allow paired PCs to run PowerShell commands here** у **Files → Options → Command line → Command history**. На обох ПК потрібен v0.6.86 або новіший.
## v0.6.85 — 2026-11-15
Files viewer and editor windows now run independently with separate Windows taskbar buttons. Reopening a file offers to switch to its existing window. F3 and F4 work in LAN panes. The editor downloads files in the background and checks the remote file before replacing it on save. Update KSwitcher on the remote PC for writeback.
**Українською.** Переглядач і редактор Files працюють в окремих вікнах із власними кнопками на панелі завдань Windows. При повторному відкритті Files пропонує перейти до наявного вікна. F3 і F4 працюють у панелях LAN. Редактор завантажує файл у фоні та перевіряє віддалений файл перед записом. Для збереження оновіть KSwitcher також на віддаленому ПК.
## v0.6.84 — 2026-11-15
KSwitcher Files adds an internal viewer on F3 and editor on F4 with syntax highlighting, paged access to large text files, and shared search history. Alt+F3 and Alt+F4 open external applications. Files Options now includes viewer and editor settings, including tab width and the FAR (blue) color scheme. F7 creates folders and directory links, supports multiple names, and remembers successful inputs. Far-style dialogs share focus, resizing and keyboard behavior.
+1 -1
View File
@@ -1 +1 @@
134d6e543976c8db86b2a80b2cf0b7289eeff7bd2b11510fdafb3b32f2e79342 KSwitcher-win-x64.zip
70deb7c661dc7eb669ced086ebd85458a2c844125992bfa01e9ac7d745329b85 KSwitcher-win-x64.zip
+55 -55
View File
@@ -1,136 +1,136 @@
{
"schema": 1,
"version": "0.6.86",
"files": [
{
"path": "KSwitcher.exe",
"size": 2342912,
"sha256": "cfe137724c1ecdef92de08de366917c9c5da998584e817444ee1d1ccf303299f"
"sha256": "119d6a114c79ebef8461eeaa8f19b30d4f87e5bbe0fdcdea30dbc30d7e33a570",
"size": 2461696
},
{
"path": "KSwitcher.Config.exe",
"size": 2342912,
"sha256": "eb398e0fda3e999c4b3d137b3e8530acaa4ac792247ba4efec60a3f134d0f677"
"sha256": "d943fd063fa5f6e96d31c4300d701426e24798080a778586692117def3cdb4bd",
"size": 2438656
},
{
"path": "KSwitcher.Screenshot.exe",
"size": 3320832,
"sha256": "27d60882ff2ac7b5a689ad576a52f52c3400d497a86e7faf5649e63e27399cce"
"sha256": "d16b8a889225ed61e7f4b8331e4bf7a3354d2a9a8957c375c8bb0e4fd408b555",
"size": 3050496
},
{
"path": "KSwitcher.Files.exe",
"size": 3618816,
"sha256": "ea5bc60f989d7f220c36196c93ac894647f4e74e9833c9abb32211e16b434149"
"sha256": "f23160a531fbf800c512e76c9b171528126a1a8ed1f1f61b241e24ba66d0b8cb",
"size": 3659264
},
{
"path": "WebView2Loader.dll",
"size": 194912,
"sha256": "d3fce90bdeecb08596e377dea6a316354dd8949e32ff09b89d51be00c14e21b6"
"sha256": "d3fce90bdeecb08596e377dea6a316354dd8949e32ff09b89d51be00c14e21b6",
"size": 194912
},
{
"path": "terminal/addon-fit-LICENSE.txt",
"size": 1103,
"sha256": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f"
"sha256": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f",
"size": 1103
},
{
"path": "terminal/addon-fit.js",
"size": 1521,
"sha256": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527"
"sha256": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527",
"size": 1521
},
{
"path": "terminal/index.html",
"size": 527,
"sha256": "084d97a727b778fb10d31adcd557ee7840a38377ed60e0ef0f3ece93b349b1ef"
"sha256": "084d97a727b778fb10d31adcd557ee7840a38377ed60e0ef0f3ece93b349b1ef",
"size": 527
},
{
"path": "terminal/terminal.js",
"size": 4167,
"sha256": "d60e6ff8948b6616695297695b05ef6e74b497a8e32e6031fc2e4fe2cca39ca7"
"sha256": "d60e6ff8948b6616695297695b05ef6e74b497a8e32e6031fc2e4fe2cca39ca7",
"size": 4167
},
{
"path": "terminal/xterm-LICENSE.txt",
"size": 1261,
"sha256": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd"
"sha256": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd",
"size": 1261
},
{
"path": "terminal/xterm.css",
"size": 7112,
"sha256": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6"
"sha256": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6",
"size": 7112
},
{
"path": "terminal/xterm.js",
"size": 488663,
"sha256": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972"
"sha256": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972",
"size": 488663
},
{
"path": "LICENSE",
"size": 4401,
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c",
"size": 4401
},
{
"path": "json-LICENSE.txt",
"size": 1075,
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86",
"size": 1075
},
{
"path": "files-text-license.txt",
"size": 883,
"sha256": "185c5a37521316e940ead89cbf0b1ba5862d810c7c966e6c6a14a4a515b23625"
"sha256": "185c5a37521316e940ead89cbf0b1ba5862d810c7c966e6c6a14a4a515b23625",
"size": 883
},
{
"path": "README.md",
"size": 94223,
"sha256": "7b34f2af7341f57d7bac1b5b7e55a40595a6bc20f461f98c0c6320336d0deba7"
"sha256": "7b34f2af7341f57d7bac1b5b7e55a40595a6bc20f461f98c0c6320336d0deba7",
"size": 94223
},
{
"path": "LAN-PREVIEW.md",
"size": 11418,
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada",
"size": 11418
},
{
"path": "dictionaries/en.txt",
"size": 682867,
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21",
"size": 682867
},
{
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20138,
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5",
"size": 20138
},
{
"path": "dictionaries/README.md",
"size": 2265,
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311"
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311",
"size": 2265
},
{
"path": "dictionaries/ru.txt",
"size": 1060364,
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b",
"size": 1060364
},
{
"path": "dictionaries/sources.json",
"size": 1280,
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408"
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408",
"size": 1280
},
{
"path": "dictionaries/uk.txt",
"size": 940799,
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf"
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf",
"size": 940799
},
{
"path": "tools/README.txt",
"size": 993,
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a",
"size": 993
},
{
"path": "tools/StayHere.exe",
"size": 134656,
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
"sha256": "ddbd6664360c6538c215abddb8c85a3694ca7976af2ecba3d9974d33c5c55187",
"size": 133120
},
{
"path": "tools/StayHere-README.md",
"size": 2796,
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"size": 2796
}
],
"schema": 1,
"version": "0.6.84"
]
}
+3 -3
View File
@@ -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,84,0
PRODUCTVERSION 0,6,84,0
FILEVERSION 0,6,86,0
PRODUCTVERSION 0,6,86,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.84"
VALUE "FileVersion", "0.6.86"
END
END
BLOCK "VarFileInfo"
+1
View File
@@ -114,6 +114,7 @@ BEGIN
CONTROL "", 321, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 27, 110, 15
LTEXT "Open command history", 322, 131, 53, 120, 12
CONTROL "", 323, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 50, 110, 15
AUTOCHECKBOX "Allow &paired PCs to run PowerShell commands here", 370, 131, 79, 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
+15 -2
View File
@@ -64,6 +64,18 @@ public:
HWND window() const { return window_; }
static HWND activeFor(HWND owner)
{
const auto root=GetAncestor(owner,GA_ROOTOWNER);
for(auto dialog=activeFilesDialog;dialog;)
{
if(GetAncestor(dialog,GA_ROOTOWNER)==root) return dialog;
const auto *state=reinterpret_cast<const FilesModalDialog *>(GetPropW(dialog,stateProperty_));
dialog=state?state->previous_:nullptr;
}
return nullptr;
}
static void fitWidthToOwner(HWND dialog,int minimum,void (*layout)(HWND))
{
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
@@ -84,8 +96,9 @@ public:
static bool dismissOwned(HWND owner)
{
for(auto parent=GetWindow(activeFilesDialog,GW_OWNER);parent;parent=GetWindow(parent,GW_OWNER))
if(parent==owner) return PostMessageW(activeFilesDialog,WM_CLOSE,0,0)!=FALSE;
const auto dialog=activeFor(owner);
for(auto parent=GetWindow(dialog,GW_OWNER);parent;parent=GetWindow(parent,GW_OWNER))
if(parent==owner) return PostMessageW(dialog,WM_CLOSE,0,0)!=FALSE;
return false;
}
+120 -19
View File
@@ -6,6 +6,7 @@
#include <commdlg.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <deque>
#include <filesystem>
#include <fstream>
@@ -279,6 +280,10 @@ struct Panel
};
Panel panels[2];
FilesTerminal terminal;
std::mutex remoteOutputMutex;
std::string remoteOutput;
std::atomic<bool> remoteCommandRunning{};
std::jthread remoteCommandThread;
std::ofstream terminalTrace;
unsigned terminalTraceLines{};
bool traceTerminalOutput{};
@@ -808,7 +813,7 @@ void layout(HWND window)
const int detailsRight=active?width:middle-2;
place(status,detailsLeft+8,panelBottom+4,std::max(0,detailsRight-detailsLeft-16),textHeight);
const auto &current=panels[active];
const auto prompt=(current.peer?L"[Remote panel]":current.directory.wstring()+L">");
const auto prompt=(current.peer?current.peer->name+L" · ":L"")+current.directory.wstring()+L">";
SetWindowTextW(terminalPrompt,prompt.c_str());
SIZE promptExtent{static_cast<LONG>(prompt.size())*cellWidth,0};
if(const auto dc=GetDC(window))
@@ -927,9 +932,8 @@ void insertCurrentPanelPath()
reinterpret_cast<LPARAM>(quoted.empty()?path.c_str():quoted.c_str()));
}
bool startTerminal(HWND owner)
bool ensureTerminalView(HWND owner)
{
if(terminal.alive()) return true;
if(!terminalViewRequested)
{
terminalViewRequested=terminalView.create(terminalOutput,[owner](const std::wstring &message)
@@ -998,12 +1002,14 @@ bool startTerminal(HWND owner)
return false;
}
}
if(panels[active].peer)
{
setStatus(L"Select a local panel to start the terminal.");
return false;
}
if(terminal.start(panels[active].directory.c_str(),terminalSize))
return true;
}
bool startTerminal(HWND owner,const fs::path &directory)
{
if(terminal.alive()) return true;
if(!ensureTerminalView(owner)) return false;
if(terminal.start(directory.c_str(),terminalSize))
{
terminalCommandPanels={-1};
terminalDirectory.clear();
@@ -1013,16 +1019,90 @@ bool startTerminal(HWND owner)
return false;
}
bool showCommandOutput(HWND owner,const Panel &panel)
{
return panel.peer?ensureTerminalView(owner):startTerminal(owner,panel.directory);
}
void updateTerminalOutput()
{
if(!terminalView.initialized()) return;
const auto bytes=terminal.takeOutput();
if(!bytes.empty()) terminalView.send(bytes);
std::string incoming;
{
const std::lock_guard lock(remoteOutputMutex);
incoming.swap(remoteOutput);
}
if(!incoming.empty()) terminalView.send(incoming);
}
void appendRemoteOutput(std::string_view output)
{
const std::lock_guard lock(remoteOutputMutex);
for(const auto character:output)
{
const auto byte=static_cast<unsigned char>(character);
if(byte>=32 || character=='\r' || character=='\n' || character=='\t')
remoteOutput+=character;
}
constexpr size_t limit=1024*1024;
if(remoteOutput.size()>limit) remoteOutput.erase(0,remoteOutput.size()-limit);
}
bool submitTerminalCommand(const Panel &panel,const std::wstring &value)
{
if(!startTerminal(GetParent(panel.list))) return false;
const auto owner=GetParent(panel.list);
if(remoteCommandRunning)
{
setStatus(L"A remote command is running. Press Escape to cancel it.");
return false;
}
if(panel.peer)
{
if(value.find_first_not_of(L" \t\r\n")==value.npos) return true;
if(!ensureTerminalView(owner)) return false;
terminalRows=std::max(terminalRows,5);
layout(owner);
const auto peer=*panel.peer;
const auto directory=panel.directory;
appendRemoteOutput(ks::utf8(L"\r\n["+peer.name+L" · "+directory.wstring()+L"]> "+value+L"\r\n"));
remoteCommandRunning=true;
try
{
remoteCommandThread=std::jthread([peer,directory,value](std::stop_token stop)
{
try
{
FilesConnection host(&peer);
const auto code=ks::lan::runRemoteCommand(host,directory,value,appendRemoteOutput,stop);
appendRemoteOutput("\r\n[Remote exit code "+std::to_string(code)+"]\r\n");
}
catch(const std::system_error &error)
{
const auto code=error.code().value();
const auto reason=code==ERROR_ACCESS_DENIED
? L"The remote PC denied this command. Check its Files command-line settings."
: code==ERROR_INVALID_FUNCTION?L"Update KSwitcher on the remote PC."
: code==ERROR_CANCELLED?L"Remote command canceled.":fileError(error);
appendRemoteOutput(ks::utf8(L"\r\n"+std::wstring(reason)+L"\r\n"));
}
catch(const std::exception &)
{
appendRemoteOutput("\r\nThe remote command connection ended.\r\n");
}
remoteCommandRunning=false;
});
}
catch(const std::system_error &error)
{
remoteCommandRunning=false;
setStatus(fileError(error));
return false;
}
return true;
}
if(!startTerminal(owner,panel.directory)) return false;
if(!terminal.write(value+L"\r"))
{
setStatus(L"Could not send PowerShell input.");
@@ -1042,6 +1122,11 @@ LRESULT CALLBACK terminalInputKeys(HWND input,UINT message,WPARAM wp,LPARAM lp,U
if(message==WM_CHAR && wp==VK_TAB && !terminalVisible) return 0;
if(message==WM_CHAR && wp==VK_ESCAPE)
{
if(remoteCommandRunning && !GetWindowTextLengthW(input))
{
remoteCommandThread.request_stop();
return 0;
}
recallCommand(terminalHistory.size());
return 0;
}
@@ -1718,7 +1803,7 @@ void openSelectedText(const Panel &panel, bool edit, bool external)
{
const auto row=currentPanelRow(panel);
if(row==LB_ERR || panel.sizes[row]==L"Folder" || panel.sizes[row]==L"Up") return;
if(panel.peer)
if(panel.peer && external)
{
setStatus(L"Copy the remote file to a local panel before opening it.");
return;
@@ -1729,7 +1814,11 @@ void openSelectedText(const Panel &panel, bool edit, bool external)
edit?L"No editor is registered for this file.":L"Cannot open the selected file.");
else ks::files::openTextWindow(GetParent(panel.list),path,edit,
{confirmTextSave,paintTextDialog,paintSearchDialog,prepareTextDialog,editTextOptions,showTextMessage,readFilesFont,
farDropDownPalette,FilesModalDialog::run,FilesModalDialog::finish,FilesModalDialog::dismissOwned});
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;
}},panel.peer?&*panel.peer:nullptr);
}
void enter(Panel &panel)
@@ -4311,7 +4400,8 @@ bool handlePanelSearchKey(HWND window,MSG &message)
void restoreCommandFocus()
{
if(GetFocus()!=terminalInput && !GetCapture() && !copyInProgress && !activeFilesDialog &&
if(GetFocus()!=terminalInput && !GetCapture() && !copyInProgress &&
!FilesModalDialog::activeFor(GetParent(terminalInput)) &&
!commandHistoryWindow && !IsWindowVisible(menuBar) && !IsWindowVisible(picker))
SetFocus(terminalInput);
}
@@ -4846,7 +4936,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_COMMAND:
if(copyInProgress || commandHistoryWindow || activeFilesDialog) return 0;
if(copyInProgress || commandHistoryWindow || FilesModalDialog::activeFor(window)) return 0;
if(LOWORD(wp)==121 && HIWORD(wp)==LBN_DBLCLK) selectDrive();
if(LOWORD(wp)>=130 && LOWORD(wp)<=134 && HIWORD(wp)==BN_CLICKED)
{
@@ -4871,7 +4961,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
if(!activeCopy && FilesModalDialog::dismissOwned(window)) return 0;
if(activeCopy)
{
if(activeFilesDialog!=activeCopy->window)
if(FilesModalDialog::activeFor(window)!=activeCopy->window)
{
setStatus(L"Resolve the copy prompt before closing.");
return 0;
@@ -4901,7 +4991,12 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
for(auto &panel:panels) panel.watch.reset();
terminalView.close();
terminal.close();
PostQuitMessage(0);
if(remoteCommandThread.joinable())
{
remoteCommandThread.request_stop();
remoteCommandThread.join();
}
ks::files::textWindowOwnerClosed();
return 0;
}
return DefWindowProcW(window, message, wp, lp);
@@ -4965,11 +5060,17 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
{
if(GetForegroundWindow()==window || IsChild(window,GetFocus())) restoreCommandFocus();
if(GetMessageW(&message,nullptr,0,0)<=0) break;
if(!IsWindow(window) || (message.hwnd && GetAncestor(message.hwnd,GA_ROOTOWNER)!=window))
{
TranslateMessage(&message);
DispatchMessageW(&message);
continue;
}
if(message.hwnd==window && message.message>=WM_KEYFIRST && message.message<=WM_KEYLAST &&
GetFocus()==terminalInput)
message.hwnd=terminalInput;
if(!copyInProgress && handlePanelSearchKey(window,message)) continue;
if(!copyInProgress && !commandHistoryWindow && !activeFilesDialog && !terminalVisible &&
if(!copyInProgress && !commandHistoryWindow && !FilesModalDialog::activeFor(window) && !terminalVisible &&
message.message==WM_SYSKEYDOWN && (message.wParam==VK_F3 || message.wParam==VK_F4) &&
(message.lParam & (1LL<<29)) &&
!(GetKeyState(VK_CONTROL)&0x8000) && !(GetKeyState(VK_SHIFT)&0x8000) &&
@@ -4992,7 +5093,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
const auto maximum=std::max(0,static_cast<int>(bounds.bottom)/textHeight-8);
const int rows=std::clamp(terminalRows+(message.wParam==VK_UP?1:-1),0,maximum);
traceTerminal(message.wParam==VK_UP?"key-grow":"key-shrink",terminalRows,rows);
if(rows && !startTerminal(window)) continue;
if(rows && !showCommandOutput(window,panels[active])) continue;
terminalRows=rows;
layout(window);
continue;
@@ -5002,7 +5103,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
(message.hwnd==window || IsChild(window,message.hwnd)) &&
!copyInProgress)
{
if(terminalVisible || startTerminal(window)) showTerminal(window,!terminalVisible);
if(terminalVisible || showCommandOutput(window,panels[active])) showTerminal(window,!terminalVisible);
continue;
}
const bool panelFocused=message.hwnd==panels[0].list || message.hwnd==panels[1].list;
+17 -6
View File
@@ -1,4 +1,5 @@
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286;
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286,
optionsRemoteTerminal=370;
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",
@@ -78,6 +79,7 @@ struct OptionsBaseline
std::array<COLORREF,ColorCount> palette=colors;
std::vector<FileStyle> styles=fileStyles;
std::array<WORD,2> keys=commandHistoryKeys;
bool remoteTerminal=ks::lan::remoteTerminalAllowed();
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};
@@ -151,7 +153,8 @@ void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
checked(202)!=base.extensions || checked(204)!=base.folders || checked(203)!=base.details ||
fields!=base.fields || draft.palette!=base.palette || draft.styles!=base.styles ||
draft.framePadding!=base.padding || optionsFontChanged(draft) ||
optionHistoryKeys(dialog)!=base.keys || draft.text!=base.text;
optionHistoryKeys(dialog)!=base.keys || checked(optionsRemoteTerminal)!=base.remoteTerminal ||
draft.text!=base.text;
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
}
@@ -420,7 +423,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};
constexpr int commandLineControls[]{320,321,322,323,optionsRemoteTerminal};
show(commandLineControls,page==CommandLine && section==0);
show(textControls,textOptionsPage(page));
show(editingControls,page==TextEditor);
@@ -515,7 +518,8 @@ void filterOptions(HWND dialog,OptionsDraft &draft)
addSection(L"File type styles",fileStyleSection,extensionMatches || matches(L"System and hidden") ||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
}
else if(page==CommandLine) addSection(L"Command history",0,matches(L"Previous command"));
else if(page==CommandLine) addSection(L"Command history",0,
matches(L"Previous command") || matches(L"Allow paired PCs to run PowerShell commands here"));
else if(textOptionsPage(page))
{
const bool propertyMatches=matches(L"Tab width") ||
@@ -579,6 +583,7 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
CheckDlgButton(dialog,274,panels[1].wide);
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
setOptionHistoryKeys(dialog,commandHistoryKeys);
CheckDlgButton(dialog,optionsRemoteTerminal,ks::lan::remoteTerminalAllowed());
filterOptions(dialog,draft);
updateOptionsApply(dialog,draft);
return TRUE;
@@ -692,7 +697,11 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
}
return TRUE;
case optionsReset:
if(draft->page==allOptions || draft->page==CommandLine) setOptionHistoryKeys(dialog,defaultHistoryKeys);
if(draft->page==allOptions || draft->page==CommandLine)
{
setOptionHistoryKeys(dialog,defaultHistoryKeys);
CheckDlgButton(dialog,optionsRemoteTerminal,BST_UNCHECKED);
}
if(draft->page==allOptions || draft->page==PanelModes)
for(const auto &property:panelProperties)
if(draft->section<0 || property.section==draft->section) resetPanelProperty(dialog,property);
@@ -731,7 +740,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 273: case 274: case optionsRemoteTerminal:
updateOptionsApply(dialog,*draft);
return TRUE;
case 321: case 323:
@@ -831,6 +840,7 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
const auto inputKeys=optionHistoryKeys(dialog);
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(L"SizeWidth",width,base.width);
saveChanged(L"AlignExtensions",checked(202),base.extensions);
saveChanged(L"AlignFolders",checked(204),base.folders);
@@ -876,6 +886,7 @@ INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
CheckDlgButton(dialog,274,panels[1].wide);
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
setOptionHistoryKeys(dialog,commandHistoryKeys);
CheckDlgButton(dialog,optionsRemoteTerminal,ks::lan::remoteTerminalAllowed());
if(draft->page==PanelColors && draft->section==fileStyleSection)
updateFileStyleEditor(dialog,*draft,true);
showOptionsPage(dialog,*draft,draft->page,draft->section);
+286 -68
View File
@@ -5,8 +5,13 @@
#include "files_text_document.h"
#include "files_text_language.h"
#include "platform.h"
#include "lan_files.h"
#include <commctrl.h>
#include <shellapi.h>
#include <shlobj.h>
#include <propkey.h>
#include <atomic>
#include <windowsx.h>
#include <commdlg.h>
#include <Scintilla.h>
@@ -21,6 +26,7 @@
#include <cwctype>
#include <deque>
#include <future>
#include <map>
#include <stdexcept>
#include <string_view>
#include <utility>
@@ -547,14 +553,90 @@ INT_PTR CALLBACK searchDialog(HWND dialog, UINT message, WPARAM wparam, LPARAM l
return TRUE;
}
struct RemoteTextFile
{
lan::FilesPeer peer;
std::filesystem::path original,cache;
lan::FileRevision revision{};
~RemoteTextFile()
{
if(cache.empty()) return;
std::error_code ignored;
std::filesystem::remove(cache,ignored);
std::filesystem::remove(cache.parent_path(),ignored);
}
static lan::FileRevision stat(lan::FilesConnection &host,const std::filesystem::path &path)
{
const auto reply=host.call(lan::fileRequest("stat",path));
lan::checkFileReply(reply);
return {reply.at("size").get<uint64_t>(),reply.at("modified").get<uint64_t>()};
}
void download(bool edit,const lan::FileProgress &progress)
{
GUID id{};
wchar_t name[40]{};
if(FAILED(CoCreateGuid(&id)) || !StringFromGUID2(id,name,40))
throw std::runtime_error("Cannot create a temporary directory for the LAN file.");
const auto directory=std::filesystem::temp_directory_path()/(L"ks-files-edit-"+std::wstring(name));
if(!std::filesystem::create_directory(directory))
throw std::runtime_error("Cannot create a temporary directory for the LAN file.");
cache=directory/original.filename();
lan::FilesConnection source(&peer),target;
const auto info=source.call(lan::fileRequest("stat",original));
lan::checkFileReply(info);
if(edit && !info.value("editCommit",false))
throw std::runtime_error("Update KSwitcher on the remote computer to edit this file.");
if(edit && (info.at("attributes").get<DWORD>()&FILE_ATTRIBUTE_READONLY))
throw std::runtime_error("The remote file is read-only.");
revision={info.at("size"),info.at("modified")};
try
{
lan::streamFile(source,target,peer.id,original,cache,revision.size,revision.modified,false,false,progress);
}
catch(...)
{
target.call({{"action","abort"}});
throw;
}
}
void upload()
{
lan::FilesConnection source,target(&peer);
const auto local=stat(source,cache);
try { lan::streamFile(source,target,"Files editor",cache,original,local.size,local.modified,true,false,{},&revision); }
catch(const std::system_error &error)
{
if(error.code().value()==ERROR_FILE_INVALID)
throw std::runtime_error("The remote file changed since it was opened. Your edits are still in the editor.");
throw;
}
revision=local;
}
};
class TextWindow;
std::map<HWND,std::shared_ptr<TextWindow>> textWindows;
bool textOwnerClosed=false;
constexpr ULONG_PTR openDocumentQuery=0x4B534644;
std::wstring documentIdentity(const std::filesystem::path &path,std::string_view peer)
{
return wide(std::string(peer))+L"\n"+path.native();
}
class TextWindow
{
public:
TextWindow(const std::filesystem::path &path, bool editable, TextDialogs dialogs)
: document(path, editable), filename(path.filename().wstring()), edit(editable),
prefs(loadTextPreferences().profiles[editable]), filesFont(dialogs.readFont()),
automatic(detectLanguage(path, document.readPage(0))), language(automatic), search{dialogs}
TextWindow(std::filesystem::path path, bool editable, TextDialogs dialogs,const lan::FilesPeer *peer)
: original(std::move(path)), peerId(peer?peer->id:""), filename(original.filename().wstring()),
edit(editable), prefs(loadTextPreferences().profiles[editable]), filesFont(dialogs.readFont()), search{dialogs}
{
if (peer) remote=std::make_unique<RemoteTextFile>(RemoteTextFile{*peer,original});
else document=std::make_unique<TextDocument>(original,edit);
for (int index = 0; index < GetLexerCount(); ++index)
{
char name[128]{};
@@ -569,9 +651,12 @@ public:
static LRESULT CALLBACK procedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam);
HWND window{}, editor{};
bool handleKey(const MSG &message);
const std::filesystem::path original;
const std::string peerId;
private:
TextDocument document;
std::unique_ptr<RemoteTextFile> remote;
std::unique_ptr<TextDocument> document;
std::wstring filename;
bool edit{}, loading{}, stagedDirty{}, closeAfterSave{}, insertRejected{};
TextPreferences prefs;
@@ -592,8 +677,12 @@ private:
intptr_t pendingCaret{};
int pageEol = SC_EOL_CRLF;
size_t undoBytes{};
bool uploadPending{};
std::atomic<bool> cancelOpen{};
std::future<void> opening;
std::future<bool> saving;
static constexpr UINT_PTR saveTimer = 1;
static constexpr UINT_PTR openTimer = 2;
static constexpr UINT trimUndo = WM_APP + 1;
LRESULT send(UINT message, WPARAM wparam = 0, LPARAM lparam = 0) const
@@ -610,10 +699,12 @@ private:
return {reinterpret_cast<const char *>(send(SCI_GETCHARACTERPOINTER)), length};
}
bool currentChanged() const { return edit && !loading && send(SCI_GETMODIFY) && pageText() != cleanPage; }
bool dirty() const { return stagedDirty || currentChanged(); }
bool dirty() const { return stagedDirty || (!busy() && uploadPending) || currentChanged(); }
bool busy() const { return saving.valid(); }
void invalidate() const { InvalidateRect(window, nullptr, FALSE); }
void initializeDocument();
void finishOpen();
void createMenu();
void releaseMenu();
void reloadPreferences();
@@ -982,13 +1073,13 @@ void TextWindow::applyLineEnding()
void TextWindow::loadPage(uint64_t index)
{
auto data = document.readPage(index);
const auto offset = document.boundary(index);
auto data = document->readPage(index);
const auto offset = document->boundary(index);
page = index;
pageOffset = offset;
pageTotal = document.pages();
fileSize = document.size();
stagedDirty = document.modified();
pageTotal = document->pages();
fileSize = document->size();
stagedDirty = document->modified();
const auto eol = data.find_first_of("\r\n");
pageEol = eol == data.npos ? SC_EOL_CRLF : data[eol] == '\n' ? SC_EOL_LF :
eol + 1 < data.size() && data[eol + 1] == '\n' ? SC_EOL_CRLF : SC_EOL_CR;
@@ -1017,9 +1108,9 @@ void TextWindow::stagePage()
{
if (!currentChanged()) return;
auto text = std::string(pageText());
document.stage(page, text);
document->stage(page, text);
cleanPage = std::move(text);
stagedDirty = document.modified();
stagedDirty = document->modified();
send(SCI_SETSAVEPOINT);
}
@@ -1030,11 +1121,39 @@ void TextWindow::go(uint64_t index)
loadPage(index);
}
void TextWindow::initializeDocument()
{
automatic=detectLanguage(original,document->readPage(0));
language=automatic;
createMenu();
installLexer();
loadPage(0);
}
void TextWindow::finishOpen()
{
if(!opening.valid() || opening.wait_for(std::chrono::milliseconds(0))!=std::future_status::ready) return;
KillTimer(window,openTimer);
try
{
opening.get();
if(cancelOpen) { DestroyWindow(window); return; }
document=std::make_unique<TextDocument>(remote->cache,edit);
initializeDocument();
invalidate();
}
catch(const std::exception &error)
{
if(!cancelOpen) search.style.message(window,wide(error.what()).c_str());
DestroyWindow(window);
}
}
void TextWindow::saveFile(bool closeWhenDone)
{
if (busy() || !edit) return;
stagePage();
if (!stagedDirty)
if (!stagedDirty && !uploadPending)
{
if (closeWhenDone) DestroyWindow(window);
return;
@@ -1043,7 +1162,20 @@ void TextWindow::saveFile(bool closeWhenDone)
closeAfterSave = closeWhenDone;
if (!SetTimer(window, saveTimer, 75, nullptr)) throw std::runtime_error("Cannot start the save timer.");
send(SCI_SETREADONLY, TRUE);
try { saving = std::async(std::launch::async, [this] { return document.save(); }); }
try
{
saving=std::async(std::launch::async,[this]
{
const auto reopened=document->save();
if(remote)
{
uploadPending=true;
remote->upload();
uploadPending=false;
}
return reopened;
});
}
catch (...)
{
KillTimer(window, saveTimer);
@@ -1067,20 +1199,21 @@ void TextWindow::finishSave()
return;
}
if (closeAfterSave) { DestroyWindow(window); return; }
if (pageTotal == 1 && document.pages() == 1)
if (pageTotal == 1 && document->pages() == 1)
{
fileSize = document.size();
fileSize = document->size();
invalidate();
return;
}
loadPage(std::min(page, document.pages() - 1));
loadPage(std::min(page, document->pages() - 1));
send(SCI_GOTOPOS, std::min<LRESULT>(pendingCaret, send(SCI_GETLENGTH)));
}
void TextWindow::requestClose()
{
if (opening.valid()) { cancelOpen=true; return; }
if (busy()) return;
if (currentChanged() ? document.modifiedWith(page, pageText()) : stagedDirty)
if (uploadPending || (currentChanged() ? document->modifiedWith(page, pageText()) : stagedDirty))
{
const auto answer = search.style.confirmSave(window);
if (answer == IDCANCEL) return;
@@ -1107,8 +1240,10 @@ void TextWindow::paint()
RECT client{};
GetClientRect(window, &client);
const auto row = chromeHeight();
SetDCBrushColor(dc, color(TextColor::chromebg));
const auto brush = static_cast<HBRUSH>(GetStockObject(DC_BRUSH));
SetDCBrushColor(dc, color(TextColor::background));
FillRect(dc, &client, brush);
SetDCBrushColor(dc, color(TextColor::chromebg));
RECT menuBar{0, 0, client.right, row};
RECT top{0, row, client.right, 2 * row};
RECT bottom{0, client.bottom - row, client.right, client.bottom};
@@ -1146,7 +1281,7 @@ void TextWindow::paint()
const auto column = send(SCI_GETCOLUMN, position) + 1;
const auto displayLanguage = textLanguageLabel(language);
auto status = filename + (edit ? L" (Edit)" : L" (View)") + (dirty() ? L" *" : L"") +
L" │ " + document.encodingName() + L" │ " + displayLanguage +
L" │ " + (document?document->encodingName():L"Loading…") + L" │ " + displayLanguage +
L" │ Ln " + std::to_wstring(line) + L":" + std::to_wstring(column);
if (pageTotal > 1)
status += L" │ Page " + std::to_wstring(page + 1) + L"/" + std::to_wstring(pageTotal) +
@@ -1207,6 +1342,7 @@ void TextWindow::find(bool down, DWORD flags)
void TextWindow::command(UINT id)
{
if(!document && id!=close) return;
if (busy()) return;
switch (id)
{
@@ -1252,6 +1388,7 @@ void TextWindow::command(UINT id)
void TextWindow::notify(const SCNotification &notice)
{
if(!document) return;
if (loading) return;
switch (notice.nmhdr.code)
{
@@ -1334,15 +1471,16 @@ bool TextWindow::handleKey(const MSG &message)
LRESULT CALLBACK TextWindow::procedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
{
if (message == WM_NCCREATE)
if(message==WM_NCCREATE)
{
const auto create = reinterpret_cast<CREATESTRUCTW *>(lparam);
const auto self = static_cast<TextWindow *>(create->lpCreateParams);
self->window = window;
SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
const auto create=reinterpret_cast<const CREATESTRUCTW *>(lparam);
const auto &state=*static_cast<const std::shared_ptr<TextWindow> *>(create->lpCreateParams);
state->window=window;
textWindows.emplace(window,state);
}
const auto self = reinterpret_cast<TextWindow *>(GetWindowLongPtrW(window, GWLP_USERDATA));
if (!self) return DefWindowProcW(window, message, wparam, lparam);
const auto found=textWindows.find(window);
if(found==textWindows.end()) return DefWindowProcW(window,message,wparam,lparam);
const auto self=found->second;
try
{
if (message == preferencesChanged)
@@ -1352,17 +1490,49 @@ LRESULT CALLBACK TextWindow::procedure(HWND window, UINT message, WPARAM wparam,
}
switch (message)
{
case WM_COPYDATA:
{
if(!lparam) return FALSE;
const auto &query=*reinterpret_cast<const COPYDATASTRUCT *>(lparam);
if(query.dwData!=openDocumentQuery || !query.lpData || query.cbData>65536 ||
query.cbData<sizeof(wchar_t) || query.cbData%sizeof(wchar_t)) return FALSE;
const std::wstring_view candidate(static_cast<const wchar_t *>(query.lpData),query.cbData/sizeof(wchar_t));
const auto identity=documentIdentity(self->original,self->peerId);
return CompareStringOrdinal(candidate.data(),static_cast<int>(candidate.size()),
identity.data(),static_cast<int>(identity.size()),TRUE)==CSTR_EQUAL;
}
case WM_CREATE:
self->editor = CreateWindowExW(0, L"Scintilla", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP |
self->editor = CreateWindowExW(0, L"Scintilla", L"", WS_CHILD | WS_TABSTOP |
WS_VSCROLL | WS_HSCROLL, 0, 0, 0, 0, window, nullptr, GetModuleHandleW(nullptr), nullptr);
if (!self->editor) throw std::runtime_error("Cannot create the text control.");
self->send(SCI_SETCODEPAGE, SC_CP_UTF8);
self->send(SCI_SETMARGINS, 1);
self->send(SCI_SETMODEVENTMASK, SC_MOD_INSERTCHECK | SC_MOD_INSERTTEXT | SC_MOD_DELETETEXT);
self->createMenu();
self->installLexer();
self->loadPage(0);
SetWindowSubclass(self->editor,[](HWND child,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR data)
->LRESULT
{
auto *state=reinterpret_cast<TextWindow *>(data);
if(msg==WM_KEYDOWN || msg==WM_SYSKEYDOWN)
{
MSG key{child,msg,wp,lp};
if(state->handleKey(key)) return 0;
}
return DefSubclassProc(child,msg,wp,lp);
},1,reinterpret_cast<DWORD_PTR>(self.get()));
if(self->remote)
{
self->applyPreferences();
self->send(SCI_SETREADONLY,TRUE);
if(!SetTimer(window,openTimer,75,nullptr))
throw std::runtime_error("Cannot start the LAN file timer.");
self->opening=std::async(std::launch::async,[state=self.get()]
{
state->remote->download(state->edit,[state](uint64_t,uint64_t){return !state->cancelOpen;});
});
}
else self->initializeDocument();
self->layout();
ShowWindow(self->editor,SW_SHOWNA);
return 0;
case WM_ERASEBKGND: return 1;
case WM_PAINT: self->paint(); return 0;
@@ -1405,6 +1575,7 @@ LRESULT CALLBACK TextWindow::procedure(HWND window, UINT message, WPARAM wparam,
return 0;
case WM_TIMER:
if (wparam == saveTimer) self->finishSave();
if (wparam == openTimer) self->finishOpen();
return 0;
case trimUndo:
if (self->undoBytes >= 32 * TextDocument::pageBytes)
@@ -1417,8 +1588,12 @@ LRESULT CALLBACK TextWindow::procedure(HWND window, UINT message, WPARAM wparam,
return 0;
case WM_DESTROY:
self->releaseMenu();
self->window = nullptr;
return 0;
case WM_NCDESTROY:
self->window=nullptr;
textWindows.erase(window);
if(textOwnerClosed && textWindows.empty()) PostQuitMessage(0);
break;
}
}
catch (const std::exception &error)
@@ -1530,50 +1705,93 @@ void showFarHistory(HWND dialog,int fieldId,const std::vector<FarHistoryEntry> &
showHistoryPopup(dialog,fieldId,history,palette);
}
void openTextWindow(HWND owner, const std::filesystem::path &path, bool edit, TextDialogs dialogs)
void textWindowOwnerClosed()
{
textOwnerClosed=true;
if(textWindows.empty()) PostQuitMessage(0);
}
HWND findOpenDocument(const std::filesystem::path &path,const lan::FilesPeer *peer)
{
const auto identity=documentIdentity(path,peer?peer->id:"");
struct Search
{
COPYDATASTRUCT query;
HWND match{};
} search{{openDocumentQuery,static_cast<DWORD>(identity.size()*sizeof(wchar_t)),
const_cast<wchar_t *>(identity.data())}};
EnumWindows([](HWND window,LPARAM parameter)->BOOL
{
wchar_t name[40]{};
if(!GetClassNameW(window,name,40) || lstrcmpW(name,windowClass)!=0) return TRUE;
auto &search=*reinterpret_cast<Search *>(parameter);
DWORD_PTR matched{};
if(SendMessageTimeoutW(window,WM_COPYDATA,0,reinterpret_cast<LPARAM>(&search.query),
SMTO_ABORTIFHUNG|SMTO_BLOCK,200,&matched) && matched)
{
search.match=window;
return FALSE;
}
return TRUE;
},reinterpret_cast<LPARAM>(&search));
return search.match;
}
void openTextWindow(HWND owner,const std::filesystem::path &path,bool edit,TextDialogs dialogs,
const lan::FilesPeer *peer)
{
try
{
const auto instance = GetModuleHandleW(nullptr);
static const bool registered = [instance] {
std::error_code error;
const auto identity=peer?path.lexically_normal():std::filesystem::weakly_canonical(path,error);
const auto &original=error?path:identity;
if(const auto existing=findOpenDocument(original,peer))
{
if(dialogs.confirmExisting(owner) && IsWindow(existing))
{
if(IsIconic(existing)) ShowWindowAsync(existing,SW_RESTORE);
SetForegroundWindow(existing);
}
return;
}
const auto instance=GetModuleHandleW(nullptr);
static const bool registered=[instance]
{
WNDCLASSW type{};
type.hInstance = instance;
type.hCursor = LoadCursorW(nullptr, IDC_ARROW);
type.hIcon = LoadIconW(instance, MAKEINTRESOURCEW(1));
type.lpfnWndProc = TextWindow::procedure;
type.lpszClassName = windowClass;
type.hInstance=instance;
type.hCursor=LoadCursorW(nullptr,IDC_ARROW);
type.hIcon=LoadIconW(instance,MAKEINTRESOURCEW(1));
type.lpfnWndProc=TextWindow::procedure;
type.lpszClassName=windowClass;
return Scintilla_RegisterClasses(instance) && RegisterClassW(&type);
}();
if (!registered) throw std::runtime_error("Cannot register the text window.");
TextWindow state(path, edit, dialogs);
const auto title = std::wstring(edit ? L"Editor — " : L"Viewer — ") + path.wstring();
if(!registered) throw std::runtime_error("Cannot register the text window.");
auto state=std::make_shared<TextWindow>(original,edit,dialogs,peer);
const auto title=std::wstring(edit?L"Editor — ":L"Viewer — ")+
(peer?peer->name+L" · ":std::wstring{})+path.wstring();
RECT bounds{};
GetWindowRect(owner, &bounds);
const auto window = CreateWindowExW(0, windowClass, title.c_str(), WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN,
bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top, owner, nullptr, instance, &state);
if (!window) return;
const auto focus = GetFocus();
EnableWindow(owner, FALSE);
ShowWindow(window, IsZoomed(owner) ? SW_MAXIMIZE : SW_SHOW);
SetFocus(state.editor);
MSG message{};
while (state.window)
GetWindowRect(owner,&bounds);
const auto window=CreateWindowExW(WS_EX_APPWINDOW,windowClass,title.c_str(),
WS_OVERLAPPEDWINDOW|WS_CLIPCHILDREN,bounds.left,bounds.top,bounds.right-bounds.left,
bounds.bottom-bounds.top,nullptr,nullptr,instance,&state);
if(!window) return;
IPropertyStore *properties{};
if(SUCCEEDED(SHGetPropertyStoreForWindow(window,IID_PPV_ARGS(&properties))))
{
const auto result = GetMessageW(&message, nullptr, 0, 0);
if (result <= 0)
{
if (result == 0) PostQuitMessage(static_cast<int>(message.wParam));
DestroyWindow(window);
break;
}
if (state.handleKey(message)) continue;
TranslateMessage(&message);
DispatchMessageW(&message);
static unsigned sequence{};
auto identity=L"HrynCo.KSwitcher.Files.Text."+std::to_wstring(GetCurrentProcessId())+
L"."+std::to_wstring(++sequence);
PROPVARIANT value{};
value.vt=VT_LPWSTR;
value.pwszVal=identity.data();
properties->SetValue(PKEY_AppUserModel_ID,value);
properties->Commit();
properties->Release();
}
EnableWindow(owner, TRUE);
SetActiveWindow(owner);
if (IsWindow(focus)) SetFocus(focus);
ShowWindow(window,IsZoomed(owner)?SW_MAXIMIZE:SW_SHOW);
SetFocus(state->editor);
}
catch (const std::exception &error) { dialogs.message(owner, wide(error.what()).c_str()); }
catch(const std::exception &error){dialogs.message(owner,wide(error.what()).c_str());}
}
}
+6 -1
View File
@@ -4,6 +4,8 @@
#include <filesystem>
#include <vector>
namespace ks::lan { struct FilesPeer; }
namespace ks::files
{
inline constexpr int searchOptionsDivider = 104;
@@ -45,7 +47,10 @@ struct TextDialogs
INT_PTR (*run)(HINSTANCE,LPCWSTR,HWND,DLGPROC,LPARAM);
void (*finish)(HWND,INT_PTR);
bool (*dismissOwned)(HWND);
bool (*confirmExisting)(HWND);
};
void openTextWindow(HWND owner, const std::filesystem::path &path, bool edit, TextDialogs dialogs);
void openTextWindow(HWND owner,const std::filesystem::path &path,bool edit,TextDialogs dialogs,
const lan::FilesPeer *peer=nullptr);
void textWindowOwnerClosed();
}
+1 -1
View File
@@ -295,7 +295,7 @@ 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);
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);
+199 -9
View File
@@ -5,7 +5,10 @@
#include <winioctl.h>
#include <atomic>
#include <bcrypt.h>
#include <wincrypt.h>
#include <sddl.h>
#include <cstring>
#include <mutex>
#include <thread>
#include "lan_files.h"
#include "lan_channel.h"
@@ -30,6 +33,13 @@ void checkFileReply(const Json &reply)
if(error) throw std::system_error(static_cast<int>(error),std::system_category());
}
bool remoteTerminalAllowed()
{
DWORD allowed{},size=sizeof(allowed);
return RegGetValueW(HKEY_CURRENT_USER,L"Software\\KSwitcher\\Files",remoteTerminalPreference,
RRF_RT_REG_DWORD,nullptr,&allowed,&size)==ERROR_SUCCESS && allowed==1;
}
bool recycleFile(HWND owner,const fs::path &path)
{
const auto initialized=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
@@ -222,6 +232,122 @@ struct FileHandle
~FileHandle(){close();}
};
struct RemoteCommand
{
FileHandle job,process,output;
void stop()
{
job.close();
process.close();
output.close();
}
DWORD start(const fs::path &directory,const std::wstring &command)
{
if(process.value!=INVALID_HANDLE_VALUE) return ERROR_BUSY;
if(command.empty() || command.size()>12000 || command.find(L'\0')!=command.npos) return ERROR_INVALID_PARAMETER;
const auto script=L"[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); "
L"$OutputEncoding=[Console]::OutputEncoding;\r\n"+command;
const auto data=reinterpret_cast<const BYTE *>(script.data());
const auto bytes=static_cast<DWORD>(script.size()*sizeof(wchar_t));
constexpr DWORD encoding=CRYPT_STRING_BASE64|CRYPT_STRING_NOCRLF;
DWORD length{};
if(!CryptBinaryToStringW(data,bytes,encoding,nullptr,&length)) return GetLastError();
std::wstring encoded(length,L'\0');
if(!CryptBinaryToStringW(data,bytes,encoding,encoded.data(),&length)) return GetLastError();
encoded.resize(length);
auto arguments=L"powershell.exe -NoLogo -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand "+encoded;
wchar_t system[MAX_PATH]{};
if(!GetSystemDirectoryW(system,MAX_PATH)) return GetLastError();
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
FileHandle token,restricted,read,write,input,processJob;
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY|TOKEN_DUPLICATE|TOKEN_ASSIGN_PRIMARY|
TOKEN_ADJUST_DEFAULT,&token.value) ||
!CreateRestrictedToken(token.value,DISABLE_MAX_PRIVILEGE|LUA_TOKEN,0,nullptr,0,nullptr,0,nullptr,
&restricted.value)) return GetLastError();
PSID medium{};
if(!ConvertStringSidToSidW(L"S-1-16-8192",&medium)) return GetLastError();
TOKEN_MANDATORY_LABEL label{{medium,SE_GROUP_INTEGRITY}};
const auto limited=SetTokenInformation(restricted.value,TokenIntegrityLevel,&label,
sizeof(label)+GetLengthSid(medium));
const auto tokenError=GetLastError();
LocalFree(medium);
if(!limited) return tokenError;
SECURITY_ATTRIBUTES inheritable{sizeof(inheritable),nullptr,TRUE};
if(!CreatePipe(&read.value,&write.value,&inheritable,0) ||
!SetHandleInformation(read.value,HANDLE_FLAG_INHERIT,0)) return GetLastError();
input.value=CreateFileW(L"NUL",GENERIC_READ,FILE_SHARE_READ|FILE_SHARE_WRITE,&inheritable,
OPEN_EXISTING,0,nullptr);
if(input.value==INVALID_HANDLE_VALUE) return GetLastError();
const auto createdJob=CreateJobObjectW(nullptr,nullptr);
if(!createdJob) return GetLastError();
processJob.value=createdJob;
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags=JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if(!SetInformationJobObject(processJob.value,JobObjectExtendedLimitInformation,&limits,sizeof(limits)))
return GetLastError();
SIZE_T attributeBytes{};
InitializeProcThreadAttributeList(nullptr,1,0,&attributeBytes);
std::vector<BYTE> storage(attributeBytes);
const auto attributes=reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
if(!InitializeProcThreadAttributeList(attributes,1,0,&attributeBytes)) return GetLastError();
HANDLE inherited[]{input.value,write.value};
STARTUPINFOEXW startup{};
startup.StartupInfo.cb=sizeof(startup);
startup.StartupInfo.dwFlags=STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput=input.value;
startup.StartupInfo.hStdOutput=startup.StartupInfo.hStdError=write.value;
startup.lpAttributeList=attributes;
PROCESS_INFORMATION child{};
const auto started=UpdateProcThreadAttribute(attributes,0,PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
inherited,sizeof(inherited),nullptr,nullptr) &&
CreateProcessAsUserW(restricted.value,shell.c_str(),arguments.data(),nullptr,nullptr,TRUE,
CREATE_SUSPENDED|CREATE_NO_WINDOW|EXTENDED_STARTUPINFO_PRESENT,nullptr,
directory.c_str(),&startup.StartupInfo,&child);
const auto processError=GetLastError();
DeleteProcThreadAttributeList(attributes);
if(!started) return processError;
FileHandle thread{child.hThread},childProcess{child.hProcess};
if(!AssignProcessToJobObject(processJob.value,childProcess.value) || ResumeThread(thread.value)==DWORD(-1))
{
const auto error=GetLastError();
TerminateProcess(childProcess.value,error);
return error;
}
job.value=std::exchange(processJob.value,INVALID_HANDLE_VALUE);
process.value=std::exchange(childProcess.value,INVALID_HANDLE_VALUE);
output.value=std::exchange(read.value,INVALID_HANDLE_VALUE);
return ERROR_SUCCESS;
}
Json poll()
{
if(process.value==INVALID_HANDLE_VALUE) return failure(ERROR_INVALID_HANDLE);
const auto wait=WaitForSingleObject(process.value,0);
if(wait==WAIT_FAILED) return failure();
DWORD available{};
if(!PeekNamedPipe(output.value,nullptr,0,nullptr,&available,nullptr) && GetLastError()!=ERROR_BROKEN_PIPE)
return failure();
Bytes bytes(std::min<DWORD>(available,FileChunk));
DWORD read{};
if(!bytes.empty() && !ReadFile(output.value,bytes.data(),static_cast<DWORD>(bytes.size()),&read,nullptr))
return failure();
bytes.resize(read);
const bool done=wait==WAIT_OBJECT_0 && bytes.empty();
DWORD exitCode{};
if(done)
{
if(!GetExitCodeProcess(process.value,&exitCode)) return failure();
stop();
}
return {{"error",0},{"data",Json::binary(std::move(bytes))},{"done",done},{"exitCode",exitCode}};
}
};
fs::path temporaryFile(const fs::path &target)
{
return target.parent_path()/wide(".kswitcher-"+randomHex(16)+".tmp");
@@ -271,6 +397,8 @@ std::string hashPrefix(HANDLE file,uint64_t size,const FileProgress &progress,ui
class FileOperations
{
FilesListener::TerminalPermission terminalPermission;
RemoteCommand terminal;
bool remote;
HANDLE search=INVALID_HANDLE_VALUE;
WIN32_FIND_DATAW entry{};
@@ -425,7 +553,8 @@ class FileOperations
return reply;
}
public:
explicit FileOperations(bool remote):remote(remote){}
explicit FileOperations(bool remote,FilesListener::TerminalPermission permission={}):
terminalPermission(std::move(permission)),remote(remote){}
~FileOperations(){closeSearch();discard();}
Json call(const Json &request,const FileProgress &progress)
{
@@ -439,6 +568,18 @@ public:
Json perform(const Json &request,const FileProgress &progress)
{
const auto action=request.at("action").get<std::string>();
if(action=="terminalRun" || action=="terminalPoll" || action=="terminalCancel")
{
if(!remote || !terminalPermission || !terminalPermission())
{
terminal.stop();
return failure(ERROR_ACCESS_DENIED);
}
if(action=="terminalCancel") {terminal.stop();return ok();}
if(action=="terminalPoll") return terminal.poll();
const auto error=terminal.start(path(request.at("path")),wide(request.at("command").get<std::string>()));
return error?failure(error):ok();
}
if(action=="drives")
{
auto reply=ok();
@@ -489,7 +630,7 @@ public:
discard(true);
return failure(error?error:ERROR_WRITE_FAULT);
}
if(action=="commit")
if(action=="commit" || action=="commitEdit")
{
if(output.value==INVALID_HANDLE_VALUE) return failure(ERROR_INVALID_HANDLE);
LARGE_INTEGER size{};
@@ -499,6 +640,26 @@ public:
const FILETIME time{static_cast<DWORD>(stamp),static_cast<DWORD>(stamp>>32)};
if(!SetFileTime(output.value,nullptr,nullptr,&time) || !FlushFileBuffers(output.value)) return failure();
output.close();
static std::mutex publicationMutex;
const std::lock_guard publication(publicationMutex);
if(action=="commitEdit")
{
WIN32_FILE_ATTRIBUTE_DATA existing{};
if(!GetFileAttributesExW(target.c_str(),GetFileExInfoStandard,&existing) ||
(existing.dwFileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT|FILE_ATTRIBUTE_READONLY)) ||
fileSize(existing.nFileSizeHigh,existing.nFileSizeLow)!=request.at("expectedSize").get<uint64_t>() ||
fileSize(existing.ftLastWriteTime.dwHighDateTime,existing.ftLastWriteTime.dwLowDateTime)!=
request.at("expectedModified").get<uint64_t>())
{
discard(true);
return failure(ERROR_FILE_INVALID);
}
const auto attributes=FILE_ATTRIBUTE_ARCHIVE|
(existing.dwFileAttributes&(FILE_ATTRIBUTE_HIDDEN|FILE_ATTRIBUTE_SYSTEM|
FILE_ATTRIBUTE_NOT_CONTENT_INDEXED));
if(!SetFileAttributesW(temporary.c_str(),attributes)) return failure();
return commit(target,true);
}
const auto reply=commit(target,request.at("replace").get<bool>());
if(reply.at("error")==ERROR_SUCCESS) SetFileAttributesW(target.c_str(),FILE_ATTRIBUTE_ARCHIVE);
return reply;
@@ -517,7 +678,8 @@ public:
{
WIN32_FILE_ATTRIBUTE_DATA data{};
if(!GetFileAttributesExW(name.c_str(),GetFileExInfoStandard,&data)) return failure();
return {{"error",0},{"attributes",data.dwFileAttributes},{"size",fileSize(data.nFileSizeHigh,data.nFileSizeLow)},
return {{"error",0},{"attributes",data.dwFileAttributes},{"editCommit",true},
{"size",fileSize(data.nFileSizeHigh,data.nFileSizeLow)},
{"modified",fileSize(data.ftLastWriteTime.dwHighDateTime,data.ftLastWriteTime.dwLowDateTime)}};
}
if(action=="canonical")
@@ -721,7 +883,7 @@ Json FilesConnection::call(const Json &request,const FileProgress &progress)
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
bool replace,bool append,const FileProgress &progress)
bool replace,bool append,const FileProgress &progress,const FileRevision *expectedDestination)
{
auto openSource=fileRequest("openRead",from);
openSource["size"]=size;
@@ -759,7 +921,13 @@ void streamFile(FilesConnection &source,FilesConnection &target,const std::strin
if(data.empty())
{
if(done!=size) throw std::system_error(ERROR_FILE_INVALID,std::system_category());
checkFileReply(target.call({{"action","commit"},{"replace",replace},{"modified",modified}}));
Json commit{{"action",expectedDestination?"commitEdit":"commit"},{"replace",replace},{"modified",modified}};
if(expectedDestination)
{
commit["expectedSize"]=expectedDestination->size;
commit["expectedModified"]=expectedDestination->modified;
}
checkFileReply(target.call(commit));
checkFileReply(source.call({{"action","closeRead"}}));
return;
}
@@ -777,6 +945,26 @@ void streamFile(FilesConnection &source,FilesConnection &target,const std::strin
}
}
DWORD runRemoteCommand(FilesConnection &host,const fs::path &directory,std::wstring_view command,
const std::function<void(std::string_view)> &output,std::stop_token stop)
{
if(stop.stop_requested()) throw std::system_error(ERROR_CANCELLED,std::system_category());
auto request=fileRequest("terminalRun",directory);
request["command"]=utf8(std::wstring(command));
checkFileReply(host.call(request));
while(!stop.stop_requested())
{
const auto reply=host.call({{"action","terminalPoll"}});
checkFileReply(reply);
const auto &bytes=reply.at("data").get_binary();
if(!bytes.empty()) output({reinterpret_cast<const char *>(bytes.data()),bytes.size()});
if(reply.at("done").get<bool>()) return reply.at("exitCode").get<DWORD>();
if(bytes.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(60));
}
checkFileReply(host.call({{"action","terminalCancel"}}));
throw std::system_error(ERROR_CANCELLED,std::system_category());
}
struct FilesListener::Impl
{
struct Client
@@ -792,10 +980,12 @@ struct FilesListener::Impl
};
std::string local;
PairKey key;
TerminalPermission terminalPermission;
WinSocket listener;
std::vector<std::unique_ptr<Client>> clients;
uint16_t boundPort{};
Impl(std::string id,PairKey lookup,uint16_t port):local(std::move(id)),key(std::move(lookup))
Impl(std::string id,PairKey lookup,uint16_t port,TerminalPermission permission):
local(std::move(id)),key(std::move(lookup)),terminalPermission(std::move(permission))
{
listener.value=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(listener.value==INVALID_SOCKET) return;
@@ -830,7 +1020,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);
FileOperations operations(true,terminalPermission);
for(;;)
{
const auto request=wire.read(true);
@@ -871,8 +1061,8 @@ struct FilesListener::Impl
}
};
FilesListener::FilesListener(std::string local,PairKey key,uint16_t port):
impl(std::make_unique<Impl>(std::move(local),std::move(key),port)){}
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()=default;
uint16_t FilesListener::port()const{return impl->boundPort;}
void FilesListener::poll(){impl->poll();}
+9 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "lan_pairing.h"
#include <functional>
#include <stop_token>
namespace ks::lan
{
@@ -13,6 +14,7 @@ struct FilesPeer
bool filesBrowse{};
};
using FileProgress = std::function<bool(uint64_t, uint64_t)>;
struct FileRevision { uint64_t size, modified; };
inline constexpr size_t FileChunk = 64 * 1024;
// One connection owns its enumeration, file handles and uncommitted upload.
@@ -29,7 +31,9 @@ public:
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
bool replace,bool append,const FileProgress &progress);
bool replace,bool append,const FileProgress &progress,const FileRevision *expectedDestination=nullptr);
DWORD runRemoteCommand(FilesConnection &host,const std::filesystem::path &directory,std::wstring_view command,
const std::function<void(std::string_view)> &output,std::stop_token stop={});
class FilesListener
{
@@ -37,7 +41,8 @@ class FilesListener
std::unique_ptr<Impl> impl;
public:
using PairKey = std::function<std::string(const std::string &)>;
FilesListener(std::string local, PairKey key,uint16_t port=FilesPort);
using TerminalPermission = std::function<bool()>;
FilesListener(std::string local, PairKey key,uint16_t port=FilesPort,TerminalPermission terminalPermission={});
uint16_t port() const;
~FilesListener();
void poll();
@@ -47,4 +52,6 @@ Json fileRequest(std::string_view action, const std::filesystem::path &path);
std::filesystem::path filePath(const Json &path);
void checkFileReply(const Json &reply);
bool recycleFile(HWND owner, const std::filesystem::path &path);
inline constexpr wchar_t remoteTerminalPreference[]=L"AllowRemoteTerminal";
bool remoteTerminalAllowed();
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.84";
inline constexpr wchar_t AppVersion[] = L"0.6.86";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+76 -2
View File
@@ -21,6 +21,33 @@ public static class SearchArrowTest {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out Rect rect);
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hwnd);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr hwnd,uint command);
[DllImport("user32.dll",EntryPoint="GetWindowLongPtrW")] public static extern IntPtr GetWindowStyle(IntPtr hwnd,int index);
[StructLayout(LayoutKind.Sequential)]
struct PropertyKey { public Guid Format; public uint Id; }
[StructLayout(LayoutKind.Explicit, Size=24)]
struct PropertyValue {
[FieldOffset(0)] public ushort Type;
[FieldOffset(8)] public IntPtr String;
}
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface WindowProperties {
[PreserveSig] int GetCount(out uint count);
[PreserveSig] int GetAt(uint index, out PropertyKey key);
[PreserveSig] int GetValue(ref PropertyKey key, out PropertyValue value);
}
[DllImport("shell32.dll")] static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out WindowProperties properties);
[DllImport("ole32.dll")] static extern int PropVariantClear(ref PropertyValue value);
public static string TaskbarIdentity(IntPtr window) {
var iid = typeof(WindowProperties).GUID;
if (SHGetPropertyStoreForWindow(window, ref iid, out var properties) < 0) return null;
try {
var key = new PropertyKey { Format = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), Id = 5 };
if (properties.GetValue(ref key, out var value) < 0) return null;
try { return value.Type == 31 ? Marshal.PtrToStringUni(value.String) : null; }
finally { PropVariantClear(ref value); }
} finally { Marshal.ReleaseComObject(properties); }
}
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr hwnd);
[StructLayout(LayoutKind.Sequential)]
struct GuiThreadInfo {
@@ -50,12 +77,12 @@ function Wait-Until($Block) {
}
throw "Timeout: $Block"
}
function Find-Window($Name, $ProcessId, [int]$ControlId=0) {
function Find-Window($Name, $ProcessId, [int]$ControlId=0, [IntPtr]$Except=[IntPtr]::Zero) {
$wnd = [IntPtr]::Zero
while (($wnd = [SearchArrowTest]::FindWindowEx(0, $wnd, $Name, [System.Management.Automation.Language.NullString]::Value)) -ne [IntPtr]::Zero) {
[uint32]$ownerProcess = 0
[void][SearchArrowTest]::GetWindowThreadProcessId($wnd, [ref]$ownerProcess)
if ($ownerProcess -eq $ProcessId -and [SearchArrowTest]::IsWindowVisible($wnd) -and
if ($ownerProcess -eq $ProcessId -and $wnd -ne $Except -and [SearchArrowTest]::IsWindowVisible($wnd) -and
(!$ControlId -or [SearchArrowTest]::GetDlgItem($wnd,$ControlId) -ne [IntPtr]::Zero)) { return $wnd }
}
}
@@ -432,10 +459,47 @@ try {
Post $panel 0x100 0x73
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
$firstControl=[SearchArrowTest]::FindWindowEx($editor,0,'Scintilla',[System.Management.Automation.Language.NullString]::Value)
if ($firstControl -eq [IntPtr]::Zero -or
[SearchArrowTest]::Send($firstControl,2482,32,0).ToInt64() -eq 0xFFFFFF) {
throw 'The editor is visible with the default white Scintilla background.'
}
$originalFocus=Wait-Until {
$focus=[SearchArrowTest]::FocusedControl($editor)
if ($focus -ne [IntPtr]::Zero) { $focus }
}
if (![SearchArrowTest]::IsWindowEnabled($main) -or [SearchArrowTest]::GetWindow($editor,4) -ne [IntPtr]::Zero -or
([SearchArrowTest]::GetWindowStyle($editor,-20).ToInt64() -band 0x40000) -eq 0) {
throw 'The editor is not an independent taskbar window.'
}
Post $panel 0x100 0x73
$existing=Wait-Until { Find-Window '#32770' $process.Id 100 }
$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.' }
Post $existing 0x111 6
[void](Wait-Until { ![SearchArrowTest]::IsWindow($existing) })
if (Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor) { throw 'Duplicate editor opened.' }
$otherFile=Join-Path $scratch 'other-editor.cs'
[IO.File]::WriteAllText($otherFile,'class Other { }')
[void](Wait-Until {
[SearchArrowTest]::SendText($panel,0x1A2,-1,'other-editor.cs').ToInt64() -ge 0
})
Select-Row $panel 'other-editor.cs'
Post $panel 0x100 0x73
$otherEditor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor }
if ([SearchArrowTest]::GetWindow($otherEditor,4) -ne [IntPtr]::Zero) { throw 'The second editor is owned.' }
$editorIdentity=[SearchArrowTest]::TaskbarIdentity($editor)
$otherIdentity=[SearchArrowTest]::TaskbarIdentity($otherEditor)
if (!$editorIdentity -or !$otherIdentity -or $editorIdentity -eq $otherIdentity) {
throw 'Editor windows do not have distinct Windows taskbar identities.'
}
Post $otherEditor 0x10 0
[void](Wait-Until { ![SearchArrowTest]::IsWindow($otherEditor) })
Select-Row $panel ([IO.Path]::GetFileName($fixture))
Post $editor 7 0
[void](Wait-Until { [SearchArrowTest]::FocusedControl($editor) -eq $originalFocus })
Write-Output 'Verified independent editor taskbar styling and the already-open prompt.'
foreach ($action in 'Escape','outside click','owner close') {
$dialog=Open-Search
$field=[SearchArrowTest]::GetDlgItem($dialog,100)
@@ -443,6 +507,11 @@ try {
if (![SearchArrowTest]::IsWindowEnabled($editor)) { throw 'Search disabled the editor frame.' }
switch ($action) {
'Escape' {
Post $main 0x111 115
$managerPrompt=Wait-Until { Find-Window '#32770' $process.Id 361 }
Post $managerPrompt 0x111 2
[void](Wait-Until { ![SearchArrowTest]::IsWindow($managerPrompt) })
if (![SearchArrowTest]::IsWindow($dialog)) { throw 'The manager dismissed the editor Search dialog.' }
Assert-MovesWithOwner $editor $dialog
$ownerBounds=Read-Bounds $editor
$dialogBounds=Read-Bounds $dialog
@@ -550,6 +619,11 @@ try {
Write-Output 'Verified outside-click cancellation in Copy and Viewer Search.'
Write-Output 'Verified that Copy and Search move and resize with their owners.'
Post $main 0x10 0
[void](Wait-Until { ![SearchArrowTest]::IsWindow($main) })
if (![SearchArrowTest]::IsWindow($editor)) { throw 'Closing the manager also closed the viewer.' }
Post $editor 0x10 0
if (!$process.WaitForExit(5000)) { throw 'Files did not exit after its last window closed.' }
}
finally {
if (!$process.HasExited) {
+52 -1
View File
@@ -2,6 +2,7 @@
#include <sddl.h>
#include "lan_files.h"
#include <bit>
#include <atomic>
#include <fstream>
#include <iostream>
#include <thread>
@@ -113,7 +114,8 @@ int main()
fs::create_directory(root);
const auto client=randomHex(16),first=randomHex(16),second=randomHex(16),key=randomHex(32);
auto lookup=[&](const std::string &id){return id==client?key:std::string{};};
FilesListener a(first,lookup,0),b(second,lookup,0);
std::atomic<bool> allowTerminal{};
FilesListener a(first,lookup,0,[&]{return allowTerminal.load();}),b(second,lookup,0);
require(a.port() && b.port() && a.port()!=b.port(),"test listeners unavailable");
std::jthread polling([&](std::stop_token stop)
{
@@ -131,6 +133,55 @@ int main()
}
catch(const std::system_error &){}
FilesConnection host(one,client,key);
auto deniedCommand=fileRequest("terminalRun",root);
deniedCommand["command"]="Write-Output denied";
require(host.call(deniedCommand).at("error")==ERROR_ACCESS_DENIED,"remote command allowed without consent");
allowTerminal=true;
std::string console;
const auto exit=runRemoteCommand(host,root,
L"[Console]::Out.WriteLine((Get-Location).ProviderPath); "
L"[Console]::Error.WriteLine('Тест stderr'); exit 7",
[&](std::string_view part){console+=part;});
require(exit==7 && console.find(utf8(root.native()))!=console.npos &&
console.find(utf8(L"Тест stderr"))!=console.npos,"remote command output or exit code differs");
std::stop_source cancel;
try
{
runRemoteCommand(host,root,L"[Console]::Out.WriteLine('Ready'); Start-Sleep -Seconds 30",
[&](std::string_view){cancel.request_stop();},cancel.get_token());
require(false,"remote command cancellation did not stop execution");
}
catch(const std::system_error &error)
{
require(error.code().value()==ERROR_CANCELLED,"remote terminal returned the wrong cancellation error");
}
allowTerminal=false;
require(host.call(deniedCommand).at("error")==ERROR_ACCESS_DENIED,"remote command allowed after revocation");
std::cout<<"Verified remote PowerShell consent, working directory, streamed output, exit code and cancellation.\n";
const auto edited=root/L"edited.txt", serverFile=root/L"server.txt", snapshot=root/L"snapshot.txt";
save(serverFile,"original remote content");
const auto initial=host.call(fileRequest("stat",serverFile));
checkFileReply(initial);
const FileRevision base{initial.at("size"),initial.at("modified")};
FilesConnection local;
streamFile(host,local,one.id,serverFile,snapshot,base.size,base.modified,false,false,{});
require(load(snapshot)=="original remote content","remote editor snapshot is incorrect");
save(edited,"editor writeback");
const auto changed=local.call(fileRequest("stat",edited));
checkFileReply(changed);
streamFile(local,host,"editor",edited,serverFile,changed.at("size"),changed.at("modified"),true,false,{},&base);
require(load(serverFile)=="editor writeback","remote edit did not replace the original");
save(serverFile,"an external replacement of the remote file");
try
{
streamFile(local,host,"editor",edited,serverFile,changed.at("size"),changed.at("modified"),true,false,{},&base);
require(false,"stale editor upload succeeded");
}
catch(const std::system_error &error)
{
require(error.code().value()==ERROR_FILE_INVALID,"stale editor returned the wrong error");
}
require(load(serverFile)=="an external replacement of the remote file","stale upload overwrote external edits");
const auto drives=host.call({{"action","drives"}});
checkFileReply(drives);
require(drives.at("drives").size()==static_cast<size_t>(std::popcount(GetLogicalDrives())),"drive mask differs");