Compare commits

...

1 Commits

Author SHA1 Message Date
agrynco 5de5b791bf Release v0.6.46: bundle StayHere activity utility 2026-11-05 12:50:37 +02:00
15 changed files with 369 additions and 23 deletions
+16 -1
View File
@@ -1,10 +1,24 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.45 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.46 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) 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) 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_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
@@ -127,6 +141,7 @@ else()
target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++)
endif() endif()
add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests) 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 scroll-regressions COMMAND KSwitcherScrollTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests) add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
+10 -2
View File
@@ -554,10 +554,18 @@ directory and are never started automatically. Their lifetime is independent of
An absent or empty folder displays a disabled placeholder; launch errors are reported. 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 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; installing an update creates the folder. Add your own trusted portable executables;
none are bundled or transferred by settings synchronization. Updates preserve your StayHere is bundled from v0.6.46, and settings synchronization does not transfer
utility files and may refresh the bundled README. Repository development scripts utility files. Updates preserve personal utilities and refresh bundled StayHere and
documentation. Repository development scripts
are not included in the portable tools folder. 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 ## Build
The easiest reproducible local route uses the pinned portable w64devkit toolchain: The easiest reproducible local route uses the pinned portable w64devkit toolchain:
Binary file not shown.
+9 -9
View File
@@ -1,21 +1,21 @@
# Latest Windows release # Latest Windows release
## v0.6.45 — 2026-11-05 ## v0.6.46 — 2026-11-05
## Changes ## Changes
- Fix Far Manager snippet expansion and caret placement. Far uses Alt+Shift+Arrow selection, Ctrl+Insert copy and Shift+Insert paste, with exact trigger and inserted-text verification. Cancellation restores the full temporary selection. Both visible and hidden panels are supported. - Bundle **tools/StayHere.exe** and **tools/StayHere-README.md**. Start the activity utility from **Utilities → StayHere**; it does not start automatically with KSwitcher.
- Run Far `{cursor}` and `{enter}` actions only after verified insertion. Far replacements must be nonempty, single-line and no longer than 4096 caret steps. Unsupported templates are rejected before selection; multiline clipboard pastes could execute commands before verification. Other applications retain accessibility verification. - 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.
- Add the tray **Utilities** menu. It scans trusted `.exe` files directly in `tools` beside the running KSwitcher.exe, sorted by filename and refreshed whenever the menu opens. Utilities run only when selected, use `tools` as their working directory and remain running independently. - 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.
- Include **tools/README.txt** in the release ZIP and manifest. Both extraction and automatic updates create the folder. Personal utility executables are preserved by updates; none are bundled or transferred through settings synchronization. Source-repository development scripts are excluded. - Retain all v0.6.45 Far snippet, Utilities and tools-folder changes and the v0.6.44 newest-history-selection fix.
- Retain v0.6.44's newest-item selection when reopening clipboard history, including after a screenshot. Saved search/pinned filters remain active; background refreshes preserve the selected record while browsing. - 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 ## Validation
Passed Utilities, dictionary equivalence (140,939 words), 105 sync checks, shared wheel regressions, 319 native checks, 574 input checks, screenshot UI/save/registration, 52 updater checks and updater progress UI. Updater tests cover creation of tools, preserving personal utilities and rollback. The actual ZIP was validated and installed through the unchanged v0.6.44 updater; all installed manifest hashes matched, tools/README.txt was created, and a staging-only utility was excluded from the ZIP. An 18-check real-Far integration run passed during development (visible/hidden panels, caret restoration, cursor markers and verified Enter). The final release-build Far run was canceled by an unrelated foreground change before expansion; an uninterrupted final-build desktop run remains outstanding. 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 ## 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. 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/`, customized dictionaries and your own tools. Look for `tools` beside the executable actually running. 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. Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
+1 -1
View File
@@ -1 +1 @@
94b4a3e86c218a42ef4fe0c75c1a621af62e1dfd6b395eea2040d6c1e7f138a8 KSwitcher-win-x64.zip e6d26d59b18559ad226359b35cd403c68326384f8e1918d31b368cbd1110f6b9 KSwitcher-win-x64.zip
+10
View File
@@ -16,6 +16,12 @@ Push-Location $root
try { 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') $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') $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')) { 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" & $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" } if ($LASTEXITCODE) { throw "Compilation failed: $name" }
@@ -41,6 +47,10 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot link failed' } if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
if ($Test) { 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 & $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe
if ($LASTEXITCODE) { throw 'Utilities test build failed' } if ($LASTEXITCODE) { throw 'Utilities test build failed' }
& .\build\KSwitcherUtilitiesTests.exe & .\build\KSwitcherUtilitiesTests.exe
+3 -1
View File
@@ -12,8 +12,10 @@ Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null 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 '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. # 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','tools\README.txt') | 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) { $entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) { 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()} @{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,45,0 FILEVERSION 0,6,46,0
PRODUCTVERSION 0,6,45,0 PRODUCTVERSION 0,6,46,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.45" VALUE "FileVersion", "0.6.46"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+8 -3
View File
@@ -8,6 +8,11 @@ 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 are not listed. Utilities start only when you choose them, with this folder
as their working directory. Closing KSwitcher does not close a utility. as their working directory. Closing KSwitcher does not close a utility.
No utility executables are bundled. Supply them separately on each computer. StayHere.exe is bundled. Choose Utilities > StayHere to start it; it starts
Normal KSwitcher updates preserve your own files in this folder; this README enabled, prevents automatic sleep, and sends F15 after 3 minutes of inactivity.
may be refreshed. Utilities are not transferred by settings synchronization. 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.
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.45"; inline constexpr wchar_t AppVersion[] = L"0.6.46";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+6 -2
View File
@@ -50,9 +50,13 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) {
const auto programFiles=_wgetenv(L"ProgramFiles"); const auto programFiles=_wgetenv(L"ProgramFiles");
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable"); if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe"; 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\" \""+folder.wstring()+L"\""; 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; STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,folder.c_str(),&startup,&child),"start isolated Far without plugins/macros"); 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); CloseHandle(child.hThread);Sleep(1800);
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation"); 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); input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
+29
View File
@@ -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()
}
+4
View File
@@ -52,6 +52,7 @@ try {
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary') [IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
[IO.Directory]::CreateDirectory((Join-Path $source 'tools')) | Out-Null [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/README.txt'),'portable utilities instructions')
[IO.File]::WriteAllText((Join-Path $source 'tools/StayHere.exe'),'bundled StayHere fixture')
function Write-TestManifest($Folder) { function Write-TestManifest($Folder) {
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object { $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} @{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
@@ -105,6 +106,7 @@ try {
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null [IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'tools')) | 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/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 'data/settings.json'),'personal settings')
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary') [IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture') [IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
@@ -118,10 +120,12 @@ try {
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved' 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/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/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' Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed 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 '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/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 '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.Config.exe'))) 'rollback removes newly introduced configurator'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor' Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
+31
View File
@@ -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 якщо утиліта не запущена.
+238
View File
@@ -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;
}