Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cdcc92eb7 | |||
| 77f27ea087 | |||
| c326db7dc5 | |||
| 8b5f632ad7 | |||
| 5c786e12a9 |
+5
-5
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.8 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.13 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
@@ -9,7 +9,7 @@ add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorre
|
||||
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 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
@@ -18,9 +18,9 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
|
||||
@@ -36,7 +36,7 @@ if(MSVC)
|
||||
else()
|
||||
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
|
||||
@@ -16,10 +16,10 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
||||
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.
|
||||
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||
@@ -178,8 +178,21 @@ Select **Update** in the tray menu to check the stable Releases of
|
||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
||||
It reports each available version once per session and never installs automatically.
|
||||
Update availability uses the same Windows toast notification channel as screenshot notifications.
|
||||
Click the notification or **Review update** to check the latest release and confirm installation;
|
||||
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
|
||||
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||
Confirm the offered version to download and install it. The updater uses the existing
|
||||
Confirm the offered version to download and install it. A progress window shows
|
||||
download percentage and received/total size, package verification,
|
||||
installation and restart. After the new process confirms startup, that window shows the installed
|
||||
version and a Done button; it does not send a separate completion notification. If installation
|
||||
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
||||
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
||||
the currently installed build, so it becomes available for subsequent updates after installing it.
|
||||
|
||||
The updater uses the existing
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||
@@ -278,6 +291,8 @@ to the detected current layout, is treated as expected. `paste-sent-unverified`
|
||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
|
||||
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
|
||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||
@@ -338,15 +353,27 @@ after Space, not immediately after a punctuation key. Save Settings to apply cha
|
||||
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
||||
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
||||
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
||||
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
||||
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
||||
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
||||
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||
are eligible as correction targets at any frequency limit. The language and target layout must still
|
||||
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
||||
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
||||
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and
|
||||
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
|
||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
|
||||
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||
files before replacing dictionaries with a new distribution.
|
||||
|
||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
|
||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
||||
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||
key presses and releases; no typing pause is required. Queued input is replayed after
|
||||
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
||||
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
||||
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
||||
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
||||
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
||||
Different valid spellings remain ambiguous and are left unchanged.
|
||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||
does not count toward this minimum; the maximum word length is 32 letters.
|
||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||
|
||||
Binary file not shown.
+6
-11
@@ -1,29 +1,24 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.8 — 2026-10-29
|
||||
## v0.6.13 — 2026-10-30
|
||||
|
||||
## Changes
|
||||
- Recognize clipboard images automatically in a background worker, including older images without OCR. Save recognized text encrypted alongside the original image and search it without rerunning OCR.
|
||||
- Keep clipboard capture, previews and OCR from delaying history search. Skip unnecessary image-payload reads; ignore superseded search results.
|
||||
- Add optional Tesseract 5 support in Settings → OCR options, with automatic installation detection, executable selection, combined languages such as ukr+eng+rus, installation validation and a Download Tesseract button linking to the Windows installer page. Windows OCR remains the default; no downloads are automatic.
|
||||
- Refresh cached image text when the OCR engine or languages change. Reuse OCR for Copy text from image; preserve the clipboard during automatic recognition.
|
||||
- Start the screenshot editor with Pencil / Freehand (F). Pointer remains available with V.
|
||||
- Correct words containing punctuation keys that become letters in the target layout, such as `hj,bnm` → `робить`. Validate letters and capitalization after conversion so personal dictionary entries can match.
|
||||
- Extend automatic word selection across internal punctuation only while the selected text matches a suffix of the tracked input. Verify the complete tracked word before replacing it.
|
||||
- Give the fixed-layout automatic-correction settings dialog a fixed window size, retaining scrolling on small displays instead of allowing empty space when resized.
|
||||
|
||||
## Validation
|
||||
203 native checks, 445 input/layout checks, screenshot editor UI integration (including the initial pencil cursor), isolated notification registration, and 45 updater checks. Native Windows OCR and optional Tesseract tests verify background recognition, encrypted persistence, unchanged clipboard, mixed Ukrainian/English text, and engine-cache migration. OCR settings were also checked in the running configurator.
|
||||
220 native regression checks, 466 input/UI checks, screenshot editor and notification registration checks, 45 updater checks and updater progress checks passed. Native editor integration verified continuous typing during correction and complete selection/conversion of `hj,bnm` to `робить`. The correction was also confirmed in the user's editor.
|
||||
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
||||
|
||||
Documentation: [English](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard) · [Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard-uk).
|
||||
|
||||
Build and package:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Test -Ocr
|
||||
.\build\KSwitcherOcrTests.exe --tesseract
|
||||
.\build.ps1 -Stage -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
31c2c9923ff0f6d71eb421bcea890409cedf7a48999ebc513d0adedf5a2c9839 KSwitcher-win-x64.zip
|
||||
eae70031520882cc6a798e11a640fa101df01db12aeba4e6c96d8aa90037ae1e KSwitcher-win-x64.zip
|
||||
|
||||
@@ -27,7 +27,7 @@ try {
|
||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||
if ($LASTEXITCODE) { throw 'Screenshot 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/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.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
|
||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||
@@ -43,7 +43,7 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||
& .\build\KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||
@@ -61,15 +61,17 @@ try {
|
||||
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-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/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||
& .\build\KSwitcherOcrTests.exe
|
||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||
|
||||
+12
-2
@@ -17,9 +17,19 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
|
||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||
|
||||
File format: UTF-8 word, TAB, positive original frequency rank, newline.
|
||||
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
|
||||
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
||||
They are included with the default unlimited rank setting and recorded in sources.json.
|
||||
|
||||
File format: UTF-8 word, TAB, positive frequency rank (or supplemental rank), newline.
|
||||
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
||||
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
||||
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator.
|
||||
Prefer Settings > Auto-correction options > My dictionary for personal words.
|
||||
Enter correct spellings in the matching language field, separated by commas or new lines.
|
||||
These words are stored in settings, survive dictionary updates, and need no ranks or sorting.
|
||||
Click OK, then Save in Settings to apply them.
|
||||
|
||||
For advanced file edits, personal words may use rank 1. Keep entries lowercase with a Tab separator.
|
||||
Words may be appended: loading sorts entries and merges duplicates using the lowest rank.
|
||||
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
||||
Back up edited files before replacing dictionaries with a new distribution.
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
"uk": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||
"words": 42549,
|
||||
"bytes": 898210,
|
||||
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
|
||||
"words": 42550,
|
||||
"bytes": 898227,
|
||||
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
|
||||
"supplementalWords": {
|
||||
"\u0440\u0435\u043b\u0456\u0437": 50001
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
||||
|
||||
@@ -31207,6 +31207,7 @@
|
||||
релігію 26138
|
||||
релігія 22202
|
||||
релігії 23427
|
||||
реліз 50001
|
||||
рембо 28603
|
||||
ремень 23872
|
||||
ремені 28385
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,8,0
|
||||
PRODUCTVERSION 0,6,8,0
|
||||
FILEVERSION 0,6,13,0
|
||||
PRODUCTVERSION 0,6,13,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.8"
|
||||
VALUE "FileVersion", "0.6.13"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+215
-5
@@ -3,10 +3,192 @@
|
||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||
[switch]$ShowProgress,
|
||||
[switch]$Library
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$script:progressWindow = $null
|
||||
|
||||
function Start-UpdateProgress {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
if (!('KSwitcherUpdateProgress' -as [type])) {
|
||||
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
public sealed class UpdateMeter : Control {
|
||||
public int Percent = -1;
|
||||
private int position;
|
||||
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
||||
public UpdateMeter() {
|
||||
DoubleBuffered = true;
|
||||
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
||||
timer.Start();
|
||||
}
|
||||
protected override void OnPaint(PaintEventArgs e) {
|
||||
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
||||
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
||||
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
||||
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
||||
}
|
||||
}
|
||||
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
||||
}
|
||||
public sealed class UpdateSteps : Control {
|
||||
public int Current = -1;
|
||||
public bool Complete;
|
||||
public UpdateSteps() { DoubleBuffered = true; }
|
||||
protected override void OnPaint(PaintEventArgs e) {
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
string[] names = {"Download","Verify","Install","Restart"};
|
||||
for(int i=0;i<4;i++) {
|
||||
int x = i*(Width-80)/3;
|
||||
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
||||
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
||||
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
||||
using(var brush = new SolidBrush(color)) {
|
||||
if(Complete || i<Current) {
|
||||
e.Graphics.FillEllipse(brush,x,0,16,16);
|
||||
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
||||
} else {
|
||||
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
||||
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
||||
}
|
||||
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public sealed class KSwitcherUpdateProgress : IDisposable {
|
||||
private Form form;
|
||||
private Label stage, detail, percentage, footer;
|
||||
private UpdateMeter progress;
|
||||
private UpdateSteps steps;
|
||||
private Button done, close;
|
||||
private bool closing;
|
||||
private Exception failure;
|
||||
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
||||
private readonly Thread thread;
|
||||
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
||||
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
||||
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
||||
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
||||
public KSwitcherUpdateProgress() {
|
||||
thread = new Thread(delegate() {
|
||||
try {
|
||||
Application.EnableVisualStyles();
|
||||
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
||||
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
||||
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
||||
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
||||
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
||||
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
||||
};
|
||||
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
||||
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
||||
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
||||
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
||||
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
||||
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
||||
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
||||
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
||||
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
||||
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
||||
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
||||
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
||||
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
||||
bottom.Controls.AddRange(new Control[]{footer,done});
|
||||
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
||||
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
||||
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
||||
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
||||
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
||||
Application.Run(form);
|
||||
} catch(Exception e) { failure = e; ready.Set(); }
|
||||
});
|
||||
thread.IsBackground = true;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
||||
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
||||
}
|
||||
public void Report(string title, string text, int percent) {
|
||||
if (form == null || form.IsDisposed) return;
|
||||
form.BeginInvoke((Action)delegate {
|
||||
stage.Text = title; detail.Text = text;
|
||||
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
||||
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
||||
int separator = text.IndexOf("% - ");
|
||||
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
||||
if(title.StartsWith("Downloading")) steps.Current=0;
|
||||
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
||||
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
||||
else if(title.StartsWith("Restarting")) steps.Current=3;
|
||||
progress.Invalidate(); steps.Invalidate();
|
||||
});
|
||||
}
|
||||
public void SavePreview(string path) {
|
||||
form.Invoke((Action)delegate {
|
||||
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
||||
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
||||
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
||||
}
|
||||
});
|
||||
}
|
||||
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
||||
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
||||
public void Finish(string title, string text, bool success) {
|
||||
form.Invoke((Action)delegate {
|
||||
closing = true;
|
||||
stage.Text = title; detail.Text = text;
|
||||
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
||||
steps.Complete = success;
|
||||
if(!success) {
|
||||
detail.Visible = false; steps.Visible = false;
|
||||
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
||||
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
||||
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
||||
}
|
||||
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
||||
close.Enabled = true; done.Visible = true;
|
||||
form.AcceptButton = done; form.CancelButton = done;
|
||||
progress.Invalidate(); steps.Invalidate();
|
||||
});
|
||||
}
|
||||
public void WaitForClose() { thread.Join(); }
|
||||
public void Dispose() {
|
||||
if (form != null && !form.IsDisposed)
|
||||
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
||||
thread.Join(3000);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
||||
}
|
||||
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
||||
if ($script:progressWindow) {
|
||||
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
||||
catch { Write-Warning 'Could not refresh update progress.' }
|
||||
}
|
||||
}
|
||||
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
||||
if ($script:progressWindow) {
|
||||
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
||||
catch { Write-Warning 'Could not display the update result.' }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-StableVersion([string]$Tag) {
|
||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||
}
|
||||
}
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$request = [Net.HttpWebRequest]::Create($Url)
|
||||
$request.AllowAutoRedirect = $false
|
||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
$buffer = New-Object byte[] 65536
|
||||
$total = 0L
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
$lastProgress = [DateTime]::MinValue
|
||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||
$total += $count
|
||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||
$outputStream.Write($buffer,0,$count)
|
||||
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
||||
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
||||
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
||||
Show-UpdateProgress $ProgressLabel $detail $percent
|
||||
$lastProgress = [DateTime]::UtcNow
|
||||
}
|
||||
}
|
||||
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
||||
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
||||
} finally {
|
||||
if ($outputStream) { $outputStream.Dispose() }
|
||||
if ($inputStream) { $inputStream.Dispose() }
|
||||
@@ -220,8 +411,9 @@ function Write-Result($Value) {
|
||||
if ($Library) { return }
|
||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||
$changed = [Collections.Generic.List[string]]::new()
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||
try {
|
||||
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||
$current = Get-StableVersion $CurrentVersion
|
||||
if (!$current) { throw 'Invalid installed version.' }
|
||||
if ($Mode -eq 'Install') {
|
||||
@@ -242,25 +434,35 @@ try {
|
||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||
Receive-File $release.zip $archive 50MB
|
||||
Show-UpdateProgress 'Downloading update' $release.version 0
|
||||
Receive-File $release.zip $archive 50MB 'Downloading update'
|
||||
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
||||
Receive-File $release.checksum $checksum 64KB
|
||||
Assert-Checksum $archive $checksum
|
||||
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
$parentExited = $true
|
||||
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||
Install-Files $stage $InstallDirectory $backup $changed
|
||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||
$success = $true
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||
}
|
||||
} catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($parentExited) {
|
||||
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||
try {
|
||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||
Undo-Files $InstallDirectory $backup $changed
|
||||
@@ -273,8 +475,12 @@ try {
|
||||
}
|
||||
Write-Result @{state='error';error=$message}
|
||||
if ($ready -and $ready.WaitOne(0)) {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||
} else {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||
}
|
||||
}
|
||||
exit 1
|
||||
} finally {
|
||||
@@ -291,4 +497,8 @@ try {
|
||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($script:progressWindow) {
|
||||
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
||||
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
||||
}
|
||||
}
|
||||
|
||||
+160
-32
@@ -233,6 +233,7 @@ class Transaction
|
||||
} // namespace
|
||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||
{
|
||||
updateNotifier = notifyUpdateAvailable;
|
||||
current = this;
|
||||
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
||||
std::filesystem::create_directories(data);
|
||||
@@ -247,6 +248,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||
}
|
||||
setLoggingEnabled(settings.writeLog);
|
||||
if (settings.autoCorrect)
|
||||
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
settings.autoCorrect = false;
|
||||
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
|
||||
}
|
||||
try
|
||||
{
|
||||
history = std::make_unique<History>(data / L"history");
|
||||
@@ -416,12 +424,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
|
||||
void App::applySettings(const Settings &s)
|
||||
{
|
||||
bool autostartChanged = false;
|
||||
AutoCorrector predictor;
|
||||
validateSettings(s);
|
||||
validateTesseract(s);
|
||||
if (s.autoCorrect)
|
||||
{
|
||||
AutoCorrector probe;
|
||||
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
||||
}
|
||||
if (!registerHotkeys(s))
|
||||
{
|
||||
@@ -453,6 +461,7 @@ void App::applySettings(const Settings &s)
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
settings = s;
|
||||
autoPredictor = std::move(predictor);
|
||||
setLoggingEnabled(s.writeLog);
|
||||
run.reset();
|
||||
last.reset();
|
||||
@@ -516,6 +525,7 @@ void App::resetInput()
|
||||
run.reset();
|
||||
last.reset();
|
||||
}
|
||||
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
{
|
||||
if (ownKeyboardInput(k))
|
||||
@@ -556,9 +566,32 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
return;
|
||||
}
|
||||
run.record(ch, fg, layout, GetTickCount64());
|
||||
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||
if (settings.autoCorrect && mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
||||
run.keys.size() <= 41 && !busy && !run.overflow)
|
||||
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
|
||||
{
|
||||
for (int source = 0; source < (int)layouts.size(); ++source)
|
||||
if (layouts[source].handle == layout)
|
||||
{
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : run.keys)
|
||||
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
|
||||
if (autoPredictor.suggest(predicted, source, layouts, settings))
|
||||
{
|
||||
// Reserve the transaction before the next physical key arrives.
|
||||
// The delimiter itself passes through; subsequent input is queued.
|
||||
typingTarget = fg;
|
||||
autoPending = true;
|
||||
busy = true;
|
||||
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
|
||||
{
|
||||
typingTarget = nullptr;
|
||||
autoPending = false;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (busy || settings.snippets.empty())
|
||||
return;
|
||||
auto keys = lower(run.keys);
|
||||
@@ -597,6 +630,27 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
// down AND up events use exactly the same path as physical keyboard input.
|
||||
if (ownKeyboardInput(key) || installingUpdate)
|
||||
return false;
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
if (typingTarget && GetForegroundWindow() == typingTarget)
|
||||
{
|
||||
INPUT input{};
|
||||
input.type = INPUT_KEYBOARD;
|
||||
input.ki.wVk = static_cast<WORD>(key.vkCode);
|
||||
input.ki.wScan = static_cast<WORD>(key.scanCode);
|
||||
input.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) |
|
||||
((key.flags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0);
|
||||
if (key.vkCode == VK_PACKET)
|
||||
{
|
||||
input.ki.wVk = 0;
|
||||
input.ki.dwFlags |= KEYEVENTF_UNICODE;
|
||||
}
|
||||
// Replay as ordinary input so tracking and shortcuts see it once,
|
||||
// after the replacement and layout switch have finished.
|
||||
deferredTyping.push_back(input);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
DWORD captureProcess{};
|
||||
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
||||
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
||||
@@ -639,6 +693,42 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
bool App::beginTypingDeferral(HWND target, uint64_t version)
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||
return false;
|
||||
typingTarget = target;
|
||||
return true;
|
||||
}
|
||||
void App::replayTyping()
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
HWND target;
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
target = typingTarget;
|
||||
typingTarget = nullptr;
|
||||
autoPending = false;
|
||||
inputs.swap(deferredTyping);
|
||||
}
|
||||
busy = false;
|
||||
if (inputs.empty())
|
||||
return;
|
||||
if (GetForegroundWindow() != target)
|
||||
{
|
||||
resetInput();
|
||||
report(L"Typing during correction was not replayed because the active window changed.");
|
||||
return;
|
||||
}
|
||||
// Runs on the hook thread: no physical key can pass between releasing the
|
||||
// gate and submitting the entire ordered batch to the Windows input queue.
|
||||
if (SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) != inputs.size())
|
||||
{
|
||||
resetInput();
|
||||
report(L"Windows could not replay all typing buffered during correction.");
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (code == HC_ACTION && current &&
|
||||
@@ -674,6 +764,7 @@ void App::dispatch(int command)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (command == 23) { checkUpdate(true); return; }
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
@@ -735,12 +826,14 @@ void App::transform(int command, int snippet)
|
||||
L" version=" + std::to_wstring(currentVersion) +
|
||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||
}
|
||||
if (busy.exchange(true))
|
||||
const bool preDeferred = command == AutoCorrect && autoPending;
|
||||
if (busy.exchange(true) && !preDeferred)
|
||||
{
|
||||
if (manual)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
||||
return;
|
||||
}
|
||||
if (preDeferred) autoPending = false;
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -755,16 +848,21 @@ void App::transform(int command, int snippet)
|
||||
version = inputVersion;
|
||||
}
|
||||
enqueue(
|
||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
|
||||
{
|
||||
struct BusyReset
|
||||
{
|
||||
std::atomic<bool> &b;
|
||||
HWND window;
|
||||
bool deferred{};
|
||||
~BusyReset()
|
||||
{
|
||||
b = false;
|
||||
if (deferred)
|
||||
PostMessageW(window, ReplayTypingMessage, 0, 0);
|
||||
else
|
||||
b = false;
|
||||
}
|
||||
} reset{busy};
|
||||
} reset{busy, window, preDeferred};
|
||||
auto start = GetTickCount64();
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
std::wstring text, result, expected;
|
||||
@@ -842,11 +940,27 @@ void App::transform(int command, int snippet)
|
||||
std::wstring predicted;
|
||||
for (wchar_t key : recent.keys)
|
||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||
expected = predicted;
|
||||
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
||||
!autoCorrectionAllowed(target, config) || !valid())
|
||||
return;
|
||||
}
|
||||
reason = L"clipboard-snapshot";
|
||||
if (command == AutoCorrect)
|
||||
{
|
||||
if (!beginTypingDeferral(target, version))
|
||||
return;
|
||||
reset.deferred = true;
|
||||
}
|
||||
// Collapse a temporary selection on every failed auto-correction,
|
||||
// including clipboard exceptions, before queued typing is replayed.
|
||||
bool autoSelected = false;
|
||||
struct SelectionReset
|
||||
{
|
||||
bool &selected;
|
||||
std::function<bool()> valid;
|
||||
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
|
||||
} selectionReset{autoSelected, valid};
|
||||
Transaction tx(*this, target, version);
|
||||
reason = L"selection-unavailable";
|
||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||
@@ -863,11 +977,15 @@ void App::transform(int command, int snippet)
|
||||
// conversion, automatic correction must never fall back to a suffix.
|
||||
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||
return;
|
||||
autoSelected = true;
|
||||
Sleep(25);
|
||||
text = tx.copy();
|
||||
// Some editors first select only ") ". Extend by whole-word
|
||||
// boundaries, then still verify against the complete tracked run.
|
||||
for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++i)
|
||||
// Editors may split hj,bnm at the comma. Extend only a verified
|
||||
// suffix of the tracked input, and still require a complete match.
|
||||
for (int i = 0; i < 3 && tx.valid() &&
|
||||
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
|
||||
(autoClosingSelection(text, config) ||
|
||||
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
|
||||
{
|
||||
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||
break;
|
||||
@@ -879,7 +997,10 @@ void App::transform(int command, int snippet)
|
||||
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
||||
{
|
||||
if (tx.valid())
|
||||
{
|
||||
chord(VK_RIGHT);
|
||||
autoSelected = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
to = suggestion->target;
|
||||
@@ -1016,6 +1137,8 @@ void App::transform(int command, int snippet)
|
||||
sent = tx.paste(textClip(result));
|
||||
else if (automatic && tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
if (sent || result == text)
|
||||
autoSelected = false;
|
||||
reason = result == text ? L"conversion-unchanged" :
|
||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||
if (result == text &&
|
||||
@@ -1303,7 +1426,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
|
||||
updateWorker = std::thread([this, interactive, install, version]
|
||||
{
|
||||
auto result = std::make_unique<UpdateResult>();
|
||||
try { *result = runUpdate(install, version); }
|
||||
try { *result = runUpdate(install, version, interactive); }
|
||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||
result->interactive = interactive;
|
||||
result->install = install;
|
||||
@@ -1328,6 +1451,8 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (app->updateWorker.joinable()) app->updateWorker.join();
|
||||
app->updateRunning = false;
|
||||
app->installingUpdate = false;
|
||||
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
|
||||
(result->error.empty() ? L"" : L"; " + result->error));
|
||||
if (result->state == L"prepared")
|
||||
{
|
||||
// The helper has verified and staged the package; it waits for process exit.
|
||||
@@ -1345,13 +1470,16 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
else if (app->notifiedUpdate != result->version)
|
||||
{
|
||||
app->notifiedUpdate = result->version;
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
|
||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
|
||||
auto text = result->version + L" is available. Select Update in the tray menu to install.";
|
||||
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
||||
if (app->updateNotifier(result->version))
|
||||
{
|
||||
app->notifiedUpdate = result->version;
|
||||
log(L"Update notification submitted to Windows: " + result->version);
|
||||
}
|
||||
else
|
||||
{
|
||||
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
|
||||
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (result->state == L"current")
|
||||
@@ -1370,6 +1498,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_HOTKEY:
|
||||
app->dispatch((int)wp);
|
||||
return 0;
|
||||
case ReplayTypingMessage:
|
||||
app->replayTyping();
|
||||
return 0;
|
||||
case TrayMessage:
|
||||
if (LOWORD(lp) == WM_CONTEXTMENU)
|
||||
app->trayMenu();
|
||||
@@ -1384,9 +1515,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->captureHistory();
|
||||
return 0;
|
||||
case AutoCorrectMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->pendingAutoVersion = (uint64_t)wp;
|
||||
SetTimer(hwnd, 3, 250, nullptr);
|
||||
if (app->autoPending)
|
||||
{
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
||||
app->transform(AutoCorrect);
|
||||
else
|
||||
app->replayTyping();
|
||||
}
|
||||
return 0;
|
||||
case SnippetMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
@@ -1412,7 +1547,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_COPYDATA:
|
||||
{
|
||||
auto *data = (COPYDATASTRUCT *)lp;
|
||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
|
||||
{
|
||||
if (app->installingUpdate) return FALSE;
|
||||
return configurationRequest(*app, (HWND)wp, *data);
|
||||
@@ -1442,13 +1577,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->dispatch(20);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 3)
|
||||
{
|
||||
KillTimer(hwnd, 3);
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
app->transform(AutoCorrect);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 2)
|
||||
{
|
||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||
@@ -1543,8 +1671,8 @@ int App::execute()
|
||||
SetTimer(window, 2, 10000, nullptr);
|
||||
else
|
||||
SetTimer(window, 5, 5000, nullptr);
|
||||
if (keyboardHook && mouseHook)
|
||||
confirmUpdatedStartup(updateStartupEvent);
|
||||
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
try { registerScreenshotNotifications(); }
|
||||
|
||||
@@ -16,6 +16,7 @@ inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, Histo
|
||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||
Screenshot = 7, Direct = 100;
|
||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
|
||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||
struct LastTransform
|
||||
{
|
||||
@@ -37,6 +38,7 @@ class App
|
||||
Settings settings;
|
||||
std::unique_ptr<History> history;
|
||||
AutoCorrector autoCorrector;
|
||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<uint64_t> inputVersion{0};
|
||||
@@ -69,6 +71,7 @@ class App
|
||||
void applySettings(const Settings &);
|
||||
void reloadLayouts();
|
||||
void clearHistory();
|
||||
void clearLog(bool previous);
|
||||
void setHotkeyCapture(HWND);
|
||||
bool isHotkey(DWORD, UINT);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
@@ -95,15 +98,20 @@ class App
|
||||
std::thread updateWorker;
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
std::wstring availableUpdate, notifiedUpdate;
|
||||
bool (*updateNotifier)(const std::wstring &){};
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||
ULONGLONG previousAttemptAt{};
|
||||
HWND previousAttemptWindow{};
|
||||
int previousAttemptCommand{}, repeatAttempts{};
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t);
|
||||
void replayTyping();
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||
|
||||
+36
-9
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
||||
}
|
||||
if (offsets.empty())
|
||||
throw std::runtime_error("Empty auto-correction dictionary");
|
||||
for (size_t i = 1; i < offsets.size(); ++i)
|
||||
if (word(i - 1) >= word(i))
|
||||
throw std::runtime_error("Dictionary must be uniquely sorted");
|
||||
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
||||
auto frequency = [&](uint32_t offset)
|
||||
{
|
||||
int rank{};
|
||||
auto start = offset + entry(offset).size() + 1;
|
||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
||||
return rank;
|
||||
};
|
||||
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
||||
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
|
||||
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
||||
{ return entry(a) == entry(b); }), offsets.end());
|
||||
offsets.shrink_to_fit();
|
||||
bytes.shrink_to_fit();
|
||||
}
|
||||
@@ -143,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
suffix.insert(suffix.begin(), word.back());
|
||||
word.pop_back();
|
||||
}
|
||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||||
// Validate letters and capitalization after conversion, not before it.
|
||||
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||||
suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
return {};
|
||||
for (auto &never : settings.autoNever)
|
||||
if (lower(never) == lower(word))
|
||||
@@ -151,12 +164,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||
if (!dictionaries.contains(sourceLanguage))
|
||||
return {};
|
||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||
{
|
||||
auto found = settings.autoWords.find(language);
|
||||
return found != settings.autoWords.end() &&
|
||||
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
|
||||
{ return lower(known) == lower(candidate); });
|
||||
};
|
||||
// Valid words from either selected language are never changed.
|
||||
for (auto &[language, d] : dictionaries)
|
||||
if (d.rank(word))
|
||||
if (d.rank(word) || personal(language, word))
|
||||
return {};
|
||||
std::optional<AutoSuggestion> candidate;
|
||||
int matches = 0;
|
||||
std::optional<std::wstring> matchedText;
|
||||
auto priority = [&](int layout)
|
||||
{
|
||||
auto language = layoutLanguage(layouts[layout]);
|
||||
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||||
};
|
||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||
{
|
||||
if (i == source || !layouts[i].convertible ||
|
||||
@@ -168,12 +193,14 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||
continue;
|
||||
int rank = dictionary->second.rank(converted);
|
||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||
if (!rank)
|
||||
continue;
|
||||
if (++matches > 1)
|
||||
if (matchedText && *matchedText != converted)
|
||||
return {};
|
||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
||||
matchedText = converted;
|
||||
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||||
(!candidate || priority(i) < priority(candidate->target)))
|
||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||
}
|
||||
return candidate;
|
||||
|
||||
+12
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
try
|
||||
{
|
||||
if (wp == 21) showSnippets(*app);
|
||||
else if (wp == 22) showLogs(*app);
|
||||
else showSettings(*app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
@@ -86,6 +88,7 @@ App::~App()
|
||||
{
|
||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||
if (logWindow) DestroyWindow(logWindow);
|
||||
if (window) DestroyWindow(window);
|
||||
current = nullptr;
|
||||
}
|
||||
@@ -120,9 +123,9 @@ void App::setHotkeyCapture(HWND field)
|
||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||
}
|
||||
}
|
||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
||||
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||
|
||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||
{
|
||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||
@@ -140,7 +143,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||
AllowSetForegroundWindow(pid);
|
||||
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
||||
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
||||
return 0;
|
||||
}
|
||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
@@ -169,9 +172,10 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||
if (snippets) showSnippets(app); else showSettings(app);
|
||||
setLoggingEnabled(app.settings.writeLog);
|
||||
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
||||
MSG message;
|
||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
{
|
||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||
{
|
||||
@@ -180,6 +184,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
}
|
||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
@@ -193,8 +198,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||
LocalFree(args);
|
||||
result = ks::runConfigurator(instance, snippets);
|
||||
result = ks::runConfigurator(instance, snippets, logs);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
|
||||
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||
}
|
||||
void launchConfiguration(App &app, bool snippets)
|
||||
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
@@ -80,6 +80,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
case ConfigLogs:
|
||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||
break;
|
||||
case ConfigClearLog:
|
||||
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
||||
throw std::runtime_error("Invalid log selection.");
|
||||
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
||||
break;
|
||||
default: return FALSE;
|
||||
}
|
||||
ok = true;
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ namespace ks
|
||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||
ConfigReply = ConfigRead + 6;
|
||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
|
||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||
bool configurationPeer(HWND, const wchar_t *filename);
|
||||
void launchConfiguration(App &, bool snippets = false);
|
||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -58,6 +59,7 @@ struct Settings
|
||||
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
||||
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
||||
std::vector<std::wstring> autoNever;
|
||||
std::map<std::wstring, std::vector<std::wstring>> autoWords;
|
||||
std::vector<std::wstring> autoExcluded{
|
||||
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
|
||||
@@ -45,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
cmd = 21;
|
||||
else if (arg == L"--exit")
|
||||
cmd = 99;
|
||||
else if (arg == L"--check-update")
|
||||
cmd = 23;
|
||||
LocalFree(argv);
|
||||
if (existing)
|
||||
{
|
||||
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
if (window)
|
||||
{
|
||||
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
|
||||
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
||||
DWORD_PTR result{};
|
||||
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
||||
|
||||
@@ -83,8 +83,12 @@ public:
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
||||
if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG;
|
||||
auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
||||
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
|
||||
const bool update=std::wstring(args)==L"check-update";
|
||||
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
|
||||
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
|
||||
auto command=update ? L"\""+path+L"\" --check-update" :
|
||||
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
|
||||
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
|
||||
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
|
||||
|
||||
@@ -181,5 +181,44 @@ bool notifyScreenshot(const Clip &clip)
|
||||
}
|
||||
std::string screenshotNotificationError() { return notificationError; }
|
||||
|
||||
std::wstring updateToastXml(const std::wstring &version)
|
||||
{
|
||||
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
|
||||
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
|
||||
L" is available. Click to review and install.</text></binding></visual>"
|
||||
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
|
||||
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
|
||||
}
|
||||
bool notifyUpdateAvailable(const std::wstring &version)
|
||||
{
|
||||
try {
|
||||
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
||||
registerScreenshotNotifications();
|
||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||
Notifications::NotificationSetting setting{};
|
||||
auto settingsResult=notifier->get_Setting(&setting);
|
||||
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
|
||||
if(setting!=Notifications::NotificationSetting_Enabled)
|
||||
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
|
||||
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
|
||||
check(RoActivateInstance(documentClass.value,&instance));
|
||||
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
|
||||
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
|
||||
String content(updateToastXml(version)); check(io->LoadXml(content.value));
|
||||
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
|
||||
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
|
||||
// Repeated submissions replace the existing update card in Notification Center.
|
||||
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
|
||||
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
|
||||
check(notifier->Show(toast.Get()));
|
||||
return true;
|
||||
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -92,6 +92,17 @@ void validateSettings(const Settings &s)
|
||||
throw std::runtime_error("Unsupported auto-correction language");
|
||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||
throw std::runtime_error("Too many auto-correction exceptions");
|
||||
for (const auto &[language, words] : s.autoWords)
|
||||
{
|
||||
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||
language == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" :
|
||||
language == L"ru" ? L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя" : L"";
|
||||
if (alphabet.empty() || words.size() > 10000)
|
||||
throw std::runtime_error("Invalid personal dictionary language or too many words");
|
||||
for (const auto &word : words)
|
||||
if (word.empty() || word.size() > 32 || lower(word).find_first_not_of(alphabet) != word.npos)
|
||||
throw std::runtime_error("Personal words must contain 1-32 letters of their selected language");
|
||||
}
|
||||
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");
|
||||
@@ -143,6 +154,10 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
|
||||
if (j.contains("autoCorrectWords"))
|
||||
for (auto &[language, words] : j.at("autoCorrectWords").items())
|
||||
for (auto &word : words)
|
||||
s.autoWords[wide(language)].push_back(lower(wide(word.get<std::string>())));
|
||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||
{"autoCorrectNever", &s.autoNever},
|
||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||
@@ -229,6 +244,13 @@ Bytes encodeSettings(const Settings &s)
|
||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
||||
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
|
||||
j["autoCorrectWords"] = Json::object();
|
||||
for (const auto &[language, words] : s.autoWords)
|
||||
{
|
||||
auto &items = j["autoCorrectWords"][utf8(language)] = Json::array();
|
||||
for (const auto &word : words)
|
||||
items.push_back(utf8(lower(word)));
|
||||
}
|
||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||
{"autoCorrectNever", &s.autoNever},
|
||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||
|
||||
+122
-63
@@ -34,6 +34,7 @@ struct WindowLayout
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||
HWND stretch{}, bottomLeft{}, bottomRight{};
|
||||
};
|
||||
WindowLayout *windowLayoutState(HWND w)
|
||||
{
|
||||
@@ -69,19 +70,58 @@ void arrangeWindow(HWND w)
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
RECT client{}; GetClientRect(w, &client);
|
||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
bool expandable = layout->history || layout->stretch;
|
||||
int extraWidth = expandable ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = expandable ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
struct Move { HWND child; RECT rect; };
|
||||
std::vector<Move> moves;
|
||||
for (const auto &[child, r] : layout->controls)
|
||||
{
|
||||
int id = GetDlgCtrlID(child);
|
||||
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x,
|
||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0),
|
||||
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0),
|
||||
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0),
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
RECT next{scale(w, r.left) - layout->x,
|
||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0)};
|
||||
next.right = next.left + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0);
|
||||
next.bottom = next.top + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0);
|
||||
if (child == layout->stretch)
|
||||
{
|
||||
next.right += extraWidth;
|
||||
next.bottom += extraHeight;
|
||||
}
|
||||
if (child == layout->bottomLeft || child == layout->bottomRight)
|
||||
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
|
||||
RECT current{}; GetWindowRect(child, ¤t);
|
||||
MapWindowPoints(nullptr, w, reinterpret_cast<POINT *>(¤t), 2);
|
||||
if (!EqualRect(¤t, &next)) moves.push_back({child, next});
|
||||
}
|
||||
if (!moves.empty())
|
||||
{
|
||||
auto batch = BeginDeferWindowPos((int)moves.size());
|
||||
for (const auto &move : moves)
|
||||
{
|
||||
if (!batch) break;
|
||||
auto r = move.rect;
|
||||
batch = DeferWindowPos(batch, move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
}
|
||||
if (!batch || !EndDeferWindowPos(batch))
|
||||
for (const auto &move : moves)
|
||||
{
|
||||
auto r = move.rect;
|
||||
SetWindowPos(move.child, nullptr, r.left, r.top, r.right-r.left, r.bottom-r.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
}
|
||||
}
|
||||
layout->arranging = false;
|
||||
InvalidateRect(w, nullptr, TRUE);
|
||||
}
|
||||
void scrollWindow(HWND w, WindowLayout *layout, bool horizontal, int requested)
|
||||
{
|
||||
SCROLLINFO info{sizeof(info), SIF_RANGE | SIF_PAGE};
|
||||
GetScrollInfo(w, horizontal ? SB_HORZ : SB_VERT, &info);
|
||||
int next = std::clamp(requested, info.nMin, std::max(info.nMin, info.nMax - std::max(0, (int)info.nPage - 1)));
|
||||
int &position = horizontal ? layout->x : layout->y;
|
||||
if (position == next) return;
|
||||
position = next;
|
||||
arrangeWindow(w);
|
||||
}
|
||||
void revealControl(HWND child)
|
||||
{
|
||||
@@ -127,7 +167,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
bool vertical = msg == WM_VSCROLL;
|
||||
SCROLLINFO info{sizeof(info), SIF_ALL};
|
||||
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
|
||||
int &position = vertical ? layout->y : layout->x;
|
||||
int position = vertical ? layout->y : layout->x;
|
||||
switch (LOWORD(wp))
|
||||
{
|
||||
case SB_LINEUP: position -= scale(w, 32); break;
|
||||
@@ -138,7 +178,7 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
case SB_BOTTOM: position = info.nMax; break;
|
||||
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
|
||||
}
|
||||
arrangeWindow(w);
|
||||
scrollWindow(w, layout, !vertical, position);
|
||||
return 0;
|
||||
}
|
||||
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
|
||||
@@ -148,8 +188,9 @@ LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
||||
remainder += GET_WHEEL_DELTA_WPARAM(wp);
|
||||
int steps = remainder / WHEEL_DELTA;
|
||||
remainder %= WHEEL_DELTA;
|
||||
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
|
||||
arrangeWindow(w);
|
||||
if (steps)
|
||||
scrollWindow(w, layout, horizontal, (horizontal ? layout->x : layout->y) +
|
||||
steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1));
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
@@ -276,7 +317,7 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
RegisterClassW(&wc);
|
||||
UINT dpi = GetDpiForSystem();
|
||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN | extra;
|
||||
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||
POINT cursor{};
|
||||
@@ -977,6 +1018,15 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (checked(w, 403))
|
||||
draft.autoLanguages.push_back(L"ru");
|
||||
draft.autoNever = parseCommaList(value(w, 405));
|
||||
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
||||
{
|
||||
auto text = lower(value(w, id));
|
||||
std::replace(text.begin(), text.end(), L'\n', L',');
|
||||
auto words = parseCommaList(text);
|
||||
std::sort(words.begin(), words.end());
|
||||
words.erase(std::unique(words.begin(), words.end()), words.end());
|
||||
draft.autoWords[language] = std::move(words);
|
||||
}
|
||||
draft.autoExcluded = parseCommaList(value(w, 404));
|
||||
draft.autoPrefixChars = value(w, 408);
|
||||
draft.autoSuffixChars = value(w, 409);
|
||||
@@ -985,6 +1035,7 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
validateSettings(draft);
|
||||
view->settings->draft.autoLanguages = draft.autoLanguages;
|
||||
view->settings->draft.autoNever = draft.autoNever;
|
||||
view->settings->draft.autoWords = draft.autoWords;
|
||||
view->settings->draft.autoExcluded = draft.autoExcluded;
|
||||
view->settings->draft.autoMaxRank = draft.autoMaxRank;
|
||||
view->settings->draft.autoMinLength = draft.autoMinLength;
|
||||
@@ -1021,7 +1072,7 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
void showAutoOptions(HWND parent, SettingsView *v)
|
||||
{
|
||||
HWND w = makeWindow(*v->app, L"KSwitcher.AutoOptions", L"Automatic layout correction", autoOptionsProc,
|
||||
560, 680);
|
||||
560, 680, 0, false);
|
||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR) new AutoOptionsView{parent, v});
|
||||
label(w, L"Offline dictionaries (only layouts in your cycle are used)", 18, 16, 525);
|
||||
@@ -1031,13 +1082,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
||||
for (auto &lang : v->draft.autoLanguages)
|
||||
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
|
||||
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525);
|
||||
edit(w, 404, 18, 122, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
edit(w, 404, 18, 122, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
value(w, 404, commaList(v->draft.autoExcluded));
|
||||
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525);
|
||||
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
label(w, L"Never correct these words (comma-separated)", 18, 184, 525);
|
||||
edit(w, 405, 18, 212, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||
value(w, 405, commaList(v->draft.autoNever));
|
||||
label(w, L"Corrects after Space and a short pause.", 18, 344, 525);
|
||||
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 525);
|
||||
label(w, L"My dictionary: correct words, separated by commas or new lines", 18, 276, 525);
|
||||
for (auto [id, language] : {std::pair{411, L"en"}, {412, L"uk"}, {413, L"ru"}})
|
||||
{
|
||||
int x = 18 + (id - 411) * 178;
|
||||
label(w, id == 411 ? L"English" : id == 412 ? L"Ukrainian" : L"Russian", x, 302, 168);
|
||||
edit(w, id, x, 328, 168, 56, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||
auto words = v->draft.autoWords.find(language);
|
||||
if (words != v->draft.autoWords.end()) value(w, id, commaList(words->second));
|
||||
}
|
||||
label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
|
||||
edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
|
||||
value(w, 406, std::to_wstring(v->draft.autoMaxRank));
|
||||
@@ -1481,53 +1539,41 @@ void showHistory(App &app, bool visible)
|
||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||
refreshHistory(app);
|
||||
}
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
namespace
|
||||
{
|
||||
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||
LogClear = 606;
|
||||
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
void fitLogDetails(HWND list)
|
||||
{
|
||||
// Header notifications go to the ListView, not its top-level parent.
|
||||
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
|
||||
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
|
||||
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
|
||||
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
|
||||
RECT client{};
|
||||
GetClientRect(list, &client);
|
||||
int remaining = client.right;
|
||||
for (int column = 0; column < 3; ++column)
|
||||
remaining -= ListView_GetColumnWidth(list, column);
|
||||
int width = std::max(scale(list, 120), remaining);
|
||||
if (ListView_GetColumnWidth(list, 3) != width)
|
||||
ListView_SetColumnWidth(list, 3, width);
|
||||
RemovePropW(list, L"FittingLogDetails");
|
||||
}
|
||||
LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
auto result = DefSubclassProc(w, msg, wp, lp);
|
||||
if (msg == WM_SIZE)
|
||||
fitLogDetails(w);
|
||||
if (msg == WM_NOTIFY && lp)
|
||||
{
|
||||
auto *draw = (NMCUSTOMDRAW *)lp;
|
||||
if (draw->dwDrawStage == CDDS_PREPAINT)
|
||||
{
|
||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||
return CDRF_NOTIFYITEMDRAW;
|
||||
}
|
||||
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
|
||||
{
|
||||
int saved = SaveDC(draw->hdc);
|
||||
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
|
||||
RECT edge = draw->rc;
|
||||
edge.top = edge.bottom - 1;
|
||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
||||
edge = draw->rc;
|
||||
edge.left = edge.right - 1;
|
||||
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
|
||||
wchar_t title[128]{};
|
||||
HDITEMW item{};
|
||||
item.mask = HDI_TEXT;
|
||||
item.pszText = title;
|
||||
item.cchTextMax = 128;
|
||||
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
|
||||
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
|
||||
if (headerFont)
|
||||
SelectObject(draw->hdc, headerFont);
|
||||
SetBkMode(draw->hdc, TRANSPARENT);
|
||||
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
|
||||
RECT text = draw->rc;
|
||||
InflateRect(&text, -scale(w, 6), 0);
|
||||
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
|
||||
RestoreDC(draw->hdc, saved);
|
||||
return CDRF_SKIPDEFAULT;
|
||||
}
|
||||
auto *header = reinterpret_cast<NMHEADERW *>(lp);
|
||||
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
|
||||
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
|
||||
header->iItem >= 0 && header->iItem < 3)
|
||||
fitLogDetails(w);
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
RemoveWindowSubclass(w, logListProc, 1);
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
|
||||
return result;
|
||||
}
|
||||
void copyLogRows(HWND w, App &app)
|
||||
{
|
||||
@@ -1547,7 +1593,11 @@ void copyLogRows(HWND w, App &app)
|
||||
copied += L"\r\n";
|
||||
}
|
||||
if (!copied.empty())
|
||||
app.ownSequence = writeClipboard(app.window, textClip(copied));
|
||||
{
|
||||
auto clip = textClip(copied);
|
||||
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||
app.ownSequence = writeClipboard(app.window, clip);
|
||||
}
|
||||
}
|
||||
void refreshLogView(HWND w, App &app)
|
||||
{
|
||||
@@ -1615,6 +1665,7 @@ void refreshLogView(HWND w, App &app)
|
||||
InvalidateRect(list, nullptr, TRUE);
|
||||
if (row)
|
||||
ListView_EnsureVisible(list, row - 1, FALSE);
|
||||
fitLogDetails(list);
|
||||
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
|
||||
: L"Logging disabled. Showing previously saved entries.");
|
||||
}
|
||||
@@ -1647,7 +1698,7 @@ LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
try
|
||||
{
|
||||
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||
refreshLogView(w, *app);
|
||||
}
|
||||
catch (const std::exception &e) { error(w, e); }
|
||||
@@ -1709,7 +1760,7 @@ void showLogs(App &app)
|
||||
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||
LogRows, 16, 82, 868, 454);
|
||||
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
|
||||
SetWindowSubclass(list, logListProc, 1, 0);
|
||||
SetWindowSubclass(list, logListResizeProc, 3, 0);
|
||||
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
|
||||
const int widths[] = {155, 65, 195, 900};
|
||||
for (int i = 0; i < 4; ++i)
|
||||
@@ -1720,12 +1771,20 @@ void showLogs(App &app)
|
||||
column.cx = scale(w, widths[i]);
|
||||
ListView_InsertColumn(list, i, &column);
|
||||
}
|
||||
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
|
||||
auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.",
|
||||
0, 0, 16, 551, 720, 22);
|
||||
button(w, L"Close", IDCANCEL, 774, 548, 110);
|
||||
auto *layout = windowLayoutState(w);
|
||||
layout->stretch = list;
|
||||
layout->bottomLeft = hint;
|
||||
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||
arrangeWindow(w);
|
||||
refreshLogView(w, app);
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
#else
|
||||
void showLogs(App &app) { launchConfiguration(app, false, true); }
|
||||
#endif
|
||||
#ifndef KS_RESIDENT_APP
|
||||
void showSettings(App &app, bool visible)
|
||||
|
||||
+5
-4
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
|
||||
return out + L'"';
|
||||
}
|
||||
}
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
|
||||
{
|
||||
UpdateResult result;
|
||||
GUID guid{};
|
||||
@@ -56,6 +56,7 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||
if (showProgress) command += L" -ShowProgress";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||
@@ -94,10 +95,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void confirmUpdatedStartup(const std::wstring &eventName)
|
||||
bool confirmUpdatedStartup(const std::wstring &eventName)
|
||||
{
|
||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
|
||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||
if (event.value) SetEvent(event.value);
|
||||
return event.value && SetEvent(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -2,14 +2,17 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.8";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.13";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
bool notifyUpdateAvailable(const std::wstring &version);
|
||||
std::wstring updateToastXml(const std::wstring &version);
|
||||
struct UpdateResult
|
||||
{
|
||||
std::wstring state, version, error;
|
||||
bool interactive{}, install{};
|
||||
};
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
||||
void confirmUpdatedStartup(const std::wstring &eventName);
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
|
||||
bool confirmUpdatedStartup(const std::wstring &eventName);
|
||||
}
|
||||
|
||||
+28
-2
@@ -1,4 +1,4 @@
|
||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
|
||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||
@@ -38,7 +38,7 @@ $checks=0
|
||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||
function Window($class) {
|
||||
for($i=0;$i -lt 100;$i++) {
|
||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
|
||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher log'}}
|
||||
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||
if($window -ne [IntPtr]::Zero){return $window}
|
||||
Start-Sleep -Milliseconds 50
|
||||
@@ -56,6 +56,24 @@ function Launch($file,$argument) {
|
||||
function Click($window,$id) {
|
||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
||||
}
|
||||
function TestLogs {
|
||||
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
|
||||
$config=Launch 'KSwitcher.Config.exe' '--logs'
|
||||
$logs=Window 'KSwitcher.Logs'
|
||||
[uint32]$logOwner=0
|
||||
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
|
||||
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
|
||||
$second=Launch 'KSwitcher.Config.exe' '--logs'
|
||||
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
|
||||
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
|
||||
Click $logs 603
|
||||
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
|
||||
Click $logs 606
|
||||
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
|
||||
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
|
||||
Click $logs 2
|
||||
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
|
||||
}
|
||||
try {
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||
@@ -67,6 +85,13 @@ try {
|
||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||
$resident=Launch 'KSwitcher.exe' ''
|
||||
$main=Window 'KSwitcher.Main'
|
||||
if ($LogsOnly) {
|
||||
TestLogs
|
||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
|
||||
"Passed $checks log process checks"
|
||||
return
|
||||
}
|
||||
$config=Launch 'KSwitcher.Config.exe' ''
|
||||
$settings=Window 'KSwitcher.Settings'
|
||||
[uint32]$ownerId=0
|
||||
@@ -110,6 +135,7 @@ try {
|
||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||
Click $snippets 2
|
||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||
TestLogs
|
||||
$config=Launch 'KSwitcher.Config.exe' ''
|
||||
$null=Window 'KSwitcher.Settings'
|
||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||
|
||||
@@ -4,8 +4,17 @@
|
||||
#include <commctrl.h>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
|
||||
{
|
||||
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
|
||||
return DefSubclassProc(w, message, wp, lp);
|
||||
}
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static inline bool delivery = true;
|
||||
static inline int notifications = 0;
|
||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
static bool updateTimer(App &a) {
|
||||
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
||||
@@ -42,15 +51,34 @@ int main(int argc, char **argv) {
|
||||
{
|
||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||
ks::InputRegressionAccess::setup(app);
|
||||
ks::InputRegressionAccess::notificationSetup(app);
|
||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||
require(app.window!=nullptr,"message-only test window");
|
||||
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
||||
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
||||
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
||||
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
|
||||
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
|
||||
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
|
||||
"successful startup signals the updater before reporting completion");
|
||||
CloseHandle(started);
|
||||
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
|
||||
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
|
||||
ks::InputRegressionAccess::delivery=false;
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
|
||||
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
|
||||
ks::InputRegressionAccess::delivery=true;
|
||||
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
|
||||
auto toast=ks::updateToastXml(L"1&2<3\"");
|
||||
require(toast.find(L"1&2<3"")!=toast.npos,"update version is XML escaped");
|
||||
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
|
||||
"toast body and action both review the update instead of installing automatically");
|
||||
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
||||
for(int size:{22,44,55}) {
|
||||
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
||||
@@ -566,6 +594,29 @@ int main(int argc, char **argv) {
|
||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||
ks::showSettings(app,false);
|
||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
require(autoOptions!=nullptr,"auto-correction options open");
|
||||
ShowWindow(autoOptions,SW_HIDE);
|
||||
for(int id:{411,412,413}) {
|
||||
RECT control{},client{};
|
||||
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||
MapWindowPoints(nullptr,autoOptions,reinterpret_cast<POINT*>(&control),2);
|
||||
GetClientRect(autoOptions,&client);
|
||||
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||
"personal dictionary field fits options window");
|
||||
}
|
||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||
ShowWindow(autoOptions,SW_HIDE);
|
||||
wchar_t personalWords[128]{};
|
||||
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
||||
require(std::wstring(personalWords)==L"релізик, словечко",
|
||||
"personal dictionary normalizes case, newlines and duplicate words");
|
||||
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||
ks::showHistory(app,false);
|
||||
auto history=app.historyWindow;
|
||||
@@ -640,8 +691,27 @@ int main(int argc, char **argv) {
|
||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
|
||||
GetScrollInfo(settings,SB_HORZ,&scroll);
|
||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
|
||||
int controlMoves = 0;
|
||||
auto trackedControl = GetDlgItem(settings,201);
|
||||
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
|
||||
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
|
||||
controlMoves=0;
|
||||
ValidateRect(settings,nullptr);
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||
require(controlMoves==1,"wheel scroll batches each control movement once");
|
||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||
controlMoves=0;
|
||||
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
||||
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||
GetClientRect(settings,&client);
|
||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
#include "app.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <commctrl.h>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
struct InputRegressionAccess
|
||||
{
|
||||
static bool deferring(App &app)
|
||||
{
|
||||
std::lock_guard lock(app.stateMutex);
|
||||
return app.typingTarget != nullptr;
|
||||
}
|
||||
};
|
||||
}
|
||||
namespace
|
||||
{
|
||||
ks::App *app{};
|
||||
@@ -11,6 +23,7 @@ ks::Clip saved;
|
||||
bool captured = false, failed = false;
|
||||
int stage = 0, ru = -1, en = -1;
|
||||
size_t punctuationCase = 0;
|
||||
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
||||
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
|
||||
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
||||
@@ -25,7 +38,14 @@ size_t diagnosticStart{};
|
||||
void require(bool ok, const char *name)
|
||||
{
|
||||
if (!ok)
|
||||
{
|
||||
wchar_t observed[2048]{};
|
||||
GetWindowTextW(field, observed, 2048);
|
||||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
||||
throw std::runtime_error(name);
|
||||
}
|
||||
std::cout << "PASS " << name << '\n';
|
||||
}
|
||||
std::wstring text()
|
||||
@@ -80,7 +100,26 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (GetTickCount64() > deadline)
|
||||
throw std::runtime_error("Native integration timed out");
|
||||
if (app->busy)
|
||||
{
|
||||
if (typeDuringCorrection && ks::InputRegressionAccess::deferring(*app))
|
||||
{
|
||||
typeDuringCorrection = false;
|
||||
typedDuringCorrection = true;
|
||||
std::vector<INPUT> inputs;
|
||||
for (auto [key, up] : std::vector<std::pair<WORD, bool>>{
|
||||
{'F', false}, {'F', true}, {VK_LSHIFT, false},
|
||||
{'F', false}, {'F', true}, {VK_LSHIFT, true},
|
||||
{'B', false}, {'B', true}, {VK_BACK, false}, {VK_BACK, true}})
|
||||
{
|
||||
auto input = ks::keyboardInput(key, up ? KEYEVENTF_KEYUP : 0);
|
||||
input.ki.dwExtraInfo = 123; // External input must traverse the actual hook.
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"concurrent typing submitted during selection/paste transaction");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
switch (stage++)
|
||||
{
|
||||
case 0:
|
||||
@@ -242,6 +281,78 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
break;
|
||||
case 17:
|
||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||
[[fallthrough]];
|
||||
case 20:
|
||||
if (!captured)
|
||||
{
|
||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||
captured = true;
|
||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||
}
|
||||
app->settings.autoCorrect = true;
|
||||
app->settings.switchLayout = true;
|
||||
app->settings.autoLanguages = {L"en", L"ru"};
|
||||
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"ghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
typeDuringCorrection = true;
|
||||
app->transform(ks::AutoCorrect);
|
||||
stage = 18;
|
||||
break;
|
||||
case 18:
|
||||
require(typedDuringCorrection && text() == L"привет аА",
|
||||
"typing during correction replays in order with shift and backspace in new layout");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"concurrent typing preserves clipboard restoration");
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"OLDghbdtn ", false);
|
||||
record(L"ghbdtn ");
|
||||
typedDuringCorrection = false;
|
||||
typeDuringCorrection = true;
|
||||
app->transform(ks::AutoCorrect);
|
||||
break;
|
||||
case 19:
|
||||
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||
"rejected correction collapses selection before replaying typing in original layout");
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"", false);
|
||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
for (WORD key : std::wstring(L"GHBDTN F"))
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||
input.ki.dwExtraInfo = 123;
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||
"continuous typing submitted in a single batch without a pause after Space");
|
||||
}
|
||||
stage = 21;
|
||||
break;
|
||||
case 21:
|
||||
{
|
||||
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
||||
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
|
||||
return ks::layoutLanguage(layout) == L"uk";
|
||||
});
|
||||
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
|
||||
app->settings.autoLanguages = {L"en", L"uk"};
|
||||
app->settings.cycle = {app->layouts[en].id, uk->id};
|
||||
app->settings.autoWords[L"uk"] = {L"робить"};
|
||||
app->autoCorrector = ks::AutoCorrector{};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"hj,bnm ", false);
|
||||
record(L"hj,bnm ");
|
||||
app->transform(ks::AutoCorrect);
|
||||
stage = 22;
|
||||
break;
|
||||
}
|
||||
case 22:
|
||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||
finish();
|
||||
break;
|
||||
}
|
||||
@@ -366,6 +477,8 @@ int main(int argc, char **argv)
|
||||
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;
|
||||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||||
stage = 20;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
app->execute();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
#include <shlobj.h>
|
||||
#include <iostream>
|
||||
static std::wstring programs;
|
||||
static std::wstring activationExecutable, activationCommand;
|
||||
static int activationCalls{};
|
||||
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
||||
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
||||
{
|
||||
++activationCalls; activationExecutable=path; activationCommand=command;
|
||||
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
||||
}
|
||||
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
||||
{
|
||||
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
||||
@@ -10,8 +18,10 @@ static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
||||
wcscpy(*out,programs.c_str()); return S_OK;
|
||||
}
|
||||
#define SHGetKnownFolderPath testKnownFolder
|
||||
#define CreateProcessW testCreateProcess
|
||||
#include "../src/screenshot_activation.cpp"
|
||||
#undef SHGetKnownFolderPath
|
||||
#undef CreateProcessW
|
||||
int main()
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
@@ -45,6 +55,18 @@ int main()
|
||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
||||
ks::Activator activator(helper.wstring(),false);
|
||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
||||
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
||||
if(activationCalls!=1 || activationExecutable!=main || activationCommand!=L"\""+main+L"\" --check-update")
|
||||
throw std::runtime_error("update toast must open review, never install automatically");
|
||||
if(activator.Activate(L"other-app",L"check-update",nullptr,0)!=E_INVALIDARG ||
|
||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"--install",nullptr,0)!=E_INVALIDARG || activationCalls!=1)
|
||||
throw std::runtime_error("invalid notification activation must not launch a process");
|
||||
const wchar_t *token=L"12345678-1234-1234-1234-123456789abc";
|
||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",token,nullptr,0);
|
||||
if(activationCalls!=2 || activationExecutable!=helper.wstring() || activationCommand.find(token)==activationCommand.npos)
|
||||
throw std::runtime_error("screenshot activation must retain its existing route");
|
||||
std::cout<<"Passed isolated screenshot registration checks\n";
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
||||
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
||||
|
||||
@@ -241,6 +241,54 @@ int main(int argc, char **argv)
|
||||
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
|
||||
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
|
||||
ks::AutoCorrector missing;
|
||||
{
|
||||
ks::AutoCorrector supplemental;
|
||||
auto options = autoSettings;
|
||||
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||
options.cycle = {L"en", L"uk", L"ru"};
|
||||
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||
{
|
||||
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||
require(shared && shared->text == expected && shared->target == 2,
|
||||
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
|
||||
}
|
||||
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
|
||||
"different dictionary results remain ambiguous");
|
||||
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
||||
require(release && release->text == L"реліз " && release->target == 2,
|
||||
"supplemental Ukrainian release word corrects from Russian layout");
|
||||
require(!supplemental.suggest(L"реліз ", 2, layouts, options),
|
||||
"correct Ukrainian release word stays unchanged");
|
||||
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
|
||||
"valid Russian release word stays unchanged");
|
||||
options.autoMaxRank = 50000;
|
||||
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
||||
"supplemental vocabulary respects explicit frequency limit");
|
||||
options.autoWords[L"uk"] = {L"релізик"};
|
||||
options.autoMaxRank = 1;
|
||||
auto custom = supplemental.suggest(L"релызик ", 1, layouts, options);
|
||||
require(custom && custom->text == L"релізик " && custom->target == 2,
|
||||
"personal word enables correction without editing dictionary files");
|
||||
require(!supplemental.suggest(L"релізик ", 2, layouts, options),
|
||||
"personal word is protected from correction");
|
||||
options.autoNever = {L"релызик"};
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"never-correct list takes precedence over personal word");
|
||||
options.autoNever.clear();
|
||||
options.autoWords[L"uk"].push_back(L"робить");
|
||||
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
||||
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
||||
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
||||
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
||||
"punctuation-key correction still rejects digits");
|
||||
options.autoMinLength = 1;
|
||||
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
||||
"known English ye remains protected at minimum length one");
|
||||
options.cycle = {L"en", L"ru"};
|
||||
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||
"personal dictionary respects disabled target layouts");
|
||||
}
|
||||
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
|
||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||
@@ -335,6 +383,25 @@ int main(int argc, char **argv)
|
||||
auto temp = std::filesystem::temp_directory_path() /
|
||||
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
|
||||
std::filesystem::create_directories(temp);
|
||||
{
|
||||
auto folder = temp / L"dictionaries";
|
||||
std::filesystem::create_directories(folder);
|
||||
auto put = [&](const wchar_t *name, const std::wstring &text)
|
||||
{
|
||||
auto data = ks::utf8(text);
|
||||
ks::atomicWrite(folder / name, ks::Bytes(data.begin(), data.end()));
|
||||
};
|
||||
put(L"en.txt", L"zoo\t3\nhello\t99\nhello\t1\n");
|
||||
put(L"uk.txt", L"дім\t3\n");
|
||||
ks::AutoCorrector edited;
|
||||
edited.configure(folder, {L"en", L"uk"});
|
||||
auto options = ks::Settings{};
|
||||
options.cycle = {L"en", L"uk"};
|
||||
options.autoMaxRank = 1;
|
||||
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
||||
require(correction && correction->text == L"hello ",
|
||||
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
||||
}
|
||||
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);
|
||||
@@ -384,11 +451,16 @@ int main(int argc, char **argv)
|
||||
settings.autoPrefixChars = L"(#";
|
||||
settings.autoSuffixChars = L");%";
|
||||
settings.autoNever = {L"myWord"};
|
||||
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
|
||||
settings.autoExcluded = {L"Code.exe"};
|
||||
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.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
||||
auto invalidWords = settings;
|
||||
invalidWords.autoWords[L"uk"] = {L"hello"};
|
||||
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
||||
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
|
||||
"configurator snapshot survives serialization without changing the concurrency token");
|
||||
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
||||
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
|
||||
Start-UpdateProgress
|
||||
try {
|
||||
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
|
||||
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
|
||||
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
|
||||
if ($Network) {
|
||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
|
||||
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
|
||||
}
|
||||
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
|
||||
Show-UpdateProgress $stage 'Update stage under test'
|
||||
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
|
||||
}
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
|
||||
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
|
||||
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
|
||||
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
|
||||
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
|
||||
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
|
||||
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
|
||||
'Passed updater progress UI checks'
|
||||
} finally {
|
||||
$script:progressWindow.Dispose()
|
||||
$script:progressWindow = $null
|
||||
}
|
||||
@@ -3,6 +3,8 @@ from pathlib import Path
|
||||
import urllib.request, hashlib, json
|
||||
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
||||
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
||||
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
|
||||
SUPPLEMENTAL={'uk': {'реліз': 50001}}
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
manifest={}
|
||||
for lang in ('en','uk','ru'):
|
||||
@@ -15,8 +17,12 @@ for lang in ('en','uk','ru'):
|
||||
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
|
||||
if int(count)<5:continue
|
||||
rows.setdefault(word,rank)
|
||||
for word,rank in SUPPLEMENTAL.get(lang,{}).items():
|
||||
rows.setdefault(word,rank)
|
||||
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
|
||||
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
|
||||
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
|
||||
if lang in SUPPLEMENTAL:
|
||||
manifest[lang]['supplementalWords']=SUPPLEMENTAL[lang]
|
||||
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(manifest,indent=2))
|
||||
|
||||
Reference in New Issue
Block a user