Compare commits

..
21 changed files with 402 additions and 37 deletions

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.47 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)
+16 -4
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:
@@ -591,8 +599,10 @@ default and require the main **Automatic correction (experimental)** checkbox to
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
are rejected. Each side must contain 1–32 letters of the same supported language, without
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
are rejected. The typo must contain 1–32 letters. The correction may contain several words
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
spaces. Both sides must use the same supported language, without punctuation; leading,
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
already valid words, and do not require dictionary membership or satisfy the frequency limit.
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
after no layout correction is found. It accepts exactly one dictionary candidate reachable
@@ -608,6 +618,8 @@ ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped.
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
are included in settings export/import and synchronization. Undo restores the original word
and suppresses its correction until restart.
When synchronizing phrase replacements, update all participating PCs to v0.6.47 or newer;
older clients reject spaces in replacement pairs.
### Layout correction and shared options
Binary file not shown.
+10 -9
View File
@@ -1,21 +1,22 @@
# Latest Windows release
## v0.6.45 — 2026-11-05
## v0.6.47 — 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.
- Custom auto-correction replacements now accept several words on the correction side, for example `мамамила=мама мила`. The source remains a single word of 1–32 letters; the correction supports up to 32 characters including single spaces between words, in the same supported language.
- Preserve initial capitalization, surrounding punctuation, the current layout and Undo when splitting joined words. Subsequent typing is preserved. Updated validation messages and the options hint explain support for spaces.
- Enable **Automatic correction (experimental)** and **Use custom replacements**, enter one `typo=correction` pair per line, then click **OK → Save**. Existing language, minimum-length and exclusion settings still apply; defaults are unchanged.
- Retain bundled StayHere and all earlier fixes. The tray menu remains **Utilities → StayHere**.
## 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 332 native regression checks, 574 input/settings checks and focused auto-correction integration checks, including phrase replacement, capitalization/punctuation, Undo and immediate subsequent typing. Also passed 105 synchronization checks and 54 updater regression checks. Release binaries, package manifest and SHA-256 checksums are verified; published assets are read back before completion.
## 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. Keep the three KSwitcher executables together; StayHere runs independently.
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.
When synchronizing phrase replacements across PCs, update each participating PC to v0.6.47 or newer: older versions reject spaces in replacement pairs.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. 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
c15371f89ea86410267d96c909739b44c207cd76256cd29dc7b5f40b04ac7ffb 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,47,0
PRODUCTVERSION 0,6,47,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.47"
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
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
replacement->second.find_first_not_of(alphabet) == small.npos)
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
+4 -2
View File
@@ -118,9 +118,11 @@ void validateSettings(const Settings &s)
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
from == lower(from) && to == lower(to) && from != to &&
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
from.find_first_not_of(alphabet) == from.npos &&
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
if (!valid)
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between correction words are allowed.");
}
for (const auto &[language, words] : s.autoWords)
{
+1 -1
View File
@@ -1285,7 +1285,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
check(w, 414, v->draft.autoReplace);
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
label(w, L"One pair per line: typo=correction (same language; correction may contain spaces)", 18, 592, 525);
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
std::wstring pairs;
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
+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.47";
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);
+2 -2
View File
@@ -901,7 +901,7 @@ int main(int argc, char **argv) {
"personal dictionary field fits options window");
}
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello\r\n Мамамила = Мама мила ");
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
@@ -923,7 +923,7 @@ int main(int argc, char **argv) {
"personal dictionary normalizes case, newlines and duplicate words");
wchar_t pairs[128]{};
GetDlgItemTextW(autoOptions,416,pairs,128);
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
require(std::wstring(pairs)==L"helo=hello\r\nмамамила=мама мила\r\nпрвиіт=привіт\r\n" &&
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
"typo options and normalized replacement pairs survive reopening");
+4 -4
View File
@@ -802,14 +802,14 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case 22:
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
app->settings.autoReplace = true;
app->settings.autoReplacements = {{L"helo", L"hello"}};
app->settings.autoReplacements = {{L"helo", L"hello world"}};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"(Helo)! ", false);
record(L"(Helo)! ");
app->transform(ks::AutoCorrect);
break;
case 23:
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
require(text() == L"(Hello world)! ", "explicit phrase pair replaces verified text with punctuation and capital");
app->transform(ks::Undo);
break;
case 24:
@@ -835,7 +835,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case 27:
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
app->settings.autoReplace = true;
app->settings.autoReplacements = {{L"tehh", L"the"}};
app->settings.autoReplacements = {{L"tehh", L"the word"}};
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
prepare(L"", false);
{
@@ -852,7 +852,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
break;
case 28:
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
require(text() == L"the word x", "custom phrase replacement through keyboard hook preserves subsequent typing");
app->settings.autoReplace = false;
app->settings.autoTypos = false;
if (typosOnly) { finish(); break; }
+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()
}
+14 -2
View File
@@ -590,6 +590,12 @@ int main(int argc, char **argv)
options.autoNever = {L"hlelo"};
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
options.autoNever.clear();
options.autoReplacements[L"мамамила"] = L"мама мила";
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
"joined words split while preserving capitalization, punctuation and layout");
edited.reject(L"мамамила ", options);
require(!edited.suggest(L"мамамила ", 2, layouts, options), "undo suppresses phrase replacements");
edited.reject(L"(Hlelo)! ", options);
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
options.autoReplace = false;
@@ -711,7 +717,7 @@ int main(int argc, char **argv)
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoReplace = settings.autoTypos = true;
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}};
settings.autoMaxRank = 10000;
settings.autoMinLength = 5;
settings.autoPrefixChars = L"(#";
@@ -726,7 +732,13 @@ int main(int argc, char **argv)
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
"typo options and Unicode pairs survive settings roundtrip");
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}})
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"},
{L"helloworld", L" hello world"}, {L"helloworld", L"hello world "},
{L"helloworld", L"hello world"}, {L"helloworld", L"hello\tworld"},
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
{
auto invalid = settings;
invalid.autoReplacements = {{pair.first, pair.second}};
+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;
}