Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de5b791bf | |||
| c109408a98 |
+32
-1
@@ -1,10 +1,24 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.44 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.46 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_executable(StayHere WIN32 utilities/StayHere/main.cpp)
|
||||
add_executable(StayHereTest WIN32 utilities/StayHere/main.cpp)
|
||||
target_compile_definitions(StayHereTest PRIVATE STAYHERE_TEST_INSTANCE)
|
||||
set_target_properties(StayHere PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tools")
|
||||
foreach(utilityTarget StayHere StayHereTest)
|
||||
target_compile_definitions(${utilityTarget} PRIVATE UNICODE _UNICODE _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(${utilityTarget} PRIVATE user32 shell32 gdi32 wtsapi32)
|
||||
if(MSVC)
|
||||
target_compile_options(${utilityTarget} PRIVATE /utf-8)
|
||||
set_property(TARGET ${utilityTarget} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
endforeach()
|
||||
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 src/sync.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
@@ -30,6 +44,15 @@ target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
# Opt-in interactive fixture: requires an installed Far Manager.
|
||||
add_executable(KSwitcherFarSnippetTests WIN32 tests/far_snippets.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherFarSnippetTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
target_compile_options(KSwitcherFarSnippetTests PRIVATE /utf-8)
|
||||
set_property(TARGET KSwitcherFarSnippetTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherFarSnippetTests PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||
@@ -111,6 +134,14 @@ add_test(NAME dictionary-regressions COMMAND KSwitcherDictionaryTests)
|
||||
set_tests_properties(dictionary-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_executable(KSwitcherUtilitiesTests tests/utilities.cpp)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherUtilitiesTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests)
|
||||
add_test(NAME stayhere-lifecycle COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/stayhere.ps1" -Executable "$<TARGET_FILE:StayHereTest>")
|
||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
|
||||
@@ -280,9 +280,15 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
Clipboard synchronizers such as Mouse Without Borders may republish a copy only when
|
||||
KSwitcher first observed it owned by the target application and the selection text and
|
||||
editor selection metadata remain identical. Unobserved or altered foreign copies are rejected.
|
||||
Snippets require accessibility verification of their selected trigger and pasted text,
|
||||
Snippets normally require accessibility verification of their selected trigger and pasted text,
|
||||
even with logging disabled. If verification is unavailable or the paste differs, expansion
|
||||
stops without running cursor or Enter actions. Clipboard changes during paste also cancel actions.
|
||||
Far Manager uses its native Alt+Shift+Arrow selection, Ctrl+Insert copy and Shift+Insert
|
||||
paste commands instead: both the trigger and inserted text must match verified clipboard
|
||||
copies from Far. Cancellation restores the full temporary selection, not one cursor step.
|
||||
Far supports nonempty single-line replacements up to 4096 caret steps, including
|
||||
`{cursor}` and verified `{enter}` actions. Empty or multiline replacements are rejected
|
||||
before selection; pasting a newline could execute a command before it can be verified.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
||||
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
||||
@@ -536,6 +542,30 @@ OpenBao administrator setup:
|
||||
and UI integration, so another service can be added without replacing the synchronization logic.
|
||||
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
|
||||
|
||||
## Portable utilities
|
||||
|
||||
The tray menu's **Utilities** submenu lists `.exe` files in a `tools` folder beside
|
||||
`KSwitcher.exe`, sorted by filename. For example, place `StayHere.exe` at
|
||||
`tools/StayHere.exe` and choose **Utilities → StayHere** to launch it manually.
|
||||
The folder is rescanned whenever the menu opens; no configuration or restart is needed.
|
||||
Only files directly in `tools` are listed (not subfolders, shortcuts, or KSwitcher's
|
||||
own executables beside the main program). Utilities start with `tools` as their working
|
||||
directory and are never started automatically. Their lifetime is independent of KSwitcher.
|
||||
An absent or empty folder displays a disabled placeholder; launch errors are reported.
|
||||
From v0.6.45, the release includes `tools/README.txt`, so extracting the ZIP or
|
||||
installing an update creates the folder. Add your own trusted portable executables;
|
||||
StayHere is bundled from v0.6.46, and settings synchronization does not transfer
|
||||
utility files. Updates preserve personal utilities and refresh bundled StayHere and
|
||||
documentation. Repository development scripts
|
||||
are not included in the portable tools folder.
|
||||
|
||||
Choose **Utilities → StayHere** to start the bundled activity utility. It starts
|
||||
enabled; left-click its tray icon to pause/resume or right-click for Exit. While
|
||||
enabled it prevents automatic sleep and, after three minutes without input, sends
|
||||
F15 on its 15-second check. It does not move the mouse, bypass locking, start with
|
||||
KSwitcher automatically, or directly control Teams status. Locked/disconnected
|
||||
sessions do not receive simulated activity. See `tools/StayHere-README.md`.
|
||||
|
||||
## Build
|
||||
|
||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||
@@ -659,6 +689,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
|
||||
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
|
||||
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
||||
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
||||
.\build.ps1 -Far # Explicit integration test using an isolated Far Manager profile
|
||||
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
+9
-8
@@ -1,20 +1,21 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.44 — 2026-11-05
|
||||
## v0.6.46 — 2026-11-05
|
||||
|
||||
## Changes
|
||||
- Opening clipboard history selects and reveals the newest matching entry instead of restoring an older selection. This fixes reopening history after recording a new screenshot.
|
||||
- Search and the pinned-only filter remain saved. Clear the search and uncheck **★ Pinned only** if the new screenshot is hidden.
|
||||
- Background clipboard/OCR refreshes preserve the selected record while browsing. Invoking the history shortcut while history is already active retains its existing quick-paste behavior.
|
||||
- No shortcuts, settings, capture behavior or storage format changed.
|
||||
- Bundle **tools/StayHere.exe** and **tools/StayHere-README.md**. Start the activity utility from **Utilities → StayHere**; it does not start automatically with KSwitcher.
|
||||
- StayHere starts enabled. Left-click its tray icon to pause/resume or right-click for Exit. While enabled, it prevents automatic display/system sleep and checks inactivity every 15 seconds; after three minutes without input it sends F15. It does not move the mouse, bypass locking, install autostart or directly control Teams presence. Locked/disconnected sessions receive no simulated activity.
|
||||
- Include StayHere source and reproducible GCC/CMake builds. CLI status/pause/resume/exit parsing tolerates launcher whitespace. Normal updates replace bundled StayHere and documentation while preserving personal utilities. A running old instance continues until exited; relaunch to use the updated executable.
|
||||
- Retain all v0.6.45 Far snippet, Utilities and tools-folder changes and the v0.6.44 newest-history-selection fix.
|
||||
- Correct the Far test fixture's working directory: a long temporary path could scroll the command line and invalidate absolute cursor-coordinate assertions. Application snippet behavior is unchanged in this release.
|
||||
|
||||
## Validation
|
||||
Passed dictionary equivalence checks for 140,939 words, 105 sync checks, shared wheel regressions, 319 native checks, 574 input checks, screenshot editor UI/save/registration, 48 updater checks and updater progress UI checks. The history regression covers reopening after a new image and preserving an explicitly selected older record during background refresh.
|
||||
Passed isolated StayHere start/status/pause/resume/exit checks, Utilities regressions, dictionary equivalence (140,939 words), 105 sync checks, shared wheel tests, 319 native checks, 574 input checks, screenshot UI/save/registration, 54 updater checks and updater progress UI. The updater replaced a running isolated StayHere executable successfully. Final package manifests and downloaded assets are checked before completion. Real-Far spot checks confirmed expansion, caret placement and markers; the uninterrupted full interactive run remains outstanding because unrelated foreground changes cancel it. No additional Far application-code change is shipped in v0.6.46.
|
||||
|
||||
## Upgrade
|
||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Find `tools/StayHere.exe` beside the executable actually running.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together; history uses another instance of KSwitcher.exe. The package contains no personal settings, credentials or clipboard history.
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep the three KSwitcher executables together; history uses another instance of KSwitcher.exe. StayHere runs independently. The package contains no personal settings, credentials or clipboard history.
|
||||
|
||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
|
||||
@@ -1 +1 @@
|
||||
a3e8a42db787ab5b8edca698fc97943a3e0d5348c5000cc632aeb07179210469 KSwitcher-win-x64.zip
|
||||
e6d26d59b18559ad226359b35cd403c68326384f8e1918d31b368cbd1110f6b9 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage)
|
||||
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage, [switch]$Far)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = $PSScriptRoot
|
||||
$compiler = Join-Path $root '.tools\w64devkit\bin\g++.exe'
|
||||
@@ -16,6 +16,12 @@ 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 = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
|
||||
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
|
||||
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
|
||||
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
|
||||
if ($LASTEXITCODE) { throw 'StayHere build failed' }
|
||||
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','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" }
|
||||
@@ -41,6 +47,14 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
|
||||
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
|
||||
if ($LASTEXITCODE) { throw 'StayHere lifecycle tests failed' }
|
||||
& $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
|
||||
& .\build\KSwitcherUtilitiesTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
|
||||
& $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
|
||||
& .\build\KSwitcherDictionaryTests.exe
|
||||
@@ -80,6 +94,13 @@ try {
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||
}
|
||||
if ($Far) {
|
||||
& $compiler @common tests/far_snippets.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Far snippet test build failed' }
|
||||
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
|
||||
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
|
||||
if ($fixture.ExitCode) { throw 'Far snippet tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
$historyFixture = Join-Path (Resolve-Path build) ('history-host-fixture-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory $historyFixture | Out-Null
|
||||
|
||||
+15
-2
@@ -10,8 +10,12 @@ 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
|
||||
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
|
||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.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','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | 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()}
|
||||
@@ -22,7 +26,16 @@ $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
|
||||
# Archive only manifest-listed files, preserving relative paths. Never sweep up
|
||||
# personal utilities or leftover developer files from the staging directory.
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$stream = [IO.File]::Open($archive,[IO.FileMode]::Create)
|
||||
$zip = [IO.Compression.ZipArchive]::new($stream,[IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($name in @($entries | ForEach-Object { $_.path }) + @('release-files.json')) {
|
||||
[IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,(Join-Path $destination $name),$name,[IO.Compression.CompressionLevel]::Optimal) | Out-Null
|
||||
}
|
||||
} finally { $zip.Dispose(); $stream.Dispose() }
|
||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||
if ($PublishRelease) {
|
||||
$release = Join-Path $root 'Release'
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,6,44,0
|
||||
PRODUCTVERSION 0,6,44,0
|
||||
FILEVERSION 0,6,46,0
|
||||
PRODUCTVERSION 0,6,46,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -20,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.6.44"
|
||||
VALUE "FileVersion", "0.6.46"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
KSwitcher portable utilities
|
||||
|
||||
Put your trusted portable .exe utilities directly in this tools folder,
|
||||
beside this README.txt. Right-click the KSwitcher tray icon and open Utilities.
|
||||
For example: tools/StayHere.exe appears as Utilities > StayHere.
|
||||
|
||||
The list refreshes each time the tray menu opens. Subfolders and shortcuts
|
||||
are not listed. Utilities start only when you choose them, with this folder
|
||||
as their working directory. Closing KSwitcher does not close a utility.
|
||||
|
||||
StayHere.exe is bundled. Choose Utilities > StayHere to start it; it starts
|
||||
enabled, prevents automatic sleep, and sends F15 after 3 minutes of inactivity.
|
||||
Left-click its tray icon to pause/resume; right-click for Exit. See StayHere-README.md.
|
||||
It does not start automatically with KSwitcher or bypass Windows locking.
|
||||
|
||||
Add other utilities separately. Updates preserve your own files in this folder;
|
||||
bundled StayHere.exe and documentation may be refreshed. Utilities are not
|
||||
transferred by settings synchronization.
|
||||
+102
-16
@@ -6,6 +6,7 @@
|
||||
#include "configuration.h"
|
||||
#include "diagnostics.h"
|
||||
#include "paste_probe.h"
|
||||
#include "utilities.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
@@ -82,9 +83,12 @@ class Transaction
|
||||
bool trustedContentsObserved{};
|
||||
std::unique_ptr<PasteProbe> selectionProof;
|
||||
std::wstring provenSelection;
|
||||
bool farSnippet{};
|
||||
size_t farSelectedSteps{};
|
||||
|
||||
public:
|
||||
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v)
|
||||
Transaction(App &a, HWND t, uint64_t v, bool farEditor = false)
|
||||
: app(a), target(t), version(v), farSnippet(farEditor)
|
||||
{
|
||||
app.suspended = true;
|
||||
try
|
||||
@@ -119,6 +123,7 @@ class Transaction
|
||||
}
|
||||
~Transaction()
|
||||
{
|
||||
if (farSnippet) finishSelection(false);
|
||||
if (captured && GetClipboardSequenceNumber() == expected)
|
||||
try
|
||||
{
|
||||
@@ -200,7 +205,7 @@ class Transaction
|
||||
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
|
||||
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target) +
|
||||
L" sentinel=" + diagnosticText(sentinel));
|
||||
if (!valid() || !chord(L'C', MOD_CONTROL))
|
||||
if (!valid() || !chord(farSnippet ? VK_INSERT : L'C', MOD_CONTROL))
|
||||
{
|
||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||
return {};
|
||||
@@ -250,7 +255,7 @@ class Transaction
|
||||
check();
|
||||
if (!valid() || GetClipboardSequenceNumber() != before)
|
||||
return {};
|
||||
if (!chord(L'C', MOD_CONTROL))
|
||||
if (!chord(farSnippet ? VK_INSERT : L'C', MOD_CONTROL))
|
||||
return {};
|
||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
|
||||
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||
@@ -337,8 +342,44 @@ class Transaction
|
||||
bool recoveredSelection() const { return selectionProof != nullptr; }
|
||||
bool select(size_t n)
|
||||
{
|
||||
if (farSnippet)
|
||||
{
|
||||
if (n > 4096 || farSelectedSteps) return false;
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
{
|
||||
if (!valid() || !chord(VK_LEFT, MOD_ALT | MOD_SHIFT)) return false;
|
||||
++farSelectedSteps;
|
||||
}
|
||||
return valid();
|
||||
}
|
||||
return selectPrevious(n, [&] { return valid(); });
|
||||
}
|
||||
bool finishSelection(bool atStart)
|
||||
{
|
||||
if (!farSnippet) return valid() && chord(atStart ? VK_LEFT : VK_RIGHT);
|
||||
if (!farSelectedSteps) return valid();
|
||||
if (atStart)
|
||||
{
|
||||
// Far arrows move one character, rather than collapsing a selection
|
||||
// to its edge. Move into the nonempty selected range and back.
|
||||
if (!valid() || !chord(VK_RIGHT)) return false;
|
||||
farSelectedSteps = 0;
|
||||
return valid() && chord(VK_LEFT);
|
||||
}
|
||||
while (farSelectedSteps && valid())
|
||||
{
|
||||
if (!chord(VK_RIGHT, MOD_ALT | MOD_SHIFT)) return false;
|
||||
--farSelectedSteps;
|
||||
}
|
||||
return !farSelectedSteps && valid();
|
||||
}
|
||||
std::optional<std::wstring> readFarSnippet(size_t count)
|
||||
{
|
||||
if (!farSnippet || !count || !select(count)) return {};
|
||||
auto observed = copy();
|
||||
if (!finishSelection(false) || observed.empty()) return {};
|
||||
return observed;
|
||||
}
|
||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||
const std::function<bool(const std::wstring &)> &matches,
|
||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||
@@ -441,8 +482,11 @@ class Transaction
|
||||
if (!valid())
|
||||
return false;
|
||||
}
|
||||
if (!chord(L'V', MOD_CONTROL))
|
||||
if (!chord(farSnippet ? VK_INSERT : L'V', farSnippet ? MOD_SHIFT : MOD_CONTROL))
|
||||
throw std::runtime_error("Windows blocked simulated input; check application privilege levels");
|
||||
// The selected trigger was replaced. Do not restore its old caret range
|
||||
// if clipboard verification below subsequently rejects a concurrent copy.
|
||||
if (farSnippet) farSelectedSteps = 0;
|
||||
Sleep(300);
|
||||
// A clipboard synchronizer may replace our payload after Ctrl+V is
|
||||
// queued but before the editor consumes it. Do not run snippet actions
|
||||
@@ -1294,14 +1338,16 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
// Collapse a temporary selection on every failed auto-correction,
|
||||
// including clipboard exceptions, before queued typing is replayed.
|
||||
const bool farSnippet = snippet >= 0 && lower(processName(target)) == L"far";
|
||||
bool autoSelected = false;
|
||||
struct SelectionReset
|
||||
{
|
||||
bool &selected;
|
||||
std::function<bool()> valid;
|
||||
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
|
||||
} selectionReset{autoSelected, valid};
|
||||
Transaction tx(*this, target, version);
|
||||
bool farEditor;
|
||||
~SelectionReset() { if (selected && !farEditor && valid()) chord(VK_RIGHT); }
|
||||
} selectionReset{autoSelected, valid, farSnippet};
|
||||
Transaction tx(*this, target, version, farSnippet);
|
||||
reason = L"selection-unavailable";
|
||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
|
||||
@@ -1381,6 +1427,15 @@ void App::transform(int command, int snippet)
|
||||
const auto &sn = config.snippets[snippet];
|
||||
if (recent.keys.size() < sn.trigger.size())
|
||||
return;
|
||||
result = parseTemplate(sn.text).text;
|
||||
// A multiline clipboard paste can execute a console command
|
||||
// before readback. Only explicit, verified {enter} may do so.
|
||||
if (farSnippet && (result.empty() || result.find_first_of(L"\r\n") != result.npos ||
|
||||
caretSteps(result) > 4096))
|
||||
{
|
||||
reason = L"far-snippet-template-unsupported";
|
||||
return;
|
||||
}
|
||||
Run tail = recent;
|
||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||
if (!tx.select(sn.trigger.size()))
|
||||
@@ -1390,7 +1445,6 @@ void App::transform(int command, int snippet)
|
||||
text = tx.copy();
|
||||
if (tail.match(text, available, physicalLayout()) < 0)
|
||||
return;
|
||||
result = parseTemplate(sn.text).text;
|
||||
automatic = true;
|
||||
}
|
||||
else if (config.recent && recent.window == target && !recent.keys.empty() && !recent.overflow)
|
||||
@@ -1480,9 +1534,10 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
bool sent = false;
|
||||
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
|
||||
PasteProbe probe(((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
||||
if (requireReadback && result != text && probe.status != L"ready")
|
||||
PasteProbe probe(!farSnippet && ((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
||||
if (requireReadback && !farSnippet && result != text && probe.status != L"ready")
|
||||
{
|
||||
verification = probe.status;
|
||||
reason = snippet >= 0 ? L"snippet-verification-unavailable" : L"recovered-selection-unavailable";
|
||||
std::lock_guard lock(stateMutex);
|
||||
last.reset();
|
||||
@@ -1494,7 +1549,7 @@ void App::transform(int command, int snippet)
|
||||
if (result != text)
|
||||
sent = tx.paste(textClip(result));
|
||||
else if (automatic && tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
tx.finishSelection(false);
|
||||
if (requireReadback && result != text && !sent)
|
||||
{
|
||||
reason = snippet >= 0 ? L"snippet-paste-cancelled" : L"recovered-selection-paste-cancelled";
|
||||
@@ -1517,11 +1572,12 @@ void App::transform(int command, int snippet)
|
||||
verification = probe.status;
|
||||
if (tx.valid())
|
||||
{
|
||||
auto observed = probe.read((int)caretSteps(result));
|
||||
auto observed = farSnippet ? tx.readFarSnippet(caretSteps(result)) :
|
||||
probe.read((int)caretSteps(result));
|
||||
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||
// Retry once only with fresh native proof that the exact original range
|
||||
// is still selected. Never retry an unknown or changed editor state.
|
||||
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||
if (!farSnippet && observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||
{
|
||||
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
||||
if (tx.paste(textClip(result)) && tx.valid())
|
||||
@@ -1529,7 +1585,8 @@ void App::transform(int command, int snippet)
|
||||
else
|
||||
observed.reset();
|
||||
}
|
||||
verification = probe.status;
|
||||
verification = farSnippet ? (observed ? L"read-back-via-far-clipboard" :
|
||||
L"far-readback-unavailable") : probe.status;
|
||||
if (observed && tx.valid())
|
||||
{
|
||||
actual = *observed;
|
||||
@@ -1573,6 +1630,7 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
if (snippet >= 0 && sent && tx.valid())
|
||||
{
|
||||
tx.check();
|
||||
auto t = parseTemplate(config.snippets[snippet].text);
|
||||
if (!t.suffix.empty())
|
||||
{
|
||||
@@ -1581,16 +1639,19 @@ void App::transform(int command, int snippet)
|
||||
if (!tx.valid()) return;
|
||||
if (newlines(suffix) != newlines(t.suffix))
|
||||
{
|
||||
chord(VK_RIGHT);
|
||||
tx.finishSelection(false);
|
||||
return;
|
||||
}
|
||||
if (!chord(VK_LEFT)) return;
|
||||
if (!tx.finishSelection(true)) return;
|
||||
}
|
||||
// Action markers run after insertion and cursor placement,
|
||||
// before any physical typing buffered during expansion.
|
||||
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
||||
{
|
||||
tx.check();
|
||||
if (!chord(VK_RETURN)) break;
|
||||
}
|
||||
}
|
||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||
{
|
||||
bool switched = switchWindowLayout(target, available[to].handle, valid);
|
||||
@@ -1761,6 +1822,10 @@ void App::recognizeEntry(std::wstring id)
|
||||
}
|
||||
void App::trayMenu()
|
||||
{
|
||||
constexpr int utilityCommandBase = 1000;
|
||||
std::error_code utilitiesError;
|
||||
auto utilities = discoverUtilities(std::filesystem::path(executable()).parent_path() / L"tools", utilitiesError);
|
||||
if (utilities.size() > 50000) utilities.resize(50000);
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||
@@ -1768,6 +1833,13 @@ void App::trayMenu()
|
||||
SetMenuDefaultItem(menu, 11, FALSE);
|
||||
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||
HMENU utilityMenu = CreatePopupMenu();
|
||||
for (size_t i = 0; i < utilities.size(); ++i)
|
||||
AppendMenuW(utilityMenu, MF_STRING, utilityCommandBase + i, utilityMenuLabel(utilities[i]).c_str());
|
||||
if (utilities.empty())
|
||||
AppendMenuW(utilityMenu, MF_STRING | MF_GRAYED, 0,
|
||||
utilitiesError ? L"Cannot read tools folder" : L"No utilities in tools folder");
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)utilityMenu, L"&Utilities");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
||||
HMENU pause = CreatePopupMenu();
|
||||
@@ -1794,6 +1866,20 @@ void App::trayMenu()
|
||||
PostMessageW(window, WM_NULL, 0, 0);
|
||||
try
|
||||
{
|
||||
if (cmd >= utilityCommandBase && size_t(cmd - utilityCommandBase) < utilities.size())
|
||||
{
|
||||
const auto &path = utilities[cmd - utilityCommandBase];
|
||||
auto command = L"\"" + path.wstring() + L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start utility " + path.filename().wstring())));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
return;
|
||||
}
|
||||
switch (cmd)
|
||||
{
|
||||
case 24:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.44";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.46";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cwchar>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
// Scan each time the menu opens, relative to the resident executable, not its working directory.
|
||||
inline std::vector<std::filesystem::path> discoverUtilities(const std::filesystem::path &folder,
|
||||
std::error_code &error)
|
||||
{
|
||||
error.clear();
|
||||
std::vector<std::filesystem::path> result;
|
||||
std::filesystem::directory_iterator it(folder, error), end;
|
||||
if (error == std::errc::no_such_file_or_directory) error.clear();
|
||||
while (!error && it != end)
|
||||
{
|
||||
std::error_code entryError;
|
||||
if (_wcsicmp(it->path().extension().c_str(), L".exe") == 0 && it->is_regular_file(entryError))
|
||||
result.push_back(it->path());
|
||||
it.increment(error);
|
||||
}
|
||||
if (error) result.clear();
|
||||
std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) {
|
||||
return _wcsicmp(a.filename().c_str(), b.filename().c_str()) < 0;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
inline std::wstring utilityMenuLabel(const std::filesystem::path &path)
|
||||
{
|
||||
std::wstring label;
|
||||
for (wchar_t ch : path.stem().wstring())
|
||||
{
|
||||
label += ch;
|
||||
if (ch == L'&') label += ch; // Win32 uses ampersands for access keys.
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Explicit Windows integration test. Opens an isolated Far profile and never
|
||||
// types into an existing Far window. Requires Far Manager at its default path.
|
||||
#include "app.h"
|
||||
#include <cstdlib>
|
||||
#include <cwctype>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static void runQueued(App &app) {
|
||||
std::function<void()> job;
|
||||
{ std::lock_guard lock(app.jobsMutex);
|
||||
if (app.jobs.empty()) throw std::runtime_error("snippet did not queue");
|
||||
job=std::move(app.jobs.front());app.jobs.pop_front(); }
|
||||
auto future=std::async(std::launch::async,[job=std::move(job)] {
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);job();CoUninitialize();
|
||||
});
|
||||
while(future.wait_for(std::chrono::milliseconds(5))!=std::future_status::ready) {
|
||||
MSG message{};while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
||||
TranslateMessage(&message);DispatchMessageW(&message);
|
||||
}
|
||||
}
|
||||
future.get();app.replayTyping();
|
||||
}
|
||||
};
|
||||
}
|
||||
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) {
|
||||
auto base=std::filesystem::path(ks::executable()).parent_path();
|
||||
std::ofstream log(base/L"far-snippets.log");
|
||||
int checks=0;
|
||||
auto require=[&](bool ok,const char *message) {
|
||||
if(!ok)throw std::runtime_error(message);
|
||||
++checks;log<<"PASS "<<message<<'\n';log.flush();
|
||||
};
|
||||
auto folder=std::filesystem::temp_directory_path()/(L"ks-far-snippets-"+ks::newSnippetId());
|
||||
std::filesystem::create_directories(folder);
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
ks::Clip saved;bool captured=false;PROCESS_INFORMATION child{};
|
||||
HANDLE input=INVALID_HANDLE_VALUE,output=INVALID_HANDLE_VALUE;
|
||||
HWND farWindow{};
|
||||
HWND owner=CreateWindowExW(0,L"STATIC",L"Far snippet fixture clipboard",0,0,0,0,0,HWND_MESSAGE,nullptr,instance,nullptr);
|
||||
auto originalForeground=GetForegroundWindow();
|
||||
int result=1;
|
||||
try {
|
||||
saved=ks::readClipboard(owner,128*1024*1024);captured=true;
|
||||
auto sentinel=ks::textClip(L"KSwitcher Far fixture clipboard");
|
||||
sentinel.formats.push_back({0,L"ExcludeClipboardContentFromMonitorProcessing",ks::Bytes{0}});
|
||||
ks::writeClipboard(owner,sentinel);
|
||||
const auto programFiles=_wgetenv(L"ProgramFiles");
|
||||
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
|
||||
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe";
|
||||
std::wstring command=L"\""+farExecutable.wstring()+L"\" -m -p -s \""+folder.wstring()+L"\\profile\"";
|
||||
// A GUID-length working directory can horizontally scroll Far's command
|
||||
// line, making absolute cursor-cell assertions invalid. Keep the prompt
|
||||
// short; profile and application data still live in the unique fixture.
|
||||
auto workingDirectory=folder.root_path();
|
||||
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
|
||||
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,workingDirectory.c_str(),&startup,&child),"start isolated Far without plugins/macros");
|
||||
CloseHandle(child.hThread);Sleep(1800);
|
||||
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation");
|
||||
input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
|
||||
output=CreateFileW(L"CONOUT$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
|
||||
require(input!=INVALID_HANDLE_VALUE && output!=INVALID_HANDLE_VALUE,"open isolated console buffers");
|
||||
auto target=GetConsoleWindow();farWindow=target;ShowWindow(target,SW_SHOW);
|
||||
require(ks::activateWindow(target),"activate only the isolated Far window");
|
||||
auto key=[&](WORD vk,wchar_t c=0,DWORD mods=0) {
|
||||
INPUT_RECORD records[2]{};
|
||||
for(int i=0;i<2;++i) {
|
||||
auto &r=records[i];r.EventType=KEY_EVENT;
|
||||
r.Event.KeyEvent={BOOL(i==0),1,vk,WORD(MapVirtualKeyW(vk,MAPVK_VK_TO_VSC)),{c},mods};
|
||||
}
|
||||
DWORD written{};
|
||||
if(!WriteConsoleInputW(input,records,2,&written)||written!=2)throw std::runtime_error("test input failed");
|
||||
Sleep(60);
|
||||
};
|
||||
auto position=[&] {CONSOLE_SCREEN_BUFFER_INFO info{};if(!GetConsoleScreenBufferInfo(output,&info))throw std::runtime_error("console position unavailable");return info.dwCursorPosition;};
|
||||
auto row=[&] {
|
||||
CONSOLE_SCREEN_BUFFER_INFO info{};GetConsoleScreenBufferInfo(output,&info);
|
||||
std::wstring text(info.dwSize.X,L' ');DWORD count{};
|
||||
if(!ReadConsoleOutputCharacterW(output,text.data(),DWORD(text.size()),{0,info.dwCursorPosition.Y},&count))throw std::runtime_error("console row unavailable");
|
||||
return text;
|
||||
};
|
||||
{
|
||||
ks::App app(instance,false,folder/L"data");app.window=owner;
|
||||
app.settings.history=false;app.settings.autoCorrect=false;app.settings.writeLog=true;
|
||||
auto prepare=[&](wchar_t last=L'r') {
|
||||
if(!ks::activateWindow(target))throw std::runtime_error("isolated Far lost foreground");
|
||||
key(VK_ESCAPE);key('Y',0,LEFT_CTRL_PRESSED);key(VK_OEM_1,L':',SHIFT_PRESSED);key(WORD(towupper(last)),last);
|
||||
app.run.reset();app.run.window=target;app.run.layout=ks::windowLayout(target);
|
||||
app.run.at=GetTickCount64();app.run.keys=L":r";
|
||||
};
|
||||
auto expand=[&](const std::wstring &replacement) {
|
||||
app.settings.snippets={{L":r",replacement}};
|
||||
app.transform(0,0);ks::InputRegressionAccess::runQueued(app);Sleep(120);
|
||||
};
|
||||
for(int panels=0;panels<2;++panels) {
|
||||
prepare();auto before=position();auto original=row();expand(L"rider .");
|
||||
auto expected=original;expected.replace(before.X-2,2,L"rider .");expected.resize(original.size());
|
||||
// Far's rightmost scroll indicator is redrawn rather than shifted.
|
||||
require(row().substr(0,before.X+5)==expected.substr(0,before.X+5),"actual snippet replaces :r with rider .");
|
||||
require(position().X==before.X+5,"successful expansion leaves cursor after replacement");
|
||||
require(ks::readClipboard(owner).text()==sentinel.text(),"snippet restores original clipboard");
|
||||
prepare(L'x');before=position();original=row();expand(L"rider .{enter}");
|
||||
require(row()==original && position().X==before.X,"mismatched trigger restores original caret and never executes Enter");
|
||||
prepare();before=position();original=row();expand(L"left{cursor}right");
|
||||
require(row().substr(before.X-2,9)==L"leftright" && position().X==before.X+2,"cursor marker uses Far selection semantics");
|
||||
prepare();before=position();original=row();expand(L"first\nsecond{enter}");
|
||||
require(row()==original && position().X==before.X,"multiline console template rejected before changing text or caret");
|
||||
prepare();before=position();original=row();expand(L"echo KSWITCHER_FAR_FIXTURE{enter}");
|
||||
for(int wait=0;wait<30 && position().X!=before.X-2;++wait)Sleep(100);
|
||||
require(position().X==before.X-2 && row().substr(before.X-2,2)==L" ",
|
||||
"verified Enter runs only the harmless fixture echo and returns to an empty command line");
|
||||
key('O',0,LEFT_CTRL_PRESSED);
|
||||
}
|
||||
app.window=nullptr;
|
||||
}
|
||||
log<<"Passed "<<checks<<" Far snippet integration checks\n";result=0;
|
||||
}catch(const std::exception &e){log<<"FAIL "<<e.what()<<" win32="<<GetLastError()<<'\n';}
|
||||
// A real user or clipboard synchronizer can copy during this explicit test.
|
||||
// Never replace that newer content with the fixture's original snapshot.
|
||||
const auto restoreSequence=GetClipboardSequenceNumber();
|
||||
const bool restoreClipboard=GetClipboardOwner()==owner || (farWindow && ks::ownsClipboard(farWindow));
|
||||
if(input!=INVALID_HANDLE_VALUE)CloseHandle(input);
|
||||
if(output!=INVALID_HANDLE_VALUE)CloseHandle(output);
|
||||
FreeConsole();
|
||||
if(child.hProcess){TerminateProcess(child.hProcess,0);WaitForSingleObject(child.hProcess,5000);CloseHandle(child.hProcess);}
|
||||
if(captured && restoreClipboard && GetClipboardSequenceNumber()==restoreSequence)
|
||||
try{ks::writeClipboard(owner,saved);}catch(...){result=1;}
|
||||
DestroyWindow(owner);
|
||||
if(IsWindow(originalForeground))ks::activateWindow(originalForeground);
|
||||
CoUninitialize();return result;
|
||||
}
|
||||
+1
-1
@@ -124,7 +124,7 @@ int main(int argc, char **argv) {
|
||||
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()));
|
||||
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+ks::newSnippetId());
|
||||
int checks=0;
|
||||
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
param([Parameter(Mandatory=$true)][string]$Executable)
|
||||
$ErrorActionPreference='Stop'
|
||||
function Command([string]$Argument) {
|
||||
$commandProcess=Start-Process -FilePath $Executable -ArgumentList $Argument -WindowStyle Hidden -PassThru
|
||||
try {
|
||||
$null=$commandProcess.Handle
|
||||
$commandProcess.WaitForExit()
|
||||
return $commandProcess.ExitCode
|
||||
} finally { $commandProcess.Dispose() }
|
||||
}
|
||||
if ((Command '--status') -ne 3) { throw 'An isolated StayHere test instance is already running' }
|
||||
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
$null=$process.Handle
|
||||
try {
|
||||
$ready=$false
|
||||
for($i=0;$i -lt 20;$i++) {
|
||||
if((Command '--status') -eq 1){$ready=$true;break}
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
if(!$ready){throw 'StayHere did not start enabled'}
|
||||
if((Command '--pause') -ne 0 -or (Command '--status') -ne 2){throw 'StayHere pause failed'}
|
||||
if((Command '--resume') -ne 0 -or (Command '--status') -ne 1){throw 'StayHere resume failed'}
|
||||
if((Command '--exit') -ne 0 -or !$process.WaitForExit(3000)){throw 'StayHere exit failed'}
|
||||
if((Command '--status') -ne 3){throw 'StayHere instance remained after exit'}
|
||||
Write-Output 'Passed isolated StayHere start/status/pause/resume/exit checks'
|
||||
} finally {
|
||||
if(!$process.HasExited){$null=Command '--exit';if(!$process.WaitForExit(3000)){$process.Kill()}}
|
||||
$process.Dispose()
|
||||
}
|
||||
@@ -50,6 +50,9 @@ try {
|
||||
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')
|
||||
[IO.Directory]::CreateDirectory((Join-Path $source 'tools')) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $source 'tools/README.txt'),'portable utilities instructions')
|
||||
[IO.File]::WriteAllText((Join-Path $source 'tools/StayHere.exe'),'bundled StayHere fixture')
|
||||
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}
|
||||
@@ -92,8 +95,18 @@ try {
|
||||
$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'
|
||||
$freshTarget=Join-Path $root 'fresh-install'
|
||||
[IO.Directory]::CreateDirectory($freshTarget) | Out-Null
|
||||
$freshChanges=[Collections.Generic.List[string]]::new()
|
||||
$freshStage=Join-Path $root 'fresh-stage'
|
||||
Copy-Item -LiteralPath $stage -Destination $freshStage -Recurse
|
||||
Install-Files $freshStage $freshTarget (Join-Path $root 'fresh-backup') $freshChanges
|
||||
Assert (Test-Path -LiteralPath (Join-Path $freshTarget 'tools/README.txt')) 'update creates portable tools folder'
|
||||
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
|
||||
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
|
||||
[IO.Directory]::CreateDirectory((Join-Path $target 'tools')) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $target 'tools/Personal.exe'),'personal utility fixture')
|
||||
[IO.File]::WriteAllText((Join-Path $target 'tools/StayHere.exe'),'older StayHere fixture')
|
||||
[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')
|
||||
@@ -105,9 +118,14 @@ try {
|
||||
}
|
||||
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 'tools/Personal.exe')) -eq 'personal utility fixture') 'personal utilities preserved'
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/README.txt')) -eq 'portable utilities instructions') 'bundled tools documentation installed'
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/StayHere.exe')) -eq 'bundled StayHere fixture') 'bundled StayHere updated'
|
||||
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 ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'rollback preserves personal utilities'
|
||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/StayHere.exe')) -eq 'older StayHere fixture') 'rollback restores previous StayHere'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#include <windows.h>
|
||||
#include "../src/utilities.h"
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
int main()
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
auto root = fs::temp_directory_path() / (L"KSwitcher-utilities-" + std::to_wstring(GetCurrentProcessId()));
|
||||
try
|
||||
{
|
||||
std::error_code error;
|
||||
if (!ks::discoverUtilities(root / L"missing", error).empty() || error)
|
||||
throw std::runtime_error("Missing folder must be empty, not an error");
|
||||
fs::create_directories(root / L"tools" / L"nested");
|
||||
fs::create_directory(root / L"tools" / L"directory.exe");
|
||||
for (auto name : {L"Zed.exe", L"alpha.EXE", L"A & B.exe", L"readme.txt", L"shortcut.lnk"})
|
||||
std::ofstream(root / L"tools" / name).put('x');
|
||||
std::ofstream(root / L"tools" / L"nested" / L"hidden.exe").put('x');
|
||||
auto found = ks::discoverUtilities(root / L"tools", error);
|
||||
if (error || found.size() != 3 || found[0].filename() != L"A & B.exe" ||
|
||||
found[1].filename() != L"alpha.EXE" || found[2].filename() != L"Zed.exe")
|
||||
throw std::runtime_error("Scan must filter direct executable files and sort names");
|
||||
if (ks::utilityMenuLabel(found[0]) != L"A && B")
|
||||
throw std::runtime_error("Ampersand must be displayed literally");
|
||||
fs::remove(found[1]);
|
||||
if (ks::discoverUtilities(root / L"tools", error).size() != 2)
|
||||
throw std::runtime_error("Scan must refresh after a file is removed");
|
||||
ks::discoverUtilities(root / L"tools" / L"readme.txt", error);
|
||||
if (!error) throw std::runtime_error("Non-directory must report an error");
|
||||
fs::remove_all(root);
|
||||
std::cout << "Utilities regression tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
std::error_code ignored;
|
||||
fs::remove_all(root, ignored);
|
||||
std::cerr << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# StayHere
|
||||
|
||||
Маленька портативна утиліта для Windows, без .NET та інсталяції.
|
||||
|
||||
У KSwitcher v0.6.46 і новіших виберіть **Utilities → StayHere** у меню трея
|
||||
або запустіть `tools/StayHere.exe` поруч із KSwitcher.exe.
|
||||
Зелена крапка в треї означає, що підтримка активності ввімкнена.
|
||||
Клацання лівою кнопкою перемикає режим; правою — відкриває меню з перемикачем і виходом.
|
||||
Після кожного запуску режим увімкнений. Автозапуск не додається.
|
||||
|
||||
Раз на 15 секунд перевіряється час бездіяльності. Після 3 хвилин без вводу
|
||||
надсилається натискання й відпускання F15. Поки ви працюєте, клавіші не надсилаються.
|
||||
Курсор миші не рухається. Увімкнений режим також утримує екран і систему від
|
||||
автоматичного сну. На заблокованому екрані та у відключеному сеансі активність
|
||||
не імітується. Ручне блокування Windows не обходиться.
|
||||
|
||||
Механізм перенесено за поведінкою з Typlo. Він підтримує системну активність,
|
||||
але не керує статусом Teams через API: статус залежить також від Teams,
|
||||
календаря, інших пристроїв та політик організації. F15 може мати власне
|
||||
призначення в окремих програмах. На підвищених вікнах Windows може блокувати ввід;
|
||||
тоді підказка іконки показує помилку. Утиліта не запитує права адміністратора.
|
||||
|
||||
## Збірка
|
||||
|
||||
У репозиторії KSwitcher виконайте `./build.ps1 -Stage` з кореня проєкту.
|
||||
Утиліта збирається з `utilities/StayHere/main.cpp` у `build/staged/tools/StayHere.exe`
|
||||
і включається пакувальником у реліз. CMake також має ціль `StayHere`.
|
||||
Для запуску достатньо одного `StayHere.exe`; .NET не потрібний.
|
||||
|
||||
CLI: `--pause`, `--resume`, `--exit` керують запущеним екземпляром.
|
||||
`--status` повертає код 1 для активного режиму, 2 для паузи, 3 якщо утиліта не запущена.
|
||||
@@ -0,0 +1,238 @@
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <wtsapi32.h>
|
||||
#include <cwchar>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
#ifdef STAYHERE_TEST_INSTANCE
|
||||
constexpr wchar_t ClassName[] = L"StayHere.Test.Tray.1";
|
||||
constexpr wchar_t MutexName[] = L"Local\\StayHere.Test.Tray.1";
|
||||
#else
|
||||
constexpr wchar_t ClassName[] = L"StayHere.Tray.1";
|
||||
constexpr wchar_t MutexName[] = L"Local\\StayHere.Tray.1";
|
||||
#endif
|
||||
constexpr UINT TrayMessage = WM_APP + 1;
|
||||
constexpr UINT ControlMessage = WM_APP + 2;
|
||||
constexpr UINT Toggle = 1, Quit = 2, Pause = 3, Resume = 4, Status = 5;
|
||||
constexpr DWORD IdleThreshold = 180000;
|
||||
bool enabled = true;
|
||||
bool sessionLocked = false;
|
||||
bool powerOk = true;
|
||||
bool inputOk = true;
|
||||
UINT taskbarCreated;
|
||||
HICON activeIcon, pausedIcon;
|
||||
NOTIFYICONDATAW tray{};
|
||||
|
||||
constexpr bool shouldSignal(bool active, bool unlocked, DWORD now, DWORD lastInput) {
|
||||
return active && unlocked && DWORD(now - lastInput) >= IdleThreshold;
|
||||
}
|
||||
static_assert(!shouldSignal(true, true, 179999, 0));
|
||||
static_assert(shouldSignal(true, true, 180000, 0));
|
||||
static_assert(!shouldSignal(false, true, 180000, 0));
|
||||
static_assert(!shouldSignal(true, false, 180000, 0));
|
||||
static_assert(shouldSignal(true, true, 179000, 0xfffffc18)); // tick counter wrap
|
||||
|
||||
bool interactiveDesktop() {
|
||||
if (sessionLocked) return false;
|
||||
HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||
if (!desktop) return false;
|
||||
wchar_t name[128]{};
|
||||
DWORD needed = 0;
|
||||
bool ok = GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &needed)
|
||||
&& _wcsicmp(name, L"Default") == 0;
|
||||
CloseDesktop(desktop);
|
||||
if (!ok) return false;
|
||||
DWORD session;
|
||||
LPWSTR buffer = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) return false;
|
||||
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, WTSConnectState, &buffer, &bytes)) return false;
|
||||
ok = bytes >= sizeof(WTS_CONNECTSTATE_CLASS)
|
||||
&& *reinterpret_cast<WTS_CONNECTSTATE_CLASS*>(buffer) == WTSActive;
|
||||
WTSFreeMemory(buffer);
|
||||
return ok;
|
||||
}
|
||||
|
||||
HICON makeIcon(COLORREF color) {
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC dc = CreateCompatibleDC(screen);
|
||||
HBITMAP bitmap = CreateCompatibleBitmap(screen, 32, 32);
|
||||
HGDIOBJ previous = SelectObject(dc, bitmap);
|
||||
RECT rect{0, 0, 32, 32};
|
||||
FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
|
||||
HBRUSH brush = CreateSolidBrush(color);
|
||||
HGDIOBJ oldBrush = SelectObject(dc, brush);
|
||||
HGDIOBJ oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||
Ellipse(dc, 3, 3, 29, 29);
|
||||
SelectObject(dc, oldBrush);
|
||||
SelectObject(dc, oldPen);
|
||||
DeleteObject(brush);
|
||||
SelectObject(dc, previous);
|
||||
HBITMAP mask = CreateBitmap(32, 32, 1, 1, nullptr);
|
||||
HGDIOBJ maskPrevious = SelectObject(dc, mask);
|
||||
PatBlt(dc, 0, 0, 32, 32, WHITENESS);
|
||||
oldBrush = SelectObject(dc, GetStockObject(BLACK_BRUSH));
|
||||
oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||
Ellipse(dc, 3, 3, 29, 29);
|
||||
SelectObject(dc, oldPen);
|
||||
SelectObject(dc, oldBrush);
|
||||
SelectObject(dc, maskPrevious);
|
||||
ICONINFO info{TRUE, 0, 0, mask, bitmap};
|
||||
HICON icon = CreateIconIndirect(&info);
|
||||
DeleteObject(bitmap);
|
||||
DeleteObject(mask);
|
||||
DeleteDC(dc);
|
||||
ReleaseDC(nullptr, screen);
|
||||
return icon;
|
||||
}
|
||||
|
||||
void updateTray() {
|
||||
tray.hIcon = enabled ? activeIcon : pausedIcon;
|
||||
const wchar_t* tip = !enabled ? L"StayHere — призупинено"
|
||||
: !powerOk ? L"StayHere — помилка утримання від сну"
|
||||
: !inputOk ? L"StayHere — не вдалося надіслати F15"
|
||||
: L"StayHere — активно (F15 після 3 хв простою)";
|
||||
lstrcpynW(tray.szTip, tip, ARRAYSIZE(tray.szTip));
|
||||
Shell_NotifyIconW(NIM_MODIFY, &tray);
|
||||
}
|
||||
|
||||
void setEnabled(bool value) {
|
||||
enabled = value;
|
||||
inputOk = true;
|
||||
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(enabled && interactiveDesktop() ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||
updateTray();
|
||||
}
|
||||
|
||||
void tick() {
|
||||
const bool unlocked = interactiveDesktop();
|
||||
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(enabled && unlocked ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||
LASTINPUTINFO idle{sizeof(idle), 0};
|
||||
if (GetLastInputInfo(&idle) && shouldSignal(enabled, unlocked, GetTickCount(), idle.dwTime)) {
|
||||
// Avoid inserting a key into a chord being held while the user is away.
|
||||
for (int key : {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN,
|
||||
VK_LBUTTON, VK_RBUTTON, VK_MBUTTON}) {
|
||||
if (GetAsyncKeyState(key) & 0x8000) return;
|
||||
}
|
||||
INPUT input[2]{};
|
||||
input[0].type = input[1].type = INPUT_KEYBOARD;
|
||||
input[0].ki.wVk = input[1].ki.wVk = VK_F15;
|
||||
input[1].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
inputOk = SendInput(2, input, sizeof(INPUT)) == 2;
|
||||
if (!inputOk) { // If only key-down succeeded, make a best effort to release it.
|
||||
SendInput(1, &input[1], sizeof(INPUT));
|
||||
}
|
||||
}
|
||||
updateTray();
|
||||
}
|
||||
|
||||
void showMenu(HWND window) {
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING | (enabled ? MF_CHECKED : 0), Toggle, L"Підтримувати активність");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, Quit, L"Вийти");
|
||||
POINT pos;
|
||||
GetCursorPos(&pos);
|
||||
SetForegroundWindow(window);
|
||||
UINT command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pos.x, pos.y, 0, window, nullptr);
|
||||
DestroyMenu(menu);
|
||||
if (command) SendMessageW(window, ControlMessage, command, 0);
|
||||
PostMessageW(window, WM_NULL, 0, 0);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if (message == taskbarCreated) {
|
||||
Shell_NotifyIconW(NIM_ADD, &tray);
|
||||
return 0;
|
||||
}
|
||||
switch (message) {
|
||||
case TrayMessage:
|
||||
if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showMenu(window);
|
||||
else if (lp == WM_LBUTTONUP) setEnabled(!enabled);
|
||||
return 0;
|
||||
case ControlMessage:
|
||||
if (wp == Status) return enabled ? 1 : 2;
|
||||
if (wp == Toggle) setEnabled(!enabled);
|
||||
else if (wp == Pause) setEnabled(false);
|
||||
else if (wp == Resume) setEnabled(true);
|
||||
else if (wp == Quit) DestroyWindow(window);
|
||||
return 0;
|
||||
case WM_TIMER: tick(); return 0;
|
||||
case WM_WTSSESSION_CHANGE:
|
||||
if (wp == WTS_SESSION_LOCK || wp == WTS_REMOTE_DISCONNECT || wp == WTS_CONSOLE_DISCONNECT)
|
||||
sessionLocked = true;
|
||||
else if (wp == WTS_SESSION_UNLOCK || wp == WTS_REMOTE_CONNECT || wp == WTS_CONSOLE_CONNECT)
|
||||
sessionLocked = false;
|
||||
setEnabled(enabled);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
KillTimer(window, 1);
|
||||
WTSUnRegisterSessionNotification(window);
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
Shell_NotifyIconW(NIM_DELETE, &tray);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wp, lp);
|
||||
}
|
||||
}
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) {
|
||||
int argc{};
|
||||
auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||
if(!argv)return 10;
|
||||
const std::wstring argument=argc==2?argv[1]:L"";
|
||||
LocalFree(argv);
|
||||
if(argc>2)return 10;
|
||||
const auto commandLine=argument.c_str();
|
||||
UINT command = 0;
|
||||
if (wcscmp(commandLine, L"--pause") == 0) command = Pause;
|
||||
else if (wcscmp(commandLine, L"--resume") == 0) command = Resume;
|
||||
else if (wcscmp(commandLine, L"--exit") == 0) command = Quit;
|
||||
else if (wcscmp(commandLine, L"--status") == 0) command = Status;
|
||||
else if (*commandLine) return 10;
|
||||
HWND existing = FindWindowW(ClassName, nullptr);
|
||||
if (command) {
|
||||
if (!existing) return 3;
|
||||
DWORD_PTR result = 0;
|
||||
if (!SendMessageTimeoutW(existing, ControlMessage, command, 0, SMTO_ABORTIFHUNG, 2000, &result)) return 4;
|
||||
return command == Status ? static_cast<int>(result) : 0;
|
||||
}
|
||||
HANDLE mutex = CreateMutexW(nullptr, FALSE, MutexName);
|
||||
if (!mutex) return 5;
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) { CloseHandle(mutex); return 0; }
|
||||
taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated");
|
||||
activeIcon = makeIcon(RGB(40, 185, 100));
|
||||
pausedIcon = makeIcon(RGB(135, 140, 150));
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = windowProc;
|
||||
wc.hInstance = instance;
|
||||
wc.lpszClassName = ClassName;
|
||||
if (!RegisterClassW(&wc)) return 6;
|
||||
HWND window = CreateWindowExW(0, ClassName, L"StayHere", WS_OVERLAPPED,
|
||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||
if (!window) return 7;
|
||||
tray.cbSize = sizeof(tray);
|
||||
tray.hWnd = window;
|
||||
tray.uID = 1;
|
||||
tray.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
tray.uCallbackMessage = TrayMessage;
|
||||
tray.hIcon = activeIcon;
|
||||
lstrcpyW(tray.szTip, L"StayHere");
|
||||
if (!Shell_NotifyIconW(NIM_ADD, &tray)) { DestroyWindow(window); return 8; }
|
||||
WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION);
|
||||
setEnabled(true);
|
||||
if (!SetTimer(window, 1, 15000, nullptr)) { DestroyWindow(window); return 9; }
|
||||
MSG msg{};
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
DestroyIcon(activeIcon);
|
||||
DestroyIcon(pausedIcon);
|
||||
CloseHandle(mutex);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user