Release v0.6.46: bundle StayHere activity utility

This commit is contained in:
agrynco committed 2026-11-05 12:50:37 +02:00
1 parent c109408a98
commit 5de5b791bf
15 files changed
+369 -23

No files matched your search

+16 -1
View File
@@ -1,10 +1,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)
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)
@@ -127,6 +141,7 @@ 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)
+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.
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;
none are bundled or transferred by settings synchronization. Updates preserve your
utility files and may refresh the bundled README. Repository development scripts
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:
Binary file not shown.
+9 -9
View File
@@ -1,21 +1,21 @@
# Latest Windows release
## v0.6.45 — 2026-11-05
## v0.6.46 — 2026-11-05
## 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.
- 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.
- 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 **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 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.
- 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 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
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.
+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 {
$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,10 @@ 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
+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
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','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) {
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()}
+3 -3
View File
@@ -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,45,0
PRODUCTVERSION 0,6,45,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.45"
VALUE "FileVersion", "0.6.46"
END
END
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
as their working directory. Closing KSwitcher does not close a utility.
No utility executables are bundled. Supply them separately on each computer.
Normal KSwitcher updates preserve your own files in this folder; this README
may be refreshed. Utilities are not transferred by settings synchronization.
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.
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
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 UpdateCheckIntervalMs = 6 * 60 * 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");
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\" \""+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;
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);
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);
+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.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}
@@ -105,6 +106,7 @@ try {
[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')
@@ -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 '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'
+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;
}