Implement native Windows KSwitcher with configurable hotkeys and clipboard history

This commit is contained in:
2026-10-27 22:14:06 +02:00
commit f683e4178c
37 changed files with 31288 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
BasedOnStyle: Microsoft
IndentWidth: 4
ColumnLimit: 110
AllowShortFunctionsOnASingleLine: Empty
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
BreakBeforeBraces: Allman
+8
View File
@@ -0,0 +1,8 @@
/build/
/.tools/
/data/
/.vs/
/.idea/
/dist/
/build-output.txt
*.user
+33
View File
@@ -0,0 +1,33 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.1.0 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
add_executable(KSwitcherOcrTests tests/ocr.cpp)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
else()
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests)
install(TARGETS KSwitcher RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bitcodepro
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+146
View File
@@ -0,0 +1,146 @@
# KSwitcher
Native C++20 / Win32 rewrite of the Windows functionality of Typlo. Windows 10/11 x64.
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
The original Typlo project and its data remain separate.
## Run
Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the executable.
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
Double-click the tray icon to open Settings, the bold default menu item.
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
the task runs in the signed-in user's interactive session, including on battery power.
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
future automatic launches. Saving unrelated settings does not recreate startup entries.
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
Exit Typlo when using overlapping shortcuts to avoid competing keyboard hooks.
### Change a shortcut
1. Open **Settings** and click a shortcut field.
2. Press the desired combination. The field records the actual keys; no typing of names is needed.
3. Click **Save**. Duplicate shortcuts within KSwitcher and protected Windows combinations are reported.
This also works for each layout's **Direct layout shortcut**. Backspace or Delete without modifiers
clears a shortcut. Tab moves to the next field. The recorded shortcut is suppressed while the field
has focus, so recording the conversion shortcut does not perform a conversion.
All configured shortcuts use the existing keyboard hook and can replace ordinary Windows/app
shortcuts, including **Win+V** and **Win+Shift+S**, while KSwitcher is running. Only exact configured
combinations are intercepted. Unassigned shortcuts and simulated copy/paste pass through.
Choose another shortcut or exit KSwitcher to restore the original action. Windows protects
Ctrl+Alt+Delete and Win+L; these cannot be assigned.
## Implemented
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
If the editor's word boundary differs from the recorded typing, it falls back to paced character
selection: Teams can coalesce a burst of Shift+Left events into just one character.
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in Typlo.
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
full-text search, pinning, deletion, full text viewing and image previews.
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
load thumbnails; the 32-entry thumbnail cache is bounded to about 3.3 MiB and released on close.
- Text rows render RTF and basic clipboard HTML emphasis (bold, italic, underline, strike-through),
paragraphs, line breaks and list bullets using native Windows RichEdit. Previews are bounded to
4096 characters / 64 KiB RTF, with at most 32 cached text previews; original paste formats are unchanged.
HTML is parsed as text, without a browser, scripts or external resources. Complex web layouts and
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
metadata (including Snipping Tool's repeated clipboard publication).
- Windows OCR, entirely native and offline. Right-click an image in history and select
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
- History exclusion flags and excluded processes, pause/resume, count/size limits.
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
`--snippets`, `--exit` commands. `--convert` cycles layouts.
Default shortcuts match Typlo: Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
Pressing the history shortcut a second time pastes the second-most-recent item.
## Data and migration
Data is in `data/` beside the executable, falling back to `%LOCALAPPDATA%/KSwitcher` when
the executable's directory is not writable. Each clipboard payload is an encrypted disk file;
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
the history popup is hidden. The log contains operational messages, never copied text or keystrokes.
Settings **Import** accepts a Typlo `settings.json`, preserving supported Windows layout IDs,
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing Typlo files are
never modified. Typlo's encrypted SQLite history is **not migrated** to the new DPAPI file format.
DPAPI data belongs to the Windows account/machine; copying the portable folder is not a cross-account backup.
## Differences still to port
This is a working native implementation, not a claim of complete UI/release parity:
- UI currently uses English and the native Windows control appearance. Typlo's ten-language UI and
explicit light/dark theme selector are not yet ported.
- Automatic release download/replacement is not enabled. KSwitcher has no configured release feed;
using Typlo's feed would install the wrong application. Updates are manual.
- No import of old encrypted clipboard history. Export/import applies to settings.
- The clipboard popup is fixed-size with per-monitor DPI scaling; PNG/DIB previews use Windows WIC/GDI.
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
## Build
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
```powershell
.\setup-toolchain.ps1
.\build.ps1 -Test
.\package.ps1
```
`setup-toolchain.ps1` downloads the compiler from its upstream GitHub release and verifies SHA-256.
`.tools/`, build objects, test executables and compiler headers are development-only. **Do not distribute
the entire source/build folder.** The package contains just the application and notices/documentation.
The vendored JSON header is nlohmann/json 3.12.0 (MIT); it is compiled into the executable.
Alternatively open this directory as a CMake project in Visual Studio with the C++ desktop workload
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
verified local build uses GCC/w64devkit.
## Verification
```powershell
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
```
The interactive test requires installed English-US and Russian layouts. It opens its own editor,
tests real copy/paste, undo, repeat conversion, recent-input verification and snippet cursor placement,
then restores the clipboard. Test data is stored in its own temporary directory. It never sends messages
to Teams or other user applications. Do not run it while typing elsewhere.
`KSwitcher.exe --smoke-test` starts a normal tray instance and exits after ten seconds for idle-memory checks.
It still uses the normal instance lock; close a running KSwitcher first if measuring a fresh process.
## Structure
- `core.*`: conversion, transliteration, snippets, shortcut parsing/formatting, recent-input model.
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
- `storage.*`: settings and encrypted, disk-backed history.
- `app.*`: message loop, hooks, background work and guarded text transactions.
- `ui.cpp`: native windows and shortcut recording.
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
- `tests/`: regression checks, real editor integration and OCR fixture.
MIT licensed; preserves the original Typlo attribution in `LICENSE`.
+46
View File
@@ -0,0 +1,46 @@
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$compiler = Join-Path $root '.tools\w64devkit\bin\g++.exe'
if (!(Test-Path $compiler)) {
$compiler = (Get-Command g++ -ErrorAction Stop).Source
}
$env:PATH = (Split-Path $compiler) + ';' + $env:PATH
$outDir = Join-Path $root 'build'
New-Item -ItemType Directory -Force $outDir | Out-Null
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
Push-Location $root
try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject')
foreach ($name in @('core','platform','autostart','storage','ocr','richtext','app','ui','main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
& windres resources/app.rc -O coff -o build/app-res.o
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
if ($Test) {
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/storage.o build/ocr.o build/richtext.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' }
}
if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
}
if ($Ocr) {
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR test build failed' }
& .\build\KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR tests failed' }
}
Get-Item build/release/KSwitcher.exe | Select-Object FullName,Length
} finally { Pop-Location }
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$destination = Join-Path $root 'dist\KSwitcher'
New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md') | ForEach-Object { Join-Path $destination $_ }
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="0.1.0.0" processorArchitecture="*" name="KSwitcher" type="win32"/>
<description>KSwitcher native keyboard and clipboard utility</description>
<dependency><dependentAssembly><assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/></dependentAssembly></dependency>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false"/></requestedPrivileges></security></trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings><dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware><dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness></windowsSettings></application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/></application></compatibility>
</assembly>
+24
View File
@@ -0,0 +1,24 @@
#include <windows.h>
#include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico"
1 RT_MANIFEST "app.manifest"
1 VERSIONINFO
FILEVERSION 0,1,0,0
PRODUCTVERSION 0,1,0,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.1.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 32 32">
<rect x="1" y="1" width="30" height="30" rx="7" fill="#4F46E5"/>
<polygon fill="#FFFFFF" points="8,7 12,7 12,25 8,25"/>
<polygon fill="#FFFFFF" points="11,15 22,6 25,9 14,18"/>
<polygon fill="#5EEAD4" points="11,14 25,24 22,27 10,18"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

+2
View File
@@ -0,0 +1,2 @@
#pragma once
#define IDI_KSWITCHER 101
+11
View File
@@ -0,0 +1,11 @@
$ErrorActionPreference = 'Stop'
$tools = Join-Path $PSScriptRoot '.tools'
New-Item -ItemType Directory -Force $tools | Out-Null
$archive = Join-Path $tools 'w64devkit.exe'
$uri = 'https://github.com/skeeto/w64devkit/releases/download/v2.10.1/w64devkit-x64-2.10.1.7z.exe'
$expected = 'ADD1EDF984CE4D473CFEB7C52E0AB38DB73A855BBA6C7F564D5E16648EB2896A'
if (!(Test-Path $archive)) { Invoke-WebRequest $uri -OutFile $archive }
if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expected) { throw 'Toolchain checksum mismatch' }
& $archive -y "-o$tools"
if ($LASTEXITCODE) { throw 'Toolchain extraction failed' }
Write-Host 'Compiler ready. Run .\build.ps1 -Test'
+1139
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "storage.h"
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <shellapi.h>
#include <thread>
namespace ks
{
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
SnippetMessage = WM_APP + 4;
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, Direct = 100;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
{
HWND window{};
std::wstring original, result;
int layout = -1;
ULONGLONG at{};
uint64_t version{};
};
class App
{
public:
HWND window{}, historyWindow{}, settingsWindow{}, snippetsWindow{};
HINSTANCE instance{};
std::filesystem::path data;
std::vector<Layout> layouts;
Settings settings;
std::unique_ptr<History> history;
std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0};
std::atomic<uint64_t> inputVersion{0};
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
std::mutex stateMutex;
Run run;
std::optional<LastTransform> last;
std::wstring registrationWarning;
bool smoke{};
int startCommand{};
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
~App();
int execute();
void dispatch(int);
void transform(int, int snippet = -1);
void pasteEntry(std::wstring, bool plain);
void recognizeEntry(std::wstring);
void captureHistory();
void enqueue(std::function<void()>);
void report(const std::wstring &);
void trayMenu();
void addTray();
bool registerHotkeys(const Settings &, bool rollback = false);
void applySettings(const Settings &);
void reloadLayouts();
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &);
void resetInput();
static App *current;
private:
HHOOK keyboardHook{}, mouseHook{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
bool stop{};
UINT taskbarCreated{};
std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook;
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void refreshHistory(App &);
void showSettings(App &);
void showSnippets(App &);
void showShortcuts(App &);
void showEntry(App &, const std::wstring &);
} // namespace ks
+205
View File
@@ -0,0 +1,205 @@
#include "platform.h"
#include <sddl.h>
#include <shellapi.h>
#include <shlobj.h>
#include <taskschd.h>
namespace ks
{
namespace
{
template <class T> struct Com
{
T *p{};
~Com()
{
if (p)
p->Release();
}
T **out()
{
return &p;
}
T *operator->()
{
return p;
}
};
struct Str
{
BSTR value;
Str(const wchar_t *s) : value(SysAllocString(s))
{
if (!value)
throw std::bad_alloc();
}
~Str()
{
SysFreeString(value);
}
operator BSTR() const
{
return value;
}
};
void check(HRESULT hr)
{
if (FAILED(hr))
throw std::runtime_error("Cannot configure Windows autostart (HRESULT " +
std::to_string((unsigned long)hr) + ")");
}
bool missing(HRESULT hr)
{
return hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || hr == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND);
}
void runEntry(bool enabled)
{
HKEY key{};
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr,
0, KEY_SET_VALUE, nullptr, &key, nullptr) != ERROR_SUCCESS)
throw std::runtime_error("Cannot access autostart settings");
auto command = L"\"" + executable() + L"\"";
auto status = enabled ? RegSetValueExW(key, L"KSwitcher", 0, REG_SZ, (const BYTE *)command.c_str(),
DWORD((command.size() + 1) * sizeof(wchar_t)))
: RegDeleteValueW(key, L"KSwitcher");
RegCloseKey(key);
if (status != ERROR_SUCCESS && status != ERROR_FILE_NOT_FOUND)
throw std::runtime_error("Cannot save autostart settings");
}
} // namespace
std::wstring currentUserSid()
{
HANDLE token{};
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
throw std::runtime_error("Cannot identify Windows account");
DWORD size{};
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
Bytes data(size);
BOOL ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
if (!ok)
throw std::runtime_error("Cannot identify Windows account");
LPWSTR sid{};
if (!ConvertSidToStringSidW(((TOKEN_USER *)data.data())->User.Sid, &sid))
throw std::runtime_error("Cannot read account SID");
std::wstring result = sid;
LocalFree(sid);
return result;
}
void configureAutostart(int mode)
{
Com<ITaskService> service;
check(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out()));
VARIANT empty;
VariantInit(&empty);
check(service->Connect(empty, empty, empty, empty));
Com<ITaskFolder> folder;
check(service->GetFolder(Str(L"\\"), folder.out()));
Com<IRegisteredTask> existing;
HRESULT found = folder->GetTask(Str(L"KSwitcher"), existing.out());
if (FAILED(found) && !missing(found))
check(found);
if (mode != 2)
{
if (SUCCEEDED(found))
check(existing->put_Enabled(VARIANT_FALSE));
runEntry(mode == 1);
return;
}
auto sid = currentUserSid();
Str user(sid.c_str());
Com<ITaskDefinition> definition;
check(service->NewTask(0, definition.out()));
Com<IPrincipal> principal;
check(definition->get_Principal(principal.out()));
check(principal->put_UserId(user));
check(principal->put_LogonType(TASK_LOGON_INTERACTIVE_TOKEN));
check(principal->put_RunLevel(TASK_RUNLEVEL_HIGHEST));
Com<ITaskSettings> settings;
check(definition->get_Settings(settings.out()));
check(settings->put_DisallowStartIfOnBatteries(VARIANT_FALSE));
check(settings->put_StopIfGoingOnBatteries(VARIANT_FALSE));
check(settings->put_ExecutionTimeLimit(Str(L"PT0S")));
check(settings->put_MultipleInstances(TASK_INSTANCES_IGNORE_NEW));
Com<ITriggerCollection> triggers;
check(definition->get_Triggers(triggers.out()));
Com<ITrigger> trigger;
check(triggers->Create(TASK_TRIGGER_LOGON, trigger.out()));
Com<ILogonTrigger> logon;
check(trigger->QueryInterface(IID_ILogonTrigger, (void **)logon.out()));
check(logon->put_UserId(user));
Com<IActionCollection> actions;
check(definition->get_Actions(actions.out()));
Com<IAction> action;
check(actions->Create(TASK_ACTION_EXEC, action.out()));
Com<IExecAction> exec;
check(action->QueryInterface(IID_IExecAction, (void **)exec.out()));
auto path = executable();
check(exec->put_Path(Str(path.c_str())));
check(exec->put_WorkingDirectory(Str(std::filesystem::path(path).parent_path().c_str())));
VARIANT account;
VariantInit(&account);
account.vt = VT_BSTR;
account.bstrVal = user;
Com<IRegisteredTask> installed;
check(folder->RegisterTaskDefinition(Str(L"KSwitcher"), definition.p, TASK_CREATE_OR_UPDATE, account,
empty, TASK_LOGON_INTERACTIVE_TOKEN, empty, installed.out()));
runEntry(false);
}
void setAutostart(bool enabled, bool elevated)
{
int mode = enabled ? (elevated ? 2 : 1) : 0;
if (IsUserAnAdmin())
{
configureAutostart(mode);
return;
}
// Ordinary startup needs no UAC unless an elevated task must be changed.
if (mode != 2)
{
Com<ITaskService> service;
HRESULT hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out());
VARIANT empty;
VariantInit(&empty);
Com<ITaskFolder> folder;
Com<IRegisteredTask> task;
if (SUCCEEDED(hr))
hr = service->Connect(empty, empty, empty, empty);
if (SUCCEEDED(hr))
hr = service->GetFolder(Str(L"\\"), folder.out());
if (SUCCEEDED(hr))
hr = folder->GetTask(Str(L"KSwitcher"), task.out());
if (missing(hr))
{
runEntry(mode == 1);
return;
}
check(hr);
}
auto file = executable(),
args = L"--configure-autostart=" + std::to_wstring(mode) + L" " + currentUserSid();
SHELLEXECUTEINFOW request{sizeof(request)};
request.fMask = SEE_MASK_NOCLOSEPROCESS;
request.lpVerb = L"runas";
request.lpFile = file.c_str();
request.lpParameters = args.c_str();
request.nShow = SW_HIDE;
if (!ShellExecuteExW(&request) || !request.hProcess)
throw std::runtime_error("Administrator approval was cancelled or unavailable");
// Continue servicing sent hook messages while UAC/helper runs, without
// dispatching another Settings Save or global command reentrantly.
DWORD wait{};
while ((wait = MsgWaitForMultipleObjects(1, &request.hProcess, FALSE, INFINITE, QS_SENDMESSAGE)) ==
WAIT_OBJECT_0 + 1)
{
MSG message;
PeekMessageW(&message, nullptr, WM_NULL, WM_NULL, PM_NOREMOVE);
}
DWORD code = 1;
GetExitCodeProcess(request.hProcess, &code);
CloseHandle(request.hProcess);
if (wait != WAIT_OBJECT_0 || code != 0)
throw std::runtime_error("Elevated autostart was not changed");
}
} // namespace ks
+467
View File
@@ -0,0 +1,467 @@
#include "core.h"
#include <algorithm>
#include <cwctype>
#include <map>
#include <stdexcept>
namespace ks
{
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
if (key >= 256)
return 0;
if (pressed[key])
{
if (!down)
pressed[key] = false;
return -1;
}
if (down)
for (const auto &[command, hotkey] : bindings)
if (hotkey.key == key && hotkey.modifiers == modifiers)
{
pressed[key] = true;
return command;
}
return 0;
}
bool letter(wchar_t c)
{
WORD type{};
GetStringTypeW(CT_CTYPE1, &c, 1, &type);
return (type & C1_ALPHA) != 0;
}
std::wstring lower(std::wstring s)
{
if (!s.empty())
CharLowerBuffW(s.data(), (DWORD)s.size());
return s;
}
std::wstring upper(std::wstring s)
{
if (!s.empty())
CharUpperBuffW(s.data(), (DWORD)s.size());
return s;
}
int Layout::code(wchar_t c) const
{
auto p = normal.find(c);
if (p != normal.npos && c)
return (int)p;
p = shifted.find(c);
return p == shifted.npos || !c ? -1 : (int)(p + normal.size());
}
wchar_t Layout::at(int c) const
{
if (c < 0)
return 0;
return c < (int)normal.size() ? normal[c]
: c < (int)(normal.size() + shifted.size()) ? shifted[c - normal.size()]
: 0;
}
bool Layout::hasLetter(wchar_t c) const
{
return letter(c) && code(c) >= 0;
}
std::wstring convert(const std::wstring &text, const Layout &from, const Layout &to, bool force)
{
std::wstring result;
for (size_t i = 0; i < text.size();)
{
if (iswspace(text[i]))
{
result += text[i++];
continue;
}
size_t end = i;
bool source = false, other = false;
while (end < text.size() && !iswspace(text[end]))
{
wchar_t c = text[end++];
if (!letter(c))
continue;
if (from.hasLetter(c) && !to.hasLetter(c))
source = true;
else
other = true;
}
for (; i < end; ++i)
{
wchar_t mapped = (!force && !source && other) ? 0 : to.at(from.code(text[i]));
result += mapped ? mapped : text[i];
}
}
return result;
}
int detect(const std::wstring &text, const std::vector<Layout> &layouts, int preferred)
{
int best = -1, score = 0;
auto check = [&](int i)
{
if (i < 0 || i >= (int)layouts.size() || !layouts[i].convertible)
return;
int n =
(int)std::count_if(text.begin(), text.end(), [&](wchar_t c) { return layouts[i].hasLetter(c); });
if (n > score)
{
score = n;
best = i;
}
};
check(preferred);
for (int i = 0; i < (int)layouts.size(); ++i)
check(i);
return best;
}
std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layouts, int target, int hint)
{
if (target < 0 || target >= (int)layouts.size() || !layouts[target].convertible)
return text;
const auto &to = layouts[target];
if (hint >= 0 && hint < (int)layouts.size() && layouts[hint].convertible &&
std::all_of(
text.begin(), text.end(), [&](wchar_t c) { return !letter(c) || layouts[hint].code(c) >= 0; }))
return hint == target ? text : convert(text, layouts[hint], to, true);
int best = -1, score = 0;
for (int i = 0; i < (int)layouts.size(); ++i)
if (i != target && layouts[i].convertible)
{
int n = (int)std::count_if(text.begin(), text.end(), [&](wchar_t c)
{ return layouts[i].hasLetter(c) && !to.hasLetter(c); });
if (n > score)
{
score = n;
best = i;
}
}
if (best < 0 && detect(text, layouts) < 0)
{
best = hint;
if (best < 0)
for (int i = 0; i < (int)layouts.size(); ++i)
if (i != target && layouts[i].convertible)
{
best = i;
break;
}
}
return best < 0 ? text : convert(text, layouts[best], to);
}
std::wstring cycleCase(const std::wstring &text)
{
if (text == upper(text))
return lower(text);
if (text != lower(text))
return upper(text);
auto out = lower(text);
bool start = true;
for (auto &c : out)
{
if (letter(c))
{
if (start)
c = upper(std::wstring(1, c))[0];
start = false;
}
else
start = !iswdigit(c) && c != L'\'' && c != L'’';
}
return out;
}
std::wstring transliterate(const std::wstring &text)
{
static const std::wstring ru = L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
static const std::vector<std::wstring> lat = {L"a", L"b", L"v", L"g", L"d", L"e", L"yo", L"zh", L"z",
L"i", L"y", L"k", L"l", L"m", L"n", L"o", L"p", L"r",
L"s", L"t", L"u", L"f", L"kh", L"ts", L"ch", L"sh", L"shch",
L"", L"y", L"", L"e", L"yu", L"ya"};
bool cyr = std::any_of(text.begin(), text.end(), [](wchar_t c) { return c >= 0x400 && c <= 0x4ff; });
std::wstring out, lo = lower(text);
if (cyr)
{
bool uk = lo.find_first_of(L"ґіїє") != lo.npos;
const std::map<wchar_t, std::wstring> overrides = {{L'г', L"h"}, {L'ґ', L"g"}, {L'и', L"y"},
{L'і', L"i"}, {L'ї', L"yi"}, {L'є', L"ye"},
{L'\'', L""}, {L'’', L""}};
for (size_t i = 0; i < text.size(); ++i)
{
std::wstring m;
auto p = ru.find(lo[i]);
auto u = overrides.find(lo[i]);
if (uk && u != overrides.end())
{
m = u->second;
if (i && letter(text[i - 1]))
{
if (lo[i] == L'ї')
m = L"i";
if (lo[i] == L'є')
m = L"ie";
}
}
else if (p != ru.npos)
m = lat[p];
else
{
out += text[i];
continue;
}
if (text[i] != lo[i] && !m.empty())
{
bool all =
(i + 1 < text.size() && text[i + 1] != lo[i + 1]) || (i > 0 && text[i - 1] != lo[i - 1]);
if (all)
m = upper(m);
else
m[0] = upper(m.substr(0, 1))[0];
}
out += m;
}
}
else
{
static const std::vector<std::pair<std::wstring, std::wstring>> reverse = {
{L"shch", L"щ"}, {L"zh", L"ж"}, {L"kh", L"х"}, {L"ts", L"ц"}, {L"ch", L"ч"}, {L"sh", L"ш"},
{L"yo", L"ё"}, {L"yu", L"ю"}, {L"ya", L"я"}, {L"ye", L"е"}, {L"a", L"а"}, {L"b", L"б"},
{L"v", L"в"}, {L"w", L"в"}, {L"g", L"г"}, {L"d", L"д"}, {L"e", L"е"}, {L"z", L"з"},
{L"i", L"и"}, {L"y", L"й"}, {L"k", L"к"}, {L"q", L"к"}, {L"l", L"л"}, {L"m", L"м"},
{L"n", L"н"}, {L"o", L"о"}, {L"p", L"п"}, {L"r", L"р"}, {L"s", L"с"}, {L"t", L"т"},
{L"u", L"у"}, {L"f", L"ф"}, {L"h", L"х"}, {L"c", L"к"}, {L"x", L"кс"}, {L"j", L"дж"}};
for (size_t i = 0; i < text.size();)
{
bool found = false;
for (const auto &[a, b] : reverse)
if (lo.compare(i, a.size(), a) == 0)
{
auto m = b;
if (text[i] != lo[i])
{
bool all = a.size() > 1 ? text[i + a.size() - 1] != lo[i + a.size() - 1]
: i + 1 < text.size() && text[i + 1] != lo[i + 1];
if (all)
m = upper(m);
else
m[0] = upper(m.substr(0, 1))[0];
}
out += m;
i += a.size();
found = true;
break;
}
if (!found)
out += text[i++];
}
}
return out;
}
Template parseTemplate(const std::wstring &text)
{
Template t;
size_t cursor = std::wstring::npos;
for (size_t i = 0; i < text.size();)
{
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
{
t.text += L"{cursor}";
i += 10;
}
else if (text.compare(i, 8, L"{cursor}") == 0)
{
cursor = t.text.size();
++t.cursors;
i += 8;
}
else
t.text += text[i++];
}
if (cursor != text.npos)
t.suffix = t.text.substr(cursor);
return t;
}
namespace
{
const std::vector<std::pair<UINT, std::wstring>> &namedKeys()
{
static const std::vector<std::pair<UINT, std::wstring>> names = {
{VK_SPACE, L"Space"}, {VK_TAB, L"Tab"},
{VK_RETURN, L"Enter"}, {VK_ESCAPE, L"Escape"},
{VK_BACK, L"Backspace"}, {VK_DELETE, L"Delete"},
{VK_INSERT, L"Insert"}, {VK_PAUSE, L"Pause"},
{VK_SCROLL, L"ScrollLock"}, {VK_HOME, L"Home"},
{VK_END, L"End"}, {VK_PRIOR, L"PageUp"},
{VK_NEXT, L"PageDown"}, {VK_LEFT, L"Left"},
{VK_RIGHT, L"Right"}, {VK_UP, L"Up"},
{VK_DOWN, L"Down"}, {VK_SNAPSHOT, L"PrintScreen"},
{VK_OEM_1, L"Semicolon"}, {VK_OEM_2, L"Slash"},
{VK_OEM_3, L"Backtick"}, {VK_OEM_4, L"LeftBracket"},
{VK_OEM_5, L"Backslash"}, {VK_OEM_6, L"RightBracket"},
{VK_OEM_7, L"Quote"}, {VK_OEM_PLUS, L"Equal"},
{VK_OEM_MINUS, L"Minus"}, {VK_OEM_COMMA, L"Comma"},
{VK_OEM_PERIOD, L"Period"}, {VK_MULTIPLY, L"Multiply"},
{VK_ADD, L"Add"}, {VK_SUBTRACT, L"Subtract"},
{VK_DIVIDE, L"Divide"}, {VK_DECIMAL, L"Decimal"}};
return names;
}
} // namespace
std::wstring Hotkey::format() const
{
if (!key)
return {};
std::wstring result;
if (modifiers & MOD_CONTROL)
result += L"Ctrl+";
if (modifiers & MOD_ALT)
result += L"Alt+";
if (modifiers & MOD_SHIFT)
result += L"Shift+";
if (modifiers & MOD_WIN)
result += L"Win+";
if ((key >= L'A' && key <= L'Z') || (key >= L'0' && key <= L'9'))
return result + wchar_t(key);
if (key >= VK_F1 && key <= VK_F24)
return result + L"F" + std::to_wstring(key - VK_F1 + 1);
if (key >= VK_NUMPAD0 && key <= VK_NUMPAD9)
return result + L"Num" + std::to_wstring(key - VK_NUMPAD0);
for (auto &[vk, name] : namedKeys())
if (key == vk)
return result + name;
return {};
}
std::optional<Hotkey> Hotkey::parse(std::wstring s)
{
Hotkey h;
if (s.empty())
return h;
s = lower(s);
size_t pos = 0;
while (true)
{
size_t end = s.find(L'+', pos);
auto token = s.substr(pos, end == s.npos ? s.npos : end - pos);
if (token == L"ctrl")
h.modifiers |= MOD_CONTROL;
else if (token == L"alt")
h.modifiers |= MOD_ALT;
else if (token == L"shift")
h.modifiers |= MOD_SHIFT;
else if (token == L"win")
h.modifiers |= MOD_WIN;
else
{
if (h.key)
return {};
if (token.size() == 1 && ((token[0] >= L'a' && token[0] <= L'z') || iswdigit(token[0])))
h.key = upper(token)[0];
else if (token.size() == 4 && token.substr(0, 3) == L"num" && token[3] >= L'0' &&
token[3] <= L'9')
h.key = VK_NUMPAD0 + token[3] - L'0';
else if (token.size() >= 2 && token[0] == L'f')
{
wchar_t *tail{};
long n = wcstol(token.c_str() + 1, &tail, 10);
if (*tail || n < 1 || n > 24)
return {};
h.key = VK_F1 + (UINT)n - 1;
}
else
{
for (auto &[key, name] : namedKeys())
if (lower(name) == token)
{
h.key = key;
break;
}
if (!h.key)
return {};
}
}
if (end == s.npos)
break;
pos = end + 1;
}
return h.key ? std::optional<Hotkey>(h) : std::nullopt;
}
std::string utf8(const std::wstring &s)
{
if (s.empty())
return {};
int n = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, s.data(), (int)s.size(), nullptr, 0, nullptr,
nullptr);
if (!n)
throw std::runtime_error("Invalid Unicode");
std::string out(n, 0);
WideCharToMultiByte(CP_UTF8, 0, s.data(), (int)s.size(), out.data(), n, nullptr, nullptr);
return out;
}
std::wstring wide(const std::string &s)
{
if (s.empty())
return {};
int n = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), (int)s.size(), nullptr, 0);
if (!n)
throw std::runtime_error("Invalid UTF-8");
std::wstring out(n, 0);
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), out.data(), n);
return out;
}
void Run::reset()
{
keys.clear();
window = nullptr;
layout = nullptr;
overflow = false;
}
void Run::record(wchar_t c, HWND w, HKL l, ULONGLONG now)
{
if (!w || c < 32)
{
reset();
return;
}
if (window != w || layout != l || now - at >= 2000 ||
(!keys.empty() && iswspace(keys.back()) && !iswspace(c)))
reset();
window = w;
layout = l;
at = now;
if (overflow)
{
if (iswspace(c))
reset();
return;
}
if (keys.empty() && iswspace(c))
return;
if (keys.size() >= 512)
{
keys.clear();
overflow = true;
return;
}
keys += c;
}
void Run::backspace(HWND w, ULONGLONG now)
{
if (w != window || now - at >= 2000 || overflow)
reset();
else if (!keys.empty())
keys.pop_back();
at = now;
}
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{
if (text.size() != keys.size() || overflow)
return -1;
for (int i = 0; i < (int)layouts.size(); ++i)
if (layouts[i].convertible)
{
std::wstring expected;
for (wchar_t c : keys)
expected += c == L' ' ? L' ' : layouts[i].at(physical.code(c));
if (lower(expected) == lower(text))
return i;
}
return -1;
}
} // namespace ks
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include <windows.h>
namespace ks
{
struct Layout
{
HKL handle{};
std::wstring id, name, normal, shifted;
bool convertible = true;
int code(wchar_t ch) const;
wchar_t at(int code) const;
bool hasLetter(wchar_t ch) const;
};
bool letter(wchar_t ch);
std::wstring lower(std::wstring text);
std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
std::wstring cycleCase(const std::wstring &);
std::wstring transliterate(const std::wstring &);
struct Snippet
{
std::wstring trigger, text;
};
struct Template
{
std::wstring text, suffix;
int cursors = 0;
};
Template parseTemplate(const std::wstring &);
struct Hotkey
{
UINT modifiers{}, key{};
bool operator==(const Hotkey &) const = default;
static std::optional<Hotkey> parse(std::wstring);
std::wstring format() const;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
std::vector<std::wstring> cycle, direct, directIds;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets;
};
// Consume repeats and key-up for intercepted chords, even if settings change.
struct HotkeyHook
{
bool pressed[256]{};
// 0: pass through, -1: consume only, positive: dispatch that action once.
int update(UINT key, bool down, UINT modifiers, const std::vector<std::pair<int, Hotkey>> &bindings);
};
std::string utf8(const std::wstring &);
std::wstring wide(const std::string &);
struct Run
{
HWND window{};
HKL layout{};
ULONGLONG at{};
std::wstring keys;
bool overflow{};
void reset();
void record(wchar_t, HWND, HKL, ULONGLONG);
void backspace(HWND, ULONGLONG);
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
};
} // namespace ks
+86
View File
@@ -0,0 +1,86 @@
#include "app.h"
#include <shellapi.h>
#include <shlobj.h>
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
int argc{};
auto argv = CommandLineToArgvW(GetCommandLineW(), &argc);
std::wstring arg = argc > 1 ? argv[1] : L"";
if (arg.starts_with(L"--configure-autostart="))
{
int result = 1;
try
{
if (argc != 3 || ks::currentUserSid() != argv[2] || !IsUserAnAdmin())
throw std::runtime_error("Administrator approval for the same Windows account is required");
if (arg != L"--configure-autostart=0" && arg != L"--configure-autostart=1" &&
arg != L"--configure-autostart=2")
throw std::runtime_error("Invalid autostart mode");
ks::configureAutostart(arg.back() - L'0');
result = 0;
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher autostart", MB_OK | MB_ICONERROR);
}
LocalFree(argv);
CoUninitialize();
return result;
}
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
int cmd = ks::ShowHistory;
if (arg == L"--convert")
cmd = ks::Toggle;
else if (arg == L"--case")
cmd = ks::Case;
else if (arg == L"--transliterate")
cmd = ks::Translit;
else if (arg == L"--settings")
cmd = 20;
else if (arg == L"--snippets")
cmd = 21;
else if (arg == L"--exit")
cmd = 99;
LocalFree(argv);
if (existing)
{
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (window)
{
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
}
CloseHandle(mutex);
CoUninitialize();
return 0;
}
if (arg == L"--exit")
{
CloseHandle(mutex);
CoUninitialize();
return 0;
}
int result = 0;
try
{
ks::App app(instance, arg == L"--smoke-test");
if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher could not start", MB_OK | MB_ICONERROR);
result = 1;
}
if (mutex)
{
ReleaseMutex(mutex);
CloseHandle(mutex);
}
CoUninitialize();
return result;
}
+274
View File
@@ -0,0 +1,274 @@
#include "ocr.h"
#include <memory>
#include <roapi.h>
#include <wincodec.h>
#include <winstring.h>
namespace ks
{
namespace
{
template <class T> struct Com
{
T *p{};
~Com()
{
if (p)
p->Release();
}
T *operator->() const
{
return p;
}
T **out()
{
return &p;
}
Com() = default;
Com(const Com &) = delete;
Com &operator=(const Com &) = delete;
};
void check(HRESULT h)
{
if (FAILED(h))
{
char text[80];
snprintf(text, sizeof(text), "Windows image/OCR service failed (0x%08lx)", (unsigned long)h);
throw std::runtime_error(text);
}
}
struct String
{
HSTRING value{};
explicit String(const wchar_t *s)
{
check(WindowsCreateString(s, (UINT32)wcslen(s), &value));
}
~String()
{
WindowsDeleteString(value);
}
};
GUID guid(const wchar_t *text)
{
GUID result{};
check(IIDFromString(text, &result));
return result;
}
template <class T> void factory(const wchar_t *name, const wchar_t *iid, Com<T> &out)
{
String s(name);
check(RoGetActivationFactory(s.value, guid(iid), (void **)out.out()));
}
} // namespace
// Minimal, strongly typed Windows Runtime ABI declarations. No CLR or C++/WinRT runtime is needed.
// Interface identities/method order follow Microsoft's Windows SDK metadata (Windows.Media.Ocr,
// Windows.Graphics.Imaging, Windows.Storage.Streams, Windows.Foundation).
struct Buffer : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Capacity(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE Length(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE SetLength(UINT32) = 0;
};
struct BufferFactory : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Create(UINT32, Buffer **) = 0;
};
struct BufferBytes : IUnknown
{
virtual HRESULT STDMETHODCALLTYPE Data(BYTE **) = 0;
};
struct BitmapStatics : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Copy(IInspectable *, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Convert(IInspectable *, INT32, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE ConvertAlpha(IInspectable *, INT32, INT32, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE FromBuffer(Buffer *, INT32, INT32, INT32, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE FromBufferAlpha(Buffer *, INT32, INT32, INT32, INT32,
IInspectable **) = 0;
};
struct OcrResult : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Lines(IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Angle(IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Text(HSTRING *) = 0;
};
struct OcrOperation : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE PutCompleted(IUnknown *) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCompleted(IUnknown **) = 0;
virtual HRESULT STDMETHODCALLTYPE Results(OcrResult **) = 0;
};
struct AsyncInfo : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Id(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE Status(INT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE ErrorCode(HRESULT *) = 0;
virtual HRESULT STDMETHODCALLTYPE Cancel() = 0;
virtual HRESULT STDMETHODCALLTYPE Close() = 0;
};
struct OcrEngine : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Recognize(IInspectable *, OcrOperation **) = 0;
virtual HRESULT STDMETHODCALLTYPE Language(IInspectable **) = 0;
};
struct OcrStatics : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE MaxDimension(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE Languages(IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Supported(IInspectable *, boolean *) = 0;
virtual HRESULT STDMETHODCALLTYPE FromLanguage(IInspectable *, OcrEngine **) = 0;
virtual HRESULT STDMETHODCALLTYPE FromProfile(OcrEngine **) = 0;
};
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
{
Bytes encoded;
for (const auto &f : clip.formats)
if (f.name == L"PNG")
{
encoded = f.data;
break;
}
if (encoded.empty())
for (const auto &f : clip.formats)
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
{
BITMAPINFOHEADER h;
memcpy(&h, f.data.data(), 40);
if (h.biSize < 40 || h.biSize > f.data.size())
continue;
uint64_t colors = h.biClrUsed ? h.biClrUsed : (h.biBitCount <= 8 ? 1ull << h.biBitCount : 0);
uint64_t offset = sizeof(BITMAPFILEHEADER) + h.biSize + 4 * colors +
(h.biSize == 40 && h.biCompression == BI_BITFIELDS ? 12 : 0);
if (offset > sizeof(BITMAPFILEHEADER) + f.data.size())
throw std::runtime_error("Invalid bitmap header");
BITMAPFILEHEADER file{};
file.bfType = 0x4d42;
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
file.bfOffBits = (DWORD)offset;
encoded.resize(sizeof(file));
memcpy(encoded.data(), &file, sizeof(file));
encoded.insert(encoded.end(), f.data.begin(), f.data.end());
break;
}
if (encoded.empty())
throw std::runtime_error("This clipboard item has no supported image");
Com<IWICImagingFactory> wic;
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
(void **)wic.out()));
Com<IWICStream> stream;
check(wic->CreateStream(stream.out()));
check(stream->InitializeFromMemory(encoded.data(), (DWORD)encoded.size()));
Com<IWICBitmapDecoder> decoder;
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
Com<IWICBitmapFrameDecode> frame;
check(decoder->GetFrame(0, frame.out()));
Pixels pixels;
check(frame->GetSize(&pixels.width, &pixels.height));
if (!pixels.width || !pixels.height || uint64_t(pixels.width) * pixels.height > 32 * 1024 * 1024)
throw std::runtime_error("Image is too large to decode safely");
Com<IWICFormatConverter> converter;
Com<IWICBitmapScaler> scaler;
IWICBitmapSource *source = frame.p;
if (maxWidth && maxHeight && (pixels.width > maxWidth || pixels.height > maxHeight))
{
const double ratio = std::min(double(maxWidth) / pixels.width, double(maxHeight) / pixels.height);
pixels.width = std::max(1u, UINT(pixels.width * ratio));
pixels.height = std::max(1u, UINT(pixels.height * ratio));
check(wic->CreateBitmapScaler(scaler.out()));
check(scaler->Initialize(frame.p, pixels.width, pixels.height, WICBitmapInterpolationModeFant));
source = scaler.p;
}
check(wic->CreateFormatConverter(converter.out()));
check(converter->Initialize(source, GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0,
WICBitmapPaletteTypeCustom));
pixels.bgra.resize(size_t(pixels.width) * pixels.height * 4);
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
return pixels;
}
Format imageDib(const Pixels &p)
{
BITMAPINFOHEADER h{};
h.biSize = sizeof(h);
h.biWidth = (LONG)p.width;
h.biHeight = -(LONG)p.height;
h.biPlanes = 1;
h.biBitCount = 32;
Format format;
format.id = CF_DIB;
format.data.resize(sizeof(h));
memcpy(format.data.data(), &h, sizeof(h));
format.data.insert(format.data.end(), p.bgra.begin(), p.bgra.end());
return format;
}
std::wstring recognizeText(const Clip &clip)
{
check(RoInitialize(RO_INIT_MULTITHREADED));
struct Apartment
{
~Apartment()
{
RoUninitialize();
}
} apartment;
auto pixels = decodeImage(clip);
Com<OcrStatics> ocr;
factory(L"Windows.Media.Ocr.OcrEngine", L"{5bffa85a-3384-3540-9940-699120d428a8}", ocr);
UINT32 max{};
check(ocr->MaxDimension(&max));
if (pixels.width > max || pixels.height > max)
throw std::runtime_error("Image exceeds Windows OCR's maximum dimensions");
Com<OcrEngine> engine;
check(ocr->FromProfile(engine.out()));
if (!engine.p)
throw std::runtime_error("No Windows OCR language is installed for your profile");
Com<BufferFactory> buffers;
factory(L"Windows.Storage.Streams.Buffer", L"{71af914d-c10f-484b-bc50-14bc623b3a27}", buffers);
Com<Buffer> buffer;
check(buffers->Create((UINT32)pixels.bgra.size(), buffer.out()));
check(buffer->SetLength((UINT32)pixels.bgra.size()));
Com<BufferBytes> bytes;
check(buffer->QueryInterface(guid(L"{905a0fef-bc53-11df-8c49-001e4fc686da}"), (void **)bytes.out()));
BYTE *memory{};
check(bytes->Data(&memory));
memcpy(memory, pixels.bgra.data(), pixels.bgra.size());
Com<BitmapStatics> bitmaps;
factory(L"Windows.Graphics.Imaging.SoftwareBitmap", L"{df0385db-672f-4a9d-806e-c2442f343e86}", bitmaps);
Com<IInspectable> bitmap;
check(bitmaps->FromBufferAlpha(buffer.p, 87, (INT32)pixels.width, (INT32)pixels.height, 2, bitmap.out()));
Com<OcrOperation> operation;
check(engine->Recognize(bitmap.p, operation.out()));
Com<AsyncInfo> info;
check(operation->QueryInterface(guid(L"{00000036-0000-0000-c000-000000000046}"), (void **)info.out()));
auto deadline = GetTickCount64() + 30000;
INT32 status{};
do
{
check(info->Status(&status));
if (status == 0)
{
if (GetTickCount64() > deadline)
{
info->Cancel();
throw std::runtime_error("Windows OCR timed out");
}
Sleep(20);
}
} while (status == 0);
if (status != 1)
{
HRESULT failure{};
check(info->ErrorCode(&failure));
check(FAILED(failure) ? failure : E_ABORT);
}
Com<OcrResult> result;
check(operation->Results(result.out()));
HSTRING text{};
check(result->Text(&text));
UINT32 length{};
const wchar_t *raw = WindowsGetStringRawBuffer(text, &length);
std::wstring out(raw, length);
WindowsDeleteString(text);
return out;
}
} // namespace ks
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "platform.h"
namespace ks
{
struct Pixels
{
UINT width{}, height{};
Bytes bgra;
};
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &);
std::wstring recognizeText(const Clip &);
} // namespace ks
+504
View File
@@ -0,0 +1,504 @@
#include "platform.h"
#include <algorithm>
#include <fstream>
#include <imm.h>
#include <map>
#include <mutex>
#include <set>
#include <shlobj.h>
#include <tlhelp32.h>
#include <wincrypt.h>
namespace ks
{
namespace
{
const UINT scans[] = {0x29, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 30, 31, 32, 33, 34, 35, 36,
37, 38, 39, 40, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 43};
struct ClipboardLock
{
ClipboardLock(HWND w)
{
for (int i = 0; i < 30; ++i)
{
if (OpenClipboard(w))
return;
Sleep(10);
}
throw std::runtime_error("Clipboard is busy");
}
~ClipboardLock()
{
CloseClipboard();
}
};
bool globalFormat(UINT f)
{
return f != CF_BITMAP && f != CF_PALETTE && f != CF_METAFILEPICT && f != CF_ENHMETAFILE &&
!(f >= CF_PRIVATEFIRST && f <= CF_PRIVATELAST) && !(f >= CF_GDIOBJFIRST && f <= CF_GDIOBJLAST) &&
f != CF_OWNERDISPLAY;
}
std::optional<ULONGLONG> started(DWORD id)
{
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, id);
if (!h)
return {};
FILETIME c, e, k, u;
BOOL ok = GetProcessTimes(h, &c, &e, &k, &u);
CloseHandle(h);
return ok ? std::optional<ULONGLONG>((ULONGLONG(c.dwHighDateTime) << 32) | c.dwLowDateTime)
: std::nullopt;
}
} // namespace
std::vector<Layout> readLayouts()
{
int n = GetKeyboardLayoutList(0, nullptr);
std::vector<HKL> handles(n);
n = GetKeyboardLayoutList(n, handles.data());
std::vector<Layout> out;
for (int i = 0; i < n; ++i)
{
Layout l;
l.handle = handles[i];
wchar_t id[32];
swprintf_s(id, L"win:%08x", (unsigned)(uintptr_t)l.handle);
l.id = id;
wchar_t name[128]{};
GetLocaleInfoW(MAKELCID(LOWORD((uintptr_t)l.handle), SORT_DEFAULT), LOCALE_SNATIVELANGNAME, name,
128);
l.name = name;
if (l.name.empty())
l.name = l.id;
l.name += L" (" + l.id.substr(4) + L")";
l.convertible =
ImmGetIMEFileNameW(l.handle, nullptr, 0) == 0 && (((uintptr_t)l.handle >> 16) & 0xf000) != 0xe000;
for (bool shift : {false, true})
for (UINT scan : scans)
{
BYTE state[256]{};
if (shift)
state[VK_SHIFT] = 0x80;
wchar_t buf[8]{};
UINT vk = MapVirtualKeyExW(scan, MAPVK_VSC_TO_VK_EX, l.handle);
int count = ToUnicodeEx(vk, scan, state, buf, 8, 4, l.handle);
wchar_t c =
count == 1 && buf[0] >= 32 && !(buf[0] >= 0xd800 && buf[0] <= 0xdfff) ? buf[0] : 0;
(shift ? l.shifted : l.normal) += c;
}
out.push_back(std::move(l));
}
return out;
}
const Layout &physicalLayout()
{
static const Layout l{nullptr,
L"physical",
L"Physical US",
L"`1234567890-=qwertyuiop[]asdfghjkl;'zxcvbnm,./\\",
L"~!@#$%^&*()_+QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?|",
true};
return l;
}
wchar_t physicalKey(DWORD scan, bool shift)
{
for (size_t i = 0; i < std::size(scans); ++i)
if (scans[i] == scan)
return (shift ? physicalLayout().shifted : physicalLayout().normal)[i];
return 0;
}
HKL windowLayout(HWND w)
{
return GetKeyboardLayout(GetWindowThreadProcessId(w, nullptr));
}
bool activateWindow(HWND window)
{
if (!IsWindow(window))
return false;
if (SetForegroundWindow(window))
return true;
DWORD foreground = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
DWORD current = GetCurrentThreadId();
bool attached = foreground && foreground != current && AttachThreadInput(current, foreground, TRUE);
BringWindowToTop(window);
SetForegroundWindow(window);
if (attached)
AttachThreadInput(current, foreground, FALSE);
return GetForegroundWindow() == window;
}
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read)
{
if (!owner || !host)
return false;
std::set<DWORD> seen;
while (owner != host)
{
if (!seen.insert(owner).second)
return false;
auto p = read(owner);
if (!p || lower(p->second) != L"msedgewebview2.exe" || !p->first)
return false;
owner = p->first;
}
return true;
}
bool ownsClipboard(HWND window)
{
HWND owner = GetClipboardOwner();
if (!owner || !window)
return false;
DWORD a{}, b{};
GetWindowThreadProcessId(owner, &a);
GetWindowThreadProcessId(window, &b);
if (!a || !b)
return false;
if (a == b)
return true;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE)
return false;
std::map<DWORD, std::pair<DWORD, std::wstring>> entries;
PROCESSENTRY32W e{};
e.dwSize = sizeof(e);
if (Process32FirstW(snapshot, &e))
do
{
entries[e.th32ProcessID] = {e.th32ParentProcessID, e.szExeFile};
} while (Process32NextW(snapshot, &e));
CloseHandle(snapshot);
return webViewDescendant(a, b,
[&](DWORD id) -> std::optional<std::pair<DWORD, std::wstring>>
{
auto it = entries.find(id);
if (it == entries.end())
return {};
auto child = started(id), parent = started(it->second.first);
if (!child || !parent || *parent > *child)
return {};
return it->second;
});
}
std::wstring processName(HWND window)
{
DWORD pid{};
GetWindowThreadProcessId(window, &pid);
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!h)
return {};
wchar_t path[32768];
DWORD n = 32768;
BOOL ok = QueryFullProcessImageNameW(h, 0, path, &n);
CloseHandle(h);
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
}
Clip readClipboard(HWND window, size_t limit)
{
ClipboardLock lock(window);
Clip c;
size_t total = 0;
for (UINT f = EnumClipboardFormats(0); f; f = EnumClipboardFormats(f))
{
if (!globalFormat(f))
continue;
HANDLE handle = GetClipboardData(f);
if (!handle)
continue;
SIZE_T size = GlobalSize(handle);
if (!size)
continue;
if (size > limit - total)
throw std::runtime_error("Clipboard exceeds the configured size limit");
const auto *p = static_cast<const unsigned char *>(GlobalLock(handle));
if (!p)
throw std::runtime_error("Cannot read clipboard format");
Format format;
format.id = f;
format.data.assign(p, p + size);
GlobalUnlock(handle);
if (f >= 0xc000)
{
wchar_t name[256]{};
GetClipboardFormatNameW(f, name, 256);
format.name = name;
}
total += size;
c.formats.push_back(std::move(format));
}
return c;
}
DWORD writeClipboard(HWND window, const Clip &c)
{
// Allocate all blocks before replacing any existing data.
std::vector<std::pair<UINT, HGLOBAL>> blocks;
try
{
for (const auto &f : c.formats)
{
if (f.data.empty())
continue;
UINT id = f.name.empty() ? f.id : RegisterClipboardFormatW(f.name.c_str());
if (!id)
throw std::runtime_error("Cannot register clipboard format");
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, f.data.size());
if (!h)
throw std::bad_alloc();
void *p = GlobalLock(h);
if (!p)
{
GlobalFree(h);
throw std::bad_alloc();
}
memcpy(p, f.data.data(), f.data.size());
GlobalUnlock(h);
blocks.emplace_back(id, h);
}
ClipboardLock lock(window);
if (!EmptyClipboard())
throw std::runtime_error("Cannot replace clipboard");
for (auto &[id, h] : blocks)
{
if (!SetClipboardData(id, h))
throw std::runtime_error("Cannot write clipboard format");
h = nullptr;
}
}
catch (...)
{
for (auto [id, h] : blocks)
if (h)
GlobalFree(h);
throw;
}
return GetClipboardSequenceNumber();
}
Clip textClip(const std::wstring &text)
{
Format f;
f.id = CF_UNICODETEXT;
f.data.resize((text.size() + 1) * sizeof(wchar_t));
memcpy(f.data.data(), text.c_str(), f.data.size());
return Clip{{std::move(f)}};
}
std::wstring Clip::text() const
{
for (const auto &f : formats)
if (f.id == CF_UNICODETEXT && f.data.size() >= 2)
{
std::wstring text(f.data.size() / 2, L'\0');
memcpy(text.data(), f.data.data(), text.size() * 2);
auto pos = text.find(L'\0');
if (pos != text.npos)
text.resize(pos);
return text;
}
return {};
}
size_t Clip::size() const
{
size_t n = 0;
for (const auto &f : formats)
n += f.data.size();
return n;
}
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;
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
};
for (auto [m, k] : {std::pair<UINT, WORD>{MOD_CONTROL, VK_CONTROL},
{MOD_ALT, VK_MENU},
{MOD_SHIFT, VK_SHIFT},
{MOD_WIN, VK_LWIN}})
if (mods & m)
add(k, false);
add(key, false);
add(key, true);
for (auto [m, k] : {std::pair<UINT, WORD>{MOD_WIN, VK_LWIN},
{MOD_SHIFT, VK_SHIFT},
{MOD_ALT, VK_MENU},
{MOD_CONTROL, VK_CONTROL}})
if (mods & m)
add(k, true);
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
}
bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
return name == L"ms-teams" || name == L"msedgewebview2";
}
bool selectPrevious(size_t count, const std::function<bool()> &valid)
{
if (count > 100000)
return false;
// Teams' WebView editor applies selection changes asynchronously. A burst of
// Shift+Left events can all use the same caret position and select only one
// character. Give each change time to settle before sending the next one.
const auto target = GetForegroundWindow();
if (needsSelectionDelay(target))
{
for (size_t i = 0; i < count; ++i)
{
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
return false;
Sleep(25);
}
return valid() && GetForegroundWindow() == target;
}
for (size_t offset = 0; offset < count; offset += 256)
{
if (!valid())
return false;
std::vector<INPUT> inputs;
auto add = [&](WORD key, DWORD flags)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = key;
i.ki.dwFlags = flags;
inputs.push_back(i);
};
add(VK_SHIFT, 0);
for (size_t i = 0; i < std::min<size_t>(256, count - offset); ++i)
{
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY);
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
}
add(VK_SHIFT, KEYEVENTF_KEYUP);
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
return false;
}
return valid();
}
bool modifiersReleased()
{
for (int k : {VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN, VK_RWIN})
if (GetAsyncKeyState(k) & 0x8000)
return false;
return true;
}
void releaseModifiers()
{
std::vector<INPUT> inputs;
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;
inputs.push_back(i);
}
if (!inputs.empty())
SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
}
std::wstring executable()
{
wchar_t path[32768];
DWORD n = GetModuleFileNameW(nullptr, path, 32768);
if (!n || n == 32768)
throw std::runtime_error("Cannot locate executable");
return std::wstring(path, n);
}
std::filesystem::path dataDirectory()
{
auto folder = std::filesystem::path(executable()).parent_path() / L"data";
std::error_code ec;
std::filesystem::create_directories(folder, ec);
HANDLE probe = CreateFileW((folder / L".probe").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr);
if (probe != INVALID_HANDLE_VALUE)
{
CloseHandle(probe);
return folder;
}
PWSTR base{};
if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &base)))
throw std::runtime_error("Cannot locate user data folder");
folder = std::filesystem::path(base) / L"KSwitcher";
CoTaskMemFree(base);
std::filesystem::create_directories(folder);
return folder;
}
void atomicWrite(const std::filesystem::path &path, const Bytes &bytes)
{
auto tmp = path;
tmp += L".tmp";
HANDLE h =
CreateFileW(tmp.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE)
throw std::runtime_error("Cannot open output file");
DWORD written{};
BOOL ok = WriteFile(h, bytes.data(), (DWORD)bytes.size(), &written, nullptr);
BOOL flushed = FlushFileBuffers(h);
CloseHandle(h);
if (!ok || written != bytes.size() || !flushed ||
!MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
{
DeleteFileW(tmp.c_str());
throw std::runtime_error("Cannot save file atomically");
}
}
Bytes readFile(const std::filesystem::path &path, size_t limit)
{
auto n = std::filesystem::file_size(path);
if (n > limit)
throw std::runtime_error("File exceeds size limit");
Bytes b((size_t)n);
std::ifstream f(path, std::ios::binary);
if (!f.read((char *)b.data(), (std::streamsize)b.size()))
throw std::runtime_error("Cannot read file");
return b;
}
Bytes protect(const Bytes &bytes)
{
DATA_BLOB in{(DWORD)bytes.size(), const_cast<BYTE *>(bytes.data())}, out{};
if (!CryptProtectData(&in, L"KSwitcher clipboard", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN,
&out))
throw std::runtime_error("Cannot encrypt history");
Bytes result(out.pbData, out.pbData + out.cbData);
LocalFree(out.pbData);
return result;
}
Bytes unprotect(const Bytes &bytes)
{
DATA_BLOB in{(DWORD)bytes.size(), const_cast<BYTE *>(bytes.data())}, out{};
if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out))
throw std::runtime_error("Cannot decrypt history for this Windows account");
Bytes result(out.pbData, out.pbData + out.cbData);
LocalFree(out.pbData);
return result;
}
std::wstring lastError(const std::wstring &context)
{
return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")";
}
void log(const std::wstring &message)
{
static std::mutex gate;
std::lock_guard lock(gate);
try
{
auto path = dataDirectory() / L"kswitcher.log";
std::error_code ec;
if (std::filesystem::file_size(path, ec) > 256 * 1024)
std::filesystem::remove(path, ec);
std::ofstream f(path, std::ios::app);
SYSTEMTIME t;
GetLocalTime(&t);
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
<< t.wSecond << ' ' << utf8(message) << '\n';
}
catch (...)
{
}
}
} // namespace ks
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "core.h"
#include <filesystem>
#include <objbase.h>
#include <span>
#include <stdexcept>
namespace ks
{
using Bytes = std::vector<unsigned char>;
struct Format
{
UINT id{};
std::wstring name;
Bytes data;
};
struct Clip
{
std::vector<Format> formats;
std::wstring text() const;
size_t size() const;
};
std::vector<Layout> readLayouts();
const Layout &physicalLayout();
wchar_t physicalKey(DWORD scan, bool shift);
HKL windowLayout(HWND);
bool activateWindow(HWND);
bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
std::wstring processName(HWND);
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);
bool needsSelectionDelay(HWND);
bool selectPrevious(size_t count, const std::function<bool()> &valid);
bool modifiersReleased();
void releaseModifiers();
std::filesystem::path dataDirectory();
void atomicWrite(const std::filesystem::path &, const Bytes &);
Bytes readFile(const std::filesystem::path &, size_t limit);
Bytes protect(const Bytes &);
Bytes unprotect(const Bytes &);
void setAutostart(bool, bool elevated = false);
std::wstring currentUserSid();
void configureAutostart(int mode);
std::wstring executable();
std::wstring lastError(const std::wstring &context);
void log(const std::wstring &message);
} // namespace ks
+304
View File
@@ -0,0 +1,304 @@
#include "richtext.h"
#include <algorithm>
#include <cctype>
#include <richedit.h>
#include <string_view>
namespace ks
{
namespace
{
std::string escape(const std::wstring &text)
{
std::string out;
for (wchar_t c : text)
{
if (c == '\r')
continue;
if (c == '\n')
out += "\\line ";
else if (c == '\t')
out += "\\tab ";
else if (c == '\\' || c == '{' || c == '}')
{
out += '\\';
out += char(c);
}
else if (c >= 32 && c < 127)
out += char(c);
else
out += "\\u" + std::to_string((int)(short)c) + "?";
}
return out;
}
const std::string header = "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Segoe UI;}}\\f0\\fs21\\uc1 ";
std::wstring entities(std::string_view input)
{
std::wstring out;
for (size_t i = 0; i < input.size();)
{
if (input[i] != '&')
{
auto end = input.find('&', i);
if (end == input.npos)
end = input.size();
out += wide(std::string(input.substr(i, end - i)));
i = end;
continue;
}
auto end = input.find(';', i + 1);
if (end != input.npos && end - i < 14)
{
auto name = std::string(input.substr(i + 1, end - i - 1));
uint32_t c = 0;
if (name == "amp")
c = '&';
else if (name == "lt")
c = '<';
else if (name == "gt")
c = '>';
else if (name == "quot")
c = '"';
else if (name == "apos")
c = '\'';
else if (name == "nbsp")
c = 160;
else if (name.starts_with('#'))
try
{
size_t used{};
bool hex = name.size() > 1 && (name[1] == 'x' || name[1] == 'X');
auto digits = name.substr(hex ? 2 : 1);
auto n = std::stoul(digits, &used, hex ? 16 : 10);
if (used == digits.size() && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff))
c = (uint32_t)n;
}
catch (...)
{
}
if (c)
{
if (c > 0xffff)
{
c -= 0x10000;
out += wchar_t(0xd800 + (c >> 10));
out += wchar_t(0xdc00 + (c & 1023));
}
else
out += wchar_t(c);
i = end + 1;
continue;
}
}
out += L'&';
++i;
}
return out;
}
std::string htmlRtf(std::string html)
{
auto start = html.find("<!--StartFragment-->");
auto end = html.find("<!--EndFragment-->");
if (start != html.npos && end != html.npos && end >= start + 20)
html = html.substr(start + 20, end - start - 20);
else
{
auto tag = html.find('<');
if (tag == html.npos)
return {};
html.erase(0, tag);
}
std::string out = header;
std::vector<std::string> stack;
std::string skip;
size_t chars = 0;
bool any = false;
for (size_t i = 0; i < html.size() && chars < 4096 && out.size() < 55000;)
{
if (html[i] != '<')
{
auto next = html.find('<', i);
if (next == html.npos)
next = html.size();
if (skip.empty())
{
auto text = entities(std::string_view(html).substr(i, next - i));
text.resize(std::min(text.size(), 4096 - chars));
chars += text.size();
out += escape(text);
any |= !text.empty();
}
i = next;
continue;
}
if (html.compare(i, 4, "<!--") == 0)
{
auto e = html.find("-->", i + 4);
if (e == html.npos)
break;
i = e + 3;
continue;
}
size_t e = i + 1;
char quote = 0;
for (; e < html.size(); ++e)
{
char c = html[e];
if (quote)
{
if (c == quote)
quote = 0;
}
else if (c == '\'' || c == '"')
quote = c;
else if (c == '>')
break;
}
if (e == html.size())
break;
auto tag = html.substr(i + 1, e - i - 1);
i = e + 1;
std::transform(tag.begin(), tag.end(), tag.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
size_t p = 0;
while (p < tag.size() && isspace((unsigned char)tag[p]))
++p;
bool close = p < tag.size() && tag[p] == '/';
if (close)
++p;
size_t begin = p;
while (p < tag.size() && (isalnum((unsigned char)tag[p]) || tag[p] == '-'))
++p;
auto name = tag.substr(begin, p - begin);
if (!skip.empty())
{
if (close && name == skip)
skip.clear();
continue;
}
if (name == "script" || name == "style" || name == "head")
{
if (!close)
skip = name;
continue;
}
if (name.empty())
continue;
bool block = name == "p" || name == "div" || name == "li" || name == "tr" || name == "blockquote";
if (close)
{
auto it = std::find(stack.rbegin(), stack.rend(), name);
if (it != stack.rend())
{
size_t n = std::distance(stack.rbegin(), it) + 1;
out.append(n, '}');
stack.resize(stack.size() - n);
}
if (block && any)
out += "\\line ";
continue;
}
if (name == "br" || name == "hr")
{
out += "\\line ";
continue;
}
if (name == "img" || name == "meta" || name == "link" || name == "input" || tag.ends_with('/'))
continue;
if (stack.size() >= 128)
return {};
if (block && any && !out.ends_with("\\line "))
out += "\\line ";
out += '{';
stack.push_back(name);
std::string compact = tag;
std::erase_if(compact, [](unsigned char c) { return isspace(c); });
if (name == "b" || name == "strong" || compact.find("font-weight:bold") != compact.npos ||
compact.find("font-weight:700") != compact.npos ||
compact.find("font-weight:600") != compact.npos)
out += "\\b ";
if (name == "i" || name == "em" || compact.find("font-style:italic") != compact.npos)
out += "\\i ";
if (name == "u" || compact.find("text-decoration:underline") != compact.npos ||
compact.find("text-decoration-line:underline") != compact.npos)
out += "\\ul ";
if (name == "s" || name == "strike" || name == "del" || compact.find("line-through") != compact.npos)
out += "\\strike ";
if (compact.find("font-weight:normal") != compact.npos ||
compact.find("font-weight:400") != compact.npos)
out += "\\b0 ";
if (compact.find("font-style:normal") != compact.npos)
out += "\\i0 ";
if (name == "li")
out += "\\bullet\\tab ";
}
if (!any)
return {};
out.append(stack.size(), '}');
out += '}';
return out;
}
struct Stream
{
const std::string &text;
size_t at{};
};
DWORD CALLBACK readStream(DWORD_PTR cookie, LPBYTE buffer, LONG size, LONG *read)
{
auto &s = *(Stream *)cookie;
*read = (LONG)std::min(size_t(size), s.text.size() - s.at);
memcpy(buffer, s.text.data() + s.at, *read);
s.at += *read;
return 0;
}
} // namespace
std::string richPreview(const Clip &clip)
{
for (const auto &f : clip.formats)
if (f.name == L"Rich Text Format" && f.data.size() <= 65536)
{
std::string rtf(f.data.begin(), f.data.end());
while (!rtf.empty() && rtf.back() == 0)
rtf.pop_back();
if (rtf.starts_with("{\\rtf"))
return rtf;
}
for (const auto &f : clip.formats)
if (f.name == L"HTML Format" && f.data.size() <= 1024 * 1024)
{
try
{
auto rtf = htmlRtf(std::string(f.data.begin(), f.data.end()));
if (!rtf.empty())
return rtf;
}
catch (...)
{
}
}
auto text = clip.text();
if (text.size() > 4096)
text.resize(4096);
return header + escape(text) + '}';
}
HWND createRichText(HWND parent, int id, bool visible)
{
static HMODULE rich = LoadLibraryExW(L"Msftedit.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!rich)
return nullptr;
return CreateWindowExW(0, L"RICHEDIT50W", L"",
WS_CHILD | ES_MULTILINE | ES_READONLY |
(visible ? (WS_VISIBLE | WS_VSCROLL | ES_AUTOVSCROLL) : 0),
0, 0, 1, 1, parent, (HMENU)(INT_PTR)id, GetModuleHandleW(nullptr), nullptr);
}
bool setRichText(HWND window, const std::string &text)
{
if (!window)
return false;
Stream data{text};
EDITSTREAM stream{(DWORD_PTR)&data, 0, readStream};
SendMessageW(window, EM_STREAMIN, SF_RTF, (LPARAM)&stream);
SendMessageW(window, EM_EMPTYUNDOBUFFER, 0, 0);
return stream.dwError == 0;
}
} // namespace ks
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "platform.h"
namespace ks
{
std::string richPreview(const Clip &);
HWND createRichText(HWND parent, int id, bool visible);
bool setRichText(HWND, const std::string &);
} // namespace ks
+427
View File
@@ -0,0 +1,427 @@
#include "storage.h"
#include "json.hpp"
#include "ocr.h"
#include <algorithm>
#include <set>
#include <sstream>
namespace ks
{
using Json = nlohmann::json;
namespace
{
Bytes jsonBytes(const Json &j)
{
auto s = j.dump(2);
return Bytes(s.begin(), s.end());
}
Json parse(const Bytes &bytes)
{
return Json::parse(bytes.begin(), bytes.end());
}
std::wstring str(const Json &j, const char *key, const std::wstring &fallback = L"")
{
return j.contains(key) ? wide(j.at(key).get<std::string>()) : fallback;
}
void put32(Bytes &b, uint32_t n)
{
for (int i = 0; i < 4; ++i)
b.push_back((unsigned char)(n >> (8 * i)));
}
uint32_t get32(const Bytes &b, size_t &p)
{
if (b.size() - p < 4)
throw std::runtime_error("Truncated clipboard record");
uint32_t n = 0;
for (int i = 0; i < 4; ++i)
n |= uint32_t(b[p++]) << (8 * i);
return n;
}
bool validId(const std::wstring &id)
{
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
}
} // namespace
void validateSettings(const Settings &s)
{
std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard};
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
for (auto &text : keys)
{
auto key = Hotkey::parse(text);
if (!key)
throw std::runtime_error("Invalid shortcut; use e.g. Ctrl+Alt+Shift+Space");
if (!key->key)
continue;
if ((key->key == VK_DELETE &&
(key->modifiers & (MOD_CONTROL | MOD_ALT)) == (MOD_CONTROL | MOD_ALT)) ||
(key->key == 'L' && key->modifiers == MOD_WIN))
throw std::runtime_error("Windows protects Ctrl+Alt+Delete and Win+L; choose another shortcut");
if (std::find(used.begin(), used.end(), *key) != used.end())
throw std::runtime_error("Keyboard shortcuts must be unique");
used.push_back(*key);
}
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
s.maxImageMiB > 64)
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
std::set<std::wstring> seen;
for (const auto &snippet : s.snippets)
{
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
if (!seen.insert(lower(snippet.trigger)).second)
throw std::runtime_error("Duplicate snippet trigger");
auto t = parseTemplate(snippet.text);
if (t.text.empty() || t.cursors > 1)
throw std::runtime_error("Each snippet needs text and at most one {cursor} marker");
}
}
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
{
Settings s;
for (size_t i = 0; i < layouts.size(); ++i)
{
if (layouts[i].convertible)
s.cycle.push_back(layouts[i].id);
s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
s.directIds.push_back(layouts[i].id);
}
if (!std::filesystem::exists(path))
return s;
auto j = parse(readFile(path, 4 * 1024 * 1024));
auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings")
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
s.recent = j.value("convertLastWord", true);
s.switchLayout = j.value("switchSystemLayout", true);
s.history = j.value("historyEnabled", true);
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
s.maxItems = std::clamp(j.value("maxItems", 500), 10, 10000);
s.maxMiB = std::clamp(j.value("maxTotalMegabytes", 200), 1, 2048);
s.maxImageMiB = std::clamp(j.value("maxImageMegabytes", 20), 1, 64);
s.toggle = str(j, "toggleHotkey", s.toggle);
s.casing = str(j, "caseHotkey", s.casing);
s.translit = str(j, "translitHotkey", s.translit);
s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
if (j.contains("layouts"))
{
s.cycle.clear();
for (auto &id : j.at("layouts"))
{
auto w = wide(id.get<std::string>());
if (std::find(s.cycle.begin(), s.cycle.end(), w) == s.cycle.end())
s.cycle.push_back(w);
}
}
if (j.contains("layoutHotkeys"))
{
std::fill(s.direct.begin(), s.direct.end(), L"");
for (auto &key : j.at("layoutHotkeys"))
for (size_t i = 0; i < layouts.size(); ++i)
if (layouts[i].id == str(key, "layoutId"))
s.direct[i] = str(key, "hotkey");
}
if (j.contains("excludedApps"))
{
s.excluded.clear();
for (auto &name : j.at("excludedApps"))
s.excluded.push_back(wide(name.get<std::string>()));
}
if (j.contains("snippets"))
for (auto &sn : j.at("snippets"))
s.snippets.push_back({str(sn, "trigger"), str(sn, "text")});
validateSettings(s);
return s;
}
void saveSettings(const std::filesystem::path &path, const Settings &s)
{
validateSettings(s);
Json j = {{"format", "kswitcher-settings"},
{"schemaVersion", 1},
{"convertLastWord", s.recent},
{"switchSystemLayout", s.switchLayout},
{"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history},
{"startWithSystem", s.autostart},
{"preventScreenLock", s.awake},
{"maxItems", s.maxItems},
{"maxTotalMegabytes", s.maxMiB},
{"maxImageMegabytes", s.maxImageMiB},
{"toggleHotkey", utf8(s.toggle)},
{"caseHotkey", utf8(s.casing)},
{"translitHotkey", utf8(s.translit)},
{"undoHotkey", utf8(s.undo)},
{"clipboardHotkey", utf8(s.clipboard)}};
j["layouts"] = Json::array();
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
j["layoutHotkeys"] = Json::array();
for (size_t i = 0; i < std::min(s.directIds.size(), s.direct.size()); ++i)
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
j["snippets"] = Json::array();
for (auto &sn : s.snippets)
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}});
j["excludedApps"] = Json::array();
for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name));
atomicWrite(path, jsonBytes(j));
}
Bytes encodeClip(const Clip &clip)
{
Bytes b;
put32(b, 0x3143534b);
put32(b, (uint32_t)clip.formats.size());
for (const auto &f : clip.formats)
{
auto name = utf8(f.name);
put32(b, f.id);
put32(b, (uint32_t)name.size());
put32(b, (uint32_t)f.data.size());
b.insert(b.end(), name.begin(), name.end());
b.insert(b.end(), f.data.begin(), f.data.end());
}
return b;
}
Clip decodeClip(const Bytes &b)
{
size_t p = 0;
if (get32(b, p) != 0x3143534b)
throw std::runtime_error("Unknown clipboard record");
auto count = get32(b, p);
if (count > 1024)
throw std::runtime_error("Too many clipboard formats");
Clip clip;
for (uint32_t i = 0; i < count; ++i)
{
Format f;
f.id = get32(b, p);
auto name = get32(b, p), size = get32(b, p);
if (name > 4096 || name > b.size() - p)
throw std::runtime_error("Invalid clipboard format name");
f.name = wide(std::string((const char *)b.data() + p, name));
p += name;
if (size > b.size() - p)
throw std::runtime_error("Truncated clipboard data");
f.data.assign(b.begin() + p, b.begin() + p + size);
p += size;
clip.formats.push_back(std::move(f));
}
if (p != b.size())
throw std::runtime_error("Trailing clipboard data");
return clip;
}
History::History(const std::filesystem::path &folder) : folder_(folder)
{
std::filesystem::create_directories(folder_);
auto index = folder_ / L"index.dat";
if (!std::filesystem::exists(index))
return;
auto j = parse(unprotect(readFile(index, 16 * 1024 * 1024)));
if (!j.is_array() || j.size() > 10000)
throw std::runtime_error("Invalid history index");
for (auto &e : j)
{
Entry item{str(e, "id"),
str(e, "preview"),
str(e, "app"),
e.value("bytes", uint64_t{}),
e.value("at", uint64_t{}),
e.value("pinned", false),
e.value("image", false),
e.value("files", false)};
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
entries_.push_back(std::move(item));
}
}
void History::saveIndex()
{
Json j = Json::array();
for (const auto &e : entries_)
j.push_back({{"id", utf8(e.id)},
{"preview", utf8(e.preview)},
{"app", utf8(e.app)},
{"bytes", e.bytes},
{"at", e.at},
{"pinned", e.pinned},
{"image", e.image},
{"files", e.files}});
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
}
std::vector<Entry> History::entries() const
{
std::lock_guard lock(mutex_);
return entries_;
}
Clip History::load(const std::wstring &id) const
{
if (!validId(id))
throw std::runtime_error("Invalid history ID");
return decodeClip(unprotect(readFile(folder_ / (id + L".dat"), 80 * 1024 * 1024)));
}
void History::prune(const Settings &s)
{
uint64_t total = 0;
for (auto &e : entries_)
total += e.bytes;
for (size_t i = entries_.size(); i > 0;)
{
--i;
if (entries_.size() <= (size_t)s.maxItems && total <= uint64_t(s.maxMiB) * 1024 * 1024)
break;
if (entries_[i].pinned)
continue;
total -= entries_[i].bytes;
std::error_code ec;
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
entries_.erase(entries_.begin() + i);
}
}
bool sameClipboardImage(const Clip &a, const Clip &b)
{
// OLE DataObject / Ole Private Data change when Snipping Tool republishes
// the same capture. They are transport metadata, not image identity.
auto image = [](const Clip &c) -> const Format *
{
if (!c.text().empty())
return nullptr;
for (const auto &f : c.formats)
if (f.id == CF_HDROP)
return nullptr;
for (const auto &f : c.formats)
if (f.name == L"PNG" && !f.data.empty())
return &f;
for (UINT id : {UINT(CF_DIBV5), UINT(CF_DIB)})
for (const auto &f : c.formats)
if (f.id == id && !f.data.empty())
return &f;
return nullptr;
};
const auto *left = image(a), *right = image(b);
if (!left || !right)
return false;
if (left->id == right->id && left->name == right->name && left->data == right->data)
return true;
try
{
// Different encodings can still represent exactly the same pixels.
// These buffers are temporary; image payloads never stay in the index.
auto x = decodeImage(a), y = decodeImage(b);
return x.width == y.width && x.height == y.height && x.bgra == y.bgra;
}
catch (const std::exception &)
{
return false; // Undecodable data is not evidence of a duplicate.
}
}
void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
{
if (clip.formats.empty() || !clip.size())
return;
auto data = encodeClip(clip);
if (data.size() > uint64_t(s.maxImageMiB) * 1024 * 1024)
return;
std::lock_guard lock(mutex_);
// Compare only the newest record; never keep image payloads in the resident index.
if (!entries_.empty())
{
try
{
auto previous = load(entries_.front().id);
if (encodeClip(previous) == data || sameClipboardImage(previous, clip))
return;
}
catch (...)
{
}
}
GUID guid{};
if (FAILED(CoCreateGuid(&guid)))
throw std::runtime_error("Cannot generate history ID");
wchar_t buf[40];
StringFromGUID2(guid, buf, 40);
std::wstring id = lower(std::wstring(buf + 1, 36));
auto encrypted = protect(data);
atomicWrite(folder_ / (id + L".dat"), encrypted);
auto text = clip.text();
bool image = false, files = false;
for (auto &f : clip.formats)
{
image |= f.id == CF_DIB || f.id == CF_DIBV5 || f.name == L"PNG";
files |= f.id == CF_HDROP;
}
if (text.empty())
text = files ? L"[Files]" : image ? L"[Image]" : L"[Clipboard data]";
for (auto &c : text)
if (c == L'\r' || c == L'\n' || c == L'\t')
c = L' ';
if (text.size() > 240)
text.resize(240);
FILETIME now;
GetSystemTimeAsFileTime(&now);
entries_.insert(entries_.begin(),
Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files});
prune(s);
saveIndex();
}
void History::remove(const std::wstring &id)
{
std::lock_guard lock(mutex_);
if (!validId(id))
return;
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
if (it == entries_.end())
return;
entries_.erase(it);
saveIndex();
std::error_code ec;
std::filesystem::remove(folder_ / (id + L".dat"), ec);
}
void History::pin(const std::wstring &id)
{
std::lock_guard lock(mutex_);
for (auto &e : entries_)
if (e.id == id)
e.pinned = !e.pinned;
saveIndex();
}
void History::clear()
{
std::lock_guard lock(mutex_);
auto old = entries_;
entries_.clear();
saveIndex();
for (auto &e : old)
{
std::error_code ec;
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
}
}
std::vector<Entry> History::search(const std::wstring &query) const
{
auto items = entries();
if (query.empty())
return items;
auto q = lower(query);
std::erase_if(items,
[&](auto &e)
{
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
return false;
try
{
return lower(load(e.id).text()).find(q) == q.npos;
}
catch (...)
{
return true;
}
});
return items;
}
} // namespace ks
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "platform.h"
#include <mutex>
namespace ks
{
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
void saveSettings(const std::filesystem::path &, const Settings &);
void validateSettings(const Settings &);
struct Entry
{
std::wstring id, preview, app;
uint64_t bytes{}, at{};
bool pinned{}, image{}, files{};
};
class History
{
std::filesystem::path folder_;
std::vector<Entry> entries_;
mutable std::mutex mutex_;
void saveIndex();
void prune(const Settings &);
public:
explicit History(const std::filesystem::path &);
std::vector<Entry> entries() const;
Clip load(const std::wstring &id) const;
void add(const Clip &, const std::wstring &app, const Settings &);
void remove(const std::wstring &);
void pin(const std::wstring &);
void clear();
std::vector<Entry> search(const std::wstring &) const;
};
Bytes encodeClip(const Clip &);
Clip decodeClip(const Bytes &);
bool sameClipboardImage(const Clip &, const Clip &);
} // namespace ks
+1155
View File
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
#include "app.h"
#include <iostream>
namespace
{
ks::App *app{};
HWND host{}, field{};
ks::Clip saved;
bool captured = false, failed = false;
int stage = 0, ru = -1, en = -1;
ULONGLONG deadline{};
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
void require(bool ok, const char *name)
{
if (!ok)
throw std::runtime_error(name);
std::cout << "PASS " << name << '\n';
}
std::wstring text()
{
wchar_t buffer[2048]{};
GetWindowTextW(field, buffer, 2048);
return buffer;
}
void prepare(const wchar_t *value, bool select)
{
app->resetInput();
SetWindowTextW(field, value);
ks::activateWindow(host);
SetFocus(field);
int end = GetWindowTextLengthW(field);
SendMessageW(field, EM_SETSEL, select ? 0 : end, end);
if (GetForegroundWindow() != host)
throw std::runtime_error("Test editor could not acquire foreground; no input was sent");
}
void record(const std::wstring &keys)
{
std::lock_guard lock(app->stateMutex);
for (wchar_t c : keys)
app->run.record(c, host, app->layouts[en].handle, GetTickCount64());
}
void finish()
{
KillTimer(host, 1);
if (captured)
{
try
{
app->ownSequence = ks::writeClipboard(app->window, saved);
}
catch (...)
{
failed = true;
}
}
DestroyWindow(host);
PostMessageW(app->window, WM_CLOSE, 0, 0);
}
LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_TIMER)
{
try
{
if (GetTickCount64() > deadline)
throw std::runtime_error("Native integration timed out");
if (app->busy)
return 0;
switch (stage++)
{
case 0:
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
break;
case 1:
std::cout << "Observed editor: " << ks::utf8(text()) << "; busy=" << app->busy
<< "; foreground=" << (GetForegroundWindow() == host) << "\n";
require(text() == L"привет", "selection conversion through actual Ctrl+C / Ctrl+V");
require(ks::readClipboard(app->window).text() == sentinel, "clipboard restored");
app->transform(ks::Undo);
break;
case 2:
require(text() == L"ghbdtn", "verified undo");
prepare(L"hello world", true);
app->transform(ks::Case);
break;
case 3:
require(text() == L"Hello World", "case conversion");
app->transform(ks::Case);
break;
case 4:
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
prepare(L"OLDghbdtn", false);
record(L"ghbdtn");
app->transform(ks::Direct + ru);
break;
case 5:
std::cout << "Recent observed: " << ks::utf8(text()) << "\n";
require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text");
prepare(L"OLDabcdef", false);
record(L"ghbdtn");
app->transform(ks::Direct + ru);
break;
case 6:
require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement");
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
prepare(L"prefix ;addr", false);
record(L";addr");
app->transform(0, 0);
break;
case 7:
{
require(text() == L"prefix LINE1\r\nLINE2", "multiline snippet expansion");
DWORD start{}, end{};
SendMessageW(field, EM_GETSEL, (WPARAM)&start, (LPARAM)&end);
require(start == end && start == 14, "verified snippet cursor placement");
require(ks::readClipboard(app->window).text() == sentinel,
"clipboard restored after repeated copy transactions");
finish();
break;
}
}
}
catch (const std::exception &e)
{
failed = true;
std::cerr << "FAIL " << e.what() << '\n';
finish();
}
return 0;
}
if (msg == WM_CLOSE)
{
failed = true;
finish();
return 0;
}
return DefWindowProcW(w, msg, wp, lp);
}
} // namespace
int main()
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
try
{
{
ks::App instance(GetModuleHandleW(nullptr), false, root);
app = &instance;
app->settings.history = false;
app->settings.switchLayout = false;
for (int i = 0; i < (int)app->layouts.size(); ++i)
{
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
if (language == 0x409)
en = i;
if (language == 0x419)
ru = i;
}
if (en < 0 || ru < 0)
throw std::runtime_error(
"Integration test requires installed English US and Russian layouts");
WNDCLASSW cls{};
cls.hInstance = GetModuleHandleW(nullptr);
cls.lpfnWndProc = procedure;
cls.lpszClassName = L"KSwitcher.TestEditor";
cls.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
RegisterClassW(&cls);
host = CreateWindowW(cls.lpszClassName, L"KSwitcher — automated test editor",
WS_OVERLAPPEDWINDOW | WS_VISIBLE, 100, 100, 650, 320, nullptr, nullptr,
cls.hInstance, nullptr);
field =
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000;
SetTimer(host, 1, 100, nullptr);
app->execute();
}
std::filesystem::remove_all(root);
}
catch (const std::exception &e)
{
std::cerr << e.what() << '\n';
failed = true;
}
CoUninitialize();
return failed ? 1 : 0;
}
+53
View File
@@ -0,0 +1,53 @@
#include "ocr.h"
#include <iostream>
int main()
{
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
try
{
BITMAPINFO info{};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = 800;
info.bmiHeader.biHeight = -150;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
void *data{};
HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &data, nullptr, 0);
if (!bitmap)
throw std::runtime_error("Cannot create OCR fixture");
HDC dc = CreateCompatibleDC(nullptr);
auto old = SelectObject(dc, bitmap);
RECT rect{0, 0, 800, 150};
FillRect(dc, &rect, (HBRUSH)GetStockObject(WHITE_BRUSH));
HFONT font = CreateFontW(-54, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, 0, 0,
ANTIALIASED_QUALITY, 0, L"Arial");
auto oldFont = SelectObject(dc, font);
SetTextColor(dc, RGB(0, 0, 0));
SetBkColor(dc, RGB(255, 255, 255));
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15);
GdiFlush();
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
SelectObject(dc, oldFont);
SelectObject(dc, old);
DeleteObject(font);
DeleteObject(bitmap);
DeleteDC(dc);
ks::Clip clip{{ks::imageDib(pixels)}};
auto decoded = ks::decodeImage(clip);
if (decoded.width != 800 || decoded.height != 150)
throw std::runtime_error("WIC decode size mismatch");
auto text = ks::recognizeText(clip);
std::cout << "OCR: " << ks::utf8(text) << '\n';
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
throw std::runtime_error("OCR fixture was not recognized");
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n";
CoUninitialize();
return 0;
}
catch (const std::exception &e)
{
std::cerr << e.what() << '\n';
CoUninitialize();
return 1;
}
}
+275
View File
@@ -0,0 +1,275 @@
#include "ocr.h"
#include "richtext.h"
#include "storage.h"
#include <algorithm>
#include <iostream>
#include <map>
#include <richedit.h>
namespace
{
int passed = 0;
void require(bool ok, const char *name)
{
if (!ok)
throw std::runtime_error(name);
++passed;
}
void rejects(const std::function<void()> &f, const char *name)
{
bool thrown = false;
try
{
f();
}
catch (...)
{
thrown = true;
}
require(thrown, name);
}
} // namespace
int main()
{
try
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
HWND richParent = CreateWindowExW(0, L"STATIC", L"Rich preview test", WS_POPUP, 0, 0, 300, 200,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
HWND rich = ks::createRichText(richParent, 1, false);
require(rich != nullptr, "native RichEdit loads without a browser runtime");
auto styled = ks::textClip(L"plain bold italic under\nnext");
std::string html = "Version:1.0\r\n<html><!--StartFragment--><div>plain <strong>bold</strong> "
"<em>italic</em> <u>under</u><br>next</div><!--EndFragment--></html>";
styled.formats.push_back({0, L"HTML Format", ks::Bytes(html.begin(), html.end())});
require(ks::setRichText(rich, ks::richPreview(styled)),
"HTML clipboard preview streams into RichEdit");
auto effects = [&](LONG first, LONG last)
{
CHARRANGE range{first, last};
SendMessageW(rich, EM_EXSETSEL, 0, (LPARAM)&range);
CHARFORMAT2W format{};
format.cbSize = sizeof(format);
SendMessageW(rich, EM_GETCHARFORMAT, SCF_SELECTION, (LPARAM)&format);
return format.dwEffects;
};
require((effects(6, 10) & CFE_BOLD) != 0, "HTML bold is displayed as native bold");
require((effects(11, 17) & CFE_ITALIC) != 0, "HTML italic is displayed as native italic");
require((effects(18, 23) & CFE_UNDERLINE) != 0, "HTML underline is displayed natively");
require((effects(0, 5) & (CFE_BOLD | CFE_ITALIC | CFE_UNDERLINE)) == 0,
"inline formatting does not leak into plain text");
wchar_t rendered[256]{};
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered).find(L"\r\nnext") != std::wstring::npos ||
std::wstring(rendered).find(L"\rnext") != std::wstring::npos,
"HTML line breaks are retained");
html = "<!--StartFragment--><span style='font-weight:700'>Привіт &amp; &#x1F30D;</span>"
"<script>never displayed</script><!--EndFragment-->";
styled.formats.back().data = ks::Bytes(html.begin(), html.end());
ks::setRichText(rich, ks::richPreview(styled));
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered) == L"Привіт & 🌍",
"HTML entities and Unicode survive without script text");
require((effects(0, 6) & CFE_BOLD) != 0, "inline CSS bold is rendered");
auto plain = ks::textClip(L"{\\literal} Привіт");
ks::setRichText(rich, ks::richPreview(plain));
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered) == plain.text(), "plain Unicode and RTF metacharacters are escaped");
std::string rtf = "{\\rtf1\\ansi \\b native}";
plain.formats.push_back({0, L"Rich Text Format", ks::Bytes(rtf.begin(), rtf.end())});
ks::setRichText(rich, ks::richPreview(plain));
require((effects(0, 6) & CFE_BOLD) != 0, "original RTF formatting is preserved");
DestroyWindow(richParent);
ks::Layout en = ks::physicalLayout();
en.id = L"en";
ks::Layout ru{nullptr, L"ru", L"Russian", L"ё1234567890-=йцукенгшщзхъфывапролджэячсмитьбю.\\",
L"Ё!\"№;%:?*()_+ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,/"};
ks::Layout uk{nullptr, L"uk", L"Ukrainian", L"'1234567890-=йцукенгшщзхїфівапролджєячсмитьбю.\\",
L"₴!\"№;%:?*()_+ЙЦУКЕНГШЩЗХЇФІВАПРОЛДЖЄЯЧСМИТЬБЮ,/"};
require(en.normal.size() == ru.normal.size(), "physical maps align");
require(en.normal.size() == uk.normal.size(), "Ukrainian maps align");
require(ks::convert(L"ghbdtn", en, ru) == L"привет", "English to Russian");
require(ks::convert(L"привіт", uk, en) == L"ghbdsn", "Ukrainian to English");
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
std::vector<ks::Layout> layouts{en, ru, uk};
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
require(ks::transliterate(L"Привет") == L"Privet", "forward transliteration");
require(ks::transliterate(L"Київ") == L"Kyiv", "Ukrainian transliteration");
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
require(!ks::Hotkey::parse(L"Ctrl+A+B"), "multiple keys rejected");
require(ks::Hotkey::parse(L"")->key == 0, "disabled shortcut");
ks::HotkeyHook hook;
std::vector<std::pair<int, ks::Hotkey>> bindings{
{5, {MOD_WIN, 'V'}}, {1, {MOD_ALT, VK_F12}}, {100, {MOD_CONTROL | MOD_SHIFT, 'U'}}};
require(hook.update('V', true, MOD_WIN, bindings) == 5, "Win+V dispatches configured action");
require(hook.update('V', true, MOD_WIN, bindings) == -1, "held hotkey does not repeat");
require(hook.update('V', false, 0, {}) == -1, "key-up consumed after settings change");
require(hook.update('V', true, MOD_CONTROL, bindings) == 0, "unassigned Ctrl+V unaffected");
require(hook.update('V', true, MOD_WIN | MOD_SHIFT, bindings) == 0, "modifiers match exactly");
require(hook.update('V', true, MOD_WIN, {}) == 0, "disabled binding leaves OS shortcut intact");
require(hook.update(VK_F12, true, MOD_ALT, bindings) == 1, "conversion uses same hook");
require(hook.update('U', true, MOD_CONTROL | MOD_SHIFT, bindings) == 100,
"direct layout uses same hook");
require(hook.update(VK_F12, false, 0, bindings) == -1, "independent key release consumed");
require(hook.update('U', false, 0, bindings) == -1, "second key release consumed");
require(hook.update('V', true, MOD_WIN, bindings) == 5, "hotkey activates again after release");
bindings.push_back({2, {MOD_WIN | MOD_SHIFT, 'S'}});
require(hook.update('S', true, MOD_WIN | MOD_SHIFT, bindings) == 2,
"Win+Shift+S can replace the Snipping Tool shortcut");
require(hook.update('S', false, 0, bindings) == -1, "Snipping Tool shortcut release consumed");
for (UINT key :
{UINT('K'), UINT(VK_F12), UINT(VK_SPACE), UINT(VK_OEM_PLUS), UINT(VK_LEFT), UINT(VK_NUMPAD7)})
{
ks::Hotkey recorded{MOD_CONTROL | MOD_ALT | MOD_SHIFT, key};
require(ks::Hotkey::parse(recorded.format()) == recorded, "recorded shortcut roundtrip");
}
ks::Run run;
run.record(L'g', (HWND)1, (HKL)1, 1);
run.record(L'h', (HWND)1, (HKL)1, 2);
require(run.match(L"пр", layouts, en) == 1, "recent input verification");
require(run.match(L"xx", layouts, en) == -1, "unrelated selected text rejected");
require(run.match(L"xпр", layouts, en) == -1,
"word selection including older text is rejected before replacement");
require(run.match(L"р", layouts, en) == -1,
"coalesced Teams selection of only the final character is rejected");
require(run.match(L"пр ", layouts, en) == -1,
"word selection including unrecorded whitespace is rejected");
run.record(L'b', (HWND)1, (HKL)1, 2002);
require(run.keys == L"b", "pause resets run");
run.record(L'c', (HWND)2, (HKL)1, 2003);
require(run.keys == L"c", "window resets run");
run.record(L'd', (HWND)2, (HKL)2, 2004);
require(run.keys == L"d", "layout resets run");
run.record(L' ', (HWND)2, (HKL)2, 2005);
run.record(L'e', (HWND)2, (HKL)2, 2006);
require(run.keys == L"e", "word boundary resets run");
for (int i = 0; i < 513; ++i)
run.record(L'x', (HWND)2, (HKL)2, 2007 + i);
require(run.overflow && run.keys.empty(), "overflow does not select partial input");
std::map<DWORD, std::pair<DWORD, std::wstring>> processes{
{10, {1, L"ms-teams.exe"}}, {20, {10, L"msedgewebview2.exe"}},
{30, {20, L"msedgewebview2.exe"}}, {40, {1, L"msedgewebview2.exe"}},
{50, {10, L"other.exe"}}, {60, {60, L"msedgewebview2.exe"}}};
auto read = [&](DWORD id) -> std::optional<std::pair<DWORD, std::wstring>>
{
auto it = processes.find(id);
return it == processes.end() ? std::nullopt : std::optional(it->second);
};
require(ks::webViewDescendant(30, 10, read), "Teams renderer belongs to host");
require(!ks::webViewDescendant(40, 10, read), "other WebView rejected");
require(!ks::webViewDescendant(50, 10, read), "arbitrary child rejected");
require(!ks::webViewDescendant(60, 10, read), "process cycles rejected");
require(!ks::webViewDescendant(0, 0, read), "unknown process rejected");
auto clip = ks::textClip(L"Привіт 🌍\r\nline 2");
clip.formats.push_back({0, L"HTML Format", {1, 2, 3, 0}});
auto encoded = ks::encodeClip(clip);
require(ks::decodeClip(encoded).text() == clip.text(), "clipboard Unicode roundtrip");
require(ks::decodeClip(encoded).formats[1].name == L"HTML Format", "registered format identity");
auto encrypted = ks::protect(encoded);
require(encrypted != encoded, "history ciphertext differs");
require(ks::unprotect(encrypted) == encoded, "Windows DPAPI roundtrip");
encrypted[encrypted.size() / 2] ^= 0x40;
rejects([&] { ks::unprotect(encrypted); }, "tampering rejected");
encoded.pop_back();
rejects([&] { ks::decodeClip(encoded); }, "truncated clip rejected");
auto temp = std::filesystem::temp_directory_path() /
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(temp);
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
"thumbnail decoding limits the resident pixel buffer");
thumbnail = ks::decodeImage(firstImage, 208, 128);
require(thumbnail.width == 2 && thumbnail.height == 1,
"thumbnail decoding does not upscale small images");
auto secondImage = firstImage;
secondImage.formats[1].data = {4, 5, 6};
secondImage.formats.push_back({0, L"DataObject", {9, 8, 7}});
require(ks::sameClipboardImage(firstImage, secondImage), "OLE metadata does not duplicate an image");
std::reverse(secondImage.formats.begin(), secondImage.formats.end());
require(ks::sameClipboardImage(firstImage, secondImage),
"format ordering does not duplicate an image");
auto recoded = firstImage;
recoded.formats[0].data[24] = 1; // Different bitmap resolution metadata; identical pixels.
require(ks::sameClipboardImage(firstImage, recoded), "decoded image ignores resolution metadata");
pixels.bgra[0]++;
ks::Clip changedImage{{ks::imageDib(pixels)}};
require(!ks::sameClipboardImage(firstImage, changedImage),
"a changed pixel remains a distinct image");
auto withText = firstImage;
withText.formats.push_back(ks::textClip(L"caption").formats.front());
require(!ks::sameClipboardImage(firstImage, withText),
"image deduplication preserves accompanying text");
auto withFiles = firstImage;
withFiles.formats.push_back({CF_HDROP, L"", {1}});
require(!ks::sameClipboardImage(firstImage, withFiles), "image deduplication preserves file copies");
{
ks::History history(temp / L"images");
ks::Settings defaults;
history.add(firstImage, L"SnippingTool", defaults);
history.pin(history.entries().front().id);
history.add(secondImage, L"SnippingTool", defaults);
require(history.entries().size() == 1 && history.entries().front().pinned,
"Snipping Tool republish retains one pinned history entry");
history.add(changedImage, L"SnippingTool", defaults);
require(history.entries().size() == 2, "a new screenshot is retained");
}
ks::Settings settings;
settings.elevatedAutostart = true;
settings.cycle = {L"en", L"uk"};
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {});
require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
require(restored.cycle == settings.cycle, "layout order roundtrip");
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
"elevated autostart is opt-in for existing and default settings");
auto invalid = settings;
invalid.undo = invalid.toggle;
rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected");
invalid = settings;
invalid.snippets[0].text = L"{cursor}{cursor}";
rejects([&] { ks::validateSettings(invalid); }, "invalid snippet rejected");
{
ks::History history(temp / L"history");
history.add(clip, L"Test", settings);
history.add(clip, L"Test", settings);
require(history.entries().size() == 1, "history consecutive deduplication");
auto id = history.entries()[0].id;
history.pin(id);
require(history.entries()[0].pinned, "pin persisted in memory");
require(history.search(L"line 2").size() == 1, "full text search");
require(history.load(id).text() == clip.text(), "history payload roundtrip");
}
{
ks::History history(temp / L"history");
require(history.entries().size() == 1 && history.entries()[0].pinned, "history index reload");
rejects([&] { history.load(L"../other"); }, "history path traversal rejected");
history.clear();
require(history.entries().empty(), "clear history");
}
// Test-owned directory only; never touch the user's clipboard or stored history.
std::filesystem::remove_all(temp);
CoUninitialize();
std::cout << "Passed " << passed << " native regression checks\n";
return 0;
}
catch (const std::exception &e)
{
std::cerr << "FAILED: " << e.what() << '\n';
return 1;
}
}
+23
View File
@@ -0,0 +1,23 @@
"""Regenerate the native multi-resolution icon (development only; requires Pillow)."""
from pathlib import Path
from PIL import Image, ImageDraw
root = Path(__file__).resolve().parents[1] / 'resources'
# Geometric K: white stem/upper arm, mint lower arm on an indigo keycap.
polygons = [('#FFFFFF', [(8, 7), (12, 7), (12, 25), (8, 25)]),
('#FFFFFF', [(11, 15), (22, 6), (25, 9), (14, 18)]),
('#5EEAD4', [(11, 14), (25, 24), (22, 27), (10, 18)])]
svg = '<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 32 32">\n'
svg += '<rect x="1" y="1" width="30" height="30" rx="7" fill="#4F46E5"/>\n'
for color, points in polygons:
svg += '<polygon fill="' + color + '" points="' + ' '.join(f'{x},{y}' for x,y in points) + '"/>\n'
(root / 'kswitcher.svg').write_text(svg + '</svg>\n', encoding='utf-8')
scale = 32
im = Image.new('RGBA', (32*scale, 32*scale))
draw = ImageDraw.Draw(im)
draw.rounded_rectangle((scale, scale, 31*scale, 31*scale), radius=7*scale, fill='#4F46E5')
for color, points in polygons:
draw.polygon([(x*scale,y*scale) for x,y in points], fill=color)
im = im.resize((256,256),Image.Resampling.LANCZOS)
im.save(root / 'kswitcher.png')
im.save(root / 'kswitcher.ico', sizes=[(s,s) for s in (16,20,24,32,40,48,64,128,256)])
+29
View File
@@ -0,0 +1,29 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot
$resultPath = Join-Path $root 'build\autostart-install-result.json'
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator approval is required.' }
$exe = Join-Path $root 'build\KSwitcher.exe'
$staged = Join-Path $root 'build\staged\KSwitcher.exe'
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
$exitProcess = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
if (-not $exitProcess.WaitForExit(5000)) { throw 'KSwitcher exit command timed out.' }
foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop; no executable was replaced.' } }
Copy-Item -LiteralPath $exe -Destination (Join-Path $root 'build\staged\KSwitcher.previous.exe') -Force
Copy-Item -LiteralPath $staged -Destination $exe -Force
$helper = Start-Process -FilePath $exe -ArgumentList @('--configure-autostart=2', $identity.User.Value) -WindowStyle Hidden -PassThru
$helper.WaitForExit()
if ($helper.ExitCode -ne 0) { throw 'The native autostart helper failed.' }
$settingsPath = Join-Path $root 'build\data\settings.json'
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
$settings.startWithSystem = $true
$settings | Add-Member -NotePropertyName autostartAsAdministrator -NotePropertyValue $true -Force
[IO.File]::WriteAllText($settingsPath, ($settings | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false))
Start-ScheduledTask -TaskName 'KSwitcher'
$task = Get-ScheduledTask -TaskName 'KSwitcher'
[pscustomobject]@{success=$true; task=$task.TaskName; runLevel=[string]$task.Principal.RunLevel; logonType=[string]$task.Principal.LogonType; user=$task.Principal.UserId; executable=$task.Actions.Execute; state=[string]$task.State} | ConvertTo-Json | Set-Content -LiteralPath $resultPath -Encoding UTF8
} catch {
[pscustomobject]@{success=$false; error=$_.Exception.Message} | ConvertTo-Json | Set-Content -LiteralPath $resultPath -Encoding UTF8
exit 1
}
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot
try {
$exe = Join-Path $root 'build\KSwitcher.exe'
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' }
foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } }
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
Start-Process -FilePath $exe -WindowStyle Hidden
'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt')
} catch { $_.Exception.Message | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt'); exit 1 }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2025 Niels Lohmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25526
View File
File diff suppressed because it is too large Load Diff