Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de5b791bf |
+16
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
94b4a3e86c218a42ef4fe0c75c1a621af62e1dfd6b395eea2040d6c1e7f138a8 KSwitcher-win-x64.zip
|
||||
e6d26d59b18559ad226359b35cd403c68326384f8e1918d31b368cbd1110f6b9 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# StayHere
|
||||
|
||||
Маленька портативна утиліта для Windows, без .NET та інсталяції.
|
||||
|
||||
У KSwitcher v0.6.46 і новіших виберіть **Utilities → StayHere** у меню трея
|
||||
або запустіть `tools/StayHere.exe` поруч із KSwitcher.exe.
|
||||
Зелена крапка в треї означає, що підтримка активності ввімкнена.
|
||||
Клацання лівою кнопкою перемикає режим; правою — відкриває меню з перемикачем і виходом.
|
||||
Після кожного запуску режим увімкнений. Автозапуск не додається.
|
||||
|
||||
Раз на 15 секунд перевіряється час бездіяльності. Після 3 хвилин без вводу
|
||||
надсилається натискання й відпускання F15. Поки ви працюєте, клавіші не надсилаються.
|
||||
Курсор миші не рухається. Увімкнений режим також утримує екран і систему від
|
||||
автоматичного сну. На заблокованому екрані та у відключеному сеансі активність
|
||||
не імітується. Ручне блокування Windows не обходиться.
|
||||
|
||||
Механізм перенесено за поведінкою з Typlo. Він підтримує системну активність,
|
||||
але не керує статусом Teams через API: статус залежить також від Teams,
|
||||
календаря, інших пристроїв та політик організації. F15 може мати власне
|
||||
призначення в окремих програмах. На підвищених вікнах Windows може блокувати ввід;
|
||||
тоді підказка іконки показує помилку. Утиліта не запитує права адміністратора.
|
||||
|
||||
## Збірка
|
||||
|
||||
У репозиторії KSwitcher виконайте `./build.ps1 -Stage` з кореня проєкту.
|
||||
Утиліта збирається з `utilities/StayHere/main.cpp` у `build/staged/tools/StayHere.exe`
|
||||
і включається пакувальником у реліз. CMake також має ціль `StayHere`.
|
||||
Для запуску достатньо одного `StayHere.exe`; .NET не потрібний.
|
||||
|
||||
CLI: `--pause`, `--resume`, `--exit` керують запущеним екземпляром.
|
||||
`--status` повертає код 1 для активного режиму, 2 для паузи, 3 якщо утиліта не запущена.
|
||||
@@ -0,0 +1,238 @@
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <wtsapi32.h>
|
||||
#include <cwchar>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
#ifdef STAYHERE_TEST_INSTANCE
|
||||
constexpr wchar_t ClassName[] = L"StayHere.Test.Tray.1";
|
||||
constexpr wchar_t MutexName[] = L"Local\\StayHere.Test.Tray.1";
|
||||
#else
|
||||
constexpr wchar_t ClassName[] = L"StayHere.Tray.1";
|
||||
constexpr wchar_t MutexName[] = L"Local\\StayHere.Tray.1";
|
||||
#endif
|
||||
constexpr UINT TrayMessage = WM_APP + 1;
|
||||
constexpr UINT ControlMessage = WM_APP + 2;
|
||||
constexpr UINT Toggle = 1, Quit = 2, Pause = 3, Resume = 4, Status = 5;
|
||||
constexpr DWORD IdleThreshold = 180000;
|
||||
bool enabled = true;
|
||||
bool sessionLocked = false;
|
||||
bool powerOk = true;
|
||||
bool inputOk = true;
|
||||
UINT taskbarCreated;
|
||||
HICON activeIcon, pausedIcon;
|
||||
NOTIFYICONDATAW tray{};
|
||||
|
||||
constexpr bool shouldSignal(bool active, bool unlocked, DWORD now, DWORD lastInput) {
|
||||
return active && unlocked && DWORD(now - lastInput) >= IdleThreshold;
|
||||
}
|
||||
static_assert(!shouldSignal(true, true, 179999, 0));
|
||||
static_assert(shouldSignal(true, true, 180000, 0));
|
||||
static_assert(!shouldSignal(false, true, 180000, 0));
|
||||
static_assert(!shouldSignal(true, false, 180000, 0));
|
||||
static_assert(shouldSignal(true, true, 179000, 0xfffffc18)); // tick counter wrap
|
||||
|
||||
bool interactiveDesktop() {
|
||||
if (sessionLocked) return false;
|
||||
HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||
if (!desktop) return false;
|
||||
wchar_t name[128]{};
|
||||
DWORD needed = 0;
|
||||
bool ok = GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &needed)
|
||||
&& _wcsicmp(name, L"Default") == 0;
|
||||
CloseDesktop(desktop);
|
||||
if (!ok) return false;
|
||||
DWORD session;
|
||||
LPWSTR buffer = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) return false;
|
||||
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, WTSConnectState, &buffer, &bytes)) return false;
|
||||
ok = bytes >= sizeof(WTS_CONNECTSTATE_CLASS)
|
||||
&& *reinterpret_cast<WTS_CONNECTSTATE_CLASS*>(buffer) == WTSActive;
|
||||
WTSFreeMemory(buffer);
|
||||
return ok;
|
||||
}
|
||||
|
||||
HICON makeIcon(COLORREF color) {
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC dc = CreateCompatibleDC(screen);
|
||||
HBITMAP bitmap = CreateCompatibleBitmap(screen, 32, 32);
|
||||
HGDIOBJ previous = SelectObject(dc, bitmap);
|
||||
RECT rect{0, 0, 32, 32};
|
||||
FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
|
||||
HBRUSH brush = CreateSolidBrush(color);
|
||||
HGDIOBJ oldBrush = SelectObject(dc, brush);
|
||||
HGDIOBJ oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||
Ellipse(dc, 3, 3, 29, 29);
|
||||
SelectObject(dc, oldBrush);
|
||||
SelectObject(dc, oldPen);
|
||||
DeleteObject(brush);
|
||||
SelectObject(dc, previous);
|
||||
HBITMAP mask = CreateBitmap(32, 32, 1, 1, nullptr);
|
||||
HGDIOBJ maskPrevious = SelectObject(dc, mask);
|
||||
PatBlt(dc, 0, 0, 32, 32, WHITENESS);
|
||||
oldBrush = SelectObject(dc, GetStockObject(BLACK_BRUSH));
|
||||
oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||
Ellipse(dc, 3, 3, 29, 29);
|
||||
SelectObject(dc, oldPen);
|
||||
SelectObject(dc, oldBrush);
|
||||
SelectObject(dc, maskPrevious);
|
||||
ICONINFO info{TRUE, 0, 0, mask, bitmap};
|
||||
HICON icon = CreateIconIndirect(&info);
|
||||
DeleteObject(bitmap);
|
||||
DeleteObject(mask);
|
||||
DeleteDC(dc);
|
||||
ReleaseDC(nullptr, screen);
|
||||
return icon;
|
||||
}
|
||||
|
||||
void updateTray() {
|
||||
tray.hIcon = enabled ? activeIcon : pausedIcon;
|
||||
const wchar_t* tip = !enabled ? L"StayHere — призупинено"
|
||||
: !powerOk ? L"StayHere — помилка утримання від сну"
|
||||
: !inputOk ? L"StayHere — не вдалося надіслати F15"
|
||||
: L"StayHere — активно (F15 після 3 хв простою)";
|
||||
lstrcpynW(tray.szTip, tip, ARRAYSIZE(tray.szTip));
|
||||
Shell_NotifyIconW(NIM_MODIFY, &tray);
|
||||
}
|
||||
|
||||
void setEnabled(bool value) {
|
||||
enabled = value;
|
||||
inputOk = true;
|
||||
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(enabled && interactiveDesktop() ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||
updateTray();
|
||||
}
|
||||
|
||||
void tick() {
|
||||
const bool unlocked = interactiveDesktop();
|
||||
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||
(enabled && unlocked ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||
LASTINPUTINFO idle{sizeof(idle), 0};
|
||||
if (GetLastInputInfo(&idle) && shouldSignal(enabled, unlocked, GetTickCount(), idle.dwTime)) {
|
||||
// Avoid inserting a key into a chord being held while the user is away.
|
||||
for (int key : {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN,
|
||||
VK_LBUTTON, VK_RBUTTON, VK_MBUTTON}) {
|
||||
if (GetAsyncKeyState(key) & 0x8000) return;
|
||||
}
|
||||
INPUT input[2]{};
|
||||
input[0].type = input[1].type = INPUT_KEYBOARD;
|
||||
input[0].ki.wVk = input[1].ki.wVk = VK_F15;
|
||||
input[1].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
inputOk = SendInput(2, input, sizeof(INPUT)) == 2;
|
||||
if (!inputOk) { // If only key-down succeeded, make a best effort to release it.
|
||||
SendInput(1, &input[1], sizeof(INPUT));
|
||||
}
|
||||
}
|
||||
updateTray();
|
||||
}
|
||||
|
||||
void showMenu(HWND window) {
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING | (enabled ? MF_CHECKED : 0), Toggle, L"Підтримувати активність");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, Quit, L"Вийти");
|
||||
POINT pos;
|
||||
GetCursorPos(&pos);
|
||||
SetForegroundWindow(window);
|
||||
UINT command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pos.x, pos.y, 0, window, nullptr);
|
||||
DestroyMenu(menu);
|
||||
if (command) SendMessageW(window, ControlMessage, command, 0);
|
||||
PostMessageW(window, WM_NULL, 0, 0);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||
if (message == taskbarCreated) {
|
||||
Shell_NotifyIconW(NIM_ADD, &tray);
|
||||
return 0;
|
||||
}
|
||||
switch (message) {
|
||||
case TrayMessage:
|
||||
if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showMenu(window);
|
||||
else if (lp == WM_LBUTTONUP) setEnabled(!enabled);
|
||||
return 0;
|
||||
case ControlMessage:
|
||||
if (wp == Status) return enabled ? 1 : 2;
|
||||
if (wp == Toggle) setEnabled(!enabled);
|
||||
else if (wp == Pause) setEnabled(false);
|
||||
else if (wp == Resume) setEnabled(true);
|
||||
else if (wp == Quit) DestroyWindow(window);
|
||||
return 0;
|
||||
case WM_TIMER: tick(); return 0;
|
||||
case WM_WTSSESSION_CHANGE:
|
||||
if (wp == WTS_SESSION_LOCK || wp == WTS_REMOTE_DISCONNECT || wp == WTS_CONSOLE_DISCONNECT)
|
||||
sessionLocked = true;
|
||||
else if (wp == WTS_SESSION_UNLOCK || wp == WTS_REMOTE_CONNECT || wp == WTS_CONSOLE_CONNECT)
|
||||
sessionLocked = false;
|
||||
setEnabled(enabled);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
KillTimer(window, 1);
|
||||
WTSUnRegisterSessionNotification(window);
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
Shell_NotifyIconW(NIM_DELETE, &tray);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wp, lp);
|
||||
}
|
||||
}
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) {
|
||||
int argc{};
|
||||
auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||
if(!argv)return 10;
|
||||
const std::wstring argument=argc==2?argv[1]:L"";
|
||||
LocalFree(argv);
|
||||
if(argc>2)return 10;
|
||||
const auto commandLine=argument.c_str();
|
||||
UINT command = 0;
|
||||
if (wcscmp(commandLine, L"--pause") == 0) command = Pause;
|
||||
else if (wcscmp(commandLine, L"--resume") == 0) command = Resume;
|
||||
else if (wcscmp(commandLine, L"--exit") == 0) command = Quit;
|
||||
else if (wcscmp(commandLine, L"--status") == 0) command = Status;
|
||||
else if (*commandLine) return 10;
|
||||
HWND existing = FindWindowW(ClassName, nullptr);
|
||||
if (command) {
|
||||
if (!existing) return 3;
|
||||
DWORD_PTR result = 0;
|
||||
if (!SendMessageTimeoutW(existing, ControlMessage, command, 0, SMTO_ABORTIFHUNG, 2000, &result)) return 4;
|
||||
return command == Status ? static_cast<int>(result) : 0;
|
||||
}
|
||||
HANDLE mutex = CreateMutexW(nullptr, FALSE, MutexName);
|
||||
if (!mutex) return 5;
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) { CloseHandle(mutex); return 0; }
|
||||
taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated");
|
||||
activeIcon = makeIcon(RGB(40, 185, 100));
|
||||
pausedIcon = makeIcon(RGB(135, 140, 150));
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = windowProc;
|
||||
wc.hInstance = instance;
|
||||
wc.lpszClassName = ClassName;
|
||||
if (!RegisterClassW(&wc)) return 6;
|
||||
HWND window = CreateWindowExW(0, ClassName, L"StayHere", WS_OVERLAPPED,
|
||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||
if (!window) return 7;
|
||||
tray.cbSize = sizeof(tray);
|
||||
tray.hWnd = window;
|
||||
tray.uID = 1;
|
||||
tray.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
tray.uCallbackMessage = TrayMessage;
|
||||
tray.hIcon = activeIcon;
|
||||
lstrcpyW(tray.szTip, L"StayHere");
|
||||
if (!Shell_NotifyIconW(NIM_ADD, &tray)) { DestroyWindow(window); return 8; }
|
||||
WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION);
|
||||
setEnabled(true);
|
||||
if (!SetTimer(window, 1, 15000, nullptr)) { DestroyWindow(window); return 9; }
|
||||
MSG msg{};
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
DestroyIcon(activeIcon);
|
||||
DestroyIcon(pausedIcon);
|
||||
CloseHandle(mutex);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user