Release v0.2.1: support remote input, safe updates and high-DPI windows

This commit is contained in:
2026-10-28 11:02:15 +02:00
parent cf18a0a3c5
commit 1093bb5b2e
19 changed files with 994 additions and 112 deletions
+144 -58
View File
@@ -256,6 +256,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
}
App::~App()
{
if (updateWorker.joinable())
updateWorker.join();
if (keyboardHook)
UnhookWindowsHookEx(keyboardHook);
if (mouseHook)
@@ -413,23 +415,15 @@ void App::resetInput()
run.reset();
last.reset();
}
void App::keyboard(const KBDLLHOOKSTRUCT &k)
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{
if (k.flags & LLKHF_INJECTED)
if (ownKeyboardInput(k))
return;
DWORD vk = k.vkCode;
if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU ||
vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
return;
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
std::lock_guard lock(stateMutex);
if (isHotkey(vk, mods))
return;
@@ -448,7 +442,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
return;
}
bool shift = (mods & MOD_SHIFT) != 0;
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift);
// VK_PACKET carries UTF-16 in scanCode, not a physical key position.
DWORD scan = vk == VK_PACKET ? 0 : k.scanCode;
if (!scan && vk != VK_PACKET)
scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout);
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift);
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
if (!ch)
@@ -492,85 +490,83 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
}
}
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
{
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) &&
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
if (code == HC_ACTION && current && current->hotkeyCapture &&
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
// Filter before capture, hotkey latching and typing history. External injected
// down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate)
return false;
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
if (!modifier)
{
if (wp == WM_KEYUP || wp == WM_SYSKEYUP)
current->hotkeyHook.update(key.vkCode, false, 0, {});
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
if (!down)
hotkeyHook.update(key.vkCode, false, 0, {});
if (down)
{
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
if (mods & MOD_WIN)
chord(0xE8);
PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
}
return 1; // Record the chord without executing a global shortcut or typing into an editor.
}
}
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
return CallNextHookEx(nullptr, code, wp, lp);
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
if (down)
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings);
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
if (action > 0)
{
// Tell the shell that Win participated in a chord, preventing Start
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
if (mods & (MOD_WIN | MOD_ALT))
chord(0xE8);
PostMessageW(current->window, WM_HOTKEY, action, 0);
PostMessageW(window, WM_HOTKEY, action, 0);
}
if (action != 0)
return 1;
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
keyboard(key, mods);
}
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
return true;
return false;
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current &&
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
{
// The current event has not updated asynchronous state yet; the preceding
// modifier events (including MWB input) have. Own events must still reach Windows.
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN;
if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp,
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
return 1;
}
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0)
return 1;
return CallNextHookEx(nullptr, code, wp, lp);
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
auto *e = (MSLLHOOKSTRUCT *)lp;
if (!(e->flags & LLMHF_INJECTED) &&
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
current->resetInput();
}
return CallNextHookEx(nullptr, code, wp, lp);
}
void App::dispatch(int command)
{
if (installingUpdate)
return;
try
{
if (command == ShowHistory)
@@ -602,6 +598,7 @@ void App::dispatch(int command)
}
void App::transform(int command, int snippet)
{
if (installingUpdate) return;
const auto attempt = ++transformAttempt;
const auto requestedAt = GetTickCount64();
HWND target = GetForegroundWindow();
@@ -957,6 +954,7 @@ void App::transform(int command, int snippet)
}
void App::pasteEntry(std::wstring id, bool plain)
{
if (installingUpdate) return;
if (!history || busy.exchange(true))
return;
HWND target = pasteTarget;
@@ -1095,6 +1093,9 @@ void App::trayMenu()
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
AppendMenuW(menu, MF_STRING, 18, L"View log…");
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
updateRunning ? L"Update (checking / preparing…)" :
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
AppendMenuW(menu, MF_STRING, 17, L"Exit");
@@ -1133,7 +1134,7 @@ void App::trayMenu()
break;
case 16:
MessageBoxW(window,
L"KSwitcher 0.2.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
L"KSwitcher 0.2.1\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
L"account.\nNo .NET, WebView or graphics framework.",
L"About KSwitcher", MB_OK);
@@ -1144,6 +1145,9 @@ void App::trayMenu()
case 18:
showLogs(*this);
break;
case 19:
checkUpdate(true);
break;
case 20:
pauseUntil = 0;
break;
@@ -1163,6 +1167,27 @@ void App::trayMenu()
report(wide(e.what()));
}
}
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{
if (updateRunning) return;
if (install && busy)
{
report(L"Wait for the current text operation to finish, then select Update again.");
return;
}
if (updateWorker.joinable()) updateWorker.join();
updateRunning = true;
installingUpdate = install;
updateWorker = std::thread([this, interactive, install, version]
{
auto result = std::make_unique<UpdateResult>();
try { *result = runUpdate(install, version); }
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
result->interactive = interactive;
result->install = install;
if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
});
}
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
auto *app = current;
@@ -1175,6 +1200,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
switch (msg)
{
case UpdateMessage:
{
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
if (app->updateWorker.joinable()) app->updateWorker.join();
app->updateRunning = false;
app->installingUpdate = false;
if (result->state == L"prepared")
{
// The helper has verified and staged the package; it waits for process exit.
DestroyWindow(hwnd);
}
else if (result->state == L"available")
{
app->availableUpdate = result->version;
if (result->interactive)
{
auto prompt = L"Install KSwitcher " + result->version +
L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved.";
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
app->checkUpdate(true, true, result->version);
}
else
{
NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
auto text = result->version + L" is available. Select Update in the tray menu to install.";
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &icon);
}
}
else if (result->state == L"current")
{
app->availableUpdate.clear();
if (result->interactive)
MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(),
L"KSwitcher Update", MB_OK | MB_ICONINFORMATION);
}
else if (result->interactive)
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
else
diagnosticEvent(L"Startup update check failed: " + result->error);
return 0;
}
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
@@ -1192,10 +1261,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->captureHistory();
return 0;
case AutoCorrectMessage:
if (app->installingUpdate) return 0;
app->pendingAutoVersion = (uint64_t)wp;
SetTimer(hwnd, 3, 250, nullptr);
return 0;
case SnippetMessage:
if (app->installingUpdate) return 0;
app->transform(0, (int)wp);
return 0;
case HistoryMessage:
@@ -1227,6 +1298,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_TIMER:
if (wp == 5)
{
KillTimer(hwnd, 5);
app->checkUpdate(false);
return 0;
}
if (wp == 4)
{
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
@@ -1238,7 +1315,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (wp == 3)
{
KillTimer(hwnd, 3);
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
@@ -1255,6 +1332,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
return 0;
case WM_CLOSE:
if (app->updateRunning)
{
app->report(L"Wait for the update check or preparation to finish before exiting.");
return 0;
}
if (app->busy)
{
app->report(L"Wait for the current text operation to finish before exiting.");
@@ -1324,6 +1406,10 @@ int App::execute()
report(registrationWarning);
if (smoke)
SetTimer(window, 2, 10000, nullptr);
else
SetTimer(window, 5, 5000, nullptr);
if (keyboardHook && mouseHook)
confirmUpdatedStartup(updateStartupEvent);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
MSG msg;
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "autocorrect.h"
#include "storage.h"
#include "update.h"
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -49,6 +50,7 @@ class App
std::wstring registrationWarning;
bool smoke{};
int startCommand{};
std::wstring updateStartupEvent;
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
~App();
int execute();
@@ -65,16 +67,22 @@ class App
void applySettings(const Settings &);
void reloadLayouts();
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
void resetInput();
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
static App *current;
private:
friend struct InputRegressionAccess;
HHOOK keyboardHook{}, mouseHook{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate;
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
@@ -90,7 +98,7 @@ class App
};
void showHistory(App &);
void refreshHistory(App &);
void showSettings(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
void showSnippets(App &);
void showShortcuts(App &);
+3 -1
View File
@@ -67,7 +67,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
try
{
ks::App app(instance, arg == L"--smoke-test");
if (!arg.empty() && arg != L"--smoke-test")
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
+21 -12
View File
@@ -347,15 +347,30 @@ size_t Clip::size() const
n += f.data.size();
return n;
}
ULONG_PTR inputMarker()
{
static const char marker{};
return reinterpret_cast<ULONG_PTR>(&marker);
}
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &key)
{
return (key.flags & LLKHF_INJECTED) && key.dwExtraInfo == inputMarker();
}
INPUT keyboardInput(WORD key, DWORD flags)
{
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.wVk = key;
input.ki.dwFlags = flags;
input.ki.dwExtraInfo = inputMarker();
return input;
}
bool chord(WORD key, UINT mods)
{
std::vector<INPUT> inputs;
auto add = [&](WORD k, bool up)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = k;
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
@@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
std::vector<INPUT> inputs;
auto add = [&](WORD key, DWORD flags)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = key;
i.ki.dwFlags = flags;
INPUT i = keyboardInput(key, flags);
inputs.push_back(i);
};
add(VK_SHIFT, 0);
@@ -437,10 +449,7 @@ void releaseModifiers()
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
if (GetAsyncKeyState(k) & 0x8000)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = (WORD)k;
i.ki.dwFlags = KEYEVENTF_KEYUP;
INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP);
inputs.push_back(i);
}
if (!inputs.empty())
+4
View File
@@ -36,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
DWORD writeClipboard(HWND, const Clip &);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
ULONG_PTR inputMarker();
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
INPUT keyboardInput(WORD key, DWORD flags = 0);
bool needsSelectionDelay(HWND);
bool selectPrevious(size_t count, const std::function<bool()> &valid);
bool modifiersReleased();
+173 -8
View File
@@ -2,6 +2,7 @@
#include "app.h"
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
#include <algorithm>
#include <commctrl.h>
#include <commdlg.h>
@@ -26,6 +27,129 @@ HFONT font(HWND w)
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
L"Segoe UI");
}
struct WindowLayout
{
int width{}, height{}, x{}, y{};
bool scrolling{}, arranging{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
};
WindowLayout *windowLayoutState(HWND w)
{
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
}
RECT workArea(RECT rect)
{
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST), &monitor);
return monitor.rcWork;
}
void fitToMonitor(HWND w, RECT rect)
{
rect = fitWindowRect(rect, workArea(rect));
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
void arrangeWindow(HWND w)
{
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling || layout->arranging) return;
layout->arranging = true;
// Showing either scrollbar can make the other necessary. Let the client size settle.
for (int pass = 0; pass < 3; ++pass)
{
RECT client{};
GetClientRect(w, &client);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
for (const auto &[child, r] : layout->controls)
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
layout->arranging = false;
InvalidateRect(w, nullptr, TRUE);
}
void revealControl(HWND child)
{
HWND parent = GetParent(child);
auto *layout = windowLayoutState(parent);
if (!layout || !layout->scrolling) return;
RECT r{}, client{};
GetWindowRect(child, &r);
MapWindowPoints(nullptr, parent, reinterpret_cast<POINT *>(&r), 2);
GetClientRect(parent, &client);
if (r.left < 0) layout->x += r.left;
else if (r.right > client.right) layout->x += r.right - client.right;
if (r.top < 0) layout->y += r.top;
else if (r.bottom > client.bottom) layout->y += r.bottom - client.bottom;
arrangeWindow(parent);
}
LRESULT CALLBACK scrollChildProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
if (msg == WM_SETFOCUS) revealControl(w);
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, scrollChildProc, 2);
return DefSubclassProc(w, msg, wp, lp);
}
void resizeForDpi(HWND, WPARAM, LPARAM);
LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
auto *layout = windowLayoutState(w);
if (!layout) return DefSubclassProc(w, msg, wp, lp);
if (msg == WM_DPICHANGED)
{
resizeForDpi(w, wp, lp);
return 0;
}
if (msg == WM_SIZE) arrangeWindow(w);
if (msg == WM_DISPLAYCHANGE || msg == WM_SETTINGCHANGE || (msg == WM_SHOWWINDOW && wp))
{
RECT rect{};
GetWindowRect(w, &rect);
fitToMonitor(w, rect);
arrangeWindow(w);
}
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
{
bool vertical = msg == WM_VSCROLL;
SCROLLINFO info{sizeof(info), SIF_ALL};
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
int &position = vertical ? layout->y : layout->x;
switch (LOWORD(wp))
{
case SB_LINEUP: position -= scale(w, 32); break;
case SB_LINEDOWN: position += scale(w, 32); break;
case SB_PAGEUP: position -= (int)info.nPage; break;
case SB_PAGEDOWN: position += (int)info.nPage; break;
case SB_TOP: position = 0; break;
case SB_BOTTOM: position = info.nMax; break;
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
}
arrangeWindow(w);
return 0;
}
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
{
bool horizontal = msg == WM_MOUSEHWHEEL || (GET_KEYSTATE_WPARAM(wp) & MK_SHIFT);
int &remainder = horizontal ? layout->horizontalWheelRemainder : layout->wheelRemainder;
remainder += GET_WHEEL_DELTA_WPARAM(wp);
int steps = remainder / WHEEL_DELTA;
remainder %= WHEEL_DELTA;
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
arrangeWindow(w);
return 0;
}
if (msg == WM_NCDESTROY)
{
RemovePropW(w, L"WindowLayout");
RemoveWindowSubclass(w, layoutProc, 2);
delete layout;
}
return DefSubclassProc(w, msg, wp, lp);
}
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
int width, int height)
{
@@ -34,6 +158,12 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
GetModuleHandleW(nullptr), nullptr);
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
{
layout->controls.push_back({w, {x, y, x + width, y + height}});
SetWindowSubclass(w, scrollChildProc, 2, 0);
arrangeWindow(parent);
}
return w;
}
void label(HWND w, const wchar_t *text, int x, int y, int width = 200)
@@ -136,13 +266,31 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RegisterClassW(&wc);
UINT dpi = GetDpiForSystem();
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_THICKFRAME | WS_MAXIMIZEBOX | extra;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
POINT cursor{};
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
HWND w =
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, CW_USEDEFAULT, CW_USEDEFAULT,
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
if (!w)
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
// The system DPI may still describe the old primary display. The HWND tells
// us the actual destination DPI, and must size the frame AND its controls.
dpi = GetDpiForWindow(w);
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
SetWindowSubclass(w, layoutProc, 2, 0);
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
SWP_NOACTIVATE | SWP_NOZORDER);
arrangeWindow(w);
SetWindowTextW(w, title);
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
@@ -154,6 +302,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
}
void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
{
// Creation may send WM_DPICHANGED before controls and the original font exist.
if (!GetPropW(w, L"Dpi"))
{
fitToMonitor(w, *(RECT *)lp);
return;
}
int previous = (int)(UINT_PTR)GetPropW(w, L"Dpi");
int next = LOWORD(wp);
if (!previous)
@@ -166,7 +320,8 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
int previous, next;
HFONT font;
} state{w, previous, next, replacement};
EnumChildWindows(
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling) EnumChildWindows(
w,
[](HWND child, LPARAM data) -> BOOL
{
@@ -183,12 +338,19 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
return TRUE;
},
(LPARAM)&state);
else
{
layout->x = MulDiv(layout->x, next, previous);
layout->y = MulDiv(layout->y, next, previous);
for (auto &[child, rect] : layout->controls)
SendMessageW(child, WM_SETFONT, (WPARAM)replacement, TRUE);
}
SetPropW(w, L"Font", replacement);
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
if (old)
DeleteObject(old);
auto *r = (RECT *)lp;
SetWindowPos(w, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER);
fitToMonitor(w, *(RECT *)lp);
arrangeWindow(w);
}
std::filesystem::path pickFile(HWND owner, bool save)
{
@@ -1447,7 +1609,7 @@ void showLogs(App &app)
showRequestedWindow(w);
activateWindow(w);
}
void showSettings(App &app)
void showSettings(App &app, bool visible)
{
if (app.settingsWindow)
{
@@ -1505,8 +1667,11 @@ void showSettings(App &app)
button(w, L"Save", IDOK, 548, 606, 100);
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
showRequestedWindow(w);
activateWindow(w);
if (visible)
{
showRequestedWindow(w);
activateWindow(w);
}
}
void showSnippets(App &app)
{
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "core.h"
#include <algorithm>
namespace ks
{
inline RECT fitWindowRect(RECT desired, RECT work)
{
LONG width = std::clamp<LONG>(desired.right - desired.left, 1, std::max<LONG>(1, work.right - work.left));
LONG height = std::clamp<LONG>(desired.bottom - desired.top, 1, std::max<LONG>(1, work.bottom - work.top));
LONG x = std::clamp(desired.left, work.left, work.right - width);
LONG y = std::clamp(desired.top, work.top, work.bottom - height);
return {x, y, x + width, y + height};
}
}
+103
View File
@@ -0,0 +1,103 @@
#include "update.h"
#include "json.hpp"
namespace ks
{
namespace
{
struct Handle
{
HANDLE value{};
~Handle() { if (value) CloseHandle(value); }
};
std::wstring quoteArgument(const std::wstring &value)
{
// Windows argv escaping, including a path ending with a backslash.
std::wstring out = L"\"";
size_t slashes = 0;
for (auto ch : value)
{
if (ch == L'\\') { ++slashes; continue; }
out.append(ch == L'"' ? slashes * 2 + 1 : slashes, L'\\');
slashes = 0;
out += ch;
}
out.append(slashes * 2, L'\\');
return out + L'"';
}
}
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
{
UpdateResult result;
GUID guid{};
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create update identifier");
wchar_t id[40]{};
StringFromGUID2(guid, id, 40);
auto work = std::filesystem::temp_directory_path() / (L"KSwitcher-update-" + std::wstring(id));
std::filesystem::create_directory(work);
bool detached = false;
Handle child;
try
{
HRSRC resource = FindResourceW(nullptr, MAKEINTRESOURCEW(201), RT_RCDATA);
HGLOBAL loaded = resource ? LoadResource(nullptr, resource) : nullptr;
auto bytes = loaded ? static_cast<const unsigned char *>(LockResource(loaded)) : nullptr;
if (!bytes) throw std::runtime_error("The updater resource is missing");
atomicWrite(work / L"update.ps1", Bytes(bytes, bytes + SizeofResource(nullptr, resource)));
std::wstring readyName = L"Local\\KSwitcher.Update.Ready." + std::wstring(id);
Handle ready{CreateEventW(nullptr, TRUE, FALSE, readyName.c_str())};
if (!ready.value) throw std::runtime_error("Cannot create updater handshake");
wchar_t system[MAX_PATH]{};
if (!GetSystemDirectoryW(system, MAX_PATH)) throw std::runtime_error("Cannot locate Windows PowerShell");
auto powershell = std::filesystem::path(system) / L"WindowsPowerShell/v1.0/powershell.exe";
std::wstring command = quoteArgument(powershell.wstring()) + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " +
quoteArgument((work / L"update.ps1").wstring()) + L" -Mode " + (install ? L"Install" : L"Check") +
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);
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, work.c_str(), &startup, &process))
throw std::runtime_error("Cannot start Windows PowerShell updater");
child.value = process.hProcess;
Handle thread{process.hThread};
HANDLE waits[]{ready.value, child.value};
DWORD waited = WaitForMultipleObjects(2, waits, FALSE, 240000);
if (waited != WAIT_OBJECT_0 && waited != WAIT_OBJECT_0 + 1)
{
// The helper cannot replace files while this process is alive.
TerminateProcess(child.value, 1);
WaitForSingleObject(child.value, 5000);
throw std::runtime_error("Update preparation timed out; the installed application was not changed");
}
auto data = readFile(work / L"result.json", 64 * 1024);
auto json = nlohmann::json::parse(data.begin(), data.end());
result.state = wide(json.at("state").get<std::string>());
result.version = wide(json.value("version", std::string{}));
result.error = wide(json.value("error", std::string{}));
detached = install && waited == WAIT_OBJECT_0 && result.state == L"prepared";
if (result.state == L"prepared" && !detached)
throw std::runtime_error("Update helper exited before installation");
if (!detached) std::filesystem::remove_all(work);
return result;
}
catch (...)
{
if (!detached && child.value && WaitForSingleObject(child.value, 0) == WAIT_TIMEOUT)
{
TerminateProcess(child.value, 1);
WaitForSingleObject(child.value, 5000);
}
if (!detached) { std::error_code ignored; std::filesystem::remove_all(work, ignored); }
throw;
}
}
void confirmUpdatedStartup(const std::wstring &eventName)
{
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
if (event.value) SetEvent(event.value);
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.2.1";
inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult
{
std::wstring state, version, error;
bool interactive{}, install{};
};
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
void confirmUpdatedStartup(const std::wstring &eventName);
}