Release v0.2.0 with incident diagnostics and in-app log viewer
This commit is contained in:
+1
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip
|
||||
7138fee6aa1e9c5e66aa212798c4cc193a987f088a45ec37a355b46cf9a40d08 KSwitcher-win-x64.zip
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
+131
-15
@@ -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,15 +166,21 @@ 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;
|
||||
}
|
||||
bool select(size_t n)
|
||||
@@ -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<History>(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<uintptr_t>(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<LastTransform> 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);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class App
|
||||
std::atomic<ULONGLONG> pauseUntil{0};
|
||||
HWND pasteTarget{};
|
||||
HWND hotkeyCapture{};
|
||||
HWND logWindow{};
|
||||
std::mutex stateMutex;
|
||||
Run run;
|
||||
std::optional<LastTransform> 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<std::pair<int, Hotkey>> 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 &);
|
||||
|
||||
@@ -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"([{«“‘\"";
|
||||
|
||||
+100
-4
@@ -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<size_t>(text.size(), 512);
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
auto c = static_cast<unsigned short>(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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "core.h"
|
||||
#include <filesystem>
|
||||
#include <deque>
|
||||
#include <objbase.h>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
@@ -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<std::pair<ULONGLONG, std::wstring>> 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
|
||||
|
||||
@@ -119,6 +119,7 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
|
||||
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
|
||||
s.recent = j.value("convertLastWord", true);
|
||||
s.switchLayout = j.value("switchSystemLayout", true);
|
||||
s.writeLog = j.value("writeLog", true);
|
||||
s.history = j.value("historyEnabled", true);
|
||||
s.autostart = j.value("startWithSystem", false);
|
||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||
@@ -182,6 +183,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
|
||||
{"schemaVersion", 1},
|
||||
{"convertLastWord", s.recent},
|
||||
{"switchSystemLayout", s.switchLayout},
|
||||
{"writeLog", s.writeLog},
|
||||
{"autostartAsAdministrator", s.elevatedAutostart},
|
||||
{"historyEnabled", s.history},
|
||||
{"startWithSystem", s.autostart},
|
||||
|
||||
+255
-1
@@ -645,6 +645,7 @@ void fillSettings(HWND w, SettingsView *v)
|
||||
auto &s = v->draft;
|
||||
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<wchar_t> 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<App *>(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<wchar_t *>(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);
|
||||
|
||||
+94
-1
@@ -1,5 +1,7 @@
|
||||
#include "app.h"
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <commctrl.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -14,6 +16,12 @@ const std::vector<std::pair<std::wstring, std::wstring>> 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<char>(file), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user