diff --git a/CMakeLists.txt b/CMakeLists.txt index 34301ea..ebd0441 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.1.0 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.2.0 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index bf47e26..4c74a2e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,29 @@ Pressing the history shortcut a second time pastes the second-most-recent item. Data is in `data/` beside the executable, falling back to `%LOCALAPPDATA%/KSwitcher` when the executable's directory is not writable. Each clipboard payload is an encrypted disk file; only bounded previews/metadata stay in memory. Image data is loaded on demand and released when -the history popup is hidden. The log contains operational messages, never copied text or keystrokes. +the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all +log writes. Disabling it also clears diagnostic context from memory; existing log files remain. +Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds. +Only failures or suspected repeated commands flush this context to `kswitcher.log`; flushed +context is discarded. Ordinary application errors are also logged when logging is enabled. +Incident context contains tracked physical input, the expected word, copied text, conversion result, +and layout IDs, correlated by request ID even when verification fails. Text fields use quoted, +escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with +original length and a truncation flag; each field is limited to 512 UTF-16 code units. +Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including +busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests +record the process, window, input version, and repeated attempts within ten seconds without new +input. A second repeated manual command triggers a suspicion, not proof of failure; intentional +layout cycling can also trigger it. An unchanged result containing no letters, or a direct command +to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows +accepted the paste shortcut; it does not prove that the editor applied the replacement. +The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates. +Open **View log…** from Settings or the tray menu to read logs inside KSwitcher. +Choose the current or previous log and press **Refresh** to reload. The ListView shows Time, +Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy +tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled. +**Clear log** clears only the selected current or previous file and refreshes the table. +Clearing the current log also discards buffered diagnostic context; new problems can still be logged. Settings **Import** accepts a [Typlo](https://github.com/bitcodepro/Typlo) `settings.json`, preserving supported Windows layout IDs, hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing [Typlo](https://github.com/bitcodepro/Typlo) files are diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 795313b..825dd5e 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 09e96d6..88edd08 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,5 +1,18 @@ # Latest Windows release +## v0.2.0 — 2026-10-28 + +- Log failed conversions and suspicious repeated hotkeys with recent attempt context. +- Include bounded text and layout diagnostics to help reproduce conversion failures. +- Add the **Write log** setting and preserve the previous log during rotation. +- View current and previous logs inside the app with Time, Attempt, Event, and Details columns. +- Copy selected rows and clear the selected log from the viewer. +- Fix the log viewer closing on focus; give column headers a distinct background. + +Repeated hotkeys are diagnostic clues, not proof of failure. Successful paste dispatch does not +verify that the target editor applied the replacement. Text diagnostics are enabled by default +and can be disabled with **Write log**. + Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder, and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable. The archive contains the application, offline dictionaries, documentation and license notices. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 6a59d9f..8fc6ec1 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip +7138fee6aa1e9c5e66aa212798c4cc193a987f088a45ec37a355b46cf9a40d08 KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 78a816c..ab4f6e4 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -3,8 +3,8 @@ IDI_KSWITCHER ICON "kswitcher.ico" 1 RT_MANIFEST "app.manifest" 1 VERSIONINFO - FILEVERSION 0,1,0,0 - PRODUCTVERSION 0,1,0,0 + FILEVERSION 0,2,0,0 + PRODUCTVERSION 0,2,0,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -14,7 +14,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.1.0" + VALUE "FileVersion", "0.2.0" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index dd1498f..a298968 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -125,7 +125,10 @@ class Transaction write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64()))); DWORD before = expected; if (!valid() || !chord(L'C', MOD_CONTROL)) + { + diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked"); return {}; + } ULONGLONG deadline = GetTickCount64() + 2000; DWORD changed = before; ULONGLONG stable = 0; @@ -146,7 +149,11 @@ class Transaction Sleep(10); } if (changed == before || !valid()) + { + diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" : + L"Copy failed: context changed"); return {}; + } if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) || changed != GetClipboardSequenceNumber()) throw std::runtime_error("Operation cancelled: copy did not belong to the target application"); @@ -159,13 +166,19 @@ class Transaction { if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" || f.name == L"MSDEVLineSelect") + { + diagnosticEvent(L"Copy rejected: editor copied line without selection"); return {}; + } if (f.name == L"vscode-editor-data") { std::string json(f.data.begin(), f.data.end()); if (json.find("\"isFromEmptySelection\":true") != json.npos || json.find("\"isFromEmptySelection\": true") != json.npos) + { + diagnosticEvent(L"Copy rejected: editor reported empty selection"); return {}; + } } } return text; @@ -188,7 +201,7 @@ class Transaction auto text = copy(); if (valid() && matches(text)) { - log(L"Selection: verified word boundary"); + diagnosticEvent(L"Selection: verified word boundary"); return text; } if (!valid() || !chord(VK_RIGHT)) @@ -230,6 +243,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan settings = loadSettings(data / L"missing-defaults.json", layouts); registrationWarning = L"Settings were not loaded: " + wide(e.what()); } + setLoggingEnabled(settings.writeLog); try { history = std::make_unique(data / L"history"); @@ -349,6 +363,7 @@ void App::applySettings(const Settings &s) { std::lock_guard lock(stateMutex); settings = s; + setLoggingEnabled(s.writeLog); run.reset(); last.reset(); ++inputVersion; @@ -587,9 +602,36 @@ void App::dispatch(int command) } void App::transform(int command, int snippet) { - if (busy.exchange(true)) - return; + const auto attempt = ++transformAttempt; + const auto requestedAt = GetTickCount64(); HWND target = GetForegroundWindow(); + const bool manual = command != AutoCorrect && snippet < 0; + if (manual) + { + const auto currentVersion = inputVersion.load(); + repeatAttempts = target == previousAttemptWindow && command == previousAttemptCommand && + currentVersion == previousAttemptVersion && + requestedAt - previousAttemptAt < 10000 + ? repeatAttempts + 1 : 1; + previousAttemptWindow = target; + previousAttemptCommand = command; + previousAttemptVersion = currentVersion; + previousAttemptAt = requestedAt; + diagnosticEvent(L"Transform request id=" + std::to_wstring(attempt) + L" command=" + + std::to_wstring(command) + L" process=" + processName(target) + + L" window=" + std::to_wstring(reinterpret_cast(target)) + + L" version=" + std::to_wstring(currentVersion) + + L" repeat=" + std::to_wstring(repeatAttempts)); + if (repeatAttempts >= 2) + diagnosticEvent(L"Transform suspicion id=" + std::to_wstring(attempt) + + L" reason=repeated-command-without-input (may be intentional cycling)"); + } + if (busy.exchange(true)) + { + if (manual) + diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true); + return; + } Run recent; std::optional previous; Settings config; @@ -604,7 +646,8 @@ void App::transform(int command, int snippet) version = inputVersion; } enqueue( - [this, target, recent, previous, config, available, version, command, snippet] + [this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, + suspicious = manual && repeatAttempts >= 2] { struct BusyReset { @@ -615,6 +658,48 @@ void App::transform(int command, int snippet) } } reset{busy}; auto start = GetTickCount64(); + std::wstring reason = L"cancelled-before-selection"; + std::wstring text, result, expected; + int hint = -1, to = command >= Direct ? command - Direct : -1; + if (manual) + { + std::wstring layoutIds; + for (const auto &layout : available) + { + layoutIds += layout.id + L","; + if (layout.handle == recent.layout) + for (wchar_t key : recent.keys) + expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key)); + } + diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) + + L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) + + L" trackedExpected=" + diagnosticText(expected)); + } + struct Audit + { + App &app; + HWND target; + uint64_t version, attempt; + ULONGLONG start; + bool enabled; + bool suspicious; + std::wstring &reason; + std::wstring &text, &result, &expected; + int &hint, &to; + ~Audit() + { + if (enabled) + diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason + + L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) + + L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) + + L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) + + L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) + + L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) + + L" result=" + diagnosticText(result), + suspicious || (reason != L"paste-sent-unverified" && reason != L"layout-only" && + reason != L"unchanged-expected")); + } + } audit{*this, target, version, attempt, requestedAt, manual, suspicious, reason, text, result, expected, hint, to}; try { auto valid = [&] @@ -627,16 +712,16 @@ void App::transform(int command, int snippet) releaseModifiers(); if (!valid()) return; - int hint = -1; for (int i = 0; i < (int)available.size(); ++i) if (available[i].handle == windowLayout(target)) hint = i; - int to = command >= Direct ? command - Direct : -1; + reason = L"invalid-target-layout"; if (to >= (int)available.size()) return; if (to >= 0 && !available[to].convertible) { - switchWindowLayout(target, available[to].handle, valid); + reason = switchWindowLayout(target, available[to].handle, valid) + ? L"layout-only" : L"layout-switch-failed"; return; } if (command == AutoCorrect) @@ -654,12 +739,16 @@ void App::transform(int command, int snippet) !autoCorrectionAllowed(target, config) || !valid()) return; } + reason = L"clipboard-snapshot"; Transaction tx(*this, target, version); - log(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) + + 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=" + std::to_wstring(recent.keys.size()) + L" sameWindow=" + - std::to_wstring(recent.window == target)); - std::wstring text, result; + std::to_wstring(recent.window == target) + L" overflow=" + + std::to_wstring(recent.overflow) + L" ageMs=" + + std::to_wstring(recent.at ? start - recent.at : 0) + + L" id=" + std::to_wstring(attempt)); bool automatic = false; if (command == AutoCorrect) { @@ -692,6 +781,9 @@ void App::transform(int command, int snippet) } else if (command == Undo) { + if (previous) + expected = previous->result; + reason = L"undo-unavailable"; if (!previous || previous->window != target || previous->version != version || GetTickCount64() - previous->at > 120000) return; @@ -700,6 +792,7 @@ void App::transform(int command, int snippet) { return newlines(candidate) == newlines(previous->result); }); if (newlines(text) != newlines(previous->result)) { + reason = L"undo-verification-mismatch"; if (tx.valid()) chord(VK_RIGHT); return; @@ -738,10 +831,11 @@ void App::transform(int command, int snippet) tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate) { return recent.match(candidate, available, physicalLayout()) >= 0; }); hint = recent.match(text, available, physicalLayout()); - log(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" + + diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" + std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint)); if (hint < 0) { + reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch"; if (tx.valid()) chord(VK_RIGHT); return; @@ -751,11 +845,13 @@ void App::transform(int command, int snippet) else if (config.recent && previous && previous->window == target && previous->version == version) { + expected = previous->result; text = tx.copyRange(caretSteps(previous->result), previous->result, [&](const std::wstring &candidate) { return newlines(candidate) == newlines(previous->result); }); if (newlines(text) != newlines(previous->result)) { + reason = L"previous-result-verification-mismatch"; if (tx.valid()) chord(VK_RIGHT); return; @@ -765,10 +861,12 @@ void App::transform(int command, int snippet) } else text = tx.copy(); + reason = L"context-changed-after-copy"; if (!tx.valid()) return; if (text.empty()) { + reason = L"copy-empty"; if (to >= 0 && config.switchLayout) switchWindowLayout(target, available[to].handle, valid); return; @@ -790,6 +888,7 @@ void App::transform(int command, int snippet) cycle.push_back(i); if (cycle.size() < 2) { + reason = L"cycle-has-fewer-than-two-layouts"; if (automatic && tx.valid()) chord(VK_RIGHT); return; @@ -804,12 +903,20 @@ void App::transform(int command, int snippet) } } bool sent = false; - log(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) + + diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) + L" changed=" + std::to_wstring(result != text)); if (result != text) sent = tx.paste(textClip(result)); else if (automatic && tx.valid()) chord(VK_RIGHT); + reason = result == text ? L"conversion-unchanged" : + sent ? L"paste-sent-unverified" : L"paste-cancelled"; + if (result == text && + (std::none_of(text.begin(), text.end(), letter) || + (command >= Direct && detect(text, available, hint) == to))) + reason = L"unchanged-expected"; + if (sent && !tx.valid()) + reason = L"context-changed-after-paste"; if (sent && command == Undo && previous && previous->autoCorrected) autoCorrector.reject(previous->original, config); if (tx.valid() && (sent || automatic)) @@ -835,12 +942,15 @@ void App::transform(int command, int snippet) if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text)) { bool switched = switchWindowLayout(target, available[to].handle, valid); - log(L"Layout switch confirmed=" + std::to_wstring(switched)); + diagnosticEvent(L"Layout switch confirmed=" + std::to_wstring(switched)); + if (!switched) + reason += L";layout-switch-failed"; } - log(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms"); + diagnosticEvent(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms"); } catch (const std::exception &e) { + reason = L"exception:" + wide(e.what()); report(wide(e.what())); } }); @@ -984,6 +1094,7 @@ void App::trayMenu() AppendMenuW(pause, MF_STRING, 23, L"Until restart"); AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history"); AppendMenuW(menu, MF_STRING, 15, L"Open data folder"); + AppendMenuW(menu, MF_STRING, 18, L"View log…"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher"); AppendMenuW(menu, MF_STRING, 17, L"Exit"); @@ -1022,7 +1133,7 @@ void App::trayMenu() break; case 16: MessageBoxW(window, - L"KSwitcher 0.1.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on " + L"KSwitcher 0.2.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on " L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows " L"account.\nNo .NET, WebView or graphics framework.", L"About KSwitcher", MB_OK); @@ -1030,6 +1141,9 @@ void App::trayMenu() case 17: PostMessageW(window, WM_CLOSE, 0, 0); break; + case 18: + showLogs(*this); + break; case 20: pauseUntil = 0; break; @@ -1225,6 +1339,8 @@ int App::execute() continue; if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg)) continue; + if (logWindow && IsDialogMessageW(logWindow, &msg)) + continue; TranslateMessage(&msg); DispatchMessageW(&msg); } diff --git a/src/app.h b/src/app.h index 1bfa719..ca84eff 100644 --- a/src/app.h +++ b/src/app.h @@ -42,6 +42,7 @@ class App std::atomic pauseUntil{0}; HWND pasteTarget{}; HWND hotkeyCapture{}; + HWND logWindow{}; std::mutex stateMutex; Run run; std::optional last; @@ -77,6 +78,10 @@ class App bool stop{}; UINT taskbarCreated{}; uint64_t pendingAutoVersion{}; + uint64_t transformAttempt{}, previousAttemptVersion{}; + ULONGLONG previousAttemptAt{}; + HWND previousAttemptWindow{}; + int previousAttemptCommand{}, repeatAttempts{}; std::vector> hookBindings; HotkeyHook hotkeyHook; static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM); @@ -86,6 +91,7 @@ class App void showHistory(App &); void refreshHistory(App &); void showSettings(App &); +void showLogs(App &); void showSnippets(App &); void showShortcuts(App &); void showEntry(App &, const std::wstring &); diff --git a/src/core.h b/src/core.h index a83ae87..93f61b5 100644 --- a/src/core.h +++ b/src/core.h @@ -47,6 +47,7 @@ struct Settings bool recent = true, switchLayout = true, history = true, autostart = false, awake = false; bool elevatedAutostart = false; bool autoCorrect = false; + bool writeLog = true; int autoMaxRank = 0; int autoMinLength = 3; std::wstring autoPrefixChars = L"([{«“‘\""; diff --git a/src/platform.cpp b/src/platform.cpp index 5c08f16..d9e9e29 100644 --- a/src/platform.cpp +++ b/src/platform.cpp @@ -527,16 +527,112 @@ std::wstring lastError(const std::wstring &context) { return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")"; } +std::wstring diagnosticText(const std::wstring &text) +{ + // Escape UTF-16 code units so invisible characters and malformed surrogate pairs + // can be reproduced without introducing fake log lines. Bound large selections. + std::wstring out = L"\""; + constexpr wchar_t hex[] = L"0123456789abcdef"; + const auto count = std::min(text.size(), 512); + for (size_t i = 0; i < count; ++i) + { + auto c = static_cast(text[i]); + if (c == L'"' || c == L'\\') + { + out += L'\\'; + out += c; + } + else if (c < 32 || c >= 127) + { + out += L"\\u"; + for (int shift = 12; shift >= 0; shift -= 4) + out += hex[(c >> shift) & 15]; + } + else + out += c; + } + return out + L"\" units=" + std::to_wstring(text.size()) + + L" truncated=" + std::to_wstring(count != text.size()); +} +namespace +{ +std::recursive_mutex logGate; +bool loggingEnabled = false; +DiagnosticBuffer diagnosticBuffer; +} +std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now) +{ + while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000)) + entries.pop_front(); + entries.emplace_back(now, message); + if (!problem) + return {}; + std::wstring out = L"Diagnostic incident (preceding context, up to 30 seconds):"; + for (const auto &[at, entry] : entries) + out += L"\n ageMs=" + std::to_wstring(now - at) + L" " + entry; + entries.clear(); + return out; +} +void setLoggingEnabled(bool enabled) +{ + std::lock_guard lock(logGate); + loggingEnabled = enabled; + if (!enabled) + diagnosticBuffer.clear(); +} +std::wstring readDiagnosticLog(bool previous) +{ + std::lock_guard lock(logGate); + auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log"); + if (!std::filesystem::exists(path)) + return previous ? L"No previous log is available yet." : L"No log yet. Entries will appear when a problem is detected."; + auto bytes = readFile(path, 2 * 1024 * 1024); + auto text = wide(std::string(bytes.begin(), bytes.end())); + std::wstring display; + for (size_t i = 0; i < text.size(); ++i) + { + if (text[i] == L'\n' && (i == 0 || text[i - 1] != L'\r')) + display += L'\r'; + display += text[i]; + } + return display.empty() ? L"The log is empty." : display; +} +void clearDiagnosticLog(bool previous) +{ + // Serialize clearing with writes and rotation. Never delete the other log. + std::lock_guard lock(logGate); + auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log"); + std::filesystem::remove(path); + if (!previous) + diagnosticBuffer.clear(); +} +void diagnosticEvent(const std::wstring &message, bool problem) +{ + std::lock_guard lock(logGate); + if (!loggingEnabled) + return; + auto incident = diagnosticBuffer.record(message, problem, GetTickCount64()); + if (!incident.empty()) + log(incident); +} void log(const std::wstring &message) { - static std::mutex gate; - std::lock_guard lock(gate); + std::lock_guard lock(logGate); + if (!loggingEnabled) + return; try { auto path = dataDirectory() / L"kswitcher.log"; std::error_code ec; - if (std::filesystem::file_size(path, ec) > 256 * 1024) - std::filesystem::remove(path, ec); + auto size = std::filesystem::file_size(path, ec); + if (!ec && size > 256 * 1024) + { + auto previous = path; + previous += L".1"; + std::filesystem::remove(previous, ec); + ec.clear(); + std::filesystem::rename(path, previous, ec); + } std::ofstream f(path, std::ios::app); SYSTEMTIME t; GetLocalTime(&t); diff --git a/src/platform.h b/src/platform.h index 8bc94a1..becade6 100644 --- a/src/platform.h +++ b/src/platform.h @@ -1,6 +1,7 @@ #pragma once #include "core.h" #include +#include #include #include #include @@ -50,4 +51,17 @@ void configureAutostart(int mode); std::wstring executable(); std::wstring lastError(const std::wstring &context); void log(const std::wstring &message); +std::wstring diagnosticText(const std::wstring &text); +// A bounded, short-lived context. A problem drains it, so old incidents are not repeated. +class DiagnosticBuffer +{ + std::deque> entries; + public: + std::wstring record(const std::wstring &message, bool problem, ULONGLONG now); + void clear() { entries.clear(); } +}; +void setLoggingEnabled(bool enabled); +void diagnosticEvent(const std::wstring &message, bool problem = false); +std::wstring readDiagnosticLog(bool previous); +void clearDiagnosticLog(bool previous); } // namespace ks diff --git a/src/storage.cpp b/src/storage.cpp index d700ede..67f458f 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -119,6 +119,7 @@ Settings loadSettings(const std::filesystem::path &path, const std::vectordraft; check(w, 201, s.recent); check(w, 202, s.switchLayout); + check(w, 208, s.writeLog); check(w, 203, s.history); check(w, 204, s.autostart); check(w, 206, s.elevatedAutostart); @@ -693,6 +694,7 @@ Settings collectSettings(HWND w, SettingsView *v) auto s = v->draft; s.recent = checked(w, 201); s.switchLayout = checked(w, 202); + s.writeLog = checked(w, 208); s.history = checked(w, 203); s.autostart = checked(w, 204); s.elevatedAutostart = checked(w, 206); @@ -908,6 +910,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) layoutSelection(w, v); return 0; } + if (id == 256) + { + showLogs(*v->app); + return 0; + } if (id == 230) { collectLayout(w, v); @@ -1195,6 +1202,251 @@ void showHistory(App &app) SetFocus(GetDlgItem(app.historyWindow, 101)); refreshHistory(app); } +namespace +{ +constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605, + LogClear = 606; +LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) +{ + // Header notifications go to the ListView, not its top-level parent. + if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) && + ((NMHDR *)lp)->code == NM_CUSTOMDRAW) + { + auto *draw = (NMCUSTOMDRAW *)lp; + if (draw->dwDrawStage == CDDS_PREPAINT) + { + FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE)); + return CDRF_NOTIFYITEMDRAW; + } + if (draw->dwDrawStage == CDDS_ITEMPREPAINT) + { + int saved = SaveDC(draw->hdc); + FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE)); + RECT edge = draw->rc; + edge.top = edge.bottom - 1; + FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW)); + edge = draw->rc; + edge.left = edge.right - 1; + FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW)); + wchar_t title[128]{}; + HDITEMW item{}; + item.mask = HDI_TEXT; + item.pszText = title; + item.cchTextMax = 128; + Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item); + auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0); + if (headerFont) + SelectObject(draw->hdc, headerFont); + SetBkMode(draw->hdc, TRANSPARENT); + SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT)); + RECT text = draw->rc; + InflateRect(&text, -scale(w, 6), 0); + DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX); + RestoreDC(draw->hdc, saved); + return CDRF_SKIPDEFAULT; + } + } + if (msg == WM_NCDESTROY) + RemoveWindowSubclass(w, logListProc, 1); + return DefSubclassProc(w, msg, wp, lp); +} +void copyLogRows(HWND w, App &app) +{ + HWND list = GetDlgItem(w, LogRows); + std::wstring copied; + for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0; + row = ListView_GetNextItem(list, row, LVNI_SELECTED)) + { + for (int column = 0; column < 4; ++column) + { + std::vector text(32768); + ListView_GetItemText(list, row, column, text.data(), (int)text.size()); + if (column) + copied += L'\t'; + copied += text.data(); + } + copied += L"\r\n"; + } + if (!copied.empty()) + app.ownSequence = writeClipboard(app.window, textClip(copied)); +} +void refreshLogView(HWND w, App &app) +{ + const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1; + std::wstring contents; + try + { + contents = readDiagnosticLog(previous); + } + catch (const std::exception &e) + { + contents = L"Could not read the log: " + wide(e.what()); + } + HWND list = GetDlgItem(w, LogRows); + SendMessageW(list, WM_SETREDRAW, FALSE, 0); + ListView_DeleteAllItems(list); + std::wistringstream lines(contents); + std::wstring line, timestamp; + int row = 0; + while (std::getline(lines, line)) + { + if (!line.empty() && line.back() == L'\r') + line.pop_back(); + if (line.empty()) + continue; + auto firstSpace = line.find(L' '); + auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1); + if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos) + { + timestamp = line.substr(0, secondSpace); + line.erase(0, secondSpace + 1); + } + auto id = line.find(L" id="); + std::wstring attempt; + if (id != line.npos) + { + auto end = line.find(L' ', id + 4); + attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4); + } + std::wstring event = line; + if (event.starts_with(L" ageMs=")) + { + auto end = event.find(L' ', 8); + if (end != event.npos) + event.erase(0, end + 1); + } + auto equals = event.find(L'='); + if (equals != event.npos) + { + auto key = event.rfind(L' ', equals); + if (key != event.npos) + event.resize(key); + } + LVITEMW item{}; + item.mask = LVIF_TEXT; + item.iItem = row; + item.pszText = timestamp.data(); + ListView_InsertItem(list, &item); + ListView_SetItemText(list, row, 1, attempt.data()); + ListView_SetItemText(list, row, 2, event.data()); + ListView_SetItemText(list, row, 3, line.data()); + ++row; + } + SendMessageW(list, WM_SETREDRAW, TRUE, 0); + InvalidateRect(list, nullptr, TRUE); + if (row) + ListView_EnsureVisible(list, row - 1, FALSE); + value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: problems and suspicious repeats only." + : L"Logging disabled. Showing previously saved entries."); +} +LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) +{ + auto *app = reinterpret_cast(GetWindowLongPtrW(w, GWLP_USERDATA)); + if (msg == WM_NCCREATE) + { + SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams); + return TRUE; + } + if (!app) + return DefWindowProcW(w, msg, wp, lp); + switch (msg) + { + case WM_COMMAND: + if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) || + (LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE)) + { + refreshLogView(w, *app); + return 0; + } + if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED) + { + try { copyLogRows(w, *app); } + catch (const std::exception &e) { error(w, e); } + return 0; + } + if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED) + { + try + { + clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1); + refreshLogView(w, *app); + } + catch (const std::exception &e) { error(w, e); } + return 0; + } + if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED) + break; + [[fallthrough]]; + case WM_CLOSE: + DestroyWindow(w); + return 0; + case WM_NOTIFY: + if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN) + { + auto key = ((NMLVKEYDOWN *)lp)->wVKey; + if (GetKeyState(VK_CONTROL) & 0x8000) + { + if (key == 'A') + ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED); + if (key == 'C') + { + try { copyLogRows(w, *app); } + catch (const std::exception &e) { error(w, e); } + } + } + } + return 0; + case WM_DPICHANGED: + resizeForDpi(w, wp, lp); + return 0; + case WM_NCDESTROY: + app->logWindow = nullptr; + disposeFont(w); + break; + } + return DefWindowProcW(w, msg, wp, lp); +} +} +void showLogs(App &app) +{ + if (app.logWindow) + { + showRequestedWindow(app.logWindow); + activateWindow(app.logWindow); + return; + } + auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590); + app.logWindow = w; + auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140); + SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log"); + SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1"); + SendMessageW(choice, CB_SETCURSEL, 0, 0); + button(w, L"Refresh", LogRefresh, 330, 14, 110); + button(w, L"Copy selected", LogCopy, 454, 14, 140); + button(w, L"Clear log", LogClear, 608, 14, 110); + control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22); + INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES}; + InitCommonControlsEx(&common); + auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS, + LogRows, 16, 82, 868, 454); + ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER); + SetWindowSubclass(list, logListProc, 1, 0); + const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"}; + const int widths[] = {155, 65, 195, 900}; + for (int i = 0; i < 4; ++i) + { + LVCOLUMNW column{}; + column.mask = LVCF_TEXT | LVCF_WIDTH; + column.pszText = const_cast(titles[i]); + column.cx = scale(w, widths[i]); + ListView_InsertColumn(list, i, &column); + } + label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720); + button(w, L"Close", IDCANCEL, 774, 548, 110); + refreshLogView(w, app); + showRequestedWindow(w); + activateWindow(w); +} void showSettings(App &app) { if (app.settingsWindow) @@ -1214,7 +1466,8 @@ void showSettings(App &app) checkbox(w, L"Record clipboard history", 203, 18, 106); checkbox(w, L"Start with Windows", 204, 18, 133); checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395); - checkbox(w, L"Keep screen awake", 205, 18, 184); + checkbox(w, L"Keep screen awake", 205, 18, 184, 210); + checkbox(w, L"Write log", 208, 240, 184, 160); label(w, L"Click a shortcut and press the desired keys", 420, 16, 350); const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history"}; @@ -1245,6 +1498,7 @@ void showSettings(App &app) label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700); edit(w, 243, 18, 544, 742); button(w, L"Auto-correction options…", 255, 18, 575, 240); + button(w, L"View log…", 256, 278, 575, 220); button(w, L"Import settings…", 250, 18, 606, 145); button(w, L"Export settings…", 251, 173, 606, 145); button(w, L"Snippets…", 252, 328, 606, 110); diff --git a/tests/integration.cpp b/tests/integration.cpp index 3ba86fe..01adba2 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -1,5 +1,7 @@ #include "app.h" #include +#include +#include namespace { @@ -14,6 +16,12 @@ const std::vector> punctuationCases{ {L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}}; ULONGLONG deadline{}; std::wstring sentinel = L"KSwitcher integration clipboard sentinel"; +std::string diagnosticLog() +{ + std::ifstream file(ks::dataDirectory() / L"kswitcher.log"); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; +} +size_t diagnosticStart{}; void require(bool ok, const char *name) { if (!ok) @@ -109,10 +117,28 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text"); prepare(L"OLDabcdef", false); record(L"ghbdtn"); + diagnosticStart = diagnosticLog().size(); + app->busy = true; + app->transform(ks::Direct + ru); + app->transform(ks::Direct + ru); + app->busy = false; app->transform(ks::Direct + ru); break; case 6: require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement"); + { + auto diagnostics = diagnosticLog(); + diagnostics = diagnostics.substr(std::min(diagnosticStart, diagnostics.size())); + require(diagnostics.find("reason=busy") != std::string::npos, + "busy conversion attempt is logged"); + require(diagnostics.find("repeat=3") != std::string::npos, + "three repeated conversion attempts are logged"); + require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos, + "failed recent-word verification has explicit outcome"); + require(diagnostics.find("copied=\"abcdef\"") != std::string::npos && + diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos, + "conversion diagnostics contain actual text and physical input for reproduction"); + } app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}}; prepare(L"prefix ;addr", false); record(L";addr"); @@ -237,7 +263,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) return DefWindowProcW(w, msg, wp, lp); } } // namespace -int main() +int main(int argc, char **argv) { CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); auto root = std::filesystem::temp_directory_path() / @@ -249,6 +275,73 @@ int main() app = &instance; app->settings.history = false; app->settings.switchLayout = false; + if (argc > 1 && std::string(argv[1]) == "--logs-only") + { + ks::showLogs(instance); + HWND viewer = instance.logWindow; + HWND rows = GetDlgItem(viewer, 602); + require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT, + "log viewer uses report ListView"); + require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns"); + require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs"); + SetFocus(rows); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows); + NMHDR clicked{rows, 602, NM_CLICK}; + SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&clicked); + require(IsWindow(viewer), "focus and click notifications do not close log viewer"); + ks::showLogs(instance); + require(instance.logWindow == viewer, "opening logs reuses the existing window"); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603)); + require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows"); + SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601)); + require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed"); + if (argc > 2 && std::string(argv[2]) == "--clear-test") + { + require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test", + "clear test runs in isolated executable directory"); + const auto logRoot = ks::dataDirectory(); + require(logRoot == std::filesystem::path(ks::executable()).parent_path() / L"data", + "clear test uses isolated portable data"); + std::ofstream(logRoot / L"kswitcher.log") << "current-marker\n"; + std::ofstream(logRoot / L"kswitcher.log.1") << "previous-marker\n"; + ks::diagnosticEvent(L"buffered-before-clear"); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606)); + require(!std::filesystem::exists(logRoot / L"kswitcher.log.1") && + ks::readDiagnosticLog(false).find(L"current-marker") != std::wstring::npos, + "Clear log removes selected previous log only"); + SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606)); + require(!std::filesystem::exists(logRoot / L"kswitcher.log") && IsWindow(viewer), + "Clear log clears current file without closing viewer"); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606)); + ks::diagnosticEvent(L"new-problem", true); + auto newLog = ks::readDiagnosticLog(false); + require(newLog.find(L"new-problem") != std::wstring::npos && + newLog.find(L"buffered-before-clear") == std::wstring::npos, + "logging resumes after clear without stale context"); + } + auto savedClipboard = ks::readClipboard(viewer); + instance.window = viewer; + try + { + ListView_SetItemState(rows, 0, LVIS_SELECTED, LVIS_SELECTED); + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(605, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 605)); + require(ks::readClipboard(viewer).text().find(L'\t') != std::wstring::npos, + "selected log row copies as tab-separated columns"); + } + catch (...) + { + ks::writeClipboard(viewer, savedClipboard); + throw; + } + ks::writeClipboard(viewer, savedClipboard); + instance.window = nullptr; + SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), (LPARAM)GetDlgItem(viewer, IDCANCEL)); + require(!IsWindow(viewer) && !instance.logWindow, "Close button closes and releases viewer"); + CoUninitialize(); + return 0; + } for (int i = 0; i < (int)app->layouts.size(); ++i) { auto language = LOWORD((uintptr_t)app->layouts[i].handle); diff --git a/tests/tests.cpp b/tests/tests.cpp index 96efc19..44e4950 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -44,7 +44,37 @@ int main(int argc, char **argv) } try { + require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0", + "diagnostic word preserved"); + require(ks::diagnosticText(L"\"\\\n\r\t") == + L"\"\\\"\\\\\\u000a\\u000d\\u0009\" units=5 truncated=0", + "diagnostic control characters cannot create log lines"); + require(ks::diagnosticText(L"\u0457\u200b") == L"\"\\u0457\\u200b\" units=2 truncated=0", + "diagnostic unicode and invisible characters preserved exactly"); + require(ks::diagnosticText(std::wstring(513, L'a')).find(L"units=513 truncated=1") != std::wstring::npos, + "large diagnostic selections are explicitly truncated"); STARTUPINFOW startup{sizeof(startup)}; + ks::DiagnosticBuffer diagnostics; + require(diagnostics.record(L"successful-attempt", false, 100).empty(), + "successful diagnostics stay in memory"); + auto incident = diagnostics.record(L"failed-attempt", true, 200); + require(incident.find(L"successful-attempt") != std::wstring::npos && + incident.find(L"failed-attempt") != std::wstring::npos, + "incident includes preceding successful context"); + require(diagnostics.record(L"next-failure", true, 300).find(L"successful-attempt") == std::wstring::npos, + "flushed context is not duplicated"); + diagnostics.record(L"expired-context", false, 400); + require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos, + "context expires after thirty seconds"); + diagnostics.record(L"oldest-context", false, 30600); + for (int i = 0; i < 40; ++i) + diagnostics.record(L"bounded-context", false, 30600 + i); + require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos, + "context buffer has a fixed size"); + diagnostics.record(L"disabled-context", false, 30800); + diagnostics.clear(); + require(diagnostics.record(L"new-failure", true, 30900).find(L"disabled-context") == std::wstring::npos, + "clearing diagnostics discards sensitive context"); startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE; PROCESS_INFORMATION child{}; @@ -346,6 +376,7 @@ int main(int argc, char **argv) require(history.entries().size() == 2, "a new screenshot is retained"); } ks::Settings settings; + settings.writeLog = false; settings.elevatedAutostart = true; settings.autoCorrect = true; settings.autoMaxRank = 10000; @@ -358,6 +389,9 @@ int main(int argc, char **argv) settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}}; ks::saveSettings(temp / L"settings.json", settings); auto restored = ks::loadSettings(temp / L"settings.json", {}); + require(!restored.writeLog, "logging setting persists when disabled"); + require(ks::loadSettings(temp / L"defaults.json", {}).writeLog, + "logging defaults on for existing diagnostic installations"); require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip"); require(restored.cycle == settings.cycle, "layout order roundtrip"); require(restored.elevatedAutostart, "elevated autostart setting roundtrip");