diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b5d557..d1832c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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$<$: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 "$") add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests) add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests) diff --git a/README.md b/README.md index 170138c..0d17150 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index eb8e451..9ae7d58 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 3de106e..e756655 100644 --- a/Release/README.md +++ b/Release/README.md @@ -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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 6f90885..04afcd9 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -94b4a3e86c218a42ef4fe0c75c1a621af62e1dfd6b395eea2040d6c1e7f138a8 KSwitcher-win-x64.zip +e6d26d59b18559ad226359b35cd403c68326384f8e1918d31b368cbd1110f6b9 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index 57da305..3b0f1a7 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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 diff --git a/package.ps1 b/package.ps1 index 319fdda..0546be0 100644 --- a/package.ps1 +++ b/package.ps1 @@ -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()} diff --git a/resources/app.rc b/resources/app.rc index 3f02783..d2188a8 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -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" diff --git a/resources/portable-tools-README.txt b/resources/portable-tools-README.txt index d6670d9..53b59fe 100644 --- a/resources/portable-tools-README.txt +++ b/resources/portable-tools-README.txt @@ -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. diff --git a/src/update.h b/src/update.h index 8f12a58..3156a8a 100644 --- a/src/update.h +++ b/src/update.h @@ -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; diff --git a/tests/far_snippets.cpp b/tests/far_snippets.cpp index adacdd3..55fddaa 100644 --- a/tests/far_snippets.cpp +++ b/tests/far_snippets.cpp @@ -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); diff --git a/tests/stayhere.ps1 b/tests/stayhere.ps1 new file mode 100644 index 0000000..bf832dc --- /dev/null +++ b/tests/stayhere.ps1 @@ -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() +} diff --git a/tests/update.ps1 b/tests/update.ps1 index d7b64ff..d764487 100644 --- a/tests/update.ps1 +++ b/tests/update.ps1 @@ -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' diff --git a/utilities/StayHere/README.md b/utilities/StayHere/README.md new file mode 100644 index 0000000..19a100f --- /dev/null +++ b/utilities/StayHere/README.md @@ -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 якщо утиліта не запущена. diff --git a/utilities/StayHere/main.cpp b/utilities/StayHere/main.cpp new file mode 100644 index 0000000..0ba3f1c --- /dev/null +++ b/utilities/StayHere/main.cpp @@ -0,0 +1,238 @@ +#include +#include +#include +#include +#include +#include + +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(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(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(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; +}