Release v0.2.1: support remote input, safe updates and high-DPI windows
This commit is contained in:
+8
-3
@@ -1,11 +1,11 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.2.0 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.2.1 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
@@ -15,20 +15,25 @@ add_executable(KSwitcherTests tests/tests.cpp)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||
add_executable(KSwitcherOcrTests tests/ocr.cpp)
|
||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||
else()
|
||||
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
enable_testing()
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher RUNTIME DESTINATION .)
|
||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||
|
||||
@@ -36,6 +36,36 @@ combinations are intercepted. Unassigned shortcuts and simulated copy/paste pass
|
||||
Choose another shortcut or exit KSwitcher to restore the original action. Windows protects
|
||||
Ctrl+Alt+Delete and Win+L; these cannot be assigned.
|
||||
|
||||
Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input
|
||||
path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets.
|
||||
KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask
|
||||
keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input
|
||||
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
|
||||
physical key positions and therefore reset recent-word tracking; selection conversion still works.
|
||||
|
||||
### Updates
|
||||
|
||||
Select **Update** in the tray menu to check the stable Releases of
|
||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||
A startup check also reports an available version, but never installs it automatically.
|
||||
Confirm the offered version to download and install 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;
|
||||
if recovery itself fails, it reports the retained backup directory.
|
||||
|
||||
Saved settings and clipboard history in `data/` or the local-app-data fallback are preserved.
|
||||
Existing dictionary files are preserved too; missing dictionary files are installed. Save settings
|
||||
edits before updating. The installation directory must be writable at KSwitcher's current privilege
|
||||
level. A blocked network request, incomplete release or checksum mismatch leaves the running version
|
||||
intact and is reported when Update is selected. Startup network failures remain non-interactive.
|
||||
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
|
||||
|
||||
Settings and the other utility windows use the destination monitor's DPI for their initial frame,
|
||||
controls and fonts. They can be resized, stay within a remaining monitor's work area after display
|
||||
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||
field into view; Shift+wheel scrolls horizontally.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
@@ -114,10 +144,8 @@ This is a working native implementation, not a claim of complete UI/release pari
|
||||
|
||||
- UI currently uses English and the native Windows control appearance. [Typlo](https://github.com/bitcodepro/Typlo)'s ten-language UI and
|
||||
explicit light/dark theme selector are not yet ported.
|
||||
- Automatic release download/replacement is not enabled. KSwitcher has no configured release feed;
|
||||
using [Typlo](https://github.com/bitcodepro/Typlo)'s feed would install the wrong application. Updates are manual.
|
||||
- No import of old encrypted clipboard history. Export/import applies to settings.
|
||||
- The clipboard popup is fixed-size with per-monitor DPI scaling; PNG/DIB previews use Windows WIC/GDI.
|
||||
- The clipboard popup uses per-monitor DPI scaling and scrolls when space is limited; PNG/DIB previews use Windows WIC/GDI.
|
||||
|
||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
||||
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
||||
@@ -201,6 +229,18 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
|
||||
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
||||
```
|
||||
|
||||
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
|
||||
package validation, file preservation and rollback in temporary fixture directories. It does not
|
||||
replace a running installation. Optional read-only network checks:
|
||||
|
||||
```powershell
|
||||
.\build\KSwitcherInputTests.exe --update-check
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\update.ps1 -Network
|
||||
```
|
||||
|
||||
These validate the live release metadata/download/checksum in a temporary directory. They do not
|
||||
prove hardware interoperability between two laptops or a physical monitor-disconnection sequence.
|
||||
|
||||
The interactive test requires installed English-US and Russian layouts. It opens its own editor,
|
||||
tests real copy/paste, undo, repeat conversion, recent-input verification and snippet cursor placement,
|
||||
then restores the clipboard. Test data is stored in its own temporary directory. It never sends messages
|
||||
|
||||
Binary file not shown.
+22
-17
@@ -1,34 +1,39 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.2.0 — 2026-10-28
|
||||
## v0.2.1 — 2026-10-28
|
||||
|
||||
- Log failed conversions and suspicious repeated hotkeys with recent attempt context.
|
||||
- Include bounded text and layout diagnostics to help reproduce conversion failures.
|
||||
- Add the **Write log** setting and preserve the previous log during rotation.
|
||||
- View current and previous logs inside the app with Time, Attempt, Event, and Details columns.
|
||||
- Copy selected rows and clear the selected log from the viewer.
|
||||
- Fix the log viewer closing on focus; give column headers a distinct background.
|
||||
- Accept keyboard and mouse input forwarded by Microsoft Mouse Without Borders, including hotkey
|
||||
releases, repeated presses, recent-word conversion and snippets. Mark KSwitcher's own generated
|
||||
input to prevent recursive commands and preserve text-operation validation.
|
||||
- Add **Update** to the tray menu and a non-installing startup check against this repository's
|
||||
Releases. Verify the x64 ZIP, SHA-256, executable identity and version before closing; restart
|
||||
automatically and roll back on replacement/startup failure. Keep settings, history and existing dictionaries.
|
||||
- Fix initial window sizing on high-DPI monitors. Size the frame and controls from the same monitor
|
||||
DPI, recover windows after display changes, and provide resizing, scrolling and focus visibility
|
||||
when the available work area is smaller than the contents.
|
||||
|
||||
Repeated hotkeys are diagnostic clues, not proof of failure. Successful paste dispatch does not
|
||||
verify that the target editor applied the replacement. Text diagnostics are enabled by default
|
||||
and can be disabled with **Write log**.
|
||||
Verified: 168 native checks, 51 input/layout checks (including hidden Settings at 240 DPI),
|
||||
24 offline updater checks, live release download/checksum/package validation, and visible Settings
|
||||
at 250% scaling. Two-laptop Mouse Without Borders use and physically disconnecting a monitor still
|
||||
require hardware verification.
|
||||
|
||||
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder,
|
||||
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable.
|
||||
The archive contains the application, offline dictionaries, documentation and license notices.
|
||||
It does not contain personal settings or clipboard history.
|
||||
`SHA256SUMS.txt` contains the archive checksum.
|
||||
|
||||
For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files.
|
||||
Keep your existing `data/` directory to retain settings and clipboard history.
|
||||
`SHA256SUMS.txt` contains the checksum of the archive.
|
||||
From version 0.2.1 onward, use **Update** in the tray for future releases. Startup checks notify;
|
||||
installation starts only after confirming Update. Older versions need this one manual upgrade:
|
||||
exit KSwitcher and replace the application files, retaining `data/` and any customized dictionaries.
|
||||
|
||||
This folder holds only the latest compiled version; older versions remain in Git history.
|
||||
|
||||
To refresh it from source:
|
||||
This folder holds the latest compiled version; older versions remain in Git history and Releases.
|
||||
To refresh the tracked package from source:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the updated ZIP and checksum together with the corresponding source changes.
|
||||
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
|
||||
one stable release tagged with the executable version, so the updater can verify it.
|
||||
|
||||
@@ -1 +1 @@
|
||||
7138fee6aa1e9c5e66aa212798c4cc193a987f088a45ec37a355b46cf9a40d08 KSwitcher-win-x64.zip
|
||||
e33c372d1401c5500f3b07b38c10aff9667a5b1db9f6e613420e7878202853b3 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -14,24 +14,30 @@ Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','app','ui','main')) {
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main')) {
|
||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||
}
|
||||
& windres resources/app.rc -O coff -o build/app-res.o
|
||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $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/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $compiler @common tests/tests.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 @libs -static -s -o build/KSwitcherTests.exe
|
||||
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/app.o build/ui.o build/app-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' }
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater 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/app.o build/ui.o build/app-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/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||
& .\build\KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||
|
||||
+4
-3
@@ -2,9 +2,10 @@
|
||||
#include "resource.h"
|
||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,2,0,0
|
||||
PRODUCTVERSION 0,2,0,0
|
||||
FILEVERSION 0,2,1,0
|
||||
PRODUCTVERSION 0,2,1,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -14,7 +15,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.2.0"
|
||||
VALUE "FileVersion", "0.2.1"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
param(
|
||||
[ValidateSet('Check','Install')][string]$Mode = 'Check',
|
||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||
[switch]$Library
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Get-StableVersion([string]$Tag) {
|
||||
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
try { return [version]($Tag -replace '^v','') } catch { return $null }
|
||||
}
|
||||
function Assert-ReleaseUrl([string]$Url) {
|
||||
$uri = [uri]$Url
|
||||
if (!$uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or $uri.Host -ne 'gitea.hrynco.com' -or
|
||||
!$uri.IsDefaultPort -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or
|
||||
!$uri.AbsolutePath.StartsWith('/hrynco/hrynco-kswitcher/releases/download/',[StringComparison]::Ordinal)) {
|
||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||
}
|
||||
}
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$request = [Net.HttpWebRequest]::Create($Url)
|
||||
$request.AllowAutoRedirect = $false
|
||||
$request.Timeout = 15000
|
||||
$request.ReadWriteTimeout = 15000
|
||||
$request.UserAgent = 'KSwitcher-Updater'
|
||||
$response = $null; $inputStream = $null; $outputStream = $null
|
||||
try {
|
||||
$response = $request.GetResponse()
|
||||
if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt $Limit) { throw 'Invalid or oversized server response.' }
|
||||
$inputStream = $response.GetResponseStream()
|
||||
$outputStream = [IO.File]::Create($Path)
|
||||
$buffer = New-Object byte[] 65536
|
||||
$total = 0L
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
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)
|
||||
}
|
||||
} finally {
|
||||
if ($outputStream) { $outputStream.Dispose() }
|
||||
if ($inputStream) { $inputStream.Dispose() }
|
||||
if ($response) { $response.Dispose() }
|
||||
}
|
||||
}
|
||||
function Select-Release($Releases,[version]$Current) {
|
||||
$candidates = @($Releases | Where-Object { !$_.draft -and !$_.prerelease -and (Get-StableVersion $_.tag_name) } |
|
||||
Sort-Object { Get-StableVersion $_.tag_name } -Descending)
|
||||
if (!$candidates.Count -or (Get-StableVersion $candidates[0].tag_name) -le $Current) { return $null }
|
||||
$release = $candidates[0]
|
||||
$zip = @($release.assets | Where-Object name -CEQ 'KSwitcher-win-x64.zip')
|
||||
$sum = @($release.assets | Where-Object name -CEQ 'SHA256SUMS.txt')
|
||||
if ($zip.Count -ne 1 -or $sum.Count -ne 1) { throw 'The newest release lacks its x64 ZIP or SHA256SUMS.txt.' }
|
||||
Assert-ReleaseUrl $zip[0].browser_download_url
|
||||
Assert-ReleaseUrl $sum[0].browser_download_url
|
||||
return @{ version=$release.tag_name; zip=$zip[0].browser_download_url; checksum=$sum[0].browser_download_url }
|
||||
}
|
||||
function Assert-Checksum([string]$Archive,[string]$Checksum) {
|
||||
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
||||
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
||||
$expected = ($matchesFound[0] -split '\s+')[0]
|
||||
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
|
||||
}
|
||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$total = 0L
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name = $entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
||||
if ($name -cnotmatch '^(KSwitcher\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
||||
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
||||
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
||||
throw "Unexpected or unsafe archive entry: $name"
|
||||
}
|
||||
$total += $entry.Length
|
||||
if ($total -gt 100MB -or $zip.Entries.Count -gt 100) { throw 'Release expands beyond allowed limits.' }
|
||||
$destinationFile = Join-Path $Destination $name
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationFile)) | Out-Null
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$destinationFile,$false)
|
||||
}
|
||||
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
|
||||
if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
$exe = Join-Path $Destination 'KSwitcher.exe'
|
||||
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
||||
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
|
||||
throw 'Executable identity/version does not match the release.'
|
||||
}
|
||||
$bytes = [IO.File]::ReadAllBytes($exe)
|
||||
if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw 'Invalid release executable.' }
|
||||
$pe = [BitConverter]::ToInt32($bytes,60)
|
||||
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
|
||||
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
||||
}
|
||||
function Assert-PlainDirectory([string]$Directory) {
|
||||
$item = Get-Item -LiteralPath $Directory -Force
|
||||
while ($item) {
|
||||
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Update directory must not contain junctions or symbolic links.' }
|
||||
$item = $item.Parent
|
||||
}
|
||||
}
|
||||
function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
|
||||
Assert-PlainDirectory $Target
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
||||
$relative = $file.FullName.Substring($Stage.Length+1)
|
||||
$destination = Join-Path $Target $relative
|
||||
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
if ((Get-Item -LiteralPath $destination -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Cannot replace a linked file.' }
|
||||
$backupFile = Join-Path $Backup $relative
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($backupFile)) | Out-Null
|
||||
Move-Item -LiteralPath $destination -Destination $backupFile
|
||||
}
|
||||
$Changed.Add($relative)
|
||||
Move-Item -LiteralPath $file.FullName -Destination $destination
|
||||
}
|
||||
}
|
||||
function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
|
||||
for ($i=$Changed.Count-1; $i -ge 0; --$i) {
|
||||
$destination = Join-Path $Target $Changed[$i]
|
||||
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force }
|
||||
$original = Join-Path $Backup $Changed[$i]
|
||||
if (Test-Path -LiteralPath $original) { Move-Item -LiteralPath $original -Destination $destination }
|
||||
}
|
||||
}
|
||||
function Write-Result($Value) {
|
||||
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
|
||||
}
|
||||
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
|
||||
try {
|
||||
$current = Get-StableVersion $CurrentVersion
|
||||
if (!$current) { throw 'Invalid installed version.' }
|
||||
if ($Mode -eq 'Install') {
|
||||
$parentProcess = Get-Process -Id $ParentProcessId
|
||||
if ([IO.Path]::GetFullPath($parentProcess.Path) -ne [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.exe'))) {
|
||||
throw 'The running executable does not match the update target.'
|
||||
}
|
||||
$ready = [Threading.EventWaitHandle]::OpenExisting($ReadyEvent)
|
||||
}
|
||||
$metadata = Join-Path $WorkDirectory 'releases.json'
|
||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
|
||||
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
|
||||
if (!$release) { Write-Result @{state='current'}; exit 0 }
|
||||
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
|
||||
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
|
||||
Assert-PlainDirectory $InstallDirectory
|
||||
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
|
||||
$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
|
||||
Receive-File $release.checksum $checksum 64KB
|
||||
Assert-Checksum $archive $checksum
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
$parentExited = $true
|
||||
Install-Files $stage $InstallDirectory $backup $changed
|
||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||
$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
|
||||
} catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($parentExited) {
|
||||
try {
|
||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||
Undo-Files $InstallDirectory $backup $changed
|
||||
Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -WorkingDirectory $InstallDirectory -WindowStyle Hidden
|
||||
$message += "`nThe previous version has been restored and restarted."
|
||||
} catch {
|
||||
$preserveBackup = $true
|
||||
$message += "`nRecovery needs attention: $($_.Exception.Message)`nBackup: $backup"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
exit 1
|
||||
} finally {
|
||||
if ($ready) { $ready.Dispose() }
|
||||
if ($started) { $started.Dispose() }
|
||||
if ($parentProcess) { $parentProcess.Dispose() }
|
||||
# Only remove the uniquely created transaction directory directly under the install directory.
|
||||
if ($transaction -and !$preserveBackup -and [IO.Path]::GetDirectoryName($transaction) -eq [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\') -and (Test-Path -LiteralPath $transaction)) {
|
||||
Remove-Item -LiteralPath $transaction -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($success) {
|
||||
# The script is loaded in memory; its unique temporary working directory is no longer needed.
|
||||
if ((Split-Path -Leaf $WorkDirectory) -like 'KSwitcher-update-*' -and [IO.Path]::GetDirectoryName($WorkDirectory) -eq [IO.Path]::GetTempPath().TrimEnd('\')) {
|
||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
-57
@@ -256,6 +256,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (updateWorker.joinable())
|
||||
updateWorker.join();
|
||||
if (keyboardHook)
|
||||
UnhookWindowsHookEx(keyboardHook);
|
||||
if (mouseHook)
|
||||
@@ -413,23 +415,15 @@ void App::resetInput()
|
||||
run.reset();
|
||||
last.reset();
|
||||
}
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
{
|
||||
if (k.flags & LLKHF_INJECTED)
|
||||
if (ownKeyboardInput(k))
|
||||
return;
|
||||
DWORD vk = k.vkCode;
|
||||
if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
|
||||
if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU ||
|
||||
vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
|
||||
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
|
||||
return;
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
|
||||
mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000)
|
||||
mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
||||
mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
|
||||
mods |= MOD_WIN;
|
||||
std::lock_guard lock(stateMutex);
|
||||
if (isHotkey(vk, mods))
|
||||
return;
|
||||
@@ -448,7 +442,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
return;
|
||||
}
|
||||
bool shift = (mods & MOD_SHIFT) != 0;
|
||||
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift);
|
||||
// VK_PACKET carries UTF-16 in scanCode, not a physical key position.
|
||||
DWORD scan = vk == VK_PACKET ? 0 : k.scanCode;
|
||||
if (!scan && vk != VK_PACKET)
|
||||
scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout);
|
||||
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift);
|
||||
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
|
||||
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
|
||||
if (!ch)
|
||||
@@ -492,85 +490,83 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
}
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
{
|
||||
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) &&
|
||||
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
|
||||
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
|
||||
if (code == HC_ACTION && current && current->hotkeyCapture &&
|
||||
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
|
||||
// Filter before capture, hotkey latching and typing history. External injected
|
||||
// down AND up events use exactly the same path as physical keyboard input.
|
||||
if (ownKeyboardInput(key) || installingUpdate)
|
||||
return false;
|
||||
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
|
||||
{
|
||||
auto &key = *(KBDLLHOOKSTRUCT *)lp;
|
||||
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
|
||||
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
|
||||
key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
|
||||
key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
|
||||
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
|
||||
if (!modifier)
|
||||
{
|
||||
if (wp == WM_KEYUP || wp == WM_SYSKEYUP)
|
||||
current->hotkeyHook.update(key.vkCode, false, 0, {});
|
||||
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
|
||||
if (!down)
|
||||
hotkeyHook.update(key.vkCode, false, 0, {});
|
||||
if (down)
|
||||
{
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
|
||||
mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000)
|
||||
mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
||||
mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
|
||||
mods |= MOD_WIN;
|
||||
if (mods & MOD_WIN)
|
||||
chord(0xE8);
|
||||
PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
|
||||
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
|
||||
}
|
||||
return 1; // Record the chord without executing a global shortcut or typing into an editor.
|
||||
}
|
||||
}
|
||||
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
|
||||
if (down)
|
||||
{
|
||||
auto &key = *(KBDLLHOOKSTRUCT *)lp;
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
|
||||
mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000)
|
||||
mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
||||
mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
|
||||
mods |= MOD_WIN;
|
||||
auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings);
|
||||
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
|
||||
if (action > 0)
|
||||
{
|
||||
// Tell the shell that Win participated in a chord, preventing Start
|
||||
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
|
||||
if (mods & (MOD_WIN | MOD_ALT))
|
||||
chord(0xE8);
|
||||
PostMessageW(current->window, WM_HOTKEY, action, 0);
|
||||
PostMessageW(window, WM_HOTKEY, action, 0);
|
||||
}
|
||||
if (action != 0)
|
||||
return 1;
|
||||
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
|
||||
keyboard(key, mods);
|
||||
}
|
||||
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
|
||||
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0)
|
||||
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (code == HC_ACTION && current &&
|
||||
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
|
||||
{
|
||||
// The current event has not updated asynchronous state yet; the preceding
|
||||
// modifier events (including MWB input) have. Own events must still reach Windows.
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN;
|
||||
if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp,
|
||||
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
|
||||
return 1;
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (code == HC_ACTION && current)
|
||||
{
|
||||
auto *e = (MSLLHOOKSTRUCT *)lp;
|
||||
if (!(e->flags & LLMHF_INJECTED) &&
|
||||
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
|
||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||
// range just like local clicks, including while a transaction is pending.
|
||||
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
||||
current->resetInput();
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
void App::dispatch(int command)
|
||||
{
|
||||
if (installingUpdate)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (command == ShowHistory)
|
||||
@@ -602,6 +598,7 @@ void App::dispatch(int command)
|
||||
}
|
||||
void App::transform(int command, int snippet)
|
||||
{
|
||||
if (installingUpdate) return;
|
||||
const auto attempt = ++transformAttempt;
|
||||
const auto requestedAt = GetTickCount64();
|
||||
HWND target = GetForegroundWindow();
|
||||
@@ -957,6 +954,7 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
void App::pasteEntry(std::wstring id, bool plain)
|
||||
{
|
||||
if (installingUpdate) return;
|
||||
if (!history || busy.exchange(true))
|
||||
return;
|
||||
HWND target = pasteTarget;
|
||||
@@ -1095,6 +1093,9 @@ void App::trayMenu()
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
||||
AppendMenuW(menu, MF_STRING, 18, L"View log…");
|
||||
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
||||
updateRunning ? L"Update (checking / preparing…)" :
|
||||
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
||||
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
||||
@@ -1133,7 +1134,7 @@ void App::trayMenu()
|
||||
break;
|
||||
case 16:
|
||||
MessageBoxW(window,
|
||||
L"KSwitcher 0.2.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
|
||||
L"KSwitcher 0.2.1\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
|
||||
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
|
||||
L"account.\nNo .NET, WebView or graphics framework.",
|
||||
L"About KSwitcher", MB_OK);
|
||||
@@ -1144,6 +1145,9 @@ void App::trayMenu()
|
||||
case 18:
|
||||
showLogs(*this);
|
||||
break;
|
||||
case 19:
|
||||
checkUpdate(true);
|
||||
break;
|
||||
case 20:
|
||||
pauseUntil = 0;
|
||||
break;
|
||||
@@ -1163,6 +1167,27 @@ void App::trayMenu()
|
||||
report(wide(e.what()));
|
||||
}
|
||||
}
|
||||
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
|
||||
{
|
||||
if (updateRunning) return;
|
||||
if (install && busy)
|
||||
{
|
||||
report(L"Wait for the current text operation to finish, then select Update again.");
|
||||
return;
|
||||
}
|
||||
if (updateWorker.joinable()) updateWorker.join();
|
||||
updateRunning = true;
|
||||
installingUpdate = install;
|
||||
updateWorker = std::thread([this, interactive, install, version]
|
||||
{
|
||||
auto result = std::make_unique<UpdateResult>();
|
||||
try { *result = runUpdate(install, version); }
|
||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||
result->interactive = interactive;
|
||||
result->install = install;
|
||||
if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
|
||||
});
|
||||
}
|
||||
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = current;
|
||||
@@ -1175,6 +1200,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
switch (msg)
|
||||
{
|
||||
case UpdateMessage:
|
||||
{
|
||||
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
|
||||
if (app->updateWorker.joinable()) app->updateWorker.join();
|
||||
app->updateRunning = false;
|
||||
app->installingUpdate = false;
|
||||
if (result->state == L"prepared")
|
||||
{
|
||||
// The helper has verified and staged the package; it waits for process exit.
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
else if (result->state == L"available")
|
||||
{
|
||||
app->availableUpdate = result->version;
|
||||
if (result->interactive)
|
||||
{
|
||||
auto prompt = L"Install KSwitcher " + result->version +
|
||||
L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved.";
|
||||
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
||||
app->checkUpdate(true, true, result->version);
|
||||
}
|
||||
else
|
||||
{
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
|
||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
|
||||
auto text = result->version + L" is available. Select Update in the tray menu to install.";
|
||||
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
||||
}
|
||||
}
|
||||
else if (result->state == L"current")
|
||||
{
|
||||
app->availableUpdate.clear();
|
||||
if (result->interactive)
|
||||
MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(),
|
||||
L"KSwitcher Update", MB_OK | MB_ICONINFORMATION);
|
||||
}
|
||||
else if (result->interactive)
|
||||
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
||||
else
|
||||
diagnosticEvent(L"Startup update check failed: " + result->error);
|
||||
return 0;
|
||||
}
|
||||
case WM_HOTKEY:
|
||||
app->dispatch((int)wp);
|
||||
return 0;
|
||||
@@ -1192,10 +1261,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->captureHistory();
|
||||
return 0;
|
||||
case AutoCorrectMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->pendingAutoVersion = (uint64_t)wp;
|
||||
SetTimer(hwnd, 3, 250, nullptr);
|
||||
return 0;
|
||||
case SnippetMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->transform(0, (int)wp);
|
||||
return 0;
|
||||
case HistoryMessage:
|
||||
@@ -1227,6 +1298,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (wp == 5)
|
||||
{
|
||||
KillTimer(hwnd, 5);
|
||||
app->checkUpdate(false);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 4)
|
||||
{
|
||||
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
|
||||
@@ -1238,7 +1315,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (wp == 3)
|
||||
{
|
||||
KillTimer(hwnd, 3);
|
||||
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
app->transform(AutoCorrect);
|
||||
return 0;
|
||||
}
|
||||
@@ -1255,6 +1332,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return 0;
|
||||
case WM_CLOSE:
|
||||
if (app->updateRunning)
|
||||
{
|
||||
app->report(L"Wait for the update check or preparation to finish before exiting.");
|
||||
return 0;
|
||||
}
|
||||
if (app->busy)
|
||||
{
|
||||
app->report(L"Wait for the current text operation to finish before exiting.");
|
||||
@@ -1324,6 +1406,10 @@ int App::execute()
|
||||
report(registrationWarning);
|
||||
if (smoke)
|
||||
SetTimer(window, 2, 10000, nullptr);
|
||||
else
|
||||
SetTimer(window, 5, 5000, nullptr);
|
||||
if (keyboardHook && mouseHook)
|
||||
confirmUpdatedStartup(updateStartupEvent);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
MSG msg;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "autocorrect.h"
|
||||
#include "storage.h"
|
||||
#include "update.h"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
@@ -49,6 +50,7 @@ class App
|
||||
std::wstring registrationWarning;
|
||||
bool smoke{};
|
||||
int startCommand{};
|
||||
std::wstring updateStartupEvent;
|
||||
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
|
||||
~App();
|
||||
int execute();
|
||||
@@ -65,16 +67,22 @@ class App
|
||||
void applySettings(const Settings &);
|
||||
void reloadLayouts();
|
||||
bool isHotkey(DWORD, UINT);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||
void resetInput();
|
||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||
static App *current;
|
||||
|
||||
private:
|
||||
friend struct InputRegressionAccess;
|
||||
HHOOK keyboardHook{}, mouseHook{};
|
||||
std::mutex jobsMutex;
|
||||
std::condition_variable jobsReady;
|
||||
std::deque<std::function<void()>> jobs;
|
||||
std::thread worker;
|
||||
std::thread updateWorker;
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
std::wstring availableUpdate;
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
@@ -90,7 +98,7 @@ class App
|
||||
};
|
||||
void showHistory(App &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
void showLogs(App &);
|
||||
void showSnippets(App &);
|
||||
void showShortcuts(App &);
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
try
|
||||
{
|
||||
ks::App app(instance, arg == L"--smoke-test");
|
||||
if (!arg.empty() && arg != L"--smoke-test")
|
||||
if (arg.starts_with(L"--update-ready-event="))
|
||||
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
|
||||
else if (!arg.empty() && arg != L"--smoke-test")
|
||||
app.startCommand = cmd;
|
||||
result = app.execute();
|
||||
}
|
||||
|
||||
+21
-12
@@ -347,15 +347,30 @@ size_t Clip::size() const
|
||||
n += f.data.size();
|
||||
return n;
|
||||
}
|
||||
ULONG_PTR inputMarker()
|
||||
{
|
||||
static const char marker{};
|
||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||
}
|
||||
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &key)
|
||||
{
|
||||
return (key.flags & LLKHF_INJECTED) && key.dwExtraInfo == inputMarker();
|
||||
}
|
||||
INPUT keyboardInput(WORD key, DWORD flags)
|
||||
{
|
||||
INPUT input{};
|
||||
input.type = INPUT_KEYBOARD;
|
||||
input.ki.wVk = key;
|
||||
input.ki.dwFlags = flags;
|
||||
input.ki.dwExtraInfo = inputMarker();
|
||||
return input;
|
||||
}
|
||||
bool chord(WORD key, UINT mods)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
auto add = [&](WORD k, bool up)
|
||||
{
|
||||
INPUT i{};
|
||||
i.type = INPUT_KEYBOARD;
|
||||
i.ki.wVk = k;
|
||||
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
|
||||
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
|
||||
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
|
||||
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
inputs.push_back(i);
|
||||
@@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||
std::vector<INPUT> inputs;
|
||||
auto add = [&](WORD key, DWORD flags)
|
||||
{
|
||||
INPUT i{};
|
||||
i.type = INPUT_KEYBOARD;
|
||||
i.ki.wVk = key;
|
||||
i.ki.dwFlags = flags;
|
||||
INPUT i = keyboardInput(key, flags);
|
||||
inputs.push_back(i);
|
||||
};
|
||||
add(VK_SHIFT, 0);
|
||||
@@ -437,10 +449,7 @@ void releaseModifiers()
|
||||
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
|
||||
if (GetAsyncKeyState(k) & 0x8000)
|
||||
{
|
||||
INPUT i{};
|
||||
i.type = INPUT_KEYBOARD;
|
||||
i.ki.wVk = (WORD)k;
|
||||
i.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP);
|
||||
inputs.push_back(i);
|
||||
}
|
||||
if (!inputs.empty())
|
||||
|
||||
@@ -36,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
|
||||
DWORD writeClipboard(HWND, const Clip &);
|
||||
Clip textClip(const std::wstring &);
|
||||
bool chord(WORD key, UINT modifiers = 0);
|
||||
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
|
||||
ULONG_PTR inputMarker();
|
||||
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
|
||||
INPUT keyboardInput(WORD key, DWORD flags = 0);
|
||||
bool needsSelectionDelay(HWND);
|
||||
bool selectPrevious(size_t count, const std::function<bool()> &valid);
|
||||
bool modifiersReleased();
|
||||
|
||||
+171
-6
@@ -2,6 +2,7 @@
|
||||
#include "app.h"
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "ui_geometry.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <commdlg.h>
|
||||
@@ -26,6 +27,129 @@ HFONT font(HWND w)
|
||||
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
|
||||
L"Segoe UI");
|
||||
}
|
||||
struct WindowLayout
|
||||
{
|
||||
int width{}, height{}, x{}, y{};
|
||||
bool scrolling{}, arranging{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||
};
|
||||
WindowLayout *windowLayoutState(HWND w)
|
||||
{
|
||||
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
|
||||
}
|
||||
RECT workArea(RECT rect)
|
||||
{
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
GetMonitorInfoW(MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST), &monitor);
|
||||
return monitor.rcWork;
|
||||
}
|
||||
void fitToMonitor(HWND w, RECT rect)
|
||||
{
|
||||
rect = fitWindowRect(rect, workArea(rect));
|
||||
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
}
|
||||
void arrangeWindow(HWND w)
|
||||
{
|
||||
auto *layout = windowLayoutState(w);
|
||||
if (!layout || !layout->scrolling || layout->arranging) return;
|
||||
layout->arranging = true;
|
||||
// Showing either scrollbar can make the other necessary. Let the client size settle.
|
||||
for (int pass = 0; pass < 3; ++pass)
|
||||
{
|
||||
RECT client{};
|
||||
GetClientRect(w, &client);
|
||||
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
|
||||
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
|
||||
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
|
||||
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
|
||||
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
for (const auto &[child, r] : layout->controls)
|
||||
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
|
||||
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
layout->arranging = false;
|
||||
InvalidateRect(w, nullptr, TRUE);
|
||||
}
|
||||
void revealControl(HWND child)
|
||||
{
|
||||
HWND parent = GetParent(child);
|
||||
auto *layout = windowLayoutState(parent);
|
||||
if (!layout || !layout->scrolling) return;
|
||||
RECT r{}, client{};
|
||||
GetWindowRect(child, &r);
|
||||
MapWindowPoints(nullptr, parent, reinterpret_cast<POINT *>(&r), 2);
|
||||
GetClientRect(parent, &client);
|
||||
if (r.left < 0) layout->x += r.left;
|
||||
else if (r.right > client.right) layout->x += r.right - client.right;
|
||||
if (r.top < 0) layout->y += r.top;
|
||||
else if (r.bottom > client.bottom) layout->y += r.bottom - client.bottom;
|
||||
arrangeWindow(parent);
|
||||
}
|
||||
LRESULT CALLBACK scrollChildProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (msg == WM_SETFOCUS) revealControl(w);
|
||||
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, scrollChildProc, 2);
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
}
|
||||
void resizeForDpi(HWND, WPARAM, LPARAM);
|
||||
LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
auto *layout = windowLayoutState(w);
|
||||
if (!layout) return DefSubclassProc(w, msg, wp, lp);
|
||||
if (msg == WM_DPICHANGED)
|
||||
{
|
||||
resizeForDpi(w, wp, lp);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_SIZE) arrangeWindow(w);
|
||||
if (msg == WM_DISPLAYCHANGE || msg == WM_SETTINGCHANGE || (msg == WM_SHOWWINDOW && wp))
|
||||
{
|
||||
RECT rect{};
|
||||
GetWindowRect(w, &rect);
|
||||
fitToMonitor(w, rect);
|
||||
arrangeWindow(w);
|
||||
}
|
||||
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
|
||||
{
|
||||
bool vertical = msg == WM_VSCROLL;
|
||||
SCROLLINFO info{sizeof(info), SIF_ALL};
|
||||
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
|
||||
int &position = vertical ? layout->y : layout->x;
|
||||
switch (LOWORD(wp))
|
||||
{
|
||||
case SB_LINEUP: position -= scale(w, 32); break;
|
||||
case SB_LINEDOWN: position += scale(w, 32); break;
|
||||
case SB_PAGEUP: position -= (int)info.nPage; break;
|
||||
case SB_PAGEDOWN: position += (int)info.nPage; break;
|
||||
case SB_TOP: position = 0; break;
|
||||
case SB_BOTTOM: position = info.nMax; break;
|
||||
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
|
||||
}
|
||||
arrangeWindow(w);
|
||||
return 0;
|
||||
}
|
||||
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
|
||||
{
|
||||
bool horizontal = msg == WM_MOUSEHWHEEL || (GET_KEYSTATE_WPARAM(wp) & MK_SHIFT);
|
||||
int &remainder = horizontal ? layout->horizontalWheelRemainder : layout->wheelRemainder;
|
||||
remainder += GET_WHEEL_DELTA_WPARAM(wp);
|
||||
int steps = remainder / WHEEL_DELTA;
|
||||
remainder %= WHEEL_DELTA;
|
||||
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
|
||||
arrangeWindow(w);
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_NCDESTROY)
|
||||
{
|
||||
RemovePropW(w, L"WindowLayout");
|
||||
RemoveWindowSubclass(w, layoutProc, 2);
|
||||
delete layout;
|
||||
}
|
||||
return DefSubclassProc(w, msg, wp, lp);
|
||||
}
|
||||
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
|
||||
int width, int height)
|
||||
{
|
||||
@@ -34,6 +158,12 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
|
||||
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||
GetModuleHandleW(nullptr), nullptr);
|
||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
||||
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
|
||||
{
|
||||
layout->controls.push_back({w, {x, y, x + width, y + height}});
|
||||
SetWindowSubclass(w, scrollChildProc, 2, 0);
|
||||
arrangeWindow(parent);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
void label(HWND w, const wchar_t *text, int x, int y, int width = 200)
|
||||
@@ -136,13 +266,31 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
RegisterClassW(&wc);
|
||||
UINT dpi = GetDpiForSystem();
|
||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_THICKFRAME | WS_MAXIMIZEBOX | extra;
|
||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||
POINT cursor{};
|
||||
GetCursorPos(&cursor);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
|
||||
HWND w =
|
||||
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, CW_USEDEFAULT, CW_USEDEFAULT,
|
||||
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
|
||||
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
|
||||
if (!w)
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
|
||||
// The system DPI may still describe the old primary display. The HWND tells
|
||||
// us the actual destination DPI, and must size the frame AND its controls.
|
||||
dpi = GetDpiForWindow(w);
|
||||
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
|
||||
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
|
||||
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
|
||||
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
|
||||
SetWindowSubclass(w, layoutProc, 2, 0);
|
||||
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
|
||||
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
arrangeWindow(w);
|
||||
SetWindowTextW(w, title);
|
||||
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
||||
@@ -154,6 +302,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
}
|
||||
void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
// Creation may send WM_DPICHANGED before controls and the original font exist.
|
||||
if (!GetPropW(w, L"Dpi"))
|
||||
{
|
||||
fitToMonitor(w, *(RECT *)lp);
|
||||
return;
|
||||
}
|
||||
int previous = (int)(UINT_PTR)GetPropW(w, L"Dpi");
|
||||
int next = LOWORD(wp);
|
||||
if (!previous)
|
||||
@@ -166,7 +320,8 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
||||
int previous, next;
|
||||
HFONT font;
|
||||
} state{w, previous, next, replacement};
|
||||
EnumChildWindows(
|
||||
auto *layout = windowLayoutState(w);
|
||||
if (!layout || !layout->scrolling) EnumChildWindows(
|
||||
w,
|
||||
[](HWND child, LPARAM data) -> BOOL
|
||||
{
|
||||
@@ -183,12 +338,19 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
},
|
||||
(LPARAM)&state);
|
||||
else
|
||||
{
|
||||
layout->x = MulDiv(layout->x, next, previous);
|
||||
layout->y = MulDiv(layout->y, next, previous);
|
||||
for (auto &[child, rect] : layout->controls)
|
||||
SendMessageW(child, WM_SETFONT, (WPARAM)replacement, TRUE);
|
||||
}
|
||||
SetPropW(w, L"Font", replacement);
|
||||
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
||||
if (old)
|
||||
DeleteObject(old);
|
||||
auto *r = (RECT *)lp;
|
||||
SetWindowPos(w, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER);
|
||||
fitToMonitor(w, *(RECT *)lp);
|
||||
arrangeWindow(w);
|
||||
}
|
||||
std::filesystem::path pickFile(HWND owner, bool save)
|
||||
{
|
||||
@@ -1447,7 +1609,7 @@ void showLogs(App &app)
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
void showSettings(App &app)
|
||||
void showSettings(App &app, bool visible)
|
||||
{
|
||||
if (app.settingsWindow)
|
||||
{
|
||||
@@ -1505,9 +1667,12 @@ void showSettings(App &app)
|
||||
button(w, L"Save", IDOK, 548, 606, 100);
|
||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||
if (visible)
|
||||
{
|
||||
showRequestedWindow(w);
|
||||
activateWindow(w);
|
||||
}
|
||||
}
|
||||
void showSnippets(App &app)
|
||||
{
|
||||
if (app.snippetsWindow)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include "core.h"
|
||||
#include <algorithm>
|
||||
namespace ks
|
||||
{
|
||||
inline RECT fitWindowRect(RECT desired, RECT work)
|
||||
{
|
||||
LONG width = std::clamp<LONG>(desired.right - desired.left, 1, std::max<LONG>(1, work.right - work.left));
|
||||
LONG height = std::clamp<LONG>(desired.bottom - desired.top, 1, std::max<LONG>(1, work.bottom - work.top));
|
||||
LONG x = std::clamp(desired.left, work.left, work.right - width);
|
||||
LONG y = std::clamp(desired.top, work.top, work.bottom - height);
|
||||
return {x, y, x + width, y + height};
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
#include "update.h"
|
||||
#include "json.hpp"
|
||||
|
||||
namespace ks
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct Handle
|
||||
{
|
||||
HANDLE value{};
|
||||
~Handle() { if (value) CloseHandle(value); }
|
||||
};
|
||||
std::wstring quoteArgument(const std::wstring &value)
|
||||
{
|
||||
// Windows argv escaping, including a path ending with a backslash.
|
||||
std::wstring out = L"\"";
|
||||
size_t slashes = 0;
|
||||
for (auto ch : value)
|
||||
{
|
||||
if (ch == L'\\') { ++slashes; continue; }
|
||||
out.append(ch == L'"' ? slashes * 2 + 1 : slashes, L'\\');
|
||||
slashes = 0;
|
||||
out += ch;
|
||||
}
|
||||
out.append(slashes * 2, L'\\');
|
||||
return out + L'"';
|
||||
}
|
||||
}
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
||||
{
|
||||
UpdateResult result;
|
||||
GUID guid{};
|
||||
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create update identifier");
|
||||
wchar_t id[40]{};
|
||||
StringFromGUID2(guid, id, 40);
|
||||
auto work = std::filesystem::temp_directory_path() / (L"KSwitcher-update-" + std::wstring(id));
|
||||
std::filesystem::create_directory(work);
|
||||
bool detached = false;
|
||||
Handle child;
|
||||
try
|
||||
{
|
||||
HRSRC resource = FindResourceW(nullptr, MAKEINTRESOURCEW(201), RT_RCDATA);
|
||||
HGLOBAL loaded = resource ? LoadResource(nullptr, resource) : nullptr;
|
||||
auto bytes = loaded ? static_cast<const unsigned char *>(LockResource(loaded)) : nullptr;
|
||||
if (!bytes) throw std::runtime_error("The updater resource is missing");
|
||||
atomicWrite(work / L"update.ps1", Bytes(bytes, bytes + SizeofResource(nullptr, resource)));
|
||||
std::wstring readyName = L"Local\\KSwitcher.Update.Ready." + std::wstring(id);
|
||||
Handle ready{CreateEventW(nullptr, TRUE, FALSE, readyName.c_str())};
|
||||
if (!ready.value) throw std::runtime_error("Cannot create updater handshake");
|
||||
wchar_t system[MAX_PATH]{};
|
||||
if (!GetSystemDirectoryW(system, MAX_PATH)) throw std::runtime_error("Cannot locate Windows PowerShell");
|
||||
auto powershell = std::filesystem::path(system) / L"WindowsPowerShell/v1.0/powershell.exe";
|
||||
std::wstring command = quoteArgument(powershell.wstring()) + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " +
|
||||
quoteArgument((work / L"update.ps1").wstring()) + L" -Mode " + (install ? L"Install" : L"Check") +
|
||||
L" -CurrentVersion " + quoteArgument(AppVersion) + L" -WorkDirectory " + quoteArgument(work.wstring()) +
|
||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||
nullptr, work.c_str(), &startup, &process))
|
||||
throw std::runtime_error("Cannot start Windows PowerShell updater");
|
||||
child.value = process.hProcess;
|
||||
Handle thread{process.hThread};
|
||||
HANDLE waits[]{ready.value, child.value};
|
||||
DWORD waited = WaitForMultipleObjects(2, waits, FALSE, 240000);
|
||||
if (waited != WAIT_OBJECT_0 && waited != WAIT_OBJECT_0 + 1)
|
||||
{
|
||||
// The helper cannot replace files while this process is alive.
|
||||
TerminateProcess(child.value, 1);
|
||||
WaitForSingleObject(child.value, 5000);
|
||||
throw std::runtime_error("Update preparation timed out; the installed application was not changed");
|
||||
}
|
||||
auto data = readFile(work / L"result.json", 64 * 1024);
|
||||
auto json = nlohmann::json::parse(data.begin(), data.end());
|
||||
result.state = wide(json.at("state").get<std::string>());
|
||||
result.version = wide(json.value("version", std::string{}));
|
||||
result.error = wide(json.value("error", std::string{}));
|
||||
detached = install && waited == WAIT_OBJECT_0 && result.state == L"prepared";
|
||||
if (result.state == L"prepared" && !detached)
|
||||
throw std::runtime_error("Update helper exited before installation");
|
||||
if (!detached) std::filesystem::remove_all(work);
|
||||
return result;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
if (!detached && child.value && WaitForSingleObject(child.value, 0) == WAIT_TIMEOUT)
|
||||
{
|
||||
TerminateProcess(child.value, 1);
|
||||
WaitForSingleObject(child.value, 5000);
|
||||
}
|
||||
if (!detached) { std::error_code ignored; std::filesystem::remove_all(work, ignored); }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void confirmUpdatedStartup(const std::wstring &eventName)
|
||||
{
|
||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||
if (event.value) SetEvent(event.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.2.1";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
struct UpdateResult
|
||||
{
|
||||
std::wstring state, version, error;
|
||||
bool interactive{}, install{};
|
||||
};
|
||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
||||
void confirmUpdatedStartup(const std::wstring &eventName);
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#include "app.h"
|
||||
#include "ui_geometry.h"
|
||||
#include <commctrl.h>
|
||||
#include <iostream>
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
||||
};
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
if (argc == 2 && std::string(argv[1]) == "--update-check") {
|
||||
try {
|
||||
auto result = ks::runUpdate(false, {});
|
||||
std::cout << "Live update check: " << ks::utf8(result.state) << " " << ks::utf8(result.version)
|
||||
<< " " << ks::utf8(result.error) << '\n';
|
||||
return result.state == L"current" || result.state == L"available" ? 0 : 1;
|
||||
} catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
|
||||
}
|
||||
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+std::to_wstring(GetCurrentProcessId()));
|
||||
int checks=0;
|
||||
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
|
||||
try {
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||
InitCommonControlsEx(&controls);
|
||||
{
|
||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||
ks::InputRegressionAccess::setup(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");
|
||||
auto event=[](DWORD vk,DWORD scan=0,bool own=false) {
|
||||
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
|
||||
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
|
||||
k.dwExtraInfo=own?ks::inputMarker():123; return k;
|
||||
};
|
||||
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
|
||||
auto f12=event(VK_F12);
|
||||
require(!app.handleKeyboard(event(VK_LCONTROL),true,0),"remote modifier passes through");
|
||||
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote Ctrl+F12 handled");
|
||||
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote repeat suppressed");
|
||||
require(commands()==1,"one command per press");
|
||||
require(!app.handleKeyboard(event(VK_F12,0,true),false,0),"own key-up bypasses latch");
|
||||
require(app.handleKeyboard(f12,true,MOD_CONTROL),"own key-up leaves remote latch intact");
|
||||
require(commands()==0,"no repeat after own key-up");
|
||||
require(app.handleKeyboard(f12,false,0),"remote release consumed after modifier release");
|
||||
require(app.handleKeyboard(f12,true,MOD_CONTROL),"second remote press handled");
|
||||
require(commands()==1,"second remote press dispatches");
|
||||
app.handleKeyboard(f12,false,0);
|
||||
require(app.handleKeyboard(event('U'),true,MOD_CONTROL|MOD_SHIFT),"combined modifiers");
|
||||
app.handleKeyboard(event('U'),false,0);
|
||||
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
||||
app.resetInput();
|
||||
app.handleKeyboard(event('A',0x1e),true,0);
|
||||
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
|
||||
auto keys=app.run.keys; auto version=app.inputVersion.load();
|
||||
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
||||
for(bool up:{false,true}) {
|
||||
auto i=ks::keyboardInput(key,up?KEYEVENTF_KEYUP:0);
|
||||
require(i.ki.dwExtraInfo==ks::inputMarker(),"all generated events tagged");
|
||||
require(!app.handleKeyboard(event(key,0,true),!up,MOD_CONTROL),"own events bypass handling");
|
||||
}
|
||||
require(app.run.keys==keys&&app.inputVersion==version,"own input preserves transaction and run");
|
||||
app.handleKeyboard(event(VK_BACK),true,0);
|
||||
require(app.run.keys.empty(),"remote backspace recorded");
|
||||
app.handleKeyboard(event('B'),true,MOD_SHIFT);
|
||||
require(!app.run.keys.empty(),"VK-only remote input resolves scan code");
|
||||
require(app.inputVersion>version,"remote input invalidates pending transaction");
|
||||
app.handleKeyboard(event(VK_PACKET,0x1e),true,0);
|
||||
require(app.run.keys.empty(),"Unicode packet is not a physical scan code");
|
||||
app.handleKeyboard(event('A',0x1e),true,0);
|
||||
ks::InputRegressionAccess::click();
|
||||
require(app.run.keys.empty(),"remote mouse resets recorded input");
|
||||
auto physical=f12; physical.flags=0; physical.dwExtraInfo=ks::inputMarker();
|
||||
require(app.handleKeyboard(physical,true,MOD_CONTROL),"physical hotkey still works");
|
||||
app.handleKeyboard(physical,false,0);
|
||||
auto fitted=ks::fitWindowRect({-2500,-800,-500,1200},{0,0,1200,800});
|
||||
require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area");
|
||||
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);
|
||||
auto settings=app.settingsWindow;
|
||||
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
||||
RECT frame{},client{},cancel{};
|
||||
GetWindowRect(settings,&frame);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"initial settings frame fits active work area");
|
||||
UINT dpi=GetDpiForWindow(settings);
|
||||
std::cout << "Hidden Settings layout checked at " << dpi << " DPI\n";
|
||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||
require(cancel.right-cancel.left==MulDiv(100,dpi,96),"controls use actual window DPI");
|
||||
SetWindowPos(settings,nullptr,0,0,480,360,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
SCROLLINFO scroll{sizeof(scroll),SIF_ALL};
|
||||
GetScrollInfo(settings,SB_VERT,&scroll);
|
||||
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");
|
||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||
GetClientRect(settings,&client);
|
||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
||||
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"bottom actions reachable by scrolling");
|
||||
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
|
||||
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
|
||||
SendMessageW(GetDlgItem(settings,IDCANCEL),WM_SETFOCUS,0,0);
|
||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
||||
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"keyboard focus reveals off-screen action");
|
||||
SetWindowPos(settings,nullptr,-20000,-20000,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
SendMessageW(settings,WM_DISPLAYCHANGE,0,0);
|
||||
GetWindowRect(settings,&frame);
|
||||
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
|
||||
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"display change recovers off-screen window");
|
||||
DestroyWindow(settings);
|
||||
DestroyWindow(app.window); app.window=nullptr;
|
||||
}
|
||||
std::filesystem::remove_all(temp);
|
||||
std::cout<<"Passed "<<checks<<" input regression checks\n"; return 0;
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
param([string]$Executable = (Join-Path $PSScriptRoot '../build/KSwitcher.exe'), [switch]$Network)
|
||||
$ErrorActionPreference='Stop'
|
||||
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
||||
$script:checks=0
|
||||
function Assert($Value,[string]$Name) { if (!$Value) { throw $Name }; ++$script:checks }
|
||||
function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { & $Action } catch { $rejected=$true }; Assert $rejected $Name }
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
try {
|
||||
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
||||
Assert ($null -eq (Get-StableVersion 'v1.0.0;whoami')) 'invalid version rejected'
|
||||
$assets=@(
|
||||
@{name='KSwitcher-win-x64.zip';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/KSwitcher-win-x64.zip'},
|
||||
@{name='SHA256SUMS.txt';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/SHA256SUMS.txt'}
|
||||
)
|
||||
$release=@{draft=$false;prerelease=$false;tag_name='v0.2.1';assets=$assets}
|
||||
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
|
||||
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
|
||||
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
||||
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
||||
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
|
||||
foreach ($url in @('http://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v1/a','https://evil.example/a','https://gitea.hrynco.com/other/a','https://gitea.hrynco.com:8443/hrynco/hrynco-kswitcher/releases/download/v1/a')) {
|
||||
Reject { Assert-ReleaseUrl $url } 'untrusted URL rejected'
|
||||
}
|
||||
$source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null
|
||||
Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe')
|
||||
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
|
||||
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive=Join-Path $root 'release.zip'
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
|
||||
$sum=Join-Path $root 'SHA256SUMS.txt'
|
||||
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
|
||||
Assert-Checksum $archive $sum; ++$script:checks
|
||||
$stage=Join-Path $root 'stage'
|
||||
Expand-Release $archive $stage ([version]'0.2.1'); ++$script:checks
|
||||
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
|
||||
('0'*64+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
|
||||
Reject { Assert-Checksum $archive $sum } 'corrupt package rejected'
|
||||
$bad=Join-Path $root 'bad.zip'
|
||||
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
|
||||
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
|
||||
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
|
||||
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
|
||||
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
|
||||
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
|
||||
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
|
||||
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
|
||||
$changed=[Collections.Generic.List[string]]::new()
|
||||
Install-Files $stage $target $backup $changed
|
||||
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed'
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
||||
Undo-Files $target $backup $changed
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
||||
# Fail after the original exe has moved to backup, as if the staged file vanished.
|
||||
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
|
||||
$locked=[IO.File]::Open((Join-Path $stage2 'KSwitcher.exe'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None)
|
||||
$changed2=[Collections.Generic.List[string]]::new()
|
||||
try { Reject { Install-Files $stage2 $target $backup $changed2 } 'locked staged file fails safely' } finally { $locked.Dispose() }
|
||||
Undo-Files $target $backup $changed2
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'partial install rollback'
|
||||
if ($Network) {
|
||||
$metadata=Join-Path $root 'live-releases.json'
|
||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
|
||||
$live=Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) ([version]'0.0.0')
|
||||
Assert ($null -ne $live) 'live repository exposes a complete stable release'
|
||||
$liveZip=Join-Path $root 'live.zip'; $liveSum=Join-Path $root 'live-sums.txt'
|
||||
Receive-File $live.zip $liveZip 50MB
|
||||
Receive-File $live.checksum $liveSum 64KB
|
||||
Assert-Checksum $liveZip $liveSum; ++$script:checks
|
||||
Expand-Release $liveZip (Join-Path $root 'live-stage') (Get-StableVersion $live.version); ++$script:checks
|
||||
Reject { Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/kswitcher-updater-missing-test-repository' (Join-Path $root 'missing') 2MB } 'server failure reported'
|
||||
Write-Output "Downloaded and validated $($live.version) in a disposable test directory; no application was updated."
|
||||
}
|
||||
Write-Output "Passed $script:checks updater regression checks"
|
||||
} finally {
|
||||
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-update-tests-*') {
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user