Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94cab4c112 | |||
| d13e9849a7 |
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.34 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.36 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
@@ -65,6 +65,15 @@ else()
|
|||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_executable(KSwitcherDictionaryTests tests/dictionary.cpp)
|
||||||
|
target_link_libraries(KSwitcherDictionaryTests PRIVATE kscore psapi)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherDictionaryTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherDictionaryTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
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 sync-regressions COMMAND KSwitcherSyncTests)
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
|
|||||||
@@ -627,7 +627,12 @@ support automatic correction. Dictionary recognition is heuristic, not an unders
|
|||||||
subtitle vocabulary is incomplete, and uncommon names or jargon can need personal exceptions.
|
subtitle vocabulary is incomplete, and uncommon names or jargon can need personal exceptions.
|
||||||
|
|
||||||
No network service or language model is used. The three dictionary files occupy about 2.6 MB on disk;
|
No network service or language model is used. The three dictionary files occupy about 2.6 MB on disk;
|
||||||
the English/Ukrainian lookup data uses less than 3 MiB of RAM and is loaded on first automatic check.
|
the bundled EN/UK/RU lookup payload uses about 0.90 MiB of RAM. When automatic correction is enabled,
|
||||||
|
selected dictionaries are loaded before typing and shared by the input predictor and worker.
|
||||||
|
Words are stored in compact indexed blocks; all words and frequency ranks are retained, and editable
|
||||||
|
UTF-8 dictionary files keep the same format. Custom dictionaries can use more memory.
|
||||||
|
`build.ps1 -Test` checks every bundled word/rank and measures lookup speed; CMake exposes the same
|
||||||
|
check as `dictionary-regressions`. The payload figure excludes the rest of the process and allocator overhead.
|
||||||
Keep `dictionaries/` beside the executable when copying the app. These word lists derive from
|
Keep `dictionaries/` beside the executable when copying the app. These word lists derive from
|
||||||
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) and are separately licensed under
|
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) and are separately licensed under
|
||||||
CC BY-SA 4.0; attribution, pinned source revision and license are in `dictionaries/`.
|
CC BY-SA 4.0; attribution, pinned source revision and license are in `dictionaries/`.
|
||||||
@@ -702,3 +707,7 @@ supporting old clients; their immutable update code only reads that page.
|
|||||||
### Editors that omit copied trailing spaces
|
### Editors that omit copied trailing spaces
|
||||||
|
|
||||||
From v0.6.31, KSwitcher recovers trailing ordinary spaces omitted by Ctrl+C only when Windows accessibility proves the complete selection. The same focused control and range are rechecked before paste, and the result is read back even without Write log. Recent-text conversion, automatic correction, repeat conversion and Undo retain exact verification. Missing letters, tabs, line breaks and unavailable or ambiguous selections are not reconstructed. No setting or shortcut has changed.
|
From v0.6.31, KSwitcher recovers trailing ordinary spaces omitted by Ctrl+C only when Windows accessibility proves the complete selection. The same focused control and range are rechecked before paste, and the result is read back even without Write log. Recent-text conversion, automatic correction, repeat conversion and Undo retain exact verification. Missing letters, tabs, line breaks and unavailable or ambiguous selections are not reconstructed. No setting or shortcut has changed.
|
||||||
|
|
||||||
|
### Notification registration and Viber — v0.6.36
|
||||||
|
|
||||||
|
Notification registration runs in KSwitcher.Screenshot.exe; keep all three executables together. Startup registration is asynchronous. Viber pastes allow 75 ms for clipboard notifications before Ctrl+V, retaining focus, ownership and selection checks. This mitigates an intermittent clipboard timing issue; it does not guarantee all Viber failures are resolved. UI Automation is unchanged.
|
||||||
|
|||||||
Binary file not shown.
+8
-5
@@ -1,14 +1,17 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.34 — 2026-11-04
|
## v0.6.36 — 2026-11-04
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Screenshot editing now uses the four-direction move cursor for both endpoints of lines/arrows, bounding-box shape handles and number-radius handles. The cursor no longer suggests that a horizontal or vertical line endpoint is restricted to one axis.
|
- Notification registration now runs in the existing KSwitcher.Screenshot.exe helper. Startup launches it asynchronously and checks its exit status without blocking keyboard work; update notification registration waits in its background worker. Keep all three executables together. UI Automation and notification activation routes remain unchanged.
|
||||||
- Object bodies, arrow labels and triangle vertices continue to use the same four-direction cursor. Thickness handles retain directional resize cursors perpendicular to the line; rotation handles retain the hand cursor.
|
- Viber paste now allows 75 ms for clipboard notifications to settle before sending Ctrl+V, then rechecks input, focus and clipboard ownership. This mitigates a suspected clipboard timing race: an intermittent corrupted Cyrillic paste was reproduced on the prior build. It is not a guarantee that every Viber timing issue is resolved; no blind paste retries or relaxed selection checks were added.
|
||||||
- This changes cursor feedback only. Dragging, snapping, circle proportions, undo/redo, shortcuts, defaults and dialog access-key behavior are unchanged. The v0.6.33 unsaved-settings warning and visible menu access letters remain included.
|
- Added regression coverage for an explicitly selected middle range followed by a repeated shortcut without selection, preserving surrounding text, trailing spaces and the original clipboard. Updated the integration fixture to initialize shared predictor dictionaries.
|
||||||
|
- Added reproducible standalone COM, UI Automation and Shell memory measurement tools. No shortcuts, settings or defaults changed.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
The full `build.ps1 -Stage -Test` run passed: 105 sync checks, shared wheel batching, 483 native regressions, 551 input/UI checks at 96 DPI, screenshot editor UI checks (including free-movement cursor feedback across drawing tools and horizontal/vertical thickness cursors), PNG saving, isolated screenshot registration, 48 updater regressions and updater progress UI checks. All three executable versions and all 12 package manifest file sizes/SHA-256 hashes were verified; the package contains no personal data.
|
Full staged build and tests cover dictionary equivalence for all 140,939 words, 105 sync checks, wheel batching, 304 native checks, 559 input checks, screenshot UI/PNG/registration, 48 updater checks and updater progress UI. Conversion integration passed 43 assertions. Eight actual Viber conversions passed on the development build, including two complete long-text layout cycles and selected/repeated middle-word conversion; test messages were not sent.
|
||||||
|
|
||||||
|
A controlled fresh-launch before/after comparison (five private-working-set samples each, same installation and settings) measured 4.69 → 4.18 MiB, about 0.51 MiB saved. This is one local comparison, not a fixed memory limit or a long-term idle guarantee. The prior compact dictionaries and cache/idle memory reductions remain included.
|
||||||
|
|
||||||
## Upgrade
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
c7f004ecd9d0f1032356060191ca3ca9fca458df434d3e5908c5d9b038f95acc KSwitcher-win-x64.zip
|
794d74113bddf13ffee7317cadee9c472bca3b84b3361796db58ff8023f66571 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $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
|
||||||
|
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
|
||||||
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||||
& .\build\KSwitcherSyncTests.exe
|
& .\build\KSwitcherSyncTests.exe
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,34,0
|
FILEVERSION 0,6,36,0
|
||||||
PRODUCTVERSION 0,6,34,0
|
PRODUCTVERSION 0,6,36,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.34"
|
VALUE "FileVersion", "0.6.36"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+33
-5
@@ -1,4 +1,5 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include <utility>
|
||||||
#include "../vendor/json.hpp"
|
#include "../vendor/json.hpp"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
@@ -357,6 +358,14 @@ class Transaction
|
|||||||
if (!valid())
|
if (!valid())
|
||||||
return false;
|
return false;
|
||||||
write(clip);
|
write(clip);
|
||||||
|
// Viber's Qt editor observes clipboard changes asynchronously. Give it
|
||||||
|
// a chance to discard the data object from Ctrl+C before requesting paste.
|
||||||
|
// Recheck ownership and input below; never retry a possibly applied paste.
|
||||||
|
if (lower(processName(target)) == L"viber")
|
||||||
|
{
|
||||||
|
Sleep(75);
|
||||||
|
diagnosticEvent(L"Paste: Viber clipboard notification settling interval completed");
|
||||||
|
}
|
||||||
for (int i = 0; i < 2; ++i)
|
for (int i = 0; i < 2; ++i)
|
||||||
{
|
{
|
||||||
check();
|
check();
|
||||||
@@ -432,6 +441,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
|||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
if (notificationRegistration) CloseHandle(notificationRegistration);
|
||||||
if (syncWorker.joinable()) syncWorker.join();
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
if (updateWorker.joinable())
|
if (updateWorker.joinable())
|
||||||
updateWorker.join();
|
updateWorker.join();
|
||||||
@@ -642,7 +652,7 @@ void App::applySettings(const Settings &requested)
|
|||||||
++inputVersion;
|
++inputVersion;
|
||||||
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
|
if (busy) diagnosticEvent(L"Input context changed: settings applied version=" + std::to_wstring(inputVersion.load()));
|
||||||
}
|
}
|
||||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
// Release the worker's old snapshot; the next check shares the freshly loaded predictor.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
scheduleHistoryOcr(s);
|
scheduleHistoryOcr(s);
|
||||||
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
nextSync = std::max(GetTickCount64() + 2000, syncRetryAfter);
|
||||||
@@ -703,6 +713,13 @@ void App::resetInput()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
}
|
}
|
||||||
|
void App::releaseIdleMemory(ULONGLONG now)
|
||||||
|
{
|
||||||
|
expireDiagnostics(now);
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
// Match the Undo eligibility deadline; never discard an available operation.
|
||||||
|
if (!busy && last && now - last->at > 120000) last.reset();
|
||||||
|
}
|
||||||
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
||||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||||
{
|
{
|
||||||
@@ -1166,9 +1183,11 @@ void App::transform(int command, int snippet)
|
|||||||
if (!config.autoCorrect || recent.window != target || recent.overflow ||
|
if (!config.autoCorrect || recent.window != target || recent.overflow ||
|
||||||
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
|
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
|
||||||
return;
|
return;
|
||||||
autoCorrector.configure(std::filesystem::path(executable()).parent_path() /
|
{
|
||||||
L"dictionaries",
|
std::lock_guard lock(stateMutex);
|
||||||
config.autoLanguages);
|
if (inputVersion.load() != version) return;
|
||||||
|
autoCorrector.shareDictionaries(autoPredictor);
|
||||||
|
}
|
||||||
std::wstring predicted;
|
std::wstring predicted;
|
||||||
for (wchar_t key : recent.keys)
|
for (wchar_t key : recent.keys)
|
||||||
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
||||||
@@ -1883,6 +1902,15 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
if (wp == 6)
|
if (wp == 6)
|
||||||
{
|
{
|
||||||
|
if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0)
|
||||||
|
{
|
||||||
|
DWORD code = ERROR_GEN_FAILURE;
|
||||||
|
GetExitCodeProcess(app->notificationRegistration, &code);
|
||||||
|
if (code) log(L"Screenshot notification registration helper failed: " + std::to_wstring(code));
|
||||||
|
CloseHandle(app->notificationRegistration);
|
||||||
|
app->notificationRegistration = nullptr;
|
||||||
|
}
|
||||||
|
app->releaseIdleMemory(GetTickCount64());
|
||||||
app->pollSync();
|
app->pollSync();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -2001,7 +2029,7 @@ int App::execute()
|
|||||||
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
try { registerScreenshotNotifications(); }
|
try { notificationRegistration = startScreenshotNotificationRegistration(); }
|
||||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||||
auto screenshotActivation=startScreenshotActivationListener();
|
auto screenshotActivation=startScreenshotActivationListener();
|
||||||
MSG msg;
|
MSG msg;
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ class App
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
friend struct InputRegressionAccess;
|
friend struct InputRegressionAccess;
|
||||||
|
void releaseIdleMemory(ULONGLONG now);
|
||||||
HHOOK keyboardHook{}, mouseHook{};
|
HHOOK keyboardHook{}, mouseHook{};
|
||||||
std::mutex jobsMutex;
|
std::mutex jobsMutex;
|
||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
std::deque<std::function<void()>> jobs;
|
std::deque<std::function<void()>> jobs;
|
||||||
std::thread worker;
|
std::thread worker;
|
||||||
|
HANDLE notificationRegistration{};
|
||||||
std::thread historySearchWorker;
|
std::thread historySearchWorker;
|
||||||
std::condition_variable historySearchReady;
|
std::condition_variable historySearchReady;
|
||||||
std::function<void()> pendingHistorySearch;
|
std::function<void()> pendingHistorySearch;
|
||||||
|
|||||||
+14
-11
@@ -46,7 +46,7 @@ std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices
|
|||||||
syncDone.store(true);
|
syncDone.store(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (...) { syncRunning = false; throw; }
|
catch (...) { syncRunning = false; Bytes().swap(syncSnapshot); syncResult = {}; throw; }
|
||||||
return syncStatus;
|
return syncStatus;
|
||||||
}
|
}
|
||||||
void App::pollSync()
|
void App::pollSync()
|
||||||
@@ -57,25 +57,28 @@ void App::pollSync()
|
|||||||
{
|
{
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
syncWorker.join();
|
syncWorker.join();
|
||||||
|
// Own completed payloads locally so every return/exception releases them.
|
||||||
|
auto result = std::exchange(syncResult, SyncResult{});
|
||||||
|
auto snapshot = std::exchange(syncSnapshot, Bytes{});
|
||||||
syncRunning = false;
|
syncRunning = false;
|
||||||
syncDone = false;
|
syncDone = false;
|
||||||
syncStatus = syncResult.status;
|
syncStatus = result.status;
|
||||||
if (syncResult.success)
|
if (result.success)
|
||||||
{
|
{
|
||||||
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != syncSnapshot)
|
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != snapshot)
|
||||||
{
|
{
|
||||||
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
||||||
nextSync = GetTickCount64() + 2000;
|
nextSync = GetTickCount64() + 2000;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
auto next = restoreSyncSettings(result.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
||||||
if (encodeSettings(next) != syncSnapshot)
|
if (encodeSettings(next) != snapshot)
|
||||||
{
|
{
|
||||||
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
atomicWrite(data / L"settings-before-sync.bin", protect(snapshot));
|
||||||
applySettings(next);
|
applySettings(next);
|
||||||
}
|
}
|
||||||
saveSyncBase(data, syncConnection, syncResult.base.empty() ? syncResult.settings : syncResult.base);
|
saveSyncBase(data, syncConnection, result.base.empty() ? result.settings : result.base);
|
||||||
pendingSyncConflicts = syncResult.conflicts;
|
pendingSyncConflicts = std::move(result.conflicts);
|
||||||
conflictConnection = syncConnection;
|
conflictConnection = syncConnection;
|
||||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||||
wchar_t stamp[32]{};
|
wchar_t stamp[32]{};
|
||||||
@@ -84,8 +87,8 @@ void App::pollSync()
|
|||||||
}
|
}
|
||||||
// Back off after errors (including bad credentials) instead of repeatedly
|
// Back off after errors (including bad credentials) instead of repeatedly
|
||||||
// hitting userpass lockout. The explicit Sync button remains available.
|
// hitting userpass lockout. The explicit Sync button remains available.
|
||||||
syncRetryAfter = syncResult.success ? 0 : GetTickCount64() + 15 * 60000;
|
syncRetryAfter = result.success ? 0 : GetTickCount64() + 15 * 60000;
|
||||||
nextSync = syncResult.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
nextSync = result.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
||||||
}
|
}
|
||||||
if (!syncRunning && GetTickCount64() >= nextSync)
|
if (!syncRunning && GetTickCount64() >= nextSync)
|
||||||
{
|
{
|
||||||
|
|||||||
+122
-44
@@ -42,82 +42,160 @@ bool safeAutoWord(const std::wstring &word, int minimumLength)
|
|||||||
auto small = lower(word);
|
auto small = lower(word);
|
||||||
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
|
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
|
||||||
}
|
}
|
||||||
std::string_view AutoCorrector::Dictionary::word(size_t index) const
|
namespace {
|
||||||
|
constexpr size_t DictionaryBlockWords = 32;
|
||||||
|
}
|
||||||
|
std::string_view AutoCorrector::Dictionary::word(size_t block) const
|
||||||
{
|
{
|
||||||
auto start = offsets[index];
|
size_t start = offsets[block];
|
||||||
auto end = bytes.find('\t', start);
|
// Every block starts with a complete word (zero shared prefix).
|
||||||
return std::string_view(bytes).substr(start, end - start);
|
size_t length = static_cast<unsigned char>(bytes[start++]) & 15;
|
||||||
|
if (length == 15) length = static_cast<unsigned char>(bytes[start++]);
|
||||||
|
return std::string_view(bytes).substr(start, length);
|
||||||
}
|
}
|
||||||
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
||||||
{
|
{
|
||||||
auto raw = readFile(path, 2 * 1024 * 1024);
|
const auto raw = readFile(path, 2 * 1024 * 1024);
|
||||||
bytes.assign(raw.begin(), raw.end());
|
const std::string_view source(reinterpret_cast<const char *>(raw.data()), raw.size());
|
||||||
for (size_t start = 0; start < bytes.size();)
|
std::vector<uint32_t> lines;
|
||||||
|
lines.reserve(std::count(source.begin(), source.end(), '\n') + 1);
|
||||||
|
for (size_t start = 0; start < source.size();)
|
||||||
{
|
{
|
||||||
auto end = bytes.find('\n', start);
|
auto end = source.find('\n', start);
|
||||||
if (end == bytes.npos)
|
if (end == source.npos) end = source.size();
|
||||||
end = bytes.size();
|
auto tab = source.find('\t', start);
|
||||||
auto tab = bytes.find('\t', start);
|
if (tab == source.npos || tab >= end || tab == start || tab - start > 160)
|
||||||
if (tab == bytes.npos || tab >= end || tab == start || tab - start > 160)
|
|
||||||
throw std::runtime_error("Invalid auto-correction dictionary");
|
throw std::runtime_error("Invalid auto-correction dictionary");
|
||||||
int rank{};
|
int rank{};
|
||||||
auto rankEnd = end > tab + 1 && bytes[end - 1] == '\r' ? end - 1 : end;
|
auto rankEnd = end > tab + 1 && source[end - 1] == '\r' ? end - 1 : end;
|
||||||
auto parse = std::from_chars(bytes.data() + tab + 1, bytes.data() + rankEnd, rank);
|
auto parse = std::from_chars(source.data() + tab + 1, source.data() + rankEnd, rank);
|
||||||
if (parse.ec != std::errc() || parse.ptr != bytes.data() + rankEnd || rank <= 0)
|
if (parse.ec != std::errc() || parse.ptr != source.data() + rankEnd || rank <= 0)
|
||||||
throw std::runtime_error("Invalid dictionary rank");
|
throw std::runtime_error("Invalid dictionary rank");
|
||||||
offsets.push_back((uint32_t)start);
|
lines.push_back(static_cast<uint32_t>(start));
|
||||||
start = end + 1;
|
start = end + 1;
|
||||||
}
|
}
|
||||||
if (offsets.empty())
|
if (lines.empty()) throw std::runtime_error("Empty auto-correction dictionary");
|
||||||
throw std::runtime_error("Empty auto-correction dictionary");
|
auto entry = [&](uint32_t offset) { return source.substr(offset, source.find('\t', offset) - offset); };
|
||||||
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
auto frequency = [&](uint32_t offset) {
|
||||||
auto frequency = [&](uint32_t offset)
|
|
||||||
{
|
|
||||||
int rank{};
|
int rank{};
|
||||||
auto start = offset + entry(offset).size() + 1;
|
auto start = offset + entry(offset).size() + 1;
|
||||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
std::from_chars(source.data() + start, source.data() + source.size(), rank);
|
||||||
return rank;
|
return rank;
|
||||||
};
|
};
|
||||||
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
std::sort(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
|
||||||
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
|
return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b);
|
||||||
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
});
|
||||||
{ return entry(a) == entry(b); }), offsets.end());
|
lines.erase(std::unique(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
|
||||||
offsets.shrink_to_fit();
|
return entry(a) == entry(b);
|
||||||
bytes.shrink_to_fit();
|
}), lines.end());
|
||||||
|
offsets.reserve((lines.size() + DictionaryBlockWords - 1) / DictionaryBlockWords);
|
||||||
|
std::string_view previous;
|
||||||
|
// Allocate the packed payload exactly once. Geometric growth and a final
|
||||||
|
// shrink otherwise leave discarded buffers in the resident process heap.
|
||||||
|
size_t packedSize = 0;
|
||||||
|
for (size_t i = 0; i < lines.size(); ++i)
|
||||||
|
{
|
||||||
|
auto current = entry(lines[i]);
|
||||||
|
size_t prefix = 0;
|
||||||
|
if (i % DictionaryBlockWords != 0)
|
||||||
|
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
|
||||||
|
auto suffix = current.size() - prefix;
|
||||||
|
packedSize += 1 + (prefix >= 15) + (suffix >= 15) + suffix + 2;
|
||||||
|
auto rank = static_cast<uint32_t>(frequency(lines[i]));
|
||||||
|
if (rank >= 65535)
|
||||||
|
do { ++packedSize; rank >>= 7; } while (rank);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
bytes.reserve(packedSize);
|
||||||
|
previous = {};
|
||||||
|
for (size_t i = 0; i < lines.size(); ++i)
|
||||||
|
{
|
||||||
|
auto current = entry(lines[i]);
|
||||||
|
size_t prefix = 0;
|
||||||
|
if (i % DictionaryBlockWords == 0)
|
||||||
|
offsets.push_back(static_cast<uint32_t>(bytes.size()));
|
||||||
|
else
|
||||||
|
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
|
||||||
|
size_t suffix = current.size() - prefix;
|
||||||
|
bytes.push_back(static_cast<char>((std::min(prefix, size_t(15)) << 4) | std::min(suffix, size_t(15))));
|
||||||
|
if (prefix >= 15) bytes.push_back(static_cast<char>(prefix));
|
||||||
|
if (suffix >= 15) bytes.push_back(static_cast<char>(suffix));
|
||||||
|
bytes.append(current.substr(prefix));
|
||||||
|
auto rank = static_cast<uint32_t>(frequency(lines[i]));
|
||||||
|
auto shortRank = std::min(rank, uint32_t(65535));
|
||||||
|
bytes.push_back(static_cast<char>(shortRank & 255));
|
||||||
|
bytes.push_back(static_cast<char>(shortRank >> 8));
|
||||||
|
if (rank >= 65535)
|
||||||
|
do {
|
||||||
|
auto next = rank & 127;
|
||||||
|
rank >>= 7;
|
||||||
|
bytes.push_back(static_cast<char>(next | (rank ? 128 : 0)));
|
||||||
|
} while (rank);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
|
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
|
||||||
{
|
{
|
||||||
auto key = utf8(lower(text));
|
auto key = utf8(lower(text));
|
||||||
size_t begin = 0, end = offsets.size();
|
size_t begin = 0, end = offsets.size();
|
||||||
|
// Find the last block whose first word is <= the search key.
|
||||||
while (begin < end)
|
while (begin < end)
|
||||||
{
|
{
|
||||||
auto mid = begin + (end - begin) / 2;
|
auto mid = begin + (end - begin) / 2;
|
||||||
if (word(mid) < key)
|
if (word(mid) <= key) begin = mid + 1;
|
||||||
begin = mid + 1;
|
else end = mid;
|
||||||
else
|
|
||||||
end = mid;
|
|
||||||
}
|
}
|
||||||
if (begin == offsets.size() || word(begin) != key)
|
if (!begin) return 0;
|
||||||
return 0;
|
size_t block = begin - 1;
|
||||||
auto start = offsets[begin] + word(begin).size() + 1;
|
size_t at = offsets[block];
|
||||||
int rank{};
|
const size_t limit = begin < offsets.size() ? offsets[begin] : bytes.size();
|
||||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
char decoded[160]; // The loader validates lengths; lookup never allocates a decoded word.
|
||||||
return rank;
|
while (at < limit)
|
||||||
|
{
|
||||||
|
auto lengths = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
size_t prefix = lengths >> 4, suffix = lengths & 15;
|
||||||
|
if (prefix == 15) prefix = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
if (suffix == 15) suffix = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
memcpy(decoded + prefix, bytes.data() + at, suffix);
|
||||||
|
at += suffix;
|
||||||
|
uint32_t rank = static_cast<unsigned char>(bytes[at]) |
|
||||||
|
(uint32_t(static_cast<unsigned char>(bytes[at + 1])) << 8);
|
||||||
|
at += 2;
|
||||||
|
if (rank == 65535) {
|
||||||
|
rank = 0;
|
||||||
|
uint32_t shift = 0;
|
||||||
|
unsigned char part;
|
||||||
|
do {
|
||||||
|
part = static_cast<unsigned char>(bytes[at++]);
|
||||||
|
rank |= uint32_t(part & 127) << shift;
|
||||||
|
shift += 7;
|
||||||
|
} while (part & 128);
|
||||||
|
}
|
||||||
|
auto compared = std::string_view(decoded, size_t(prefix) + suffix).compare(key);
|
||||||
|
if (compared == 0) return static_cast<int>(rank);
|
||||||
|
if (compared > 0) return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
|
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
|
||||||
{
|
{
|
||||||
if (languages == wanted)
|
if (languages == wanted)
|
||||||
return;
|
return;
|
||||||
std::map<std::wstring, Dictionary> next;
|
std::map<std::wstring, std::shared_ptr<const Dictionary>> next;
|
||||||
for (auto &lang : wanted)
|
for (auto &lang : wanted)
|
||||||
{
|
{
|
||||||
if (lang != L"en" && lang != L"uk" && lang != L"ru")
|
if (lang != L"en" && lang != L"uk" && lang != L"ru")
|
||||||
throw std::runtime_error("Unsupported dictionary language");
|
throw std::runtime_error("Unsupported dictionary language");
|
||||||
next.emplace(lang, Dictionary(folder / (lang + L".txt")));
|
next.emplace(lang, std::make_shared<const Dictionary>(folder / (lang + L".txt")));
|
||||||
}
|
}
|
||||||
dictionaries = std::move(next);
|
dictionaries = std::move(next);
|
||||||
languages = wanted;
|
languages = wanted;
|
||||||
}
|
}
|
||||||
|
void AutoCorrector::shareDictionaries(const AutoCorrector &source)
|
||||||
|
{
|
||||||
|
dictionaries = source.dictionaries;
|
||||||
|
languages = source.languages;
|
||||||
|
}
|
||||||
void AutoCorrector::reject(std::wstring word, const Settings &settings)
|
void AutoCorrector::reject(std::wstring word, const Settings &settings)
|
||||||
{
|
{
|
||||||
while (!word.empty() && openingDelimiter(word.front(), settings))
|
while (!word.empty() && openingDelimiter(word.front(), settings))
|
||||||
@@ -132,7 +210,7 @@ size_t AutoCorrector::memoryBytes() const
|
|||||||
{
|
{
|
||||||
size_t bytes = 0;
|
size_t bytes = 0;
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
bytes += d.bytes.capacity() + d.offsets.capacity() * sizeof(uint32_t);
|
bytes += d->bytes.capacity() + d->offsets.capacity() * sizeof(uint32_t);
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
|
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
|
||||||
@@ -191,7 +269,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
};
|
};
|
||||||
// Valid words from either selected language are never changed.
|
// Valid words from either selected language are never changed.
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
if (d.rank(word) || personal(language, word))
|
if (d->rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
std::optional<std::wstring> matchedText;
|
std::optional<std::wstring> matchedText;
|
||||||
@@ -211,7 +289,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||||
continue;
|
continue;
|
||||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second->rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (matchedText && *matchedText != converted)
|
if (matchedText && *matchedText != converted)
|
||||||
@@ -233,7 +311,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto consider = [&](const std::wstring &text)
|
auto consider = [&](const std::wstring &text)
|
||||||
{
|
{
|
||||||
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||||||
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
|
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage)->rank(text);
|
||||||
if (!rank) return true;
|
if (!rank) return true;
|
||||||
if (!found.empty()) return false;
|
if (!found.empty()) return false;
|
||||||
found = text;
|
found = text;
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
namespace ks
|
namespace ks
|
||||||
@@ -18,23 +19,30 @@ int matchAutoInput(const Run &, const std::wstring &, const std::vector<Layout>
|
|||||||
bool autoCorrectionAllowed(HWND, const Settings &);
|
bool autoCorrectionAllowed(HWND, const Settings &);
|
||||||
class AutoCorrector
|
class AutoCorrector
|
||||||
{
|
{
|
||||||
|
friend struct AutoCorrectorRegressionAccess;
|
||||||
struct Dictionary
|
struct Dictionary
|
||||||
{
|
{
|
||||||
|
// Front-coded UTF-8 words in independently searchable blocks of 32.
|
||||||
|
// Prefix/suffix lengths share a byte (15 escapes to a full byte length).
|
||||||
|
// Ranks use two bytes; 65535 escapes to a varint for larger custom ranks.
|
||||||
std::string bytes;
|
std::string bytes;
|
||||||
std::vector<uint32_t> offsets;
|
std::vector<uint32_t> offsets;
|
||||||
std::string_view word(size_t) const;
|
std::string_view word(size_t) const;
|
||||||
explicit Dictionary(const std::filesystem::path &);
|
explicit Dictionary(const std::filesystem::path &);
|
||||||
int rank(const std::wstring &) const;
|
int rank(const std::wstring &) const;
|
||||||
};
|
};
|
||||||
std::map<std::wstring, Dictionary> dictionaries;
|
std::map<std::wstring, std::shared_ptr<const Dictionary>> dictionaries;
|
||||||
std::vector<std::wstring> languages;
|
std::vector<std::wstring> languages;
|
||||||
std::set<std::wstring> rejected;
|
std::set<std::wstring> rejected;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
|
void configure(const std::filesystem::path &, const std::vector<std::wstring> &);
|
||||||
|
// Share immutable dictionary storage, keeping each consumer's undo rejections private.
|
||||||
|
void shareDictionaries(const AutoCorrector &);
|
||||||
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
|
std::optional<AutoSuggestion> suggest(const std::wstring &, int source, const std::vector<Layout> &,
|
||||||
const Settings &) const;
|
const Settings &) const;
|
||||||
void reject(std::wstring word, const Settings & = {});
|
void reject(std::wstring word, const Settings & = {});
|
||||||
|
// Referenced dictionary payload, including shared storage (do not sum across consumers).
|
||||||
size_t memoryBytes() const;
|
size_t memoryBytes() const;
|
||||||
};
|
};
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
+17
-3
@@ -4,14 +4,28 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
|
inline constexpr size_t HistoryThumbnailBudget = 1024 * 1024;
|
||||||
|
inline constexpr size_t HistoryRichTextBudget = 256 * 1024;
|
||||||
// Keep the visible working set even in unusually tall windows. Never evict a
|
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||||
// visible preview to admit another visible preview: that creates a paint loop.
|
// visible preview to admit another visible preview: that creates a paint loop.
|
||||||
|
template<class T, class Weight>
|
||||||
|
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||||
|
const std::set<std::wstring> &visible, size_t limit,
|
||||||
|
size_t byteLimit, Weight weight)
|
||||||
|
{
|
||||||
|
size_t bytes = 0;
|
||||||
|
for (const auto &[id, value] : cache) bytes += weight(value);
|
||||||
|
for(auto it=cache.begin(); (cache.size()>limit || bytes>byteLimit) && it!=cache.end();)
|
||||||
|
if(!visible.contains(it->first)) {
|
||||||
|
bytes -= weight(it->second);
|
||||||
|
it=cache.erase(it);
|
||||||
|
}
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
template<class T>
|
template<class T>
|
||||||
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||||
const std::set<std::wstring> &visible, size_t limit=32)
|
const std::set<std::wstring> &visible, size_t limit=32)
|
||||||
{
|
{
|
||||||
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
trimHistoryPreviews(cache, visible, limit, 0, [](const T &) { return size_t{}; });
|
||||||
if(!visible.contains(it->first)) it=cache.erase(it);
|
|
||||||
else ++it;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -123,13 +123,16 @@ struct OcrStatics : IInspectable
|
|||||||
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
||||||
{
|
{
|
||||||
Bytes encoded;
|
Bytes encoded;
|
||||||
|
const Bytes *input = &encoded;
|
||||||
for (const auto &f : clip.formats)
|
for (const auto &f : clip.formats)
|
||||||
if (f.name == L"PNG")
|
if (f.name == L"PNG")
|
||||||
{
|
{
|
||||||
encoded = f.data;
|
input = &f.data;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (encoded.empty())
|
if (input->empty())
|
||||||
|
{
|
||||||
|
input = &encoded;
|
||||||
for (const auto &f : clip.formats)
|
for (const auto &f : clip.formats)
|
||||||
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
|
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
|
||||||
{
|
{
|
||||||
@@ -146,19 +149,21 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
|||||||
file.bfType = 0x4d42;
|
file.bfType = 0x4d42;
|
||||||
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
|
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
|
||||||
file.bfOffBits = (DWORD)offset;
|
file.bfOffBits = (DWORD)offset;
|
||||||
encoded.resize(sizeof(file));
|
encoded.resize(sizeof(file) + f.data.size());
|
||||||
memcpy(encoded.data(), &file, sizeof(file));
|
memcpy(encoded.data(), &file, sizeof(file));
|
||||||
encoded.insert(encoded.end(), f.data.begin(), f.data.end());
|
memcpy(encoded.data() + sizeof(file), f.data.data(), f.data.size());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (encoded.empty())
|
}
|
||||||
|
if (input->empty())
|
||||||
throw std::runtime_error("This clipboard item has no supported image");
|
throw std::runtime_error("This clipboard item has no supported image");
|
||||||
Com<IWICImagingFactory> wic;
|
Com<IWICImagingFactory> wic;
|
||||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
|
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
|
||||||
(void **)wic.out()));
|
(void **)wic.out()));
|
||||||
Com<IWICStream> stream;
|
Com<IWICStream> stream;
|
||||||
check(wic->CreateStream(stream.out()));
|
check(wic->CreateStream(stream.out()));
|
||||||
check(stream->InitializeFromMemory(encoded.data(), (DWORD)encoded.size()));
|
// WIC decoding only reads the stream. The clip outlives every WIC object here.
|
||||||
|
check(stream->InitializeFromMemory(const_cast<BYTE *>(input->data()), (DWORD)input->size()));
|
||||||
Com<IWICBitmapDecoder> decoder;
|
Com<IWICBitmapDecoder> decoder;
|
||||||
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
|
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
|
||||||
Com<IWICBitmapFrameDecode> frame;
|
Com<IWICBitmapFrameDecode> frame;
|
||||||
|
|||||||
+17
-2
@@ -604,10 +604,25 @@ std::recursive_mutex logGate;
|
|||||||
bool loggingEnabled = false;
|
bool loggingEnabled = false;
|
||||||
DiagnosticBuffer diagnosticBuffer;
|
DiagnosticBuffer diagnosticBuffer;
|
||||||
}
|
}
|
||||||
|
size_t DiagnosticBuffer::expire(ULONGLONG now)
|
||||||
|
{
|
||||||
|
size_t removed = 0;
|
||||||
|
while (!entries.empty() && now - entries.front().first > 30000)
|
||||||
|
{
|
||||||
|
entries.pop_front();
|
||||||
|
++removed;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
void expireDiagnostics(ULONGLONG now)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(logGate);
|
||||||
|
diagnosticBuffer.expire(now);
|
||||||
|
}
|
||||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
||||||
{
|
{
|
||||||
while (!entries.empty() && (entries.size() >= 128 || now - entries.front().first > 30000))
|
expire(now);
|
||||||
entries.pop_front();
|
while (entries.size() >= 128) entries.pop_front();
|
||||||
entries.emplace_back(now, message);
|
entries.emplace_back(now, message);
|
||||||
if (!problem)
|
if (!problem)
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ class DiagnosticBuffer
|
|||||||
{
|
{
|
||||||
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
|
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
|
||||||
public:
|
public:
|
||||||
|
size_t expire(ULONGLONG now);
|
||||||
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
|
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
|
||||||
void clear() { entries.clear(); }
|
void clear() { entries.clear(); }
|
||||||
};
|
};
|
||||||
void setLoggingEnabled(bool enabled);
|
void setLoggingEnabled(bool enabled);
|
||||||
|
void expireDiagnostics(ULONGLONG now);
|
||||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||||
void diagnosticOperation(const std::string &json);
|
void diagnosticOperation(const std::string &json);
|
||||||
std::wstring readDiagnosticLog(bool previous);
|
std::wstring readDiagnosticLog(bool previous);
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ void registerScreenshotNotifications()
|
|||||||
// Notify the shell that notification identity/activation registration changed.
|
// Notify the shell that notification identity/activation registration changed.
|
||||||
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||||
}
|
}
|
||||||
|
HANDLE startScreenshotNotificationRegistration()
|
||||||
|
{
|
||||||
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
auto command=L"\""+path.wstring()+L"\" --register-notifications";
|
||||||
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
|
startup.dwFlags=STARTF_USESHOWWINDOW; startup.wShowWindow=SW_HIDE;
|
||||||
|
PROCESS_INFORMATION process{};
|
||||||
|
if(!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW,
|
||||||
|
nullptr,path.parent_path().c_str(),&startup,&process))
|
||||||
|
throw std::runtime_error(utf8(lastError(L"Cannot start notification registration helper")));
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
return process.hProcess;
|
||||||
|
}
|
||||||
|
void registerScreenshotNotificationsInHelper()
|
||||||
|
{
|
||||||
|
auto process=startScreenshotNotificationRegistration();
|
||||||
|
auto wait=WaitForSingleObject(process,10000);
|
||||||
|
DWORD code=ERROR_GEN_FAILURE;
|
||||||
|
bool completed=wait==WAIT_OBJECT_0 && GetExitCodeProcess(process,&code);
|
||||||
|
CloseHandle(process);
|
||||||
|
if(!completed || code)
|
||||||
|
throw std::runtime_error(completed ? "Notification registration helper failed: "+std::to_string(code)
|
||||||
|
: "Notification registration helper did not complete within 10 seconds");
|
||||||
|
}
|
||||||
bool validScreenshotToken(const std::wstring &id)
|
bool validScreenshotToken(const std::wstring &id)
|
||||||
{
|
{
|
||||||
if(id.size()!=36) return false;
|
if(id.size()!=36) return false;
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ bool notifyUpdateAvailable(const std::wstring &version)
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
||||||
registerScreenshotNotifications();
|
registerScreenshotNotificationsInHelper();
|
||||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ namespace ks {
|
|||||||
bool notifyScreenshot(const Clip &);
|
bool notifyScreenshot(const Clip &);
|
||||||
std::string screenshotNotificationError();
|
std::string screenshotNotificationError();
|
||||||
void registerScreenshotNotifications();
|
void registerScreenshotNotifications();
|
||||||
|
// Caller owns the returned process handle; the helper exits after registration.
|
||||||
|
HANDLE startScreenshotNotificationRegistration();
|
||||||
|
// For background notification workers only; bounded wait, never on the UI thread.
|
||||||
|
void registerScreenshotNotificationsInHelper();
|
||||||
int runScreenshotToastActivator();
|
int runScreenshotToastActivator();
|
||||||
DWORD startScreenshotActivationListener();
|
DWORD startScreenshotActivationListener();
|
||||||
bool validScreenshotToken(const std::wstring &);
|
bool validScreenshotToken(const std::wstring &);
|
||||||
|
|||||||
+20
-3
@@ -479,12 +479,14 @@ struct HistoryView
|
|||||||
{
|
{
|
||||||
App *app{};
|
App *app{};
|
||||||
std::vector<Entry> items;
|
std::vector<Entry> items;
|
||||||
|
std::wstring rememberedSelection, rememberedTop;
|
||||||
uint64_t generation{};
|
uint64_t generation{};
|
||||||
std::map<std::wstring, Pixels> thumbnails;
|
std::map<std::wstring, Pixels> thumbnails;
|
||||||
std::map<std::wstring, std::string> richTexts;
|
std::map<std::wstring, std::string> richTexts;
|
||||||
HWND rich{};
|
HWND rich{};
|
||||||
std::set<std::wstring> pending;
|
std::set<std::wstring> pending;
|
||||||
bool scrolling{};
|
bool scrolling{};
|
||||||
|
bool closed{};
|
||||||
};
|
};
|
||||||
bool selectedImage(HWND w, HistoryView *v)
|
bool selectedImage(HWND w, HistoryView *v)
|
||||||
{
|
{
|
||||||
@@ -502,6 +504,7 @@ std::wstring selected(HWND w, HistoryView *v)
|
|||||||
}
|
}
|
||||||
void search(HWND w, HistoryView *v)
|
void search(HWND w, HistoryView *v)
|
||||||
{
|
{
|
||||||
|
v->closed = false;
|
||||||
auto q = value(w, 101);
|
auto q = value(w, 101);
|
||||||
auto generation = ++v->generation;
|
auto generation = ++v->generation;
|
||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
@@ -770,6 +773,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
KillTimer(w, 1);
|
KillTimer(w, 1);
|
||||||
|
if (v->closed) return 0;
|
||||||
search(w, v);
|
search(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
case ResultsMessage:
|
case ResultsMessage:
|
||||||
@@ -778,8 +782,10 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (result->generation != v->generation)
|
if (result->generation != v->generation)
|
||||||
return 0;
|
return 0;
|
||||||
auto old = selected(w, v);
|
auto old = selected(w, v);
|
||||||
|
if (old.empty()) old = v->rememberedSelection;
|
||||||
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
int top=(int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||||
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
auto topId=top>=0 && top<(int)v->items.size()?v->items[top].id:L"";
|
||||||
|
if (topId.empty()) topId = v->rememberedTop;
|
||||||
v->items = std::move(result->items);
|
v->items = std::move(result->items);
|
||||||
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
SendDlgItemMessageW(w, 102, WM_SETREDRAW, FALSE, 0);
|
||||||
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
SendDlgItemMessageW(w, 102, LB_RESETCONTENT, 0, 0);
|
||||||
@@ -823,8 +829,10 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
v->richTexts[result->id] = std::move(result->rtf);
|
v->richTexts[result->id] = std::move(result->rtf);
|
||||||
else
|
else
|
||||||
v->thumbnails[result->id] = std::move(result->pixels);
|
v->thumbnails[result->id] = std::move(result->pixels);
|
||||||
trimHistoryPreviews(v->thumbnails,visible);
|
trimHistoryPreviews(v->thumbnails,visible,32,HistoryThumbnailBudget,
|
||||||
trimHistoryPreviews(v->richTexts,visible);
|
[](const Pixels &p) { return p.bgra.capacity(); });
|
||||||
|
trimHistoryPreviews(v->richTexts,visible,32,HistoryRichTextBudget,
|
||||||
|
[](const std::string &s) { return s.capacity(); });
|
||||||
if(repaint) InvalidateRect(list,&changed,FALSE);
|
if(repaint) InvalidateRect(list,&changed,FALSE);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -875,14 +883,23 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_CLOSE:
|
case WM_CLOSE:
|
||||||
|
{
|
||||||
|
v->rememberedSelection = selected(w, v);
|
||||||
|
int top = (int)SendDlgItemMessageW(w,102,LB_GETTOPINDEX,0,0);
|
||||||
|
v->rememberedTop = top >= 0 && top < (int)v->items.size() ? v->items[top].id : L"";
|
||||||
|
KillTimer(w, 1);
|
||||||
|
v->closed = true;
|
||||||
|
++v->generation; // Discard search/preview results queued before closing.
|
||||||
ShowWindow(w, SW_HIDE);
|
ShowWindow(w, SW_HIDE);
|
||||||
|
SendDlgItemMessageW(w,102,LB_RESETCONTENT,0,0);
|
||||||
|
std::vector<Entry>().swap(v->items);
|
||||||
v->thumbnails.clear();
|
v->thumbnails.clear();
|
||||||
v->richTexts.clear();
|
v->richTexts.clear();
|
||||||
if (v->rich)
|
if (v->rich)
|
||||||
SetWindowTextW(v->rich, L"");
|
SetWindowTextW(v->rich, L"");
|
||||||
v->pending.clear();
|
v->pending.clear();
|
||||||
++v->generation;
|
|
||||||
return 0;
|
return 0;
|
||||||
|
}
|
||||||
case WM_NCDESTROY:
|
case WM_NCDESTROY:
|
||||||
disposeFont(w);
|
disposeFont(w);
|
||||||
v->app->historyWindow = nullptr;
|
v->app->historyWindow = nullptr;
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.34";
|
inline constexpr wchar_t AppVersion[] = L"0.6.36";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#include "autocorrect.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <psapi.h>
|
||||||
|
#include <random>
|
||||||
|
namespace ks {
|
||||||
|
struct AutoCorrectorRegressionAccess {
|
||||||
|
static int rank(const AutoCorrector &c,const std::wstring &lang,const std::wstring &word) {
|
||||||
|
return c.dictionaries.at(lang)->rank(word);
|
||||||
|
}
|
||||||
|
static std::vector<int> ranks(const std::filesystem::path &path,const std::vector<std::wstring> &words) {
|
||||||
|
AutoCorrector::Dictionary d(path);
|
||||||
|
std::vector<int> values;for(const auto &word:words)values.push_back(d.rank(word));return values;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int main(int argc,char **argv) {
|
||||||
|
try {
|
||||||
|
auto start=std::chrono::steady_clock::now();
|
||||||
|
ks::AutoCorrector c;c.configure(L"dictionaries",{L"en",L"uk",L"ru"});
|
||||||
|
PROCESS_MEMORY_COUNTERS_EX m{};
|
||||||
|
GetProcessMemoryInfo(GetCurrentProcess(),reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&m),sizeof(m));
|
||||||
|
std::cout<<"dictionary_bytes="<<c.memoryBytes()<<" private_bytes="<<m.PrivateUsage
|
||||||
|
<<" load_ms="<<std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count()<<'\n';
|
||||||
|
struct Query {std::wstring language,word; int rank;};
|
||||||
|
std::vector<Query> queries;
|
||||||
|
for(const auto *lang:{L"en",L"uk",L"ru"}) {
|
||||||
|
std::ifstream input(std::filesystem::path(L"dictionaries")/(std::wstring(lang)+L".txt"));
|
||||||
|
std::map<std::wstring,int> expected;
|
||||||
|
for(std::string line;std::getline(input,line);) {
|
||||||
|
auto tab=line.find('\t');auto word=ks::wide(line.substr(0,tab));auto rank=std::stoi(line.substr(tab+1));
|
||||||
|
auto [it,added]=expected.emplace(word,rank);if(!added)it->second=std::min(it->second,rank);
|
||||||
|
}
|
||||||
|
if(expected.empty())throw std::runtime_error("Missing benchmark dictionary");
|
||||||
|
for(auto &[word,rank]:expected) {
|
||||||
|
if(ks::AutoCorrectorRegressionAccess::rank(c,lang,word)!=rank)throw std::runtime_error("Dictionary rank differs from source");
|
||||||
|
if(ks::AutoCorrectorRegressionAccess::rank(c,lang,word+L"~")!=0)throw std::runtime_error("False dictionary hit");
|
||||||
|
queries.push_back({lang,word,rank});queries.push_back({lang,word+L"~",0});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::mt19937 random(42);std::shuffle(queries.begin(),queries.end(),random);
|
||||||
|
uint64_t checksum=0,expected=0;
|
||||||
|
for(auto &q:queries)expected+=q.rank;
|
||||||
|
start=std::chrono::steady_clock::now();
|
||||||
|
for(int pass=0;pass<5;++pass)for(auto &q:queries)checksum+=ks::AutoCorrectorRegressionAccess::rank(c,q.language,q.word);
|
||||||
|
auto ms=std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count();
|
||||||
|
if(checksum!=expected*5)throw std::runtime_error("Lookup checksum mismatch");
|
||||||
|
std::cout<<"verified_words="<<queries.size()/2<<" queries="<<queries.size()*5<<" lookup_ms="<<ms
|
||||||
|
<<" ns_per_lookup="<<ms*1000000/(queries.size()*5)<<" checksum="<<checksum<<'\n';
|
||||||
|
auto fixture=std::filesystem::temp_directory_path()/(L"KSwitcher-dictionary-"+
|
||||||
|
std::to_wstring(GetCurrentProcessId())+L"-"+std::to_wstring(GetTickCount64())+L".txt");
|
||||||
|
struct RemoveFixture { std::filesystem::path path; ~RemoveFixture(){std::error_code ec;std::filesystem::remove(path,ec);} } cleanup{fixture};
|
||||||
|
auto put=[&](const std::string &text){ks::atomicWrite(fixture,ks::Bytes(text.begin(),text.end()));};
|
||||||
|
std::string text="duplicate\t9\r\nduplicate\t2\n";
|
||||||
|
std::vector<std::wstring> words;
|
||||||
|
std::vector<int> expectedRanks;
|
||||||
|
for(int i=129;i>=0;--i) {
|
||||||
|
auto word=L"prefix_"+std::to_wstring(i);
|
||||||
|
text+=ks::utf8(word)+"\t"+std::to_string(i+1)+"\n";
|
||||||
|
words.push_back(word);expectedRanks.push_back(i+1);
|
||||||
|
}
|
||||||
|
text+=std::string(160,'x')+"\t2147483647"; // Maximum length/rank, no final newline.
|
||||||
|
words.insert(words.end(),{std::wstring(160,L'x'),L"DUPLICATE",L"",L"prefix_",L"zzzz"});
|
||||||
|
expectedRanks.insert(expectedRanks.end(),{2147483647,2,0,0,0});
|
||||||
|
for(auto rank:{65534,65535,65536}) {
|
||||||
|
auto word=std::wstring(159,L'x')+wchar_t(L'y'+rank-65534);
|
||||||
|
text+="\n"+ks::utf8(word)+"\t"+std::to_string(rank);
|
||||||
|
words.push_back(word);expectedRanks.push_back(rank);
|
||||||
|
}
|
||||||
|
put(text);
|
||||||
|
if(ks::AutoCorrectorRegressionAccess::ranks(fixture,words)!=expectedRanks)
|
||||||
|
throw std::runtime_error("Block boundaries, duplicate ranks or maximum values failed");
|
||||||
|
for(const auto &invalid:std::vector<std::string>{"","missing-tab","x\t0","x\t-1","x\t2147483648",std::string(161,'x')+"\t1","\t1","x\t1bad"}) {
|
||||||
|
put(invalid);bool rejected=false;
|
||||||
|
try {ks::AutoCorrectorRegressionAccess::ranks(fixture,{L"x"});}catch(const std::runtime_error &){rejected=true;}
|
||||||
|
if(!rejected)throw std::runtime_error("Invalid dictionary was accepted");
|
||||||
|
}
|
||||||
|
const bool baseline=argc==2 && std::string(argv[1])=="--baseline";
|
||||||
|
if(!baseline && c.memoryBytes()>=1536*1024)throw std::runtime_error("Bundled dictionaries exceed compact memory budget");
|
||||||
|
std::cout<<"Passed complete dictionary equivalence and format regressions\n";
|
||||||
|
return 0;
|
||||||
|
}catch(const std::exception&e){std::cerr<<e.what()<<'\n';return 1;}
|
||||||
|
}
|
||||||
@@ -25,6 +25,38 @@ LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, U
|
|||||||
}
|
}
|
||||||
namespace ks {
|
namespace ks {
|
||||||
struct InputRegressionAccess {
|
struct InputRegressionAccess {
|
||||||
|
static bool idleUndo(App &a) {
|
||||||
|
a.last=LastTransform{}; a.last->at=100;
|
||||||
|
a.releaseIdleMemory(120100);
|
||||||
|
if (!a.last) return false;
|
||||||
|
a.busy=true;
|
||||||
|
a.releaseIdleMemory(120101);
|
||||||
|
if (!a.last) { a.busy=false; return false; }
|
||||||
|
a.busy=false;
|
||||||
|
a.releaseIdleMemory(120101);
|
||||||
|
return !a.last;
|
||||||
|
}
|
||||||
|
static bool releasedSync(const App &a) {
|
||||||
|
return a.syncSnapshot.capacity()==0 && a.syncResult.settings.capacity()==0 &&
|
||||||
|
a.syncResult.base.capacity()==0 && a.syncResult.conflicts.capacity()==0;
|
||||||
|
}
|
||||||
|
static bool completedSync(App &a, int mode) {
|
||||||
|
sampleConflicts(a);
|
||||||
|
auto conflicts=encodeSyncConflicts(a.pendingSyncConflicts);
|
||||||
|
a.syncConnection=loadSyncConfig(a.data);
|
||||||
|
a.syncSnapshot=encodeSettings(a.settings);
|
||||||
|
a.syncResult={mode!=1,sharedSyncSettings(a.settings),2,L"test"};
|
||||||
|
a.syncResult.base=a.syncResult.settings;
|
||||||
|
a.syncResult.conflicts=a.pendingSyncConflicts;
|
||||||
|
if(mode==2) a.syncResult.settings={1,2,3}; // Decode exception after joining.
|
||||||
|
a.syncWorker=std::thread([]{});
|
||||||
|
a.syncRunning=true; a.syncDone=true; a.busy=true;
|
||||||
|
a.pollSync();
|
||||||
|
bool retained=!a.syncSnapshot.empty() && a.syncRunning;
|
||||||
|
a.busy=false; a.pollSync();
|
||||||
|
return retained && releasedSync(a) && !a.syncRunning &&
|
||||||
|
encodeSyncConflicts(a.pendingSyncConflicts)==conflicts;
|
||||||
|
}
|
||||||
static inline bool delivery = true;
|
static inline bool delivery = true;
|
||||||
static inline int notifications = 0;
|
static inline int notifications = 0;
|
||||||
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||||
@@ -44,6 +76,7 @@ struct InputRegressionAccess {
|
|||||||
a.syncRunning=true; a.syncDone=true;
|
a.syncRunning=true; a.syncDone=true;
|
||||||
a.pollSync();
|
a.pollSync();
|
||||||
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
||||||
|
releasedSync(a) &&
|
||||||
!std::filesystem::exists(a.data/L"sync-base.bin");
|
!std::filesystem::exists(a.data/L"sync-base.bin");
|
||||||
}
|
}
|
||||||
static bool syncBackoff(App &a) {
|
static bool syncBackoff(App &a) {
|
||||||
@@ -791,6 +824,10 @@ int main(int argc, char **argv) {
|
|||||||
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||||
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||||
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
||||||
|
require(ks::InputRegressionAccess::idleUndo(app),"idle cleanup retains available/busy Undo and frees expired Undo");
|
||||||
|
for(int mode=0;mode<3;++mode)
|
||||||
|
require(ks::InputRegressionAccess::completedSync(app,mode),
|
||||||
|
"completed, failed and invalid sync release buffers but preserve conflict choices");
|
||||||
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
||||||
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
@@ -902,6 +939,25 @@ int main(int argc, char **argv) {
|
|||||||
SetDlgItemTextW(history,101,L"");
|
SetDlgItemTextW(history,101,L"");
|
||||||
require(pumpUntil(2),"clearing search restores history");
|
require(pumpUntil(2),"clearing search restores history");
|
||||||
require(GetFocus()==originalFocus,"history search preserves focus");
|
require(GetFocus()==originalFocus,"history search preserves focus");
|
||||||
|
SendDlgItemMessageW(history,102,LB_SETCURSEL,1,0);
|
||||||
|
ks::refreshHistory(app); // A result may still be in flight when closing.
|
||||||
|
SendMessageW(history,WM_CLOSE,0,0);
|
||||||
|
SendMessageW(history,WM_TIMER,1,0); // KillTimer cannot retract a posted timer message.
|
||||||
|
require(pumpUntil(0),"closing history releases list strings");
|
||||||
|
auto until=GetTickCount64()+250;
|
||||||
|
while(GetTickCount64()<until) {
|
||||||
|
MSG message{};
|
||||||
|
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
||||||
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
Sleep(5);
|
||||||
|
}
|
||||||
|
require(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==0,
|
||||||
|
"late search results do not refill closed history");
|
||||||
|
ks::refreshHistory(app);
|
||||||
|
require(pumpUntil(2),"history reloads after releasing hidden rows");
|
||||||
|
require(SendDlgItemMessageW(history,102,LB_GETCURSEL,0,0)==1,
|
||||||
|
"history retains selected record across close and reload");
|
||||||
}
|
}
|
||||||
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
||||||
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
||||||
|
|||||||
+17
-1
@@ -529,6 +529,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(text() == L"wrong ", "unrelated selection remains rejected with trimmed clipboard");
|
require(text() == L"wrong ", "unrelated selection remains rejected with trimmed clipboard");
|
||||||
app->settings.autoCorrect = true;
|
app->settings.autoCorrect = true;
|
||||||
app->settings.autoLanguages = {L"en", L"ru"};
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
// The worker now shares the predictor's immutable dictionaries.
|
||||||
|
// This fixture changes settings directly instead of applySettings.
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
app->autoCorrector = ks::AutoCorrector{};
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
prepare(L"ghbdtn ", false);
|
prepare(L"ghbdtn ", false);
|
||||||
@@ -556,9 +559,22 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
require(trimmedCopies >= 7, "fixture actually stripped spaces during recovery scenarios");
|
require(trimmedCopies >= 7, "fixture actually stripped spaces during recovery scenarios");
|
||||||
pasteBehavior = 0;
|
pasteBehavior = 0;
|
||||||
trimCopiedSpaces = false;
|
trimCopiedSpaces = false;
|
||||||
finish();
|
// User-selected middle range, followed by the same command with
|
||||||
|
// no selection: retain both surrounding text and trailing spaces.
|
||||||
|
prepare(L"OLDghbdtn NEXT", false);
|
||||||
|
SendMessageW(field, EM_SETSEL, 3, 11);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 58:
|
||||||
|
require(text() == L"OLDпривет NEXT", "explicit middle selection preserves surrounding text");
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 59:
|
||||||
|
require(text() == L"OLDghbdtn NEXT", "hotkey without selection reconverts only the previous middle range");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
case 32:
|
case 32:
|
||||||
case 34:
|
case 34:
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
static std::wstring programs;
|
static std::wstring programs;
|
||||||
static std::wstring activationExecutable, activationCommand;
|
static std::wstring activationExecutable, activationCommand;
|
||||||
static int activationCalls{};
|
static int activationCalls{};
|
||||||
|
static DWORD launchFlags{}; static bool inheritsHandles{};
|
||||||
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
||||||
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
LPSECURITY_ATTRIBUTES, BOOL inherit, DWORD flags, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
||||||
{
|
{
|
||||||
|
launchFlags=flags; inheritsHandles=inherit;
|
||||||
++activationCalls; activationExecutable=path; activationCommand=command;
|
++activationCalls; activationExecutable=path; activationCommand=command;
|
||||||
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,14 @@ int main()
|
|||||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
||||||
|
bool launchFailed=false;
|
||||||
|
try { ks::startScreenshotNotificationRegistration(); }
|
||||||
|
catch(const std::exception &) { launchFailed=true; }
|
||||||
|
if(!launchFailed || activationExecutable!=helper.wstring() ||
|
||||||
|
activationCommand!=L"\""+helper.wstring()+L"\" --register-notifications" ||
|
||||||
|
!(launchFlags&CREATE_NO_WINDOW) || inheritsHandles)
|
||||||
|
throw std::runtime_error("registration must launch the sibling helper and propagate failure");
|
||||||
|
activationCalls=0;
|
||||||
ks::Activator activator(helper.wstring(),false);
|
ks::Activator activator(helper.wstring(),false);
|
||||||
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
||||||
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
||||||
|
|||||||
+66
-14
@@ -23,20 +23,6 @@ void rejects(const std::function<void()> &f, const char *name)
|
|||||||
bool thrown = false;
|
bool thrown = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
{
|
|
||||||
std::map<std::wstring,int> previews;
|
|
||||||
std::set<std::wstring> visible;
|
|
||||||
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
|
||||||
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
|
||||||
ks::trimHistoryPreviews(previews,visible);
|
|
||||||
require(previews.size()==32,"preview cache stays bounded");
|
|
||||||
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
|
||||||
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
|
||||||
ks::trimHistoryPreviews(previews,visible);
|
|
||||||
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
|
||||||
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
|
||||||
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
|
||||||
}
|
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
@@ -60,6 +46,34 @@ int main(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Byte budgets must retain visible entries even when they alone exceed the budget.
|
||||||
|
{
|
||||||
|
std::map<std::wstring, size_t> cache{{L"a", 800}, {L"b", 800}, {L"c", 100}};
|
||||||
|
std::set<std::wstring> visible{L"b"};
|
||||||
|
auto weight=[](size_t bytes) { return bytes; };
|
||||||
|
ks::trimHistoryPreviews(cache, visible, 32, 1000, weight);
|
||||||
|
require(cache.size()==2 && cache.contains(L"b") && cache.contains(L"c"),
|
||||||
|
"byte budget evicts offscreen previews below count limit");
|
||||||
|
ks::trimHistoryPreviews(cache, visible, 32, 100, weight);
|
||||||
|
require(cache.size()==1 && cache.contains(L"b"), "oversized visible preview never thrashes");
|
||||||
|
visible.clear();
|
||||||
|
ks::trimHistoryPreviews(cache, visible, 32, 100, weight);
|
||||||
|
require(cache.empty(), "oversized preview is freed after leaving viewport");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::map<std::wstring,int> previews;
|
||||||
|
std::set<std::wstring> visible;
|
||||||
|
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
||||||
|
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"preview cache stays bounded");
|
||||||
|
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
||||||
|
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
||||||
|
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
||||||
|
}
|
||||||
{
|
{
|
||||||
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
||||||
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
||||||
@@ -99,6 +113,10 @@ int main(int argc, char **argv)
|
|||||||
"large diagnostic selections are explicitly truncated");
|
"large diagnostic selections are explicitly truncated");
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
ks::DiagnosticBuffer diagnostics;
|
ks::DiagnosticBuffer diagnostics;
|
||||||
|
diagnostics.record(L"idle-expiry", false, 0);
|
||||||
|
require(diagnostics.expire(30000)==0,"diagnostics remain available at expiry boundary");
|
||||||
|
require(diagnostics.expire(30001)==1,"idle expiry releases context without another log event");
|
||||||
|
require(diagnostics.expire(60000)==0,"repeated idle expiry is safe");
|
||||||
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
|
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
|
||||||
"successful diagnostics stay in memory");
|
"successful diagnostics stay in memory");
|
||||||
auto incident = diagnostics.record(L"failed-attempt", true, 200);
|
auto incident = diagnostics.record(L"failed-attempt", true, 200);
|
||||||
@@ -217,6 +235,26 @@ int main(int argc, char **argv)
|
|||||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||||
autoSettings.cycle = {L"en", L"uk"};
|
autoSettings.cycle = {L"en", L"uk"};
|
||||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
||||||
|
{
|
||||||
|
ks::AutoCorrector consumer;
|
||||||
|
consumer.shareDictionaries(corrector);
|
||||||
|
require(consumer.suggest(L"ghbdsn ", 0, layouts, autoSettings).has_value(),
|
||||||
|
"shared dictionaries produce the same correction");
|
||||||
|
consumer.reject(L"ghbdsn");
|
||||||
|
consumer.shareDictionaries(corrector);
|
||||||
|
require(!consumer.suggest(L"ghbdsn ", 0, layouts, autoSettings),
|
||||||
|
"refreshing shared dictionaries preserves consumer undo rejections");
|
||||||
|
require(corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings).has_value(),
|
||||||
|
"consumer rejections do not affect the predictor");
|
||||||
|
ks::AutoCorrector retained;
|
||||||
|
retained.shareDictionaries(corrector);
|
||||||
|
corrector.configure({}, {});
|
||||||
|
require(retained.suggest(L"ghbdsn ", 0, layouts, autoSettings).has_value(),
|
||||||
|
"in-flight dictionary snapshot survives source reset");
|
||||||
|
consumer.shareDictionaries(corrector);
|
||||||
|
require(consumer.memoryBytes() == 0, "disabled correction releases consumer dictionaries");
|
||||||
|
corrector.shareDictionaries(retained);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
auto options = autoSettings;
|
auto options = autoSettings;
|
||||||
options.autoTypos = true;
|
options.autoTypos = true;
|
||||||
@@ -545,6 +583,20 @@ int main(int argc, char **argv)
|
|||||||
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
||||||
}
|
}
|
||||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||||
|
{
|
||||||
|
auto png = ks::encodePng(pixels);
|
||||||
|
ks::Clip encoded{{{0, L"PNG", png}}};
|
||||||
|
for (int i=0; i<3; ++i) {
|
||||||
|
auto decoded=ks::decodeImage(encoded);
|
||||||
|
require(decoded.bgra==pixels.bgra && decoded.width==2 && decoded.height==1,
|
||||||
|
"borrowed PNG stream preserves decoded pixels on repeated reads");
|
||||||
|
require(encoded.formats[0].data==png,"PNG decoder never modifies the borrowed input");
|
||||||
|
}
|
||||||
|
ks::Clip fallback{{{0,L"PNG",{}},ks::imageDib(pixels)}};
|
||||||
|
require(ks::decodeImage(fallback).bgra==pixels.bgra,"empty PNG falls back to DIB");
|
||||||
|
ks::Clip invalid{{{0,L"PNG",{1,2,3}}}};
|
||||||
|
rejects([&] { ks::decodeImage(invalid); },"invalid borrowed PNG fails safely");
|
||||||
|
}
|
||||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||||
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
||||||
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
|
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
param([ValidateRange(1,20)][int]$Runs=3,[ValidateRange(100,10000)][int]$SettleMilliseconds=1500)
|
||||||
|
# Private resident pages are counted externally, so the measurement buffer is
|
||||||
|
# allocated in PowerShell, not in the measured process. Never trim working sets.
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
$root=Split-Path $PSScriptRoot -Parent
|
||||||
|
$output=Join-Path $root 'build/memory-components'
|
||||||
|
New-Item -ItemType Directory -Force $output | Out-Null
|
||||||
|
$exe=Join-Path $output 'probe.exe'
|
||||||
|
& "$root/.tools/w64devkit/bin/g++.exe" -std=c++20 -Os -static -s -DUNICODE -D_UNICODE -D_WIN32_WINNT=0x0A00 "$PSScriptRoot/memory-components.cpp" -o $exe -lole32 -loleaut32 -lshell32 -lpropsys -lshlwapi -luuid -luiautomationcore
|
||||||
|
if($LASTEXITCODE) { throw 'Probe compilation failed' }
|
||||||
|
Add-Type @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class ComponentWorkingSet {
|
||||||
|
[DllImport("psapi.dll", SetLastError=true)] static extern bool QueryWorkingSet(IntPtr process,IntPtr buffer,int size);
|
||||||
|
public static long PrivateBytes(IntPtr process) {
|
||||||
|
int size=4*1024*1024;
|
||||||
|
IntPtr buffer=Marshal.AllocHGlobal(size);
|
||||||
|
try {
|
||||||
|
if(!QueryWorkingSet(process,buffer,size)) throw new System.ComponentModel.Win32Exception();
|
||||||
|
long count=Marshal.ReadIntPtr(buffer).ToInt64(), pages=0;
|
||||||
|
if(count>(size/IntPtr.Size)-1) throw new InvalidOperationException("Working set buffer is too small");
|
||||||
|
for(int i=0;i<count;i++) if((Marshal.ReadIntPtr(buffer,(i+1)*IntPtr.Size).ToInt64() & 256)==0) pages++;
|
||||||
|
return pages*Environment.SystemPageSize;
|
||||||
|
} finally { Marshal.FreeHGlobal(buffer); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
$rows=[Collections.Generic.List[object]]::new()
|
||||||
|
foreach($run in 1..$Runs) {
|
||||||
|
foreach($mode in @('baseline','sta','mta','shell','uia','focused','combined')) {
|
||||||
|
$start=[Diagnostics.ProcessStartInfo]::new($exe,$mode)
|
||||||
|
$start.UseShellExecute=$false; $start.CreateNoWindow=$true
|
||||||
|
$start.RedirectStandardOutput=$true; $start.RedirectStandardInput=$true; $start.RedirectStandardError=$true
|
||||||
|
$process=[Diagnostics.Process]::Start($start)
|
||||||
|
$errors=$process.StandardError.ReadToEndAsync()
|
||||||
|
try {
|
||||||
|
foreach($expected in @('baseline','com','active','released','uninitialized')) {
|
||||||
|
$pending=$process.StandardOutput.ReadLineAsync()
|
||||||
|
if(!$pending.Wait(15000)) { throw "Timeout: $mode/$expected" }
|
||||||
|
$phase=$pending.Result
|
||||||
|
if($phase -ne $expected) { throw "Unexpected phase '$phase', expected $expected" }
|
||||||
|
Start-Sleep -Milliseconds $SettleMilliseconds
|
||||||
|
$process.Refresh()
|
||||||
|
$rows.Add([pscustomobject]@{Run=$run;Mode=$mode;Phase=$phase;PrivateBytes=$process.PrivateMemorySize64;PrivateWorkingSet=[ComponentWorkingSet]::PrivateBytes($process.Handle);WorkingSet=$process.WorkingSet64;Threads=$process.Threads.Count;Modules=@($process.Modules | ForEach-Object ModuleName)})
|
||||||
|
$process.StandardInput.Write("`n"); $process.StandardInput.Flush()
|
||||||
|
}
|
||||||
|
if(!$process.WaitForExit(15000)) { throw 'Exit timeout' }
|
||||||
|
if($process.ExitCode) { throw "Probe failed: $($errors.Result)" }
|
||||||
|
$errors.Result | Set-Content (Join-Path $output "$run-$mode.txt")
|
||||||
|
} finally {
|
||||||
|
if(!$process.HasExited) { $process.Kill(); $process.WaitForExit() }
|
||||||
|
$process.Dispose()
|
||||||
|
$rows | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $output 'results.json')
|
||||||
|
}
|
||||||
|
Write-Host "Completed $run/$Runs $mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows | Group-Object Mode,Phase | ForEach-Object {
|
||||||
|
[pscustomobject]@{Case=$_.Name;PrivateMiB=[math]::Round(($_.Group.PrivateBytes | Measure-Object -Average).Average/1MB,3);PrivateWorkingSetMiB=[math]::Round(($_.Group.PrivateWorkingSet | Measure-Object -Average).Average/1MB,3)}
|
||||||
|
} | Format-Table
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Standalone attribution probe. No clipboard, registry or user input mutations.
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <propkey.h>
|
||||||
|
#include <propvarutil.h>
|
||||||
|
#include <uiautomation.h>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
static void check(HRESULT hr) {
|
||||||
|
if (FAILED(hr)) { fprintf(stderr,"HRESULT=%08lx\n",(unsigned long)hr); exit(2); }
|
||||||
|
}
|
||||||
|
static void phase(const char *name) {
|
||||||
|
printf("%s\n",name); fflush(stdout);
|
||||||
|
if (getchar()==EOF) exit(3);
|
||||||
|
}
|
||||||
|
int main(int argc,char **argv) {
|
||||||
|
const char *mode=argc>1?argv[1]:"baseline";
|
||||||
|
const bool baseline=!strcmp(mode,"baseline");
|
||||||
|
const bool shell=!strcmp(mode,"shell") || !strcmp(mode,"combined");
|
||||||
|
const bool uia=!strcmp(mode,"uia") || !strcmp(mode,"focused") || !strcmp(mode,"combined");
|
||||||
|
phase("baseline");
|
||||||
|
if(!baseline) check(CoInitializeEx(nullptr,!strcmp(mode,"sta") || shell ? COINIT_APARTMENTTHREADED : COINIT_MULTITHREADED));
|
||||||
|
phase("com");
|
||||||
|
IShellLinkW *link{}; IPropertyStore *store{}; IPersistFile *file{};
|
||||||
|
IUIAutomation2 *automation{}; IUIAutomationElement *element{}; IUIAutomationTextPattern *pattern{};
|
||||||
|
wchar_t temporary[MAX_PATH]{},shortcut[MAX_PATH]{},exe[MAX_PATH]{};
|
||||||
|
if(shell) {
|
||||||
|
check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
||||||
|
GetModuleFileNameW(nullptr,exe,MAX_PATH);
|
||||||
|
check(link->SetPath(exe)); check(link->SetIconLocation(exe,0));
|
||||||
|
check(link->QueryInterface(IID_PPV_ARGS(&store)));
|
||||||
|
PROPVARIANT value{}; check(InitPropVariantFromString(L"KSwitcher.MemoryProbe",&value));
|
||||||
|
check(store->SetValue(PKEY_AppUserModel_ID,value)); PropVariantClear(&value);
|
||||||
|
GUID id{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||||
|
value.vt=VT_CLSID; value.puuid=&id;
|
||||||
|
check(store->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,value)); check(store->Commit());
|
||||||
|
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs)); CoTaskMemFree(programs);
|
||||||
|
GetTempPathW(MAX_PATH,temporary); if(!GetTempFileNameW(temporary,L"ksm",0,shortcut)) return 4;
|
||||||
|
check(link->QueryInterface(IID_PPV_ARGS(&file))); check(file->Save(shortcut,TRUE));
|
||||||
|
}
|
||||||
|
if(uia) {
|
||||||
|
check(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&automation)));
|
||||||
|
check(automation->put_ConnectionTimeout(200)); check(automation->put_TransactionTimeout(200));
|
||||||
|
if(strcmp(mode,"uia")) {
|
||||||
|
HRESULT focused=automation->GetFocusedElement(&element);
|
||||||
|
fprintf(stderr,"focus=%08lx\n",(unsigned long)focused);
|
||||||
|
if(SUCCEEDED(focused) && element) {
|
||||||
|
BOOL password=TRUE,enabled=FALSE; CONTROLTYPEID type{};
|
||||||
|
element->get_CurrentIsPassword(&password); element->get_CurrentIsEnabled(&enabled); element->get_CurrentControlType(&type);
|
||||||
|
fprintf(stderr,"type=%d enabled=%d protected=%d\n",type,enabled,password);
|
||||||
|
if(!password) fprintf(stderr,"pattern=%08lx\n",(unsigned long)element->GetCurrentPatternAs(UIA_TextPatternId,IID_PPV_ARGS(&pattern)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase("active");
|
||||||
|
if(pattern) pattern->Release(); if(element) element->Release(); if(automation) automation->Release();
|
||||||
|
if(file) file->Release(); if(store) store->Release(); if(link) link->Release();
|
||||||
|
if(shortcut[0]) DeleteFileW(shortcut);
|
||||||
|
phase("released");
|
||||||
|
if(!baseline) { CoFreeUnusedLibrariesEx(0,0); CoUninitialize(); }
|
||||||
|
phase("uninitialized");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user