diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e9555e..5b5d557 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.44 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.45 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() @@ -30,6 +30,15 @@ target_link_libraries(KSwitcherScrollTests PRIVATE kscore) target_link_libraries(KSwitcherTests PRIVATE kscore) add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherIntegration PRIVATE kscore) +# Opt-in interactive fixture: requires an installed Far Manager. +add_executable(KSwitcherFarSnippetTests WIN32 tests/far_snippets.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +target_link_libraries(KSwitcherFarSnippetTests PRIVATE kscore) +if(MSVC) + target_compile_options(KSwitcherFarSnippetTests PRIVATE /utf-8) + set_property(TARGET KSwitcherFarSnippetTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +else() + target_link_options(KSwitcherFarSnippetTests PRIVATE -municode -static -static-libgcc -static-libstdc++) +endif() add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherInputTests PRIVATE kscore) target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES) @@ -111,6 +120,13 @@ add_test(NAME dictionary-regressions COMMAND KSwitcherDictionaryTests) set_tests_properties(dictionary-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME sync-regressions COMMAND KSwitcherSyncTests) add_test(NAME native-regressions COMMAND KSwitcherTests) +add_executable(KSwitcherUtilitiesTests tests/utilities.cpp) +if(MSVC) + set_property(TARGET KSwitcherUtilitiesTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +else() + target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++) +endif() +add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests) 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 94e64ab..170138c 100644 --- a/README.md +++ b/README.md @@ -280,9 +280,15 @@ thumbnails and source labels aligned with the scaled fonts and spacing. Clipboard synchronizers such as Mouse Without Borders may republish a copy only when KSwitcher first observed it owned by the target application and the selection text and editor selection metadata remain identical. Unobserved or altered foreign copies are rejected. - Snippets require accessibility verification of their selected trigger and pasted text, + Snippets normally require accessibility verification of their selected trigger and pasted text, even with logging disabled. If verification is unavailable or the paste differs, expansion stops without running cursor or Enter actions. Clipboard changes during paste also cancel actions. + Far Manager uses its native Alt+Shift+Arrow selection, Ctrl+Insert copy and Shift+Insert + paste commands instead: both the trigger and inserted text must match verified clipboard + copies from Far. Cancellation restores the full temporary selection, not one cursor step. + Far supports nonempty single-line replacements up to 4096 caret steps, including + `{cursor}` and verified `{enter}` actions. Empty or multiline replacements are rejected + before selection; pasting a newline could execute a command before it can be verified. - Teams recent-word conversion uses a single word-selection command and validates the copied range. If the editor's word boundary differs from the recorded typing, it falls back to paced character selection: Teams can coalesce a burst of Shift+Left events into just one character. @@ -536,6 +542,22 @@ OpenBao administrator setup: and UI integration, so another service can be added without replacing the synchronization logic. Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test. +## Portable utilities + +The tray menu's **Utilities** submenu lists `.exe` files in a `tools` folder beside +`KSwitcher.exe`, sorted by filename. For example, place `StayHere.exe` at +`tools/StayHere.exe` and choose **Utilities → StayHere** to launch it manually. +The folder is rescanned whenever the menu opens; no configuration or restart is needed. +Only files directly in `tools` are listed (not subfolders, shortcuts, or KSwitcher's +own executables beside the main program). Utilities start with `tools` as their working +directory and are never started automatically. Their lifetime is independent of KSwitcher. +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 +are not included in the portable tools folder. + ## Build The easiest reproducible local route uses the pinned portable w64devkit toolchain: @@ -659,6 +681,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr .\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard .\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR .\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds +.\build.ps1 -Far # Explicit integration test using an isolated Far Manager profile .\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first ``` diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 286822e..eb8e451 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 f7acb71..3de106e 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,19 +1,20 @@ # Latest Windows release -## v0.6.44 — 2026-11-05 +## v0.6.45 — 2026-11-05 ## Changes -- Opening clipboard history selects and reveals the newest matching entry instead of restoring an older selection. This fixes reopening history after recording a new screenshot. -- Search and the pinned-only filter remain saved. Clear the search and uncheck **★ Pinned only** if the new screenshot is hidden. -- Background clipboard/OCR refreshes preserve the selected record while browsing. Invoking the history shortcut while history is already active retains its existing quick-paste behavior. -- No shortcuts, settings, capture behavior or storage format changed. +- 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. ## Validation -Passed dictionary equivalence checks for 140,939 words, 105 sync checks, shared wheel regressions, 319 native checks, 574 input checks, screenshot editor UI/save/registration, 48 updater checks and updater progress UI checks. The history regression covers reopening after a new image and preserving an explicitly selected older record during background refresh. +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. ## 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/` and customized dictionaries. +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. 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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 07f75a6..6f90885 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -a3e8a42db787ab5b8edca698fc97943a3e0d5348c5000cc632aeb07179210469 KSwitcher-win-x64.zip +94b4a3e86c218a42ef4fe0c75c1a621af62e1dfd6b395eea2040d6c1e7f138a8 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index 911b0f6..57da305 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,4 +1,4 @@ -param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage) +param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage, [switch]$Far) $ErrorActionPreference = 'Stop' $root = $PSScriptRoot $compiler = Join-Path $root '.tools\w64devkit\bin\g++.exe' @@ -41,6 +41,10 @@ try { if ($LASTEXITCODE) { throw 'Screenshot link failed' } Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force if ($Test) { + & $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe + if ($LASTEXITCODE) { throw 'Utilities test build failed' } + & .\build\KSwitcherUtilitiesTests.exe + if ($LASTEXITCODE) { throw 'Utilities tests failed' } & $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe if ($LASTEXITCODE) { throw 'Dictionary test build failed' } & .\build\KSwitcherDictionaryTests.exe @@ -80,6 +84,13 @@ try { & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1 if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' } } + if ($Far) { + & $compiler @common tests/far_snippets.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe + if ($LASTEXITCODE) { throw 'Far snippet test build failed' } + $fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru + Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log') + if ($fixture.ExitCode) { throw 'Far snippet tests failed' } + } if ($Integration) { $historyFixture = Join-Path (Resolve-Path build) ('history-host-fixture-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory $historyFixture | Out-Null diff --git a/package.ps1 b/package.ps1 index c06f9d0..319fdda 100644 --- a/package.ps1 +++ b/package.ps1 @@ -10,8 +10,10 @@ Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination 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 # 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') | ForEach-Object { Join-Path $destination $_ } +$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries','tools\README.txt') | 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()} @@ -22,7 +24,16 @@ $manifest=Join-Path $destination 'release-files.json' @{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8 $files += $manifest $archive = Join-Path $root 'dist\KSwitcher-win-x64.zip' -Compress-Archive -LiteralPath $files -DestinationPath $archive -Force +# Archive only manifest-listed files, preserving relative paths. Never sweep up +# personal utilities or leftover developer files from the staging directory. +Add-Type -AssemblyName System.IO.Compression.FileSystem +$stream = [IO.File]::Open($archive,[IO.FileMode]::Create) +$zip = [IO.Compression.ZipArchive]::new($stream,[IO.Compression.ZipArchiveMode]::Create) +try { + foreach ($name in @($entries | ForEach-Object { $_.path }) + @('release-files.json')) { + [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,(Join-Path $destination $name),$name,[IO.Compression.CompressionLevel]::Optimal) | Out-Null + } +} finally { $zip.Dispose(); $stream.Dispose() } Get-FileHash -LiteralPath $archive -Algorithm SHA256 if ($PublishRelease) { $release = Join-Path $root 'Release' diff --git a/resources/app.rc b/resources/app.rc index c12c4b9..3f02783 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,44,0 - PRODUCTVERSION 0,6,44,0 + FILEVERSION 0,6,45,0 + PRODUCTVERSION 0,6,45,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.44" + VALUE "FileVersion", "0.6.45" END END BLOCK "VarFileInfo" diff --git a/resources/portable-tools-README.txt b/resources/portable-tools-README.txt new file mode 100644 index 0000000..d6670d9 --- /dev/null +++ b/resources/portable-tools-README.txt @@ -0,0 +1,13 @@ +KSwitcher portable utilities + +Put your trusted portable .exe utilities directly in this tools folder, +beside this README.txt. Right-click the KSwitcher tray icon and open Utilities. +For example: tools/StayHere.exe appears as Utilities > StayHere. + +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. diff --git a/src/app.cpp b/src/app.cpp index 6f7176f..99b460f 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6,6 +6,7 @@ #include "configuration.h" #include "diagnostics.h" #include "paste_probe.h" +#include "utilities.h" #include "screenshot_notifications.h" #include #include @@ -82,9 +83,12 @@ class Transaction bool trustedContentsObserved{}; std::unique_ptr selectionProof; std::wstring provenSelection; + bool farSnippet{}; + size_t farSelectedSteps{}; public: - Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v) + Transaction(App &a, HWND t, uint64_t v, bool farEditor = false) + : app(a), target(t), version(v), farSnippet(farEditor) { app.suspended = true; try @@ -119,6 +123,7 @@ class Transaction } ~Transaction() { + if (farSnippet) finishSelection(false); if (captured && GetClipboardSequenceNumber() == expected) try { @@ -200,7 +205,7 @@ class Transaction diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" + std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target) + L" sentinel=" + diagnosticText(sentinel)); - if (!valid() || !chord(L'C', MOD_CONTROL)) + if (!valid() || !chord(farSnippet ? VK_INSERT : L'C', MOD_CONTROL)) { diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked"); return {}; @@ -250,7 +255,7 @@ class Transaction check(); if (!valid() || GetClipboardSequenceNumber() != before) return {}; - if (!chord(L'C', MOD_CONTROL)) + if (!chord(farSnippet ? VK_INSERT : L'C', MOD_CONTROL)) return {}; diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) + L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started)); @@ -337,8 +342,44 @@ class Transaction bool recoveredSelection() const { return selectionProof != nullptr; } bool select(size_t n) { + if (farSnippet) + { + if (n > 4096 || farSelectedSteps) return false; + for (size_t i = 0; i < n; ++i) + { + if (!valid() || !chord(VK_LEFT, MOD_ALT | MOD_SHIFT)) return false; + ++farSelectedSteps; + } + return valid(); + } return selectPrevious(n, [&] { return valid(); }); } + bool finishSelection(bool atStart) + { + if (!farSnippet) return valid() && chord(atStart ? VK_LEFT : VK_RIGHT); + if (!farSelectedSteps) return valid(); + if (atStart) + { + // Far arrows move one character, rather than collapsing a selection + // to its edge. Move into the nonempty selected range and back. + if (!valid() || !chord(VK_RIGHT)) return false; + farSelectedSteps = 0; + return valid() && chord(VK_LEFT); + } + while (farSelectedSteps && valid()) + { + if (!chord(VK_RIGHT, MOD_ALT | MOD_SHIFT)) return false; + --farSelectedSteps; + } + return !farSelectedSteps && valid(); + } + std::optional readFarSnippet(size_t count) + { + if (!farSnippet || !count || !select(count)) return {}; + auto observed = copy(); + if (!finishSelection(false) || observed.empty()) return {}; + return observed; + } std::wstring copyRange(size_t count, const std::wstring &wordCandidate, const std::function &matches, const std::function &suffixMatches = {}) @@ -441,8 +482,11 @@ class Transaction if (!valid()) return false; } - if (!chord(L'V', MOD_CONTROL)) + if (!chord(farSnippet ? VK_INSERT : L'V', farSnippet ? MOD_SHIFT : MOD_CONTROL)) throw std::runtime_error("Windows blocked simulated input; check application privilege levels"); + // The selected trigger was replaced. Do not restore its old caret range + // if clipboard verification below subsequently rejects a concurrent copy. + if (farSnippet) farSelectedSteps = 0; Sleep(300); // A clipboard synchronizer may replace our payload after Ctrl+V is // queued but before the editor consumes it. Do not run snippet actions @@ -1294,14 +1338,16 @@ void App::transform(int command, int snippet) } // Collapse a temporary selection on every failed auto-correction, // including clipboard exceptions, before queued typing is replayed. + const bool farSnippet = snippet >= 0 && lower(processName(target)) == L"far"; bool autoSelected = false; struct SelectionReset { bool &selected; std::function valid; - ~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); } - } selectionReset{autoSelected, valid}; - Transaction tx(*this, target, version); + bool farEditor; + ~SelectionReset() { if (selected && !farEditor && valid()) chord(VK_RIGHT); } + } selectionReset{autoSelected, valid, farSnippet}; + Transaction tx(*this, target, version, farSnippet); reason = L"selection-unavailable"; diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) + L" recent=" + std::to_wstring(config.recent) + L" keys=" + @@ -1381,6 +1427,15 @@ void App::transform(int command, int snippet) const auto &sn = config.snippets[snippet]; if (recent.keys.size() < sn.trigger.size()) return; + result = parseTemplate(sn.text).text; + // A multiline clipboard paste can execute a console command + // before readback. Only explicit, verified {enter} may do so. + if (farSnippet && (result.empty() || result.find_first_of(L"\r\n") != result.npos || + caretSteps(result) > 4096)) + { + reason = L"far-snippet-template-unsupported"; + return; + } Run tail = recent; tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size()); if (!tx.select(sn.trigger.size())) @@ -1390,7 +1445,6 @@ void App::transform(int command, int snippet) text = tx.copy(); if (tail.match(text, available, physicalLayout()) < 0) return; - result = parseTemplate(sn.text).text; automatic = true; } else if (config.recent && recent.window == target && !recent.keys.empty() && !recent.overflow) @@ -1480,9 +1534,10 @@ void App::transform(int command, int snippet) } bool sent = false; const bool requireReadback = tx.recoveredSelection() || snippet >= 0; - PasteProbe probe(((config.writeLog && manual) || requireReadback) && result != text, text, target); - if (requireReadback && result != text && probe.status != L"ready") + PasteProbe probe(!farSnippet && ((config.writeLog && manual) || requireReadback) && result != text, text, target); + if (requireReadback && !farSnippet && result != text && probe.status != L"ready") { + verification = probe.status; reason = snippet >= 0 ? L"snippet-verification-unavailable" : L"recovered-selection-unavailable"; std::lock_guard lock(stateMutex); last.reset(); @@ -1494,7 +1549,7 @@ void App::transform(int command, int snippet) if (result != text) sent = tx.paste(textClip(result)); else if (automatic && tx.valid()) - chord(VK_RIGHT); + tx.finishSelection(false); if (requireReadback && result != text && !sent) { reason = snippet >= 0 ? L"snippet-paste-cancelled" : L"recovered-selection-paste-cancelled"; @@ -1517,11 +1572,12 @@ void App::transform(int command, int snippet) verification = probe.status; if (tx.valid()) { - auto observed = probe.read((int)caretSteps(result)); + auto observed = farSnippet ? tx.readFarSnippet(caretSteps(result)) : + probe.read((int)caretSteps(result)); // A native Edit may silently ignore Ctrl+V while the clipboard is busy. // Retry once only with fresh native proof that the exact original range // is still selected. Never retry an unknown or changed editor state. - if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text)) + if (!farSnippet && observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text)) { diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original"); if (tx.paste(textClip(result)) && tx.valid()) @@ -1529,7 +1585,8 @@ void App::transform(int command, int snippet) else observed.reset(); } - verification = probe.status; + verification = farSnippet ? (observed ? L"read-back-via-far-clipboard" : + L"far-readback-unavailable") : probe.status; if (observed && tx.valid()) { actual = *observed; @@ -1573,6 +1630,7 @@ void App::transform(int command, int snippet) } if (snippet >= 0 && sent && tx.valid()) { + tx.check(); auto t = parseTemplate(config.snippets[snippet].text); if (!t.suffix.empty()) { @@ -1581,15 +1639,18 @@ void App::transform(int command, int snippet) if (!tx.valid()) return; if (newlines(suffix) != newlines(t.suffix)) { - chord(VK_RIGHT); + tx.finishSelection(false); return; } - if (!chord(VK_LEFT)) return; + if (!tx.finishSelection(true)) return; } // Action markers run after insertion and cursor placement, // before any physical typing buffered during expansion. for (int i = 0; i < t.enters && tx.valid(); ++i) + { + tx.check(); if (!chord(VK_RETURN)) break; + } } if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text)) { @@ -1761,6 +1822,10 @@ void App::recognizeEntry(std::wstring id) } void App::trayMenu() { + constexpr int utilityCommandBase = 1000; + std::error_code utilitiesError; + auto utilities = discoverUtilities(std::filesystem::path(executable()).parent_path() / L"tools", utilitiesError); + if (utilities.size() > 50000) utilities.resize(50000); HMENU menu = CreatePopupMenu(); AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history"); AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…"); @@ -1768,6 +1833,13 @@ void App::trayMenu() SetMenuDefaultItem(menu, 11, FALSE); AppendMenuW(menu, MF_STRING, 12, L"S&nippets…"); AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…"); + HMENU utilityMenu = CreatePopupMenu(); + for (size_t i = 0; i < utilities.size(); ++i) + AppendMenuW(utilityMenu, MF_STRING, utilityCommandBase + i, utilityMenuLabel(utilities[i]).c_str()); + if (utilities.empty()) + AppendMenuW(utilityMenu, MF_STRING | MF_GRAYED, 0, + utilitiesError ? L"Cannot read tools folder" : L"No utilities in tools folder"); + AppendMenuW(menu, MF_POPUP, (UINT_PTR)utilityMenu, L"&Utilities"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake"); HMENU pause = CreatePopupMenu(); @@ -1794,6 +1866,20 @@ void App::trayMenu() PostMessageW(window, WM_NULL, 0, 0); try { + if (cmd >= utilityCommandBase && size_t(cmd - utilityCommandBase) < utilities.size()) + { + const auto &path = utilities[cmd - utilityCommandBase]; + auto command = L"\"" + path.wstring() + L"\""; + STARTUPINFOW startup{sizeof(startup)}; + PROCESS_INFORMATION process{}; + if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, + path.parent_path().c_str(), &startup, &process)) + throw std::runtime_error(utf8(lastError(L"Cannot start utility " + path.filename().wstring()))); + AllowSetForegroundWindow(process.dwProcessId); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return; + } switch (cmd) { case 24: diff --git a/src/update.h b/src/update.h index 99c6fad..8f12a58 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.44"; +inline constexpr wchar_t AppVersion[] = L"0.6.45"; 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/src/utilities.h b/src/utilities.h new file mode 100644 index 0000000..028cecc --- /dev/null +++ b/src/utilities.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace ks +{ +// Scan each time the menu opens, relative to the resident executable, not its working directory. +inline std::vector discoverUtilities(const std::filesystem::path &folder, + std::error_code &error) +{ + error.clear(); + std::vector result; + std::filesystem::directory_iterator it(folder, error), end; + if (error == std::errc::no_such_file_or_directory) error.clear(); + while (!error && it != end) + { + std::error_code entryError; + if (_wcsicmp(it->path().extension().c_str(), L".exe") == 0 && it->is_regular_file(entryError)) + result.push_back(it->path()); + it.increment(error); + } + if (error) result.clear(); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { + return _wcsicmp(a.filename().c_str(), b.filename().c_str()) < 0; + }); + return result; +} +inline std::wstring utilityMenuLabel(const std::filesystem::path &path) +{ + std::wstring label; + for (wchar_t ch : path.stem().wstring()) + { + label += ch; + if (ch == L'&') label += ch; // Win32 uses ampersands for access keys. + } + return label; +} +} diff --git a/tests/far_snippets.cpp b/tests/far_snippets.cpp new file mode 100644 index 0000000..adacdd3 --- /dev/null +++ b/tests/far_snippets.cpp @@ -0,0 +1,129 @@ +// Explicit Windows integration test. Opens an isolated Far profile and never +// types into an existing Far window. Requires Far Manager at its default path. +#include "app.h" +#include +#include +#include +#include + +namespace ks { +struct InputRegressionAccess { + static void runQueued(App &app) { + std::function job; + { std::lock_guard lock(app.jobsMutex); + if (app.jobs.empty()) throw std::runtime_error("snippet did not queue"); + job=std::move(app.jobs.front());app.jobs.pop_front(); } + auto future=std::async(std::launch::async,[job=std::move(job)] { + CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);job();CoUninitialize(); + }); + while(future.wait_for(std::chrono::milliseconds(5))!=std::future_status::ready) { + MSG message{};while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) { + TranslateMessage(&message);DispatchMessageW(&message); + } + } + future.get();app.replayTyping(); + } +}; +} +int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) { + auto base=std::filesystem::path(ks::executable()).parent_path(); + std::ofstream log(base/L"far-snippets.log"); + int checks=0; + auto require=[&](bool ok,const char *message) { + if(!ok)throw std::runtime_error(message); + ++checks;log<<"PASS "< +#include "../src/utilities.h" +#include +#include +#include + +int main() +{ + namespace fs = std::filesystem; + auto root = fs::temp_directory_path() / (L"KSwitcher-utilities-" + std::to_wstring(GetCurrentProcessId())); + try + { + std::error_code error; + if (!ks::discoverUtilities(root / L"missing", error).empty() || error) + throw std::runtime_error("Missing folder must be empty, not an error"); + fs::create_directories(root / L"tools" / L"nested"); + fs::create_directory(root / L"tools" / L"directory.exe"); + for (auto name : {L"Zed.exe", L"alpha.EXE", L"A & B.exe", L"readme.txt", L"shortcut.lnk"}) + std::ofstream(root / L"tools" / name).put('x'); + std::ofstream(root / L"tools" / L"nested" / L"hidden.exe").put('x'); + auto found = ks::discoverUtilities(root / L"tools", error); + if (error || found.size() != 3 || found[0].filename() != L"A & B.exe" || + found[1].filename() != L"alpha.EXE" || found[2].filename() != L"Zed.exe") + throw std::runtime_error("Scan must filter direct executable files and sort names"); + if (ks::utilityMenuLabel(found[0]) != L"A && B") + throw std::runtime_error("Ampersand must be displayed literally"); + fs::remove(found[1]); + if (ks::discoverUtilities(root / L"tools", error).size() != 2) + throw std::runtime_error("Scan must refresh after a file is removed"); + ks::discoverUtilities(root / L"tools" / L"readme.txt", error); + if (!error) throw std::runtime_error("Non-directory must report an error"); + fs::remove_all(root); + std::cout << "Utilities regression tests passed\n"; + return 0; + } + catch (const std::exception &e) + { + std::error_code ignored; + fs::remove_all(root, ignored); + std::cerr << e.what() << '\n'; + return 1; + } +}