Implement native Windows KSwitcher with configurable hotkeys and clipboard history
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
BasedOnStyle: Microsoft
|
||||||
|
IndentWidth: 4
|
||||||
|
ColumnLimit: 110
|
||||||
|
AllowShortFunctionsOnASingleLine: Empty
|
||||||
|
AllowShortIfStatementsOnASingleLine: Never
|
||||||
|
AllowShortLoopsOnASingleLine: false
|
||||||
|
BreakBeforeBraces: Allman
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/build/
|
||||||
|
/.tools/
|
||||||
|
/data/
|
||||||
|
/.vs/
|
||||||
|
/.idea/
|
||||||
|
/dist/
|
||||||
|
/build-output.txt
|
||||||
|
*.user
|
||||||
@@ -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 .)
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,2 @@
|
|||||||
|
#pragma once
|
||||||
|
#define IDI_KSWITCHER 101
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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'>Привіт & 🌍</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)])
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
Vendored
+21
@@ -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.
|
||||||
Vendored
+25526
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user