Compare commits

...

2 Commits

29 changed files with 969 additions and 62 deletions
+16 -8
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.4.1 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.0 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -8,8 +8,8 @@ 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 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)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
@@ -18,12 +18,18 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc)
set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot")
target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES)
target_link_libraries(KSwitcherScreenshot PRIVATE kscore)
if(MSVC)
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
@@ -33,13 +39,15 @@ endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot 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)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
else()
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherScreenshot 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++)
@@ -51,7 +59,7 @@ add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
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 KSwitcherConfig RUNTIME DESTINATION .)
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+41 -8
View File
@@ -16,12 +16,25 @@ Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, besid
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
in the resident app. Keep both executables together when copying or updating the application.
in the resident app. Screen capture and all screenshot editors run on demand in
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
stable numbered titles within that process. Keep all three executables together when copying or
updating the application. Close screenshot editors before installing an update.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
Update in the tray menu. Select Update again to install the full current release;
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
tags, which the bridge and newer clients recognize. Older clients ignore those tags
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
still require the previously documented initial configurator migration.
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
hashes). The updater validates that every packaged file is declared, all hashes match,
and paths stay within the installation directory. `data/` is protected; existing
dictionaries are preserved. Adding a new program file no longer needs a new updater.
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
@@ -58,8 +71,16 @@ physical key positions and therefore reset recent-word tracking; selection conve
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
The editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately,
or annotate first: **N** places sequential numbered red markers with a click, and **A** draws
The selected area is copied immediately and Windows shows a notification with a thumbnail.
Click the notification to open the editor, including after the screenshot process has exited.
If notifications are disabled or cannot be sent, the already-copied screenshot opens in the editor.
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
@@ -118,10 +139,10 @@ Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows.
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
The window close button asks for confirmation before discarding an uncopied screenshot.
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy closes the editor and publishes the image
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
at its original pixel size, ready to paste. The preview fits the window without changing export size.
The shortcut is configurable in Settings under **Screen capture**.
Each capture opens an independent editor; use the shortcut again to take another screenshot while
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
keeping earlier editors and their annotations open.
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
@@ -346,3 +367,15 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
Dictionary data has its own CC BY-SA 4.0 license as described above.
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
### Release channel rule
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
legacy version tag: clients that have not installed the bridge would select it.
Keep the compatibility bridge in the first 100 releases returned by Gitea while
supporting old clients; their immutable update code only reads that page.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7c29980c5d3b8862b1d78c06d1c3daa2b2bff24b56d200616971b92d784b51e6 KSwitcher-win-x64.zip
e1d11c833adab5190ec49af1bb4eb25babdd6658aafb7423f9c502a1e71b2581 KSwitcher-win-x64.zip
+12 -6
View File
@@ -13,8 +13,8 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
Push-Location $root
try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','app','ui','main','configuration','config_main')) {
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
@@ -24,24 +24,30 @@ try {
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.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 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/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/screenshot_ui.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/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
@@ -49,7 +55,7 @@ try {
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/update.o build/screenshot.o build/app.o build/configuration.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/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
+11 -1
View File
@@ -5,12 +5,22 @@ $destination = Join-Path $root 'dist\KSwitcher'
New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
}
}
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
$manifest=Join-Path $destination 'release-files.json'
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
$files += $manifest
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256
+9 -3
View File
@@ -2,12 +2,15 @@
#include <windows.h>
#include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico"
#ifdef KS_SCREENSHOT_RESOURCES
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
#endif
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,4,1,0
PRODUCTVERSION 0,4,1,0
FILEVERSION 0,6,0,0
PRODUCTVERSION 0,6,0,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -17,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.4.1"
VALUE "FileVersion", "0.6.0"
END
END
BLOCK "VarFileInfo"
@@ -25,6 +28,7 @@ BEGIN
VALUE "Translation", 0x409, 1200
END
END
#ifdef KS_SCREENSHOT_RESOURCES
300 ICON "icons/lucide/circle-dot-normal.ico"
310 ICON "icons/lucide/circle-dot-active.ico"
320 ICON "icons/lucide/circle-dot-disabled.ico"
@@ -73,3 +77,5 @@ END
405 ICON "icons/lucide/a-large-small-normal.ico"
415 ICON "icons/lucide/a-large-small-active.ico"
425 ICON "icons/lucide/a-large-small-disabled.ico"
#endif
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<defs>
<linearGradient id="blue" x1="8" y1="4" x2="36" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#38bdf8"/>
<stop offset=".55" stop-color="#1687e8"/>
<stop offset="1" stop-color="#2563eb"/>
</linearGradient>
</defs>
<circle cx="24" cy="25" r="21" fill="#1e40af" opacity=".12"/>
<circle cx="24" cy="23" r="21" fill="url(#blue)"/>
<circle cx="24" cy="23" r="20.5" fill="none" stroke="white" stroke-opacity=".25"/>
<path d="M18 17.5c0-3.1 2.5-5 6-5s6 2 6 5c0 4.8-6 4.8-6 9" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round"/>
<circle cx="24" cy="33" r="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 711 B

+1
View File
@@ -1,3 +1,4 @@
#pragma once
#define IDI_KSWITCHER 101
#define IDI_SCREENSHOT_QUESTION 102
#define IDC_SCREENSHOT_PENCIL 500
+77 -6
View File
@@ -9,8 +9,8 @@ $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 }
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
}
function Assert-ReleaseUrl([string]$Url) {
$uri = [uri]$Url
@@ -71,7 +71,7 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
finally { $sha.Dispose(); $stream.Dispose() }
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
@@ -80,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.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"
@@ -98,7 +98,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() }
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) {
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
$exe = Join-Path $Destination $binary
if (!(Test-Path -LiteralPath $exe)) { continue }
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
@@ -112,6 +112,75 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
}
function Get-Sha256([IO.Stream]$Stream) {
$sha=[Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Assert-PackagePath([string]$Name) {
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
$parts=$Name.Split('/')
foreach ($part in $parts) {
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
}
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
try {
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
if (!$manifests.Count) {
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
$zip.Dispose(); $zip=$null
Expand-LegacyRelease $Archive $Destination $Expected
return
}
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
$reader=[IO.StreamReader]::new($manifests[0].Open())
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $manifest.files) {
Assert-PackagePath $file.path
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
$declared.Add($file.path,$file)
}
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$total=0L
foreach ($entry in $zip.Entries) {
$name=$entry.FullName.Replace('\','/')
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
Assert-PackagePath $name
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
if ($name -ceq 'release-files.json') { continue }
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
$file=$declared[$name]
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
$total+=$entry.Length
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
$stream=$entry.Open()
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
}
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
[IO.Directory]::CreateDirectory($Destination) | Out-Null
Assert-PlainDirectory $Destination
foreach ($entry in $zip.Entries) {
if ($entry.FullName.EndsWith('/')) { continue }
$target=Join-Path $Destination $entry.FullName
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
}
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
} finally { if ($zip) { $zip.Dispose() } }
}
function Assert-PlainDirectory([string]$Directory) {
$item = Get-Item -LiteralPath $Directory -Force
while ($item) {
@@ -124,7 +193,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
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 }
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -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) {
@@ -177,6 +246,8 @@ try {
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
Expand-Release $archive $stage (Get-StableVersion $release.version)
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
Write-Result @{state='prepared';version=$release.version}
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
+4 -1
View File
@@ -2,6 +2,7 @@
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include "screenshot_notifications.h"
#include <algorithm>
#include <commctrl.h>
#include <set>
@@ -589,7 +590,7 @@ void App::dispatch(int command)
{
if (command == Screenshot)
{
if (!busy) showScreenshot(*this);
if (!busy) { resetInput(); launchScreenshot(*this); }
return;
}
if (command == ShowHistory)
@@ -1452,6 +1453,7 @@ int App::execute()
confirmUpdatedStartup(updateStartupEvent);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
auto screenshotActivation=startScreenshotActivationListener();
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
{
@@ -1470,6 +1472,7 @@ int App::execute()
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
return (int)msg.wParam;
}
} // namespace ks
+2 -2
View File
@@ -99,8 +99,8 @@ class App
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void showScreenshot(App &);
void showHistory(App &, bool visible = true);
void launchScreenshot(App &);
void refreshHistory(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
+62 -16
View File
@@ -1,5 +1,5 @@
#include "app.h"
#include "screenshot.h"
#include "ocr.h"
#include "../resources/resource.h"
#include <algorithm>
#include <cmath>
@@ -13,14 +13,21 @@ namespace
{
// Only selection is exclusive; each completed capture owns an independent editor.
HWND activeCapture{};
// Keep titles stable and distinct for every editor opened during this session.
unsigned long long nextEditorIndex=1;
bool confirmScreenshotClose(HWND owner)
{
TASKDIALOGCONFIG dialog{sizeof(dialog)};
dialog.hwndParent=owner;
dialog.dwFlags=TDF_POSITION_RELATIVE_TO_WINDOW|TDF_ALLOW_DIALOG_CANCELLATION;
dialog.dwFlags=TDF_POSITION_RELATIVE_TO_WINDOW|TDF_ALLOW_DIALOG_CANCELLATION|TDF_USE_HICON_MAIN;
const int iconSize=MulDiv(32,GetDpiForWindow(owner),96);
auto icon=(HICON)LoadImageW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(IDI_SCREENSHOT_QUESTION),
IMAGE_ICON,iconSize,iconSize,0);
dialog.hMainIcon=icon;
dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON;
dialog.pszWindowTitle=L"KSwitcher";
dialog.pszContent=L"Close without copying this screenshot to the clipboard?";
dialog.pszMainInstruction=L"Close without copying?";
dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard.";
dialog.nDefaultButton=IDNO;
dialog.lpCallbackData=(LONG_PTR)owner;
dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT {
@@ -40,6 +47,7 @@ bool confirmScreenshotClose(HWND owner)
};
int button=IDNO;
auto result=TaskDialogIndirect(&dialog,&button,nullptr,nullptr);
if(icon) DestroyIcon(icon);
// If Windows cannot display the confirmation, keep the screenshot open.
return SUCCEEDED(result) && button==IDYES;
}
@@ -115,7 +123,7 @@ bool chooseTextFont(HWND owner,Mark &m)
}
struct Capture
{
App &app;
ScreenshotHost &app;
std::unique_ptr<Surface> image;
std::unique_ptr<Surface> dimmed, frame;
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
@@ -153,7 +161,7 @@ struct Capture
int hoveredTool=0;
MarkPart hoveredPart=MarkPart::None;
std::wstring statusText;
explicit Capture(App &a) : app(a) {}
explicit Capture(ScreenshotHost &a) : app(a) {}
~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor);
if (font) DeleteObject(font);
@@ -162,6 +170,7 @@ struct Capture
}
void remember(const std::vector<Mark> &previous)
{
copied=false;
history.remember(previous);
syncHistory();
}
@@ -720,14 +729,13 @@ LRESULT CALLBACK toolHover(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWO
if(msg==WM_MOUSELEAVE) InvalidateRect(w,nullptr,FALSE);
return DefSubclassProc(w,msg,wp,lp);
}
void copy(HWND w, Capture &c)
void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard)
{
finishInlineText(w,c,true);
// The resident message window owns the clipboard after this editor closes.
// The resident message window keeps ownership if the editor is closed later.
auto clip=screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks);
writeClipboard(c.app.window, clip);
publish(c.app.window, clip);
c.copied=true;
DestroyWindow(w);
}
void edit(HWND w, Capture &c)
{
@@ -745,8 +753,11 @@ void edit(HWND w, Capture &c)
POINT cursor; GetCursorPos(&cursor);
GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTOPRIMARY), &monitor);
ShowWindow(w,SW_HIDE);
SetWindowLongPtrW(w,GWLP_HWNDPARENT,0);
SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW);
SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN);
SetWindowTextW(w,L"KSwitcher — Screenshot");
auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++);
SetWindowTextW(w,title.c_str());
INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES};
InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
@@ -791,6 +802,20 @@ void edit(HWND w, Capture &c)
std::min(LONG(MulDiv(700,GetDpiForWindow(w),96)),work.bottom-work.top-40),SWP_FRAMECHANGED|SWP_SHOWWINDOW);
layout(w,c); SetForegroundWindow(w); SetFocus(w);
}
void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=writeClipboard)
{
if(!c.app.notify) { edit(w,c); return; }
auto r=region(c.start,c.end);
if(r.right-r.left<2 || r.bottom-r.top<2) return;
Surface cropped(r.right-r.left,r.bottom-r.top);
BitBlt(cropped.dc,0,0,cropped.width,cropped.height,c.image->dc,r.left,r.top,SRCCOPY);
auto clip=screenshotClip(cropped.dc,cropped.width,cropped.height,{});
publish(c.app.window,clip);
c.copied=true;
ShowWindow(w,SW_HIDE);
if(c.app.notify(clip)) DestroyWindow(w);
else edit(w,c);
}
LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto c = reinterpret_cast<Capture *>(GetWindowLongPtrW(w,GWLP_USERDATA));
@@ -806,7 +831,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
switch (msg)
{
case WM_CLOSE:
if(c->editing && !c->copied && !confirmScreenshotClose(w)) return 0;
finishInlineText(w,*c,true);
if(c->editing && (!c->copied || c->dragging) && !confirmScreenshotClose(w)) return 0;
DestroyWindow(w); return 0;
case FinishTextMessage:
if(c->inlineText && (HWND)lp==c->inlineText && (wp!=0 || GetFocus()!=c->inlineText)) {
@@ -908,7 +934,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (LOWORD(wp)==12 || LOWORD(wp)==18)
{
bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks);
if(changed) { c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
c->syncHistory();
}
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26))
@@ -1089,7 +1115,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if (msg==WM_LBUTTONUP)
{
c->dragging=false; ReleaseCapture();
if (!c->editing) edit(w,*c);
if (!c->editing) { finishSelection(w,*c); return 0; }
else if(c->part!=MarkPart::None)
{
if(c->part==MarkPart::Start || c->part==MarkPart::End)
@@ -1226,7 +1252,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case WM_NCDESTROY:
if (activeCapture == w) activeCapture=nullptr;
SetWindowLongPtrW(w,GWLP_USERDATA,0);
if (c->owned) delete c;
if (c->owned) {
auto &host=c->app;
if (--host.windows==0) PostMessageW(host.window,ScreenshotIdle,0,0);
delete c;
}
return DefWindowProcW(w,msg,wp,lp);
}
}
@@ -1498,10 +1528,25 @@ Clip screenshotClip(HDC source, int width, int height, const std::vector<Screens
memcpy(bytes.data()+sizeof(header),output.pixels,bytes.size()-sizeof(header));
return Clip{{{CF_DIB,L"",std::move(bytes)}}};
}
void showScreenshot(App &app)
void showScreenshotEditor(ScreenshotHost &app,const Clip &clip)
{
auto pixels=decodeImage(clip);
auto c=std::make_unique<Capture>(app);
c->image=std::make_unique<Surface>(pixels.width,pixels.height);
memcpy(c->image->pixels,pixels.bgra.data(),pixels.bgra.size());
c->start={0,0}; c->end={(LONG)pixels.width,(LONG)pixels.height}; c->copied=true;
WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=captureProc; cls.lpszClassName=L"KSwitcher.Capture";
cls.style=CS_DBLCLKS; cls.hCursor=LoadCursorW(nullptr,IDC_CROSS);
cls.hIcon=LoadIconW(app.instance,MAKEINTRESOURCEW(IDI_KSWITCHER)); RegisterClassW(&cls);
auto window=CreateWindowExW(0,cls.lpszClassName,L"KSwitcher Screenshot",WS_POPUP,0,0,1,1,
app.window,nullptr,app.instance,c.get());
if(!window) throw std::runtime_error("Cannot open screenshot editor");
c->owned=true; ++app.windows;
auto state=c.release(); edit(window,*state);
}
void showScreenshot(ScreenshotHost &app)
{
if (activeCapture) { showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); return; }
app.resetInput();
auto c=std::make_unique<Capture>(app);
int x=GetSystemMetrics(SM_XVIRTUALSCREEN),y=GetSystemMetrics(SM_YVIRTUALSCREEN);
int width=GetSystemMetrics(SM_CXVIRTUALSCREEN),height=GetSystemMetrics(SM_CYVIRTUALSCREEN);
@@ -1528,6 +1573,7 @@ void showScreenshot(App &app)
WS_POPUP,x,y,width,height,app.window,nullptr,app.instance,c.get());
if (!activeCapture) throw std::runtime_error("Cannot open screen capture");
c->owned=true;
++app.windows;
c.release();
showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); SetFocus(activeCapture);
}
+10
View File
@@ -2,6 +2,16 @@
#include "platform.h"
namespace ks
{
// Only the on-demand screenshot process owns this state; no resident App dependency.
struct ScreenshotHost {
HINSTANCE instance{};
HWND window{};
unsigned windows{};
bool (*notify)(const Clip &){};
};
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &);
void showScreenshotEditor(ScreenshotHost &,const Clip &);
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
enum class LineEnds { End, Start, Both, None };
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
+100
View File
@@ -0,0 +1,100 @@
#include "screenshot_notifications.h"
#include <atomic>
namespace ks {
namespace {
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
}
bool validScreenshotToken(const std::wstring &id)
{
if(id.size()!=36) return false;
for(size_t i=0;i<id.size();++i) {
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
}
return true;
}
namespace {
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
struct NotificationInput { LPCWSTR key,value; };
struct NotificationCallback : IUnknown {
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
};
class Activator final : public NotificationCallback {
std::atomic<ULONG> references{1};
DWORD thread=GetCurrentThreadId();
std::wstring target;
bool quit;
public:
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
if(!out) return E_POINTER;
*out=nullptr;
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG;
auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
if(ok) {
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
CloseHandle(process.hThread); CloseHandle(process.hProcess);
}
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
return result;
}
};
class ActivatorFactory final : public IClassFactory {
std::atomic<ULONG> references{1};
std::wstring target;
bool quit;
public:
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
if(!out) return E_POINTER;
*out=nullptr;
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
if(outer) return CLASS_E_NOAGGREGATION;
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
}
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
};
}
int runScreenshotToastActivator()
{
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,&registration);
factory->Release();
if(SUCCEEDED(result)) {
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
if(timer) KillTimer(nullptr,timer);
CoRevokeClassObject(registration);
}
CoUninitialize(); return FAILED(result)?1:0;
}
DWORD startScreenshotActivationListener()
{
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
auto factory=new ActivatorFactory(target.wstring(),false);
DWORD registration{};
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,&registration);
factory->Release();
return SUCCEEDED(result)?registration:0;
}
}
+21
View File
@@ -0,0 +1,21 @@
#include "app.h"
namespace ks {
void launchScreenshot(App &)
{
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\"";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
// Grant foreground rights before the child's first selection window opens.
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
nullptr,path.parent_path().c_str(),&startup,&process))
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
AllowSetForegroundWindow(process.dwProcessId);
ResumeThread(process.hThread);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
}
+109
View File
@@ -0,0 +1,109 @@
#include "screenshot.h"
#include "screenshot_notifications.h"
#include <commctrl.h>
#include <shellapi.h>
#include <roapi.h>
namespace {
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
{
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
if(message==WM_NCCREATE) {
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
}
if(host && message==ks::ScreenshotRequest) {
try { ks::showScreenshot(*host); }
catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
PostMessageW(window,ks::ScreenshotIdle,0,0);
}
return TRUE;
}
if(host && message==WM_COPYDATA) {
auto data=(COPYDATASTRUCT *)lp;
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
std::wstring token((const wchar_t *)data->lpData,36);
if(!ks::validScreenshotToken(token)) return FALSE;
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
PostMessageW(window,ks::ScreenshotIdle,0,0);
}
return TRUE;
}
if(host && message==ks::ScreenshotIdle && !host->windows) {
DestroyWindow(window);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(window,message,wp,lp);
}
}
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
{
std::wstring token;
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
LocalFree(argv); return ks::runScreenshotToastActivator();
}
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
CoUninitialize(); return result;
}
if(argc>1) {
if(argc==3 && std::wstring(argv[1])==L"--open") {
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
}
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
}
LocalFree(argv);
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
if(!mutex) return 1;
// Wait through startup/shutdown races; the controller accepts requests only while alive.
bool acquired=false;
for(int attempt=0;attempt<100;++attempt) {
auto wait=WaitForSingleObject(mutex,0);
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
AllowSetForegroundWindow(pid);
DWORD_PTR accepted{};
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,0,
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
CloseHandle(mutex); return 0;
}
}
Sleep(50);
}
if(!acquired) { CloseHandle(mutex); return 1; }
RoInitialize(RO_INIT_SINGLETHREADED);
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
InitCommonControlsEx(&controls);
ks::ScreenshotHost host{instance};
host.notify=ks::notifyScreenshot;
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
RegisterClassW(&cls);
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
int result=1;
if(host.window) {
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,0,0);
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
MSG message{};
while((result=GetMessageW(&message,nullptr,0,0))>0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
result=result<0?1:0;
}
RoUninitialize();
ReleaseMutex(mutex); CloseHandle(mutex);
return result;
}
+220
View File
@@ -0,0 +1,220 @@
#include "screenshot_notifications.h"
#include "ocr.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <aclapi.h>
#include <sddl.h>
#include <shlobj.h>
#include <propkey.h>
#include <propvarutil.h>
#include <roapi.h>
#include <winstring.h>
#include <wincodec.h>
#ifdef __MINGW32__
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
#endif
#include <windows.ui.notifications.h>
#include <windows.data.xml.dom.h>
#include <wrl/client.h>
namespace ks {
namespace {
using Microsoft::WRL::ComPtr;
namespace Notifications=ABI::Windows::UI::Notifications;
namespace Xml=ABI::Windows::Data::Xml::Dom;
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
std::string notificationError;
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
struct String {
HSTRING value{};
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
~String() { WindowsDeleteString(value); }
};
std::filesystem::path cacheDirectory()
{
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
}
std::filesystem::path previewDirectory()
{
HANDLE token{};
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
std::vector<BYTE> data(size);
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
std::wstring sid(sidText); LocalFree(sidText);
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
// The notification broker cannot load previews from some profile directories.
// Keep this cache outside the profile, private to the current user and SYSTEM.
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
PSECURITY_DESCRIPTOR security{};
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
check(HRESULT_FROM_WIN32(GetLastError()));
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
error=created?ERROR_SUCCESS:GetLastError();
LocalFree(security);
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
// Do not use a pre-created directory or junction controlled by another user.
DWORD flags=GetFileAttributesW(path.c_str());
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
throw std::runtime_error("Invalid screenshot preview directory");
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
&owner,nullptr,nullptr,nullptr,&existing);
check(HRESULT_FROM_WIN32(error));
bool owned=EqualSid(owner,user); LocalFree(existing);
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
return path;
}
void cleanCache(const std::filesystem::path &path)
{
std::vector<std::filesystem::directory_entry> files;
for(const auto &file:std::filesystem::directory_iterator(path))
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
uintmax_t size=0;
for(size_t i=0;i<files.size();++i) {
size+=files[i].file_size();
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
}
}
}
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
{
HKEY key{};
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
throw std::runtime_error("Cannot register screenshot notifications");
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
RegCloseKey(key);
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
}
void registerNotifications()
{
auto exe=executable();
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
registryString(identity,L"IconUri",exe);
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
// A shortcut supplies desktop notification identity and its COM activation callback.
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
ComPtr<IPropertyStore> properties; check(link.As(&properties));
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
GUID activator=ActivatorId;
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
check(properties->Commit());
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
// The notification broker caches protocol handlers independently of ShellExecute.
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
check(SetCurrentProcessExplicitAppUserModelID(AppId));
}
void savePreview(const Clip &clip,const std::filesystem::path &path)
{
auto pixels=decodeImage(clip,360,220);
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
check(frame->SetSize(pixels.width,pixels.height));
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
check(frame->Commit()); check(encoder->Commit());
}
std::wstring escapeXml(const std::wstring &s)
{
std::wstring result;
for(auto ch:s) { if(ch==L'&') result+=L"&amp;"; else if(ch==L'\"') result+=L"&quot;"; else if(ch==L'<') result+=L"&lt;"; else result+=ch; }
return result;
}
}
Clip loadNotifiedScreenshot(const std::wstring &id)
{
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
auto directory=cacheDirectory(); cleanCache(directory);
auto path=directory/(id+L".capture");
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
}
bool notifyScreenshot(const Clip &clip)
{
notificationError.clear();
const char *stage="registration";
try {
registerNotifications();
stage="notification manager";
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
stage="notifier";
check(manager->CreateToastNotifierWithId(appId.value,&notifier));
stage="settings";
Notifications::NotificationSetting setting{};
auto settingResult=notifier->get_Setting(&setting);
// A first-time sender has no settings record until its first notification.
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
stage="cache";
auto directory=cacheDirectory(); cleanCache(directory);
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
std::wstring id(value+1,36);
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
atomicWrite(directory/(id+L".capture"),protect(dib->data));
stage="preview";
auto previews=previewDirectory(); cleanCache(previews);
auto preview=previews/(id+L".png"); savePreview(clip,preview);
cleanCache(previews);
cleanCache(directory);
stage="XML";
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
check(RoActivateInstance(documentClass.value,&documentInstance));
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
auto uri=L"file:///"+preview.generic_wstring();
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
check(documentIO->LoadXml(content.value));
stage="toast factory";
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
String propertyClass(L"Windows.Foundation.PropertyValue");
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
FILETIME now{}; GetSystemTimeAsFileTime(&now);
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
stage="show";
check(notifier->Show(toast.Get()));
return true;
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); return false; } // Clipboard is already populated; fall back to the editor.
}
std::string screenshotNotificationError() { return notificationError; }
void registerScreenshotNotifications() { registerNotifications(); }
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "platform.h"
namespace ks {
bool notifyScreenshot(const Clip &);
std::string screenshotNotificationError();
void registerScreenshotNotifications();
int runScreenshotToastActivator();
DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &);
Clip loadNotifiedScreenshot(const std::wstring &);
}
+16 -5
View File
@@ -31,7 +31,7 @@ HFONT font(HWND w)
struct WindowLayout
{
int width{}, height{}, x{}, y{};
bool scrolling{}, arranging{};
bool scrolling{}, arranging{}, history{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
};
@@ -68,9 +68,18 @@ void arrangeWindow(HWND w)
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
RECT client{}; GetClientRect(w, &client);
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
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);
{
int id = GetDlgCtrlID(child);
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x,
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0),
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0),
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0),
SWP_NOACTIVATE | SWP_NOZORDER);
}
layout->arranging = false;
InvalidateRect(w, nullptr, TRUE);
}
@@ -1325,7 +1334,7 @@ void refreshHistory(App &app)
search(app.historyWindow, v);
}
}
void showHistory(App &app)
void showHistory(App &app, bool visible)
{
if (!app.history)
{
@@ -1333,7 +1342,7 @@ void showHistory(App &app)
return;
}
HWND foreground = GetForegroundWindow();
if (app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
if (visible && app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
{
auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA);
if (v->items.size() > 1)
@@ -1346,6 +1355,7 @@ void showHistory(App &app)
{
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
app.historyWindow = w;
windowLayoutState(w)->history = true;
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
label(w, L"Search clipboard history", 16, 12, 500);
edit(w, 101, 16, 38, 728);
@@ -1362,6 +1372,7 @@ void showHistory(App &app)
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
}
if (!visible) return;
POINT cursor;
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.4.1";
inline constexpr wchar_t AppVersion[] = L"0.6.0";
inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult
{
+14
View File
@@ -485,6 +485,20 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
ks::showHistory(app,false);
auto history=app.historyWindow;
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
auto settings=app.settingsWindow;
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
for(auto pair:{std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
+73
View File
@@ -0,0 +1,73 @@
param([string]$Executable = "$PSScriptRoot\..\build\staged\KSwitcher.Screenshot.exe")
$ErrorActionPreference='Stop'
if (Get-Process KSwitcher.Screenshot -ErrorAction SilentlyContinue) { throw 'Close existing screenshot editors before this test.' }
Add-Type @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
public static class ScreenshotProcessTest {
public delegate bool EnumProc(IntPtr w, IntPtr p);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr param);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr w, StringBuilder name, int count);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr w, StringBuilder name, int count);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w, uint msg, IntPtr wp, IntPtr lp);
public static IntPtr[] Windows(int process, string cls) {
var windows=new List<IntPtr>();
EnumWindows((w,p)=>{ uint pid; GetWindowThreadProcessId(w,out pid);
var name=new StringBuilder(256); GetClassName(w,name,256);
if(pid==process && name.ToString()==cls) windows.Add(w); return true;
},IntPtr.Zero); return windows.ToArray();
}
public static string Title(IntPtr w) { var s=new StringBuilder(256); GetWindowText(w,s,256); return s.ToString(); }
}
'@
function Wait-For([scriptblock]$Check,[string]$Message) {
for($attempt=0;$attempt -lt 100;$attempt++) { if (& $Check) { return }; Start-Sleep -Milliseconds 50 }
throw $Message
}
function Send($Window,$Message,$Wparam=0,$Lparam=0) {
[ScreenshotProcessTest]::PostMessage($Window,$Message,[IntPtr]$Wparam,[IntPtr]$Lparam) | Out-Null
}
function Capture-Windows { @([ScreenshotProcessTest]::Windows($script:process.Id,'KSwitcher.Capture')) }
function Select-Region($Window) {
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).'
}
function Close-Editor($Window) {
Send $Window 0x10
Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.'
}
function Latest-Token {
(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName
}
$process=$null
try {
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
Select-Region (Capture-Windows)[0]
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
$token=Latest-Token
$process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.'
$first=(Capture-Windows)[0]
$secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' }
Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.'
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
Close-Editor $first
if ($process.HasExited) { throw 'Process exited with an editor open.' }
Close-Editor $second
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
'Passed screenshot process lifecycle checks'
} finally {
if ($process -and !$process.HasExited) { Stop-Process -Id $process.Id }
}
+31 -4
View File
@@ -4,10 +4,10 @@
#include <iostream>
int main()
{
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
ks::App app(GetModuleHandleW(nullptr),true,temp);
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
@@ -18,6 +18,8 @@ int main()
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
require(window!=nullptr,"editor creation");
ks::edit(window,c); ShowWindow(window,SW_HIDE);
require(GetWindow(window,GW_OWNER)==nullptr && (GetWindowLongPtrW(window,GWL_EXSTYLE)&WS_EX_APPWINDOW),
"editor is an independent Alt+Tab application window");
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
for(int size:{32,48,64,96}) {
@@ -30,7 +32,7 @@ int main()
DestroyCursor(cursor);
}
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no usage hints");
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c);
@@ -124,6 +126,18 @@ int main()
SendMessageW(window,WM_COMMAND,18,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
ks::copy(window,c,[](HWND,const ks::Clip &clip)->DWORD {
if(clip.formats.empty()) throw std::runtime_error("copy produced no image");
return 0;
});
require(IsWindow(window) && c.copied,"copy publishes screenshot and keeps editor open");
c.selected=0; c.selectedLabel=false;
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
require(!c.copied,"editing after copy requires confirmation again");
c.copied=true; SendMessageW(window,WM_COMMAND,12,0);
require(!c.copied,"undo after copy requires confirmation again");
c.copied=true; SendMessageW(window,WM_COMMAND,18,0);
require(!c.copied,"redo after copy requires confirmation again");
SetPropW(window,L"ConfirmationCentered",nullptr);
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
@@ -149,8 +163,21 @@ int main()
require(IsWindow(window),"Escape does not close the screenshot editor");
SendMessageW(window,WM_CLOSE,0,0);
require(!IsWindow(window),"copied screenshot closes without confirmation");
static bool published=false;
app.notify=[](const ks::Clip &) { return published; };
ks::Capture selection(app);
selection.image=std::make_unique<ks::Surface>(100,100);
PatBlt(selection.image->dc,0,0,100,100,WHITENESS);
selection.start={10,20}; selection.end={70,60};
auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection);
ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD {
auto pixels=ks::decodeImage(clip);
if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection");
published=true; return 0;
});
require(published && selection.copied && !IsWindow(overlay),"selection copies before notification and closes without editor");
DestroyWindow(app.window); app.window=nullptr;
std::filesystem::remove_all(temp);
CoUninitialize();
std::cout<<"Passed screenshot editor UI integration checks\n";
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}
+28
View File
@@ -22,6 +22,9 @@ try {
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'
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
$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'
@@ -34,9 +37,19 @@ try {
if (Test-Path -LiteralPath $configurator) {
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
}
$screenshot = Join-Path (Split-Path $Executable) 'KSwitcher.Screenshot.exe'
if (Test-Path -LiteralPath $screenshot) { Copy-Item -LiteralPath $screenshot -Destination (Join-Path $source 'KSwitcher.Screenshot.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')
function Write-TestManifest($Folder) {
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
})
@{schema=1;version=$binaryVersion.ToString();files=$entries} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $Folder 'release-files.json') -Encoding utf8
}
[IO.File]::WriteAllText((Join-Path $source 'future-tool.exe'),'future companion fixture')
Write-TestManifest $source
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive=Join-Path $root 'release.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
@@ -57,6 +70,19 @@ try {
$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'
foreach ($unsafe in @('../escape','C:/escape','data/settings.json','DATA/history.bin','folder/../escape','folder/file:stream','CON.txt','folder/file.','/absolute','folder\file')) {
Reject { Assert-PackagePath $unsafe } 'unsafe or protected manifest path rejected'
}
$corrupt=Join-Path $root 'corrupt.zip'
Copy-Item -LiteralPath $archive -Destination $corrupt
$z=[IO.Compression.ZipFile]::Open($corrupt,[IO.Compression.ZipArchiveMode]::Update)
$z.GetEntry('future-tool.exe').Delete()
$writer=[IO.StreamWriter]::new($z.CreateEntry('future-tool.exe').Open()); $writer.Write('tampered'); $writer.Dispose(); $z.Dispose()
Reject { Expand-Release $corrupt (Join-Path $root 'corruptstage') $binaryVersion } 'manifest detects tampered companion'
$extra=Join-Path $root 'extra.zip'; Copy-Item -LiteralPath $archive -Destination $extra
$z=[IO.Compression.ZipFile]::Open($extra,[IO.Compression.ZipArchiveMode]::Update)
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file 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
@@ -69,12 +95,14 @@ try {
if (Test-Path -LiteralPath $configurator) {
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
}
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
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'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
# 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')
+77
View File
@@ -0,0 +1,77 @@
param([Parameter(Mandatory)][string]$BridgeArchive,[Parameter(Mandatory)][string]$FullArchive)
$ErrorActionPreference='Stop'
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class UpdateResource {
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags);
[DllImport("kernel32.dll")] static extern IntPtr FindResource(IntPtr module, IntPtr name, IntPtr type);
[DllImport("kernel32.dll")] static extern IntPtr LoadResource(IntPtr module, IntPtr resource);
[DllImport("kernel32.dll")] static extern IntPtr LockResource(IntPtr resource);
[DllImport("kernel32.dll")] static extern uint SizeofResource(IntPtr module, IntPtr resource);
[DllImport("kernel32.dll")] static extern bool FreeLibrary(IntPtr module);
public static byte[] Read(string path) {
var module=LoadLibraryEx(path,IntPtr.Zero,2|32);
if(module==IntPtr.Zero) throw new Exception("Cannot load bridge resources");
try {
var resource=FindResource(module,(IntPtr)201,(IntPtr)10);
var size=SizeofResource(module,resource);
if(size==0 || size>1024*1024) throw new Exception("Invalid bridge updater resource");
var bytes=new byte[size];
Marshal.Copy(LockResource(LoadResource(module,resource)),bytes,0,(int)size);
return bytes;
} finally { FreeLibrary(module); }
}
}
'@
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-transition-'+[guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($root) | Out-Null
try {
$legacy=Join-Path $root 'legacy.ps1'
$oldSource=git -C (Join-Path $PSScriptRoot '..') show v0.4.1:resources/update.ps1
if ($LASTEXITCODE) { throw 'Cannot read the released v0.4.1 updater.' }
$oldSource | Set-Content -LiteralPath $legacy -Encoding utf8
$releases=@('v0.4.2','release-v0.5.0') | ForEach-Object {
@{tag_name=$_;draft=$false;prerelease=$false;assets=@(
@{name='KSwitcher-win-x64.zip';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/KSwitcher-win-x64.zip"},
@{name='SHA256SUMS.txt';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/SHA256SUMS.txt"})}
}
$install=Join-Path $root 'install'
[IO.Directory]::CreateDirectory((Join-Path $install 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $install 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $install 'data/settings.json'),'personal settings')
[IO.File]::WriteAllText((Join-Path $install 'dictionaries/en.txt'),'personal dictionary')
& {
. $legacy -Library
$selected=Select-Release $releases ([version]'0.4.1')
if ($selected.version -ne 'v0.4.2') { throw 'Legacy client skipped the bridge.' }
$stage=Join-Path $root 'bridge-stage'
Expand-Release $BridgeArchive $stage ([version]'0.4.2')
$changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $install (Join-Path $root 'bridge-backup') $changed
}
$bridgeHelper=Join-Path $root 'bridge-helper.ps1'
[IO.File]::WriteAllBytes($bridgeHelper,[UpdateResource]::Read((Join-Path $install 'KSwitcher.exe')))
. $bridgeHelper -Library
$selected=Select-Release $releases ([version]'0.4.2')
if ($selected.version -ne 'release-v0.5.0') { throw 'Bridge did not find the full release.' }
$stage=Join-Path $root 'full-stage'
Expand-Release $FullArchive $stage ([version]'0.5.0')
$changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $install (Join-Path $root 'full-backup') $changed
foreach ($name in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install $name)).FileVersion
if ((Get-StableVersion $version) -ne [version]'0.5.0') { throw "Incorrect installed version: $name" }
}
if ([IO.File]::ReadAllText((Join-Path $install 'data/settings.json')) -ne 'personal settings' -or
[IO.File]::ReadAllText((Join-Path $install 'dictionaries/en.txt')) -ne 'personal dictionary') { throw 'User data changed during transition.' }
if (Select-Release $releases ([version]'0.5.0')) { throw 'Current version offers another update.' }
Undo-Files $install (Join-Path $root 'full-backup') $changed
if ((Get-StableVersion ([Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install 'KSwitcher.exe')).FileVersion)) -ne [version]'0.4.2' -or
(Test-Path -LiteralPath (Join-Path $install 'KSwitcher.Screenshot.exe'))) { throw 'Full release rollback did not restore the bridge.' }
'Passed real-package transition: v0.4.1 updater -> v0.4.2 bridge -> release-v0.5.0; user data preserved; rollback verified.'
} finally {
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-transition-*') {
Remove-Item -LiteralPath $root -Recurse -Force
}
}
+6
View File
@@ -40,3 +40,9 @@ for index in range(struct.unpack_from('<H', cursor, 4)[0]):
# Drawing point is the tip of the pencil, not the center of its image.
struct.pack_into('<HH', cursor, entry+4, round(size*4/28), round(size*24/28))
(root / 'pencil.cur').write_bytes(cursor)
# Original confirmation artwork, with native-resolution variants for display scaling.
question = root.parent / 'question.svg'
question_png = resvg_py.svg_to_bytes(svg_string=question.read_text(encoding='utf-8'), width=256, height=256)
Image.open(BytesIO(question_png)).convert('RGBA').save(
question.with_suffix('.ico'), sizes=[(s,s) for s in (16,20,24,32,40,48,64,96,128,256)])
+2
View File
@@ -1,6 +1,7 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot
try {
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $root 'build\KSwitcher.Screenshot.exe') }) { throw 'Close screenshot editors before updating the development build.' }
$exe = Join-Path $root 'build\KSwitcher.exe'
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
@@ -10,6 +11,7 @@ try {
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Screenshot.exe') -Destination (Join-Path $root 'build\KSwitcher.Screenshot.exe') -Force
$eventName = 'Local\KSwitcher.Update.Started.' + [guid]::NewGuid().ToString('N')
$started = [System.Threading.EventWaitHandle]::new($false, [System.Threading.EventResetMode]::ManualReset, $eventName)
try {