Compare commits

..

2 Commits

24 changed files with 638 additions and 49 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.9 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.11 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+21 -4
View File
@@ -184,7 +184,15 @@ Click the notification or **Review update** to check the latest release and conf
Failed notification submissions are logged and retried after five minutes instead of being marked 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. 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. 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 `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 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; before the application exits. If replacement or startup fails, it restores the previous files;
@@ -343,14 +351,23 @@ 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 **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 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. 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 **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. 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 Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited loading sorts them and merges duplicates using the lowest rank. Back up your edited
files before replacing dictionaries with a new distribution. 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 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. dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
pending work. During the replacement itself, key presses and releases are queued and 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 exactly one alternative is a dictionary word. It only corrects unknown words when exactly one alternative is a dictionary word.
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation **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. does not count toward this minimum; the maximum word length is 32 letters.
Binary file not shown.
+8 -8
View File
@@ -1,26 +1,26 @@
# Latest Windows release # Latest Windows release
## v0.6.9 — 2026-10-29 ## v0.6.11 — 2026-10-29
## Changes ## Changes
- Show automatic update announcements using the same native Windows notification channel as screenshots. Review update opens the update check and confirmation; Later dismisses the notification. - Show an update progress window for manual checks and installation. Downloads display percentage and received/total size; verification, unpacking, installation and restart have distinct status messages.
- Retry notification delivery after five minutes if Windows rejects it, and log update checks and delivery results. Successfully submitted notifications are deduplicated for the current version during the running session. - Keep the progress window running independently while KSwitcher exits and restarts. After the new application confirms startup, show the installed version and a Done button in the same window, without a separate completion notification.
- Fix Settings flicker when using the mouse wheel: skip layout work at scroll boundaries, move only changed controls in one batch, and avoid erasing the entire window background. - Show recovery and installation errors in the same window after the application exits. Background update checks remain quiet.
The progress UI is provided by the currently installed updater. Updating from v0.6.10 to this release still uses the older interface; subsequent updates use the new window.
## Validation ## Validation
203 native checks, 456 input/layout checks, screenshot editor UI integration, isolated notification registration and activation checks, and 45 updater checks. Regression coverage includes notification retry/deduplication, review-only activation, partial wheel steps, scroll boundaries and batched control movement. 214 native regression checks, 466 input/UI checks, screenshot editor UI checks, isolated notification registration, 45 updater regression checks, and progress-window checks for download, installation, recovery and completion states. Network tests verify real download progress and release download/checksum validation. Startup acknowledgement tests prevent reporting success for ordinary startup or a missing updater handshake.
**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. **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. 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. 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.
Windows notification banners follow system notification and Do not disturb settings. The new update notification behavior takes effect after installing v0.6.9.
Build and package: Build and package:
```powershell ```powershell
.\build.ps1 -Test .\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease .\package.ps1 -PublishRelease
``` ```
+1 -1
View File
@@ -1 +1 @@
cd6b3dd62065e50b68cf77344fbad8d7be41467142f31527933342f00e3c0f63 KSwitcher-win-x64.zip d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip
+2
View File
@@ -61,6 +61,8 @@ try {
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
if ($LASTEXITCODE) { throw 'Updater tests failed' } 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) { 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/screenshot_notifications.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
+12 -2
View File
@@ -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 hashes. The lists contain word forms from subtitles, not a complete spelling
or grammar dictionary. Recognition can miss names, jargon and uncommon forms. 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 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. (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. 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. Back up edited files before replacing dictionaries with a new distribution.
+6 -3
View File
@@ -9,9 +9,12 @@
"uk": { "uk": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt", "source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a", "sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
"words": 42549, "words": 42550,
"bytes": 898210, "bytes": 898227,
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802" "sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
"supplementalWords": {
"\u0440\u0435\u043b\u0456\u0437": 50001
}
}, },
"ru": { "ru": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt", "source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
+1
View File
@@ -31207,6 +31207,7 @@
релігію 26138 релігію 26138
релігія 22202 релігія 22202
релігії 23427 релігії 23427
реліз 50001
рембо 28603 рембо 28603
ремень 23872 ремень 23872
ремені 28385 ремені 28385
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,9,0 FILEVERSION 0,6,11,0
PRODUCTVERSION 0,6,9,0 PRODUCTVERSION 0,6,11,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.9" VALUE "FileVersion", "0.6.11"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+213 -3
View File
@@ -3,10 +3,192 @@
[string]$CurrentVersion, [string]$ExpectedVersion, [string]$CurrentVersion, [string]$ExpectedVersion,
[string]$InstallDirectory, [string]$WorkDirectory, [string]$InstallDirectory, [string]$WorkDirectory,
[int]$ParentProcessId, [string]$ReadyEvent, [int]$ParentProcessId, [string]$ReadyEvent,
[switch]$ShowProgress,
[switch]$Library [switch]$Library
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest 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) { 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 } 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.' 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 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$request = [Net.HttpWebRequest]::Create($Url) $request = [Net.HttpWebRequest]::Create($Url)
$request.AllowAutoRedirect = $false $request.AllowAutoRedirect = $false
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
$buffer = New-Object byte[] 65536 $buffer = New-Object byte[] 65536
$total = 0L $total = 0L
$deadline = [DateTime]::UtcNow.AddSeconds(60) $deadline = [DateTime]::UtcNow.AddSeconds(60)
$lastProgress = [DateTime]::MinValue
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
$total += $count $total += $count
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' } if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
$outputStream.Write($buffer,0,$count) $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 { } finally {
if ($outputStream) { $outputStream.Dispose() } if ($outputStream) { $outputStream.Dispose() }
if ($inputStream) { $inputStream.Dispose() } if ($inputStream) { $inputStream.Dispose() }
@@ -220,8 +411,9 @@ function Write-Result($Value) {
if ($Library) { return } if ($Library) { return }
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null $transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
$changed = [Collections.Generic.List[string]]::new() $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 { try {
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
$current = Get-StableVersion $CurrentVersion $current = Get-StableVersion $CurrentVersion
if (!$current) { throw 'Invalid installed version.' } if (!$current) { throw 'Invalid installed version.' }
if ($Mode -eq 'Install') { if ($Mode -eq 'Install') {
@@ -242,25 +434,35 @@ try {
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup' $stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
[IO.Directory]::CreateDirectory($stage) | Out-Null [IO.Directory]::CreateDirectory($stage) | Out-Null
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt' $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 Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum Assert-Checksum $archive $checksum
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
Expand-Release $archive $stage (Get-StableVersion $release.version) Expand-Release $archive $stage (Get-StableVersion $release.version)
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe')) $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.' } 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} Write-Result @{state='prepared';version=$release.version}
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
$ready.Set() | Out-Null $ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' } if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
$parentExited = $true $parentExited = $true
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
Install-Files $stage $InstallDirectory $backup $changed Install-Files $stage $InstallDirectory $backup $changed
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N') $startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName) $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 $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.' } if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
$success = $true $success = $true
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
}
} catch { } catch {
$message = $_.Exception.Message $message = $_.Exception.Message
if ($parentExited) { if ($parentExited) {
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
try { try {
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() } if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
Undo-Files $InstallDirectory $backup $changed Undo-Files $InstallDirectory $backup $changed
@@ -273,9 +475,13 @@ try {
} }
Write-Result @{state='error';error=$message} Write-Result @{state='error';error=$message}
if ($ready -and $ready.WaitOne(0)) { if ($ready -and $ready.WaitOne(0)) {
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
} else {
Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null [Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
} }
}
exit 1 exit 1
} finally { } finally {
if ($ready) { $ready.Dispose() } if ($ready) { $ready.Dispose() }
@@ -291,4 +497,8 @@ try {
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
} }
} }
if ($script:progressWindow) {
if ($uiFinished) { $script:progressWindow.WaitForClose() }
$script:progressWindow.Dispose(); $script:progressWindow = $null
}
} }
+89 -4
View File
@@ -598,6 +598,27 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input. // down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate) if (ownKeyboardInput(key) || installingUpdate)
return false; 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{}; DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess); if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess)) if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
@@ -640,6 +661,41 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
return true; return true;
return false; 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;
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) LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{ {
if (code == HC_ACTION && current && if (code == HC_ACTION && current &&
@@ -762,11 +818,16 @@ void App::transform(int command, int snippet)
struct BusyReset struct BusyReset
{ {
std::atomic<bool> &b; std::atomic<bool> &b;
HWND window;
bool deferred{};
~BusyReset() ~BusyReset()
{ {
if (deferred)
PostMessageW(window, ReplayTypingMessage, 0, 0);
else
b = false; b = false;
} }
} reset{busy}; } reset{busy, window};
auto start = GetTickCount64(); auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection"; std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected; std::wstring text, result, expected;
@@ -849,6 +910,21 @@ void App::transform(int command, int snippet)
return; return;
} }
reason = L"clipboard-snapshot"; 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); Transaction tx(*this, target, version);
reason = L"selection-unavailable"; reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) + diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
@@ -865,6 +941,7 @@ void App::transform(int command, int snippet)
// conversion, automatic correction must never fall back to a suffix. // conversion, automatic correction must never fall back to a suffix.
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT)) if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
return; return;
autoSelected = true;
Sleep(25); Sleep(25);
text = tx.copy(); text = tx.copy();
// Some editors first select only ") ". Extend by whole-word // Some editors first select only ") ". Extend by whole-word
@@ -881,7 +958,10 @@ void App::transform(int command, int snippet)
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config)) if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
{ {
if (tx.valid()) if (tx.valid())
{
chord(VK_RIGHT); chord(VK_RIGHT);
autoSelected = false;
}
return; return;
} }
to = suggestion->target; to = suggestion->target;
@@ -1018,6 +1098,8 @@ void App::transform(int command, int snippet)
sent = tx.paste(textClip(result)); sent = tx.paste(textClip(result));
else if (automatic && tx.valid()) else if (automatic && tx.valid())
chord(VK_RIGHT); chord(VK_RIGHT);
if (sent || result == text)
autoSelected = false;
reason = result == text ? L"conversion-unchanged" : reason = result == text ? L"conversion-unchanged" :
sent ? L"paste-sent-unverified" : L"paste-cancelled"; sent ? L"paste-sent-unverified" : L"paste-cancelled";
if (result == text && if (result == text &&
@@ -1305,7 +1387,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
updateWorker = std::thread([this, interactive, install, version] updateWorker = std::thread([this, interactive, install, version]
{ {
auto result = std::make_unique<UpdateResult>(); 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()); } catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
result->interactive = interactive; result->interactive = interactive;
result->install = install; result->install = install;
@@ -1377,6 +1459,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_HOTKEY: case WM_HOTKEY:
app->dispatch((int)wp); app->dispatch((int)wp);
return 0; return 0;
case ReplayTypingMessage:
app->replayTyping();
return 0;
case TrayMessage: case TrayMessage:
if (LOWORD(lp) == WM_CONTEXTMENU) if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu(); app->trayMenu();
@@ -1550,8 +1635,8 @@ int App::execute()
SetTimer(window, 2, 10000, nullptr); SetTimer(window, 2, 10000, nullptr);
else else
SetTimer(window, 5, 5000, nullptr); SetTimer(window, 5, 5000, nullptr);
if (keyboardHook && mouseHook) if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
confirmUpdatedStartup(updateStartupEvent); log(std::wstring(L"Update completed; running version ") + AppVersion);
if (startCommand) if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0); PostMessageW(window, WM_HOTKEY, startCommand, 0);
try { registerScreenshotNotifications(); } try { registerScreenshotNotifications(); }
+5
View File
@@ -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, inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
Screenshot = 7, Direct = 100; Screenshot = 7, Direct = 100;
inline constexpr UINT AutoCorrectMessage = WM_APP + 6; inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5; inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform struct LastTransform
{ {
@@ -105,6 +106,10 @@ class App
int previousAttemptCommand{}, repeatAttempts{}; int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings; std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook; HotkeyHook hotkeyHook;
HWND typingTarget{};
std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t);
void replayTyping();
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM); static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM); static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM); static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
+21 -5
View File
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
} }
if (offsets.empty()) if (offsets.empty())
throw std::runtime_error("Empty auto-correction dictionary"); throw std::runtime_error("Empty auto-correction dictionary");
for (size_t i = 1; i < offsets.size(); ++i) auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
if (word(i - 1) >= word(i)) auto frequency = [&](uint32_t offset)
throw std::runtime_error("Dictionary must be uniquely sorted"); {
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(); offsets.shrink_to_fit();
bytes.shrink_to_fit(); bytes.shrink_to_fit();
} }
@@ -151,9 +160,16 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto sourceLanguage = layoutLanguage(layouts[source]); auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage)) if (!dictionaries.contains(sourceLanguage))
return {}; 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. // Valid words from either selected language are never changed.
for (auto &[language, d] : dictionaries) for (auto &[language, d] : dictionaries)
if (d.rank(word)) if (d.rank(word) || personal(language, word))
return {}; return {};
std::optional<AutoSuggestion> candidate; std::optional<AutoSuggestion> candidate;
int matches = 0; int matches = 0;
@@ -168,7 +184,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto converted = convert(word, layouts[source], layouts[i], true); auto converted = convert(word, layouts[source], layouts[i], true);
if (converted == word || !safeAutoWord(converted, settings.autoMinLength)) if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
continue; continue;
int rank = dictionary->second.rank(converted); int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
if (!rank) if (!rank)
continue; continue;
if (++matches > 1) if (++matches > 1)
+2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <map>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -58,6 +59,7 @@ struct Settings
std::wstring autoSuffixChars = L")]}»”’\".,!?:;"; std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
std::vector<std::wstring> autoLanguages{L"en", L"uk"}; std::vector<std::wstring> autoLanguages{L"en", L"uk"};
std::vector<std::wstring> autoNever; std::vector<std::wstring> autoNever;
std::map<std::wstring, std::vector<std::wstring>> autoWords;
std::vector<std::wstring> autoExcluded{ std::vector<std::wstring> autoExcluded{
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh", 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"}; L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
+22
View File
@@ -92,6 +92,17 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Unsupported auto-correction language"); throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000) if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions"); 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 || if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
s.maxImageMiB > 64) s.maxImageMiB > 64)
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image"); 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.autoMinLength = j.value("autoCorrectMinLength", 3);
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars); s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars); 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}, for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever}, {"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}}) {"autoCorrectExcludedApps", &s.autoExcluded}})
@@ -229,6 +244,13 @@ Bytes encodeSettings(const Settings &s)
j["autoCorrectMinLength"] = s.autoMinLength; j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars); j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars); 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}, for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever}, {"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}}) {"autoCorrectExcludedApps", &s.autoExcluded}})
+22 -5
View File
@@ -1009,6 +1009,15 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (checked(w, 403)) if (checked(w, 403))
draft.autoLanguages.push_back(L"ru"); draft.autoLanguages.push_back(L"ru");
draft.autoNever = parseCommaList(value(w, 405)); 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.autoExcluded = parseCommaList(value(w, 404));
draft.autoPrefixChars = value(w, 408); draft.autoPrefixChars = value(w, 408);
draft.autoSuffixChars = value(w, 409); draft.autoSuffixChars = value(w, 409);
@@ -1017,6 +1026,7 @@ LRESULT CALLBACK autoOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
validateSettings(draft); validateSettings(draft);
view->settings->draft.autoLanguages = draft.autoLanguages; view->settings->draft.autoLanguages = draft.autoLanguages;
view->settings->draft.autoNever = draft.autoNever; view->settings->draft.autoNever = draft.autoNever;
view->settings->draft.autoWords = draft.autoWords;
view->settings->draft.autoExcluded = draft.autoExcluded; view->settings->draft.autoExcluded = draft.autoExcluded;
view->settings->draft.autoMaxRank = draft.autoMaxRank; view->settings->draft.autoMaxRank = draft.autoMaxRank;
view->settings->draft.autoMinLength = draft.autoMinLength; view->settings->draft.autoMinLength = draft.autoMinLength;
@@ -1063,13 +1073,20 @@ void showAutoOptions(HWND parent, SettingsView *v)
for (auto &lang : v->draft.autoLanguages) for (auto &lang : v->draft.autoLanguages)
check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true); check(w, lang == L"en" ? 401 : lang == L"uk" ? 402 : 403, true);
label(w, L"Excluded applications (process names, comma-separated)", 18, 94, 525); 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)); value(w, 404, commaList(v->draft.autoExcluded));
label(w, L"Personal dictionary: never correct these words (comma-separated)", 18, 220, 525); label(w, L"Never correct these words (comma-separated)", 18, 184, 525);
edit(w, 405, 18, 248, 524, 84, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL); edit(w, 405, 18, 212, 524, 52, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL);
value(w, 405, commaList(v->draft.autoNever)); value(w, 405, commaList(v->draft.autoNever));
label(w, L"Corrects after Space and a short pause.", 18, 344, 525); label(w, L"My dictionary: correct words, separated by commas or new lines", 18, 276, 525);
label(w, L"Undo skips this word until KSwitcher restarts.", 18, 366, 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); label(w, L"Frequency rank limit (0 = entire dictionary)", 18, 400, 390);
edit(w, 406, 420, 396, 122, 26, ES_NUMBER); edit(w, 406, 420, 396, 122, 26, ES_NUMBER);
value(w, 406, std::to_wstring(v->draft.autoMaxRank)); value(w, 406, std::to_wstring(v->draft.autoMaxRank));
+5 -4
View File
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
return out + L'"'; return out + L'"';
} }
} }
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion) UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
{ {
UpdateResult result; UpdateResult result;
GUID guid{}; 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" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName); L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion); if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
if (showProgress) command += L" -ShowProgress";
STARTUPINFOW startup{sizeof(startup)}; STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{}; PROCESS_INFORMATION process{};
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, 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; 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())}; Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
if (event.value) SetEvent(event.value); return event.value && SetEvent(event.value);
} }
} }
+3 -3
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.9"; inline constexpr wchar_t AppVersion[] = L"0.6.11";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
@@ -13,6 +13,6 @@ struct UpdateResult
std::wstring state, version, error; std::wstring state, version, error;
bool interactive{}, install{}; bool interactive{}, install{};
}; };
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion); UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
void confirmUpdatedStartup(const std::wstring &eventName); bool confirmUpdatedStartup(const std::wstring &eventName);
} }
+31
View File
@@ -55,6 +55,14 @@ int main(int argc, char **argv) {
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor. // 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); 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(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::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks"); 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","background update records announced version");
@@ -586,6 +594,29 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080}); 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"); require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false); 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"); if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
ks::showHistory(app,false); ks::showHistory(app,false);
auto history=app.historyWindow; auto history=app.historyWindow;
+75
View File
@@ -3,6 +3,17 @@
#include <fstream> #include <fstream>
#include <commctrl.h> #include <commctrl.h>
namespace ks
{
struct InputRegressionAccess
{
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
return app.typingTarget != nullptr;
}
};
}
namespace namespace
{ {
ks::App *app{}; ks::App *app{};
@@ -11,6 +22,7 @@ ks::Clip saved;
bool captured = false, failed = false; bool captured = false, failed = false;
int stage = 0, ru = -1, en = -1; int stage = 0, ru = -1, en = -1;
size_t punctuationCase = 0; size_t punctuationCase = 0;
bool typeDuringCorrection = false, typedDuringCorrection = false;
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{ 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"{дома} "},
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}}; {L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
@@ -25,7 +37,14 @@ size_t diagnosticStart{};
void require(bool ok, const char *name) void require(bool ok, const char *name)
{ {
if (!ok) 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); throw std::runtime_error(name);
}
std::cout << "PASS " << name << '\n'; std::cout << "PASS " << name << '\n';
} }
std::wstring text() std::wstring text()
@@ -80,7 +99,26 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (GetTickCount64() > deadline) if (GetTickCount64() > deadline)
throw std::runtime_error("Native integration timed out"); throw std::runtime_error("Native integration timed out");
if (app->busy) 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; return 0;
}
switch (stage++) switch (stage++)
{ {
case 0: case 0:
@@ -242,6 +280,41 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break; break;
case 17: case 17:
require(text() == punctuationCases.back().first, "undo preserves custom punctuation"); 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");
finish(); finish();
break; break;
} }
@@ -366,6 +439,8 @@ int main(int argc, char **argv)
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN, CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr); 20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000; deadline = GetTickCount64() + 30000;
if (argc > 1 && std::string(argv[1]) == "--typing-only")
stage = 20;
SetTimer(host, 1, 100, nullptr); SetTimer(host, 1, 100, nullptr);
app->execute(); app->execute();
} }
+55
View File
@@ -241,6 +241,37 @@ int main(int argc, char **argv)
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB"); require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed"); require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
ks::AutoCorrector missing; 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);
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.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"); 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"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged"); require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
@@ -335,6 +366,25 @@ int main(int argc, char **argv)
auto temp = std::filesystem::temp_directory_path() / auto temp = std::filesystem::temp_directory_path() /
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId())); (L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(temp); 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::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}}}}; ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
auto thumbnail = ks::decodeImage(firstImage, 1, 1); auto thumbnail = ks::decodeImage(firstImage, 1, 1);
@@ -384,11 +434,16 @@ int main(int argc, char **argv)
settings.autoPrefixChars = L"(#"; settings.autoPrefixChars = L"(#";
settings.autoSuffixChars = L");%"; settings.autoSuffixChars = L");%";
settings.autoNever = {L"myWord"}; settings.autoNever = {L"myWord"};
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
settings.autoExcluded = {L"Code.exe"}; settings.autoExcluded = {L"Code.exe"};
settings.cycle = {L"en", L"uk"}; settings.cycle = {L"en", L"uk"};
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}}; settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings); ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {}); 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), require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
"configurator snapshot survives serialization without changing the concurrency token"); "configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); }, rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
+31
View File
@@ -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
}
+6
View File
@@ -3,6 +3,8 @@ from pathlib import Path
import urllib.request, hashlib, json import urllib.request, hashlib, json
ROOT=Path(__file__).resolve().parents[1]/'dictionaries' ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
REV='525f9b560de45753a5ea01069454e72e9aa541c6' REV='525f9b560de45753a5ea01069454e72e9aa541c6'
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
SUPPLEMENTAL={'uk': {'реліз': 50001}}
ROOT.mkdir(exist_ok=True) ROOT.mkdir(exist_ok=True)
manifest={} manifest={}
for lang in ('en','uk','ru'): 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 not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
if int(count)<5:continue if int(count)<5:continue
rows.setdefault(word,rank) 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)) out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8')) (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()} 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') (ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
print(json.dumps(manifest,indent=2)) print(json.dumps(manifest,indent=2))