Compare commits

...

4 Commits

21 changed files with 1250 additions and 307 deletions
+35
View File
@@ -0,0 +1,35 @@
# Project instructions
## Mandatory Wiki updates for every release
A release is not complete until the published project Wiki reflects that release.
Apply this rule to every release, including patch releases and bug-fix-only releases.
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
1. Confirm the release version from `CMakeLists.txt`, the release tag and
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
preserve the release channel rules in `README.md`.
2. Update the latest-version information on the Wiki home pages and any other
pages identifying the current release. Update release dates, download links
and upgrade instructions where needed. Keep historical version references
and compatibility bridge instructions when they are still accurate.
3. Review the changes since the last release documented in the Wiki, not just
the immediately preceding release. Bring the relevant feature pages up to
date with the shipped behavior: new, changed and removed functionality,
settings, shortcuts, defaults, limitations and troubleshooting. Update
screenshots or examples when they no longer match the application.
For bug-fix-only releases, check whether documented behavior or workarounds
need correction; do not invent feature changes.
4. Keep English and Ukrainian pages synchronized, including `Home` and
`Home-uk`, and preserve working translation and navigation links.
5. Publish the Wiki changes as part of the release workflow. Read back the
published pages and verify the current version, feature descriptions,
download links and translation links. A local Wiki edit alone is not enough.
6. In the release completion report, link the updated Wiki pages and summarize
the documentation changes. If Wiki publication or verification is blocked,
explicitly report the outstanding step and do not claim the release workflow
is complete.
Updating only the release notes or changing only the Wiki version number does
not satisfy this rule; the current functionality must also be reviewed.
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.15 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.19 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+38 -11
View File
@@ -160,7 +160,11 @@ Hold Ctrl to start drawing over an existing object. Line style applies to outlin
for shapes.
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
While holding Shift, moving more than 50 image pixels away from the current line selects a new
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
@@ -222,11 +226,16 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
If the editor's word boundary differs from the recorded typing, it falls back to paced character
selection: Teams can coalesce a burst of Shift+Left events into just one character.
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
A silently dropped copy is retried up to three total attempts while the clipboard and input
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
history and cloud synchronization. Paste is not blindly retried.
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
For example, `rider .{enter}` inserts `rider .` and presses Enter.
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
and no Enter action is sent.
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
full-text search, pinning, deletion, full text viewing and image previews.
@@ -278,27 +287,38 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
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 flush this context to `kswitcher.log`; flushed
context is discarded. Ordinary application errors are also logged when logging is enabled.
Diagnostic stages stay in a memory buffer of at most 40 events for 30 seconds.
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
context. Successful operations stay in that bounded memory buffer and are included only when
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
errors are also logged when logging is enabled. This works in normal Release builds.
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.
original length and a truncation flag; context fields are limited to 512 UTF-16 code units.
Structured summaries retain up to 4096 UTF-16 code units per text field and mark truncation.
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. Successful repeated commands do not flush the log; intentional layout cycling is normal.
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.
input. An unchanged manual conversion is recorded as a problem even when the detected source
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
checks the focused editor without moving the caret, changing selection, or copying again.
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
**Paste not verified**. Unsupported/protected controls, changed focus, and readback limits are
explicit verification reasons, never proof of success. Readback is bounded to 4096 characters;
provider-specific character units or delayed editor updates may prevent verification.
With logging disabled, this additional accessibility check is skipped.
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.
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
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.
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
Select a row to inspect original, expected and read-back text, verification status and stages
in the panel below. **Copy selected** or Ctrl+C copies the selected rows with their details;
Ctrl+A selects all rows. Older unstructured log entries remain readable. 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.
@@ -450,6 +470,13 @@ against a staged build. It briefly opens real selection/editor windows without c
### Release channel rule
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
the latest version and relevant feature documentation in both English and Ukrainian.
Review all changes since the last documented release, update affected instructions,
shortcuts, settings and screenshots, and verify the published pages and links.
The release workflow is incomplete until this is done. Follow the mandatory
[Wiki release checklist](AGENTS.md#mandatory-wiki-updates-for-every-release).
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
Binary file not shown.
+9 -8
View File
@@ -1,17 +1,18 @@
# Latest Windows release
## v0.6.15 — 2026-10-30
## v0.6.19 — 2026-10-31
## Changes
- Preserve typing that arrives while a snippet is being expanded.
- Add `{enter}` to press Enter after insertion and cursor placement, with an Insert Enter marker button. Use `{{enter}}` for literal text.
- Suppress automatic correction during snippet expansion and buffered typing, without changing the global setting. Normal typing resumes automatic correction afterward.
- Restore the caret when snippet verification fails and record snippet failures in diagnostic logs.
Includes the earlier automatic-correction and log-viewer improvements. Existing modern releases update directly to this version without installing intermediate releases.
- Retry a silently dropped Ctrl+C up to three total attempts, only while the transaction clipboard remains unchanged and the original input context is still valid. Failed snippet verification preserves the trigger and following input without sending Enter.
- Exclude temporary transaction clipboard contents from Windows clipboard history and cloud synchronization. The original clipboard snapshot is still restored after the operation when ownership permits.
- Fix snippet integration test isolation: restore a multiline editor after earlier single-line scenarios, reset layout/correction state, coordinate test-editor clipboard access and disable unrelated background update checks.
- Add deterministic coverage for one dropped copy and exhausted retries. Keep exact assertions for Enter ordering, buffered typing, clipboard restoration and subsequent auto-correction.
## Validation
223 native regression checks, 472 input/UI checks, screenshot editor and notification registration checks, 45 updater checks and updater progress checks. Targeted native integration checks cover continuous snippet typing, clipboard restoration, actual Enter key events, punctuation preservation, suppression of automatic correction and its resumption afterward. The updated development build was also confirmed by the user in Notepad. The broader integration suite showed intermittent failures during earlier interactive runs; it is not claimed as fully passing.
The fixes passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks and updater progress checks. The full editor integration suite passed all 63 assertions; conversion-specific diagnostics also passed. The final snippet suite passed 20 consecutive runs, including injected copy failures and the three-attempt limit. The v0.6.19 staged Release build also passed the native, input/UI, screenshot and updater checks; the installed application was not replaced or restarted.
## Limitations
The fix does not retry paste blindly: an editor may already have changed its selection or text. Clipboard contention, editor capabilities and input privileges can still prevent an operation. Existing accessibility readback limitations and problem-only diagnostic logging remain unchanged.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
+1 -1
View File
@@ -1 +1 @@
04229e97b239683db7c113342f7c1e95e6f50b8fcbd1994e7b0920987766b9ab KSwitcher-win-x64.zip
8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,15,0
PRODUCTVERSION 0,6,15,0
FILEVERSION 0,6,19,0
PRODUCTVERSION 0,6,19,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.15"
VALUE "FileVersion", "0.6.19"
END
END
BLOCK "VarFileInfo"
+90 -17
View File
@@ -2,6 +2,8 @@
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include "diagnostics.h"
#include "paste_probe.h"
#include "screenshot_notifications.h"
#include <algorithm>
#include <commctrl.h>
@@ -12,6 +14,12 @@ namespace ks
App *App::current = nullptr;
namespace
{
const char *transformCommandName(int command)
{
return command == Toggle ? "Convert / cycle" : command >= Direct ? "Convert / direct" :
command == Case ? "Change case" : command == Translit ? "Transliterate" :
command == Undo ? "Undo" : "Automatic / snippet";
}
ULONG_PTR snippetReplayMarker()
{
static const char marker{};
@@ -116,7 +124,13 @@ class Transaction
{
check();
adopted.clear();
app.ownSequence = expected = writeClipboard(app.window, c);
// These are temporary transaction contents, not a user's clipboard entry.
// In particular, do not invite the Windows history service to read our
// sentinel while the target editor is trying to open the clipboard.
auto temporary = c;
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
app.ownSequence = expected = writeClipboard(app.window, temporary);
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
// Never adopt that writer's sequence as our own and paste its content.
if (GetClipboardOwner() != app.window)
@@ -138,6 +152,8 @@ class Transaction
return {};
}
ULONGLONG deadline = GetTickCount64() + 2000;
ULONGLONG retryAt = GetTickCount64() + 250;
unsigned attempts = 1;
DWORD changed = before;
ULONGLONG stable = 0;
bool changedAgain = false;
@@ -154,6 +170,21 @@ class Transaction
}
if (changed != before && GetTickCount64() - stable >= 60)
break;
// Native editors can silently drop Ctrl+C if another process has
// the clipboard open. Retry only while our sentinel is untouched;
// never overwrite a copy result or another application's content.
if (changed == before && attempts < 3 && GetTickCount64() >= retryAt &&
!GetOpenClipboardWindow())
{
check();
if (!valid() || GetClipboardSequenceNumber() != before)
return {};
if (!chord(L'C', MOD_CONTROL))
return {};
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
++attempts;
retryAt = GetTickCount64() + 250;
}
Sleep(10);
}
if (changed == before || !valid())
@@ -854,7 +885,18 @@ void App::transform(int command, int snippet)
if (busy.exchange(true) && !preDeferred)
{
if (manual)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
{
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy");
try
{
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
{"elapsedMs", GetTickCount64() - requestedAt}};
diagnosticOperation(record.dump());
}
catch (...) {}
}
return;
}
if (preDeferred)
@@ -893,7 +935,7 @@ void App::transform(int command, int snippet)
} reset{busy, window, preDeferred};
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected;
std::wstring text, result, expected, actual, verification = L"not-attempted";
int hint = -1, to = command >= Direct ? command - Direct : -1;
if (manual)
{
@@ -918,21 +960,34 @@ void App::transform(int command, int snippet)
bool enabled;
std::wstring &reason;
std::wstring &text, &result, &expected;
std::wstring &actual, &verification;
int &hint, &to;
int command;
const std::vector<Layout> &layouts;
~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),
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected");
try
{
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
!(command == 0 && reason == L"paste-sent-unverified")},
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
{"expected", bounded(result)}, {"actual", bounded(actual)},
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
}
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected, hint, to};
catch (...) {} // Logging must never abort a completed input transaction.
}
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
actual, verification, hint, to, command, available};
try
{
auto valid = [&]
@@ -1157,6 +1212,7 @@ void App::transform(int command, int snippet)
}
}
bool sent = false;
PasteProbe probe(config.writeLog && manual && result != text, text);
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" changed=" + std::to_wstring(result != text));
if (result != text)
@@ -1167,10 +1223,27 @@ void App::transform(int command, int snippet)
autoSelected = false;
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 (manual && result == text)
{
reason = L"Conversion unchanged";
verification = L"calculated-result-equals-original";
}
if (sent && manual && config.writeLog)
{
verification = probe.status;
if (tx.valid())
{
auto observed = probe.read((int)caretSteps(result));
verification = probe.status;
if (observed && tx.valid())
{
actual = *observed;
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
}
else reason = L"Paste not verified";
}
else verification = L"context-changed-after-paste";
}
if (sent && !tx.valid())
reason = L"context-changed-after-paste";
if (sent && command == Undo && previous && previous->autoCorrected)
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "../vendor/json.hpp"
#include "platform.h"
#include <sstream>
namespace ks
{
using DiagnosticJson = nlohmann::json;
struct OperationLogRow
{
std::wstring time, process, command, source, target, outcome, details;
bool problem = true;
};
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
{
std::vector<OperationLogRow> rows;
std::wistringstream stream(contents);
std::wstring line;
OperationLogRow *legacy = nullptr;
while (std::getline(stream, line))
{
if (!line.empty() && line.back() == L'\r')
line.pop_back();
if (line.empty())
continue;
auto marker = line.find(L" Operation {");
if (marker != line.npos)
{
try
{
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
field("source"), field("target"), field("outcome")};
row.problem = j.value("problem", true);
row.details =
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
field("original") + L"\r\n\r\nExpected result:\r\n" + field("expected") +
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
L"\r\n\r\nContext and stages:\r\n" + field("context");
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
if (at == 0 || row.details[at - 1] != L'\r')
row.details.insert(at++, 1, L'\r');
rows.push_back(std::move(row));
legacy = nullptr;
continue;
}
catch (...)
{
} // Preserve malformed and old records for diagnosis.
}
if (line.front() != L' ' || !legacy)
{
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
legacy = &rows.back();
}
else
legacy->details += L"\r\n" + line;
}
return rows;
}
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
const std::wstring &expected)
{
if (actual == expected)
return L"Paste verified";
if (actual == original)
return L"Original text remained";
return L"Readback differs";
}
} // namespace ks
+162
View File
@@ -0,0 +1,162 @@
#pragma once
#include "platform.h"
#include <uiautomation.h>
namespace ks
{
// Read-only UI Automation: never select, move the caret, or touch the clipboard.
class PasteProbe
{
IUIAutomation2 *automation{};
IUIAutomationElement *element{};
IUIAutomationTextPattern *pattern{};
public:
std::wstring status = L"not-requested";
PasteProbe(bool enabled, const std::wstring &original)
{
if (!enabled)
return;
status = L"accessibility-unavailable";
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
(void **)&automation)))
return;
automation->put_ConnectionTimeout(200);
automation->put_TransactionTimeout(200);
if (FAILED(automation->GetFocusedElement(&element)) || !element)
return;
BOOL password = TRUE;
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
{
status = L"protected-or-inaccessible-field";
return;
}
if (FAILED(element->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern,
(void **)&pattern)) ||
!pattern)
return;
auto selected = read(1, false);
auto normalize = [](std::wstring value)
{
for (size_t at = 0; (at = value.find(L"\r\n", at)) != value.npos;)
value.erase(at, 1);
return value;
};
if (!selected || normalize(*selected) != normalize(original))
{
if (selected)
status = L"initial-selection-mismatch";
if (pattern)
pattern->Release();
pattern = nullptr;
return;
}
status = L"ready";
}
PasteProbe(const PasteProbe &) = delete;
~PasteProbe()
{
if (pattern)
pattern->Release();
if (element)
element->Release();
if (automation)
automation->Release();
}
std::optional<std::wstring> read(int characters, bool allowCaret = true)
{
if (!pattern)
return {};
if (characters <= 0 || characters > 4096)
{
status = L"readback-size-limit";
return {};
}
IUIAutomationElement *focused{};
BOOL same = FALSE;
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
{
automation->CompareElements(element, focused, &same);
}
if (!same)
{
if (focused)
focused->Release();
status = L"focused-control-changed";
return {};
}
BOOL password = TRUE;
if (FAILED(focused->get_CurrentIsPassword(&password)) || password)
{
focused->Release();
status = L"protected-or-inaccessible-field";
return {};
}
// Native Edit accessibility proxies can retain a text snapshot in the
// pattern object. Obtain a fresh pattern after paste, not the pre-paste one.
pattern->Release();
pattern = nullptr;
HRESULT refreshed =
focused->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern, (void **)&pattern);
focused->Release();
if (FAILED(refreshed) || !pattern)
{
status = L"accessibility-unavailable";
return {};
}
IUIAutomationTextRangeArray *ranges{};
if (FAILED(pattern->GetSelection(&ranges)) || !ranges)
{
status = L"selection-unavailable";
return {};
}
int count{};
ranges->get_Length(&count);
IUIAutomationTextRange *range{};
if (count == 1)
ranges->GetElement(0, &range);
ranges->Release();
if (!range)
{
status = L"selection-ambiguous";
return {};
}
int difference{};
HRESULT hr = range->CompareEndpoints(TextPatternRangeEndpoint_Start, range,
TextPatternRangeEndpoint_End, &difference);
if (SUCCEEDED(hr) && difference == 0)
{
if (!allowCaret)
{
range->Release();
status = L"initial-selection-unavailable";
return {};
}
int moved{};
hr = range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start, TextUnit_Character, -characters,
&moved);
if (SUCCEEDED(hr) && moved != -characters)
hr = E_FAIL;
}
BSTR value{};
if (SUCCEEDED(hr))
hr = range->GetText(8193, &value);
range->Release();
if (FAILED(hr))
{
SysFreeString(value);
status = L"range-read-failed";
return {};
}
std::wstring text(value ? value : L"", SysStringLen(value));
SysFreeString(value);
if (text.size() > 8192)
{
status = L"readback-size-limit";
return {};
}
status = L"read-back-via-accessibility";
return text;
}
};
} // namespace ks
+21 -2
View File
@@ -1,6 +1,8 @@
#include "platform.h"
#include "../vendor/json.hpp"
#include <algorithm>
#include <fstream>
#include <iomanip>
#include <imm.h>
#include <map>
#include <mutex>
@@ -624,6 +626,22 @@ void diagnosticEvent(const std::wstring &message, bool problem)
if (!incident.empty())
log(incident);
}
void diagnosticOperation(const std::string &json)
{
std::lock_guard lock(logGate);
if (!loggingEnabled) return;
auto record = nlohmann::json::parse(json);
if (!record.value("problem", true))
{
// Keep successful operations only in the bounded, short-lived context.
// They are persisted with a subsequent failure, never as standalone rows.
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
nlohmann::json::error_handler_t::replace)), false, GetTickCount64());
return;
}
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
}
void log(const std::wstring &message)
{
std::lock_guard lock(logGate);
@@ -645,8 +663,9 @@ void log(const std::wstring &message)
std::ofstream f(path, std::ios::app);
SYSTEMTIME t;
GetLocalTime(&t);
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
<< t.wSecond << ' ' << utf8(message) << '\n';
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
}
catch (...)
{
+1
View File
@@ -66,6 +66,7 @@ class DiagnosticBuffer
};
void setLoggingEnabled(bool enabled);
void diagnosticEvent(const std::wstring &message, bool problem = false);
void diagnosticOperation(const std::string &json);
std::wstring readDiagnosticLog(bool previous);
void clearDiagnosticLog(bool previous);
} // namespace ks
+75 -2
View File
@@ -178,6 +178,11 @@ struct Capture
COLORREF currentColor=RGB(225,42,50);
COLORREF customColors[16]{};
Mark stroke{};
bool strokeConstrained{};
size_t strokeAnchor{};
int strokeAxis{}; // 0 = not chosen, 1 = horizontal, 2 = vertical, 3/4 = diagonals.
bool strokeAxisSettled{};
std::vector<POINT> strokeMotion;
POINT start{}, end{};
RECT selection{}, view{};
std::vector<Mark> marks;
@@ -250,7 +255,7 @@ void updateStatus(Capture &c)
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
else if(c.freehand) text=L"Freehand (F): Shift = 45-degree steps; move >50 px off line to turn | Ctrl = draw over objects";
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
if(text!=c.statusText) {
@@ -841,7 +846,7 @@ void edit(HWND w, Capture &c)
InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Shift for 45-degree steps; move >50 px off line to turn; Ctrl to draw over objects", L"Highlight (H); Ctrl+drag to paint over an object",
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
static_assert(std::size(labels)==std::size(ToolIds));
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
@@ -1045,6 +1050,13 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
case WM_KEYDOWN:
if(wp==VK_SHIFT && c->editing && c->dragging && c->freehand && c->part==MarkPart::None && !c->strokeConstrained) {
c->strokeConstrained=true;
c->strokeAnchor=c->stroke.points.size()-1;
c->strokeAxis=0;
c->strokeAxisSettled=false;
c->strokeMotion={c->stroke.points.back()};
}
if (wp==VK_ESCAPE) {
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
return 0;
@@ -1100,6 +1112,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
}
return 0;
case WM_KEYUP:
if(wp==VK_SHIFT) c->strokeConstrained=false;
return 0;
case WM_LBUTTONDBLCLK:
if(c->editing && !c->dragging) {
POINT p{GET_X_LPARAM(lp),GET_Y_LPARAM(lp)};
@@ -1156,6 +1171,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
c->stroke={p,p,0}; c->stroke.thickness=c->highlight?24:3; c->stroke.color=c->currentColor;
c->stroke.freehand=c->freehand; c->stroke.style=c->lineStyle; c->stroke.ends=c->freehandEnds;
c->stroke.points.push_back(p);
c->strokeConstrained=false;
}
c->dragging=true; SetCapture(w);
}
@@ -1193,6 +1209,63 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(c->editing && c->part==MarkPart::None && c->shape!=ScreenshotShape::None)
c->end=constrainScreenshotShape(c->shape,c->start,c->end,c->image->width,c->image->height);
if(c->editing && (c->highlight || c->freehand) && c->part==MarkPart::None) {
bool constrained=c->freehand && ((wp&MK_SHIFT) || (GetKeyState(VK_SHIFT)&0x8000));
if(constrained) {
if(!c->strokeConstrained) {
c->strokeAnchor=c->stroke.points.size()-1;
c->strokeAxis=0;
c->strokeAxisSettled=false;
c->strokeMotion={c->stroke.points.back()};
}
auto anchor=c->stroke.points[c->strokeAnchor];
// Measure the actual mouse trajectory, not its snapped projection.
auto raw=c->end;
if(c->strokeMotion.back().x!=raw.x || c->strokeMotion.back().y!=raw.y)
c->strokeMotion.push_back(raw);
while(c->strokeMotion.size()>2 && (c->strokeMotion.size()>64 ||
std::hypot(double(raw.x-c->strokeMotion[1].x),double(raw.y-c->strokeMotion[1].y))>=16))
c->strokeMotion.erase(c->strokeMotion.begin());
auto chooseAxis=[](POINT from,POINT to) {
LONG dx=to.x-from.x,dy=to.y-from.y;
constexpr double diagonalBoundary=2.414213562373095; // tan(67.5 degrees)
if(std::abs(dx)>std::abs(dy)*diagonalBoundary) return 1;
if(std::abs(dy)>std::abs(dx)*diagonalBoundary) return 2;
return (dx<0)==(dy<0)?3:4;
};
auto distance=[](int axis,POINT from,POINT to) {
double dx=to.x-from.x,dy=to.y-from.y;
return axis==1?std::abs(dy):axis==2?std::abs(dx):
std::abs(axis==3?dy-dx:dy+dx)/std::sqrt(2.0);
};
if(!c->strokeAxisSettled && (c->end.x!=anchor.x || c->end.y!=anchor.y)) {
c->strokeAxis=chooseAxis(anchor,c->end);
// A one-pixel first movement must not lock a diagonal gesture to an axis.
c->strokeAxisSettled=std::hypot(double(raw.x-anchor.x),double(raw.y-anchor.y))>=8;
}
else if(c->strokeAxis && distance(c->strokeAxis,anchor,c->end)>50) {
// Keep the completed segment and turn at its endpoint.
auto corner=c->stroke.points.back();
int nextAxis=chooseAxis(c->strokeMotion.front(),raw);
if(nextAxis!=c->strokeAxis) {
c->strokeAnchor=c->stroke.points.size()-1;
anchor=corner;
c->strokeAxis=nextAxis;
}
}
if(c->strokeAxis==1) c->end.y=anchor.y;
else if(c->strokeAxis==2) c->end.x=anchor.x;
else if(c->strokeAxis>=3) {
int sign=c->strokeAxis==3?1:-1;
LONG delta=std::lround(((c->end.x-anchor.x)+sign*(c->end.y-anchor.y))/2.0);
LONG minY=sign==1?-anchor.y:anchor.y-(c->image->height-1);
LONG maxY=sign==1?c->image->height-1-anchor.y:anchor.y;
delta=std::clamp(delta,std::max(-anchor.x,minY),std::min(LONG(c->image->width-1)-anchor.x,maxY));
c->end={anchor.x+delta,anchor.y+sign*delta};
}
// Only the current segment is a preview; earlier corners stay fixed.
c->stroke.points.resize(c->strokeAnchor+1);
}
c->strokeConstrained=constrained;
auto last=c->stroke.points.back();
if(last.x!=c->end.x || last.y!=c->end.y) c->stroke.points.push_back(c->end);
c->stroke.to=c->end;
+21 -253
View File
@@ -4,6 +4,7 @@
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
#include "diagnostics.h"
#include <algorithm>
#include <commctrl.h>
#include <commdlg.h>
@@ -34,7 +35,7 @@ struct WindowLayout
bool scrolling{}, arranging{}, history{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
HWND stretch{}, bottomLeft{}, bottomRight{};
HWND stretch{}, bottomLeft{}, bottomRight{}, details{};
bool autoOptions{};
};
WindowLayout *windowLayoutState(HWND w)
@@ -88,6 +89,11 @@ void arrangeWindow(HWND w)
next.right += extraWidth;
next.bottom += extraHeight;
}
if (child == layout->details)
{
next.right += extraWidth;
OffsetRect(&next, 0, extraHeight);
}
if (child == layout->bottomLeft || child == layout->bottomRight)
OffsetRect(&next, child == layout->bottomRight ? extraWidth : 0, extraHeight);
if (layout->autoOptions)
@@ -311,7 +317,7 @@ std::wstring value(HWND w, int id)
text.resize(n);
return text;
}
void value(HWND w, int id, const std::wstring &text)
[[maybe_unused]] void value(HWND w, int id, const std::wstring &text)
{
SetDlgItemTextW(w, id, text.c_str());
}
@@ -1375,6 +1381,14 @@ struct SnippetView
std::vector<Snippet> snippets;
int selection = -1;
};
void updateSnippetEditor(HWND w, SnippetView *v)
{
bool selected = v->selection >= 0 && v->selection < (int)v->snippets.size();
value(w, 302, selected ? v->snippets[v->selection].trigger : L"");
value(w, 303, selected ? v->snippets[v->selection].text : L"");
for (int id : {302, 303, 311, 312, 313})
EnableWindow(GetDlgItem(w, id), selected);
}
void fillSnippets(HWND w, SnippetView *v)
{
SendDlgItemMessageW(w, 301, LB_RESETCONTENT, 0, 0);
@@ -1382,6 +1396,7 @@ void fillSnippets(HWND w, SnippetView *v)
SendDlgItemMessageW(w, 301, LB_ADDSTRING, 0, (LPARAM)sn.trigger.c_str());
if (v->selection >= 0)
SendDlgItemMessageW(w, 301, LB_SETCURSEL, v->selection, 0);
updateSnippetEditor(w, v);
}
void collectSnippet(HWND w, SnippetView *v)
{
@@ -1418,11 +1433,7 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
collectSnippet(w, v);
v->selection = (int)SendDlgItemMessageW(w, 301, LB_GETCURSEL, 0, 0);
if (v->selection >= 0)
{
value(w, 302, v->snippets[v->selection].trigger);
value(w, 303, v->snippets[v->selection].text);
}
updateSnippetEditor(w, v);
return 0;
}
if (id == 310)
@@ -1431,8 +1442,6 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
v->snippets.push_back({L";new", L""});
v->selection = (int)v->snippets.size() - 1;
fillSnippets(w, v);
value(w, 302, L";new");
value(w, 303, L"");
SetFocus(GetDlgItem(w, 302));
return 0;
}
@@ -1443,13 +1452,13 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
v->snippets.erase(v->snippets.begin() + v->selection);
v->selection = -1;
fillSnippets(w, v);
value(w, 302, L"");
value(w, 303, L"");
SetFocus(GetDlgItem(w, 301));
}
return 0;
}
if (id == 312 || id == 313)
{
if (v->selection < 0 || v->selection >= (int)v->snippets.size()) return 0;
SendDlgItemMessageW(w, 303, EM_REPLACESEL, TRUE, (LPARAM)(id == 312 ? L"{cursor}" : L"{enter}"));
return 0;
}
@@ -1570,248 +1579,7 @@ void showHistory(App &app, bool visible)
}
#endif
#ifndef KS_RESIDENT_APP
namespace
{
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606;
void fitLogDetails(HWND list)
{
if (GetPropW(list, L"FittingLogDetails") || Header_GetItemCount(ListView_GetHeader(list)) != 4) return;
SetPropW(list, L"FittingLogDetails", (HANDLE)1);
RECT client{};
GetClientRect(list, &client);
int remaining = client.right;
for (int column = 0; column < 3; ++column)
remaining -= ListView_GetColumnWidth(list, column);
int width = std::max(scale(list, 120), remaining);
if (ListView_GetColumnWidth(list, 3) != width)
ListView_SetColumnWidth(list, 3, width);
RemovePropW(list, L"FittingLogDetails");
}
LRESULT CALLBACK logListResizeProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
auto result = DefSubclassProc(w, msg, wp, lp);
if (msg == WM_SIZE)
fitLogDetails(w);
if (msg == WM_NOTIFY && lp)
{
auto *header = reinterpret_cast<NMHEADERW *>(lp);
if (header->hdr.hwndFrom == ListView_GetHeader(w) &&
(header->hdr.code == HDN_ITEMCHANGEDW || header->hdr.code == HDN_ITEMCHANGEDA) &&
header->iItem >= 0 && header->iItem < 3)
fitLogDetails(w);
}
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, logListResizeProc, 3);
return result;
}
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())
{
auto clip = textClip(copied);
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
app.ownSequence = writeClipboard(app.window, clip);
}
}
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);
fitLogDetails(list);
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations 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
{
app->clearLog(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, logListResizeProc, 3, 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);
}
auto hint = control(w, L"STATIC", L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.",
0, 0, 16, 551, 720, 22);
button(w, L"Close", IDCANCEL, 774, 548, 110);
auto *layout = windowLayoutState(w);
layout->stretch = list;
layout->bottomLeft = hint;
layout->bottomRight = GetDlgItem(w, IDCANCEL);
arrangeWindow(w);
refreshLogView(w, app);
showRequestedWindow(w);
activateWindow(w);
}
#include "ui_log.inl"
#else
void showLogs(App &app) { launchConfiguration(app, false, true); }
#endif
+357
View File
@@ -0,0 +1,357 @@
// Included inside namespace ks by ui.cpp; shares the native control/layout helpers.
namespace
{
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606, LogProgram = 607, LogCommand = 608, LogResult = 609, LogProblems = 610,
LogDetails = 611, LogReset = 612;
struct LogViewState
{
std::vector<OperationLogRow> rows;
std::vector<size_t> visible;
int sortColumn = 0;
bool descending = true, rendering = false;
};
LogViewState *logState(HWND w)
{
return reinterpret_cast<LogViewState *>(GetPropW(w, L"LogViewState"));
}
const std::wstring &logCell(const OperationLogRow &row, int column)
{
switch (column)
{
case 0:
return row.time;
case 1:
return row.process;
case 2:
return row.command;
case 3:
return row.source;
case 4:
return row.target;
default:
return row.outcome;
}
}
void showLogDetails(HWND w)
{
auto *state = logState(w);
if (!state || state->rendering)
return;
int selected = ListView_GetNextItem(GetDlgItem(w, LogRows), -1, LVNI_SELECTED);
value(w, LogDetails,
selected >= 0 && (size_t)selected < state->visible.size()
? state->rows[state->visible[selected]].details
: L"Select an operation to inspect its text and stages.");
}
void renderLogRows(HWND w, App &app)
{
auto *state = logState(w);
if (!state)
return;
state->rendering = true;
state->visible.clear();
auto matches = [&](int id, const std::wstring &field)
{ return SendDlgItemMessageW(w, id, CB_GETCURSEL, 0, 0) <= 0 || value(w, id) == field; };
for (size_t i = 0; i < state->rows.size(); ++i)
{
const auto &row = state->rows[i];
if (matches(LogProgram, row.process) && matches(LogCommand, row.command) &&
matches(LogResult, row.outcome) && (!checked(w, LogProblems) || row.problem))
state->visible.push_back(i);
}
std::stable_sort(state->visible.begin(), state->visible.end(),
[&](size_t a, size_t b)
{
const auto &left = logCell(state->rows[a], state->sortColumn);
const auto &right = logCell(state->rows[b], state->sortColumn);
if (left == right)
return state->descending ? a > b : a < b;
int comparison = CompareStringOrdinal(left.c_str(), -1, right.c_str(), -1, TRUE);
return state->descending ? comparison == CSTR_GREATER_THAN
: comparison == CSTR_LESS_THAN;
});
HWND list = GetDlgItem(w, LogRows);
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
ListView_DeleteAllItems(list);
for (size_t i = 0; i < state->visible.size(); ++i)
{
const auto &row = state->rows[state->visible[i]];
LVITEMW item{};
item.mask = LVIF_TEXT;
item.iItem = (int)i;
item.pszText = const_cast<wchar_t *>(row.time.c_str());
ListView_InsertItem(list, &item);
for (int column = 1; column < 6; ++column)
ListView_SetItemText(list, (int)i, column, const_cast<wchar_t *>(logCell(row, column).c_str()));
}
HWND header = ListView_GetHeader(list);
for (int i = 0; i < 6; ++i)
{
HDITEMW item{};
item.mask = HDI_FORMAT;
Header_GetItem(header, i, &item);
item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN);
if (i == state->sortColumn)
item.fmt |= state->descending ? HDF_SORTDOWN : HDF_SORTUP;
Header_SetItem(header, i, &item);
}
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
InvalidateRect(list, nullptr, TRUE);
state->rendering = false;
value(w, LogStatus,
std::to_wstring(state->visible.size()) + L" of " + std::to_wstring(state->rows.size()) +
L" operations / events. " +
(app.settings.writeLog ? L"Diagnostic logging enabled."
: L"Logging disabled; saved entries only."));
if (!state->visible.empty())
ListView_SetItemState(list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
showLogDetails(w);
if (state->visible.empty())
value(w, LogDetails,
state->rows.empty()
? L"No recorded operations. Use a conversion shortcut with logging enabled, then refresh."
: L"No operations match these filters. Reset filters to show all entries.");
}
void fillLogFilter(HWND w, int id, const std::set<std::wstring> &choices)
{
auto previous = value(w, id);
HWND combo = GetDlgItem(w, id);
SendMessageW(combo, CB_RESETCONTENT, 0, 0);
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"All");
int selected = 0, index = 1;
for (const auto &choice : choices)
{
if (choice.empty())
continue;
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)choice.c_str());
if (choice == previous)
selected = index;
++index;
}
SendMessageW(combo, CB_SETCURSEL, selected, 0);
}
void refreshLogView(HWND w, App &app)
{
auto *state = logState(w);
if (!state)
return;
try
{
auto contents = readDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
state->rows = parseOperationLog(contents);
if (contents.starts_with(L"No log yet.") || contents.starts_with(L"No previous log") ||
contents == L"The log is empty.")
state->rows.clear();
std::set<std::wstring> programs, commands, results;
for (const auto &row : state->rows)
{
programs.insert(row.process);
commands.insert(row.command);
results.insert(row.outcome);
}
fillLogFilter(w, LogProgram, programs);
fillLogFilter(w, LogCommand, commands);
fillLogFilter(w, LogResult, results);
renderLogRows(w, app);
}
catch (const std::exception &e)
{
value(w, LogStatus, L"Could not read the log: " + wide(e.what()));
}
}
void copyLogRows(HWND w, App &app)
{
auto *state = logState(w);
if (!state)
return;
std::wstring copied;
HWND list = GetDlgItem(w, LogRows);
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
{
if ((size_t)row >= state->visible.size())
continue;
const auto &entry = state->rows[state->visible[row]];
copied += entry.time + L"\t" + entry.process + L"\t" + entry.command + L"\t" + entry.source +
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
}
if (!copied.empty())
{
auto clip = textClip(copied);
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
app.ownSequence = writeClipboard(app.window, clip);
}
}
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);
SetPropW(w, L"LogViewState", new LogViewState);
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) >= LogProgram && LOWORD(wp) <= LogResult && HIWORD(wp) == CBN_SELCHANGE) ||
(LOWORD(wp) == LogProblems && HIWORD(wp) == BN_CLICKED))
{
renderLogRows(w, *app);
return 0;
}
if (LOWORD(wp) == LogReset && HIWORD(wp) == BN_CLICKED)
{
for (int id : {LogProgram, LogCommand, LogResult})
SendDlgItemMessageW(w, id, CB_SETCURSEL, 0, 0);
CheckDlgButton(w, LogProblems, BST_UNCHECKED);
renderLogRows(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
{
app->clearLog(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)
break;
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
showLogDetails(w);
if (((NMHDR *)lp)->code == LVN_COLUMNCLICK)
{
auto *state = logState(w);
int column = ((NMLISTVIEW *)lp)->iSubItem;
state->descending = state->sortColumn == column ? !state->descending : false;
state->sortColumn = column;
renderLogRows(w, *app);
}
if (((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:
delete logState(w);
RemovePropW(w, L"LogViewState");
app->logWindow = nullptr;
disposeFont(w);
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
} // namespace
void showLogs(App &app)
{
if (app.logWindow)
{
showRequestedWindow(app.logWindow);
activateWindow(app.logWindow);
return;
}
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher diagnostic operations", logProc, 1100, 760);
app.logWindow = w;
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 280, 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, 310, 14, 100);
button(w, L"Copy selected", LogCopy, 424, 14, 140);
button(w, L"Clear log", LogClear, 578, 14, 100);
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
label(w, L"Program", 16, 78, 170);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
label(w, L"Command", 206, 78, 200);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
label(w, L"Result", 426, 78, 300);
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
checkbox(w, L"Only problems", LogProblems, 752, 102, 166);
button(w, L"Reset filters", LogReset, 938, 98, 146);
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, 142, 1068, 260);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER |
LVS_EX_INFOTIP);
const wchar_t *titles[] = {L"Time", L"Program", L"Command",
L"Source layout", L"Target layout", L"Result"};
const int widths[] = {165, 145, 150, 130, 130, 320};
for (int i = 0; i < 6; ++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);
}
auto details = edit(w, LogDetails, 16, 416, 1068, 276,
ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL | WS_BORDER);
auto hint = control(
w, L"STATIC",
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
16, 712, 860, 22);
button(w, L"Close", IDCANCEL, 974, 708, 110);
auto *layout = windowLayoutState(w);
layout->stretch = list;
layout->details = details;
layout->bottomLeft = hint;
layout->bottomRight = GetDlgItem(w, IDCANCEL);
arrangeWindow(w);
refreshLogView(w, app);
showRequestedWindow(w);
activateWindow(w);
}
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.14";
inline constexpr wchar_t AppVersion[] = L"0.6.19";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+25 -1
View File
@@ -18,6 +18,7 @@ public static class ConfigurationTestUi {
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
@@ -38,7 +39,7 @@ $checks=0
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
function Window($class) {
for($i=0;$i -lt 100;$i++) {
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher log'}}
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
$window=[ConfigurationTestUi]::FindWindow($class,$title)
if($window -ne [IntPtr]::Zero){return $window}
Start-Sleep -Milliseconds 50
@@ -127,6 +128,29 @@ try {
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
$config=Launch 'KSwitcher.Config.exe' '--snippets'
$snippets=Window 'KSwitcher.Snippets'
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
foreach($id in @(302,303,311,312,313)) {
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
}
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
foreach($id in @(302,303,311,312,313)) {
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
}
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
foreach($id in @(302,303,311,312,313)) {
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
}
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
$snippetText=[Text.StringBuilder]::new(128)
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
$second=Launch 'KSwitcher.Config.exe' ''
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
$settings=Window 'KSwitcher.Settings'
+198 -7
View File
@@ -1,4 +1,5 @@
#include "app.h"
#include "diagnostics.h"
#include <algorithm>
#include <iostream>
#include <fstream>
@@ -21,9 +22,59 @@ ks::App *app{};
HWND host{}, field{};
ks::Clip saved;
bool captured = false, failed = false;
bool conversionOnly = false;
int pasteBehavior = 0;
int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0;
bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
// This bare EDIT control is our test fixture, not a full editor with
// clipboard retries. Keep clipboard access on its owning UI thread while
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
// make the fixture silently discard the operation under test.
struct ClipboardAccess
{
bool opened = false;
~ClipboardAccess() { if (opened) CloseClipboard(); }
} access;
if (message == WM_CHAR && (wp == 3 || wp == 22))
{
auto deadline = GetTickCount64() + 1000;
do
{
if (OpenClipboard(w)) { access.opened = true; break; }
Sleep(10);
} while (GetTickCount64() < deadline);
if (!access.opened)
{
clipboardAccessTimedOut = true;
return 0;
}
}
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
{
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
if (message == WM_CHAR)
{
++copyRequests;
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
// Reproduce that outcome deterministically, without depending on
// the timing of the Windows clipboard-history service.
if (copyFailuresRemaining > 0)
{
--copyFailuresRemaining;
return 0;
}
}
}
if (message == WM_PASTE && pasteBehavior)
{
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
return 0;
}
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
return DefSubclassProc(w, message, wp, lp);
}
@@ -43,13 +94,15 @@ std::string diagnosticLog()
size_t diagnosticStart{};
void require(bool ok, const char *name)
{
if (!ok)
if (!ok || clipboardAccessTimedOut)
{
wchar_t observed[2048]{};
GetWindowTextW(field, observed, 2048);
std::cerr << "Editor at failure: " << ks::utf8(observed)
<< "; foregroundSame=" << (GetForegroundWindow() == host)
<< "; inputVersion=" << app->inputVersion.load() << '\n';
if (clipboardAccessTimedOut)
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
throw std::runtime_error(name);
}
std::cout << "PASS " << name << '\n';
@@ -101,6 +154,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return 0;
if (msg == WM_TIMER)
{
// Input tests must not start a network update check. Its close veto can
// otherwise leave a completed test running after the editor is gone.
if (app->window) KillTimer(app->window, 5);
try
{
if (GetTickCount64() > deadline)
@@ -178,12 +234,20 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
"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,
require(diagnostics.find("\"outcome\":\"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,
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
"conversion diagnostics contain actual text and physical input for reproduction");
}
if (conversionOnly)
{
pasteBehavior = 1;
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
stage = 40;
break;
}
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
prepare(L"prefix ;addr", false);
record(L";addr");
@@ -241,6 +305,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
SetWindowSubclass(field, observeEnter, 1, 0);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
@@ -252,6 +317,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
nullptr, GetModuleHandleW(nullptr), nullptr);
SetWindowSubclass(field, observeEnter, 1, 0);
prepare(L"(ljvf ", false);
record(L"(ljvf ");
app->transform(ks::AutoCorrect);
@@ -289,9 +355,60 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
stage = 20;
break;
case 40:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
"blocked paste records original text remaining in the actual editor");
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
pasteBehavior = 2;
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
break;
}
case 41:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
"unexpected editor content records readback mismatch");
pasteBehavior = 0;
prepare(L"привет", true);
app->transform(ks::Direct + ru);
break;
}
case 42:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
"manual unchanged conversion is recorded as a problem");
diagnosticStart = diagnosticLog().size();
app->settings.writeLog = false;
ks::setLoggingEnabled(false);
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
break;
}
case 43:
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
"disabled diagnostics preserves conversion and writes no records");
ks::setLoggingEnabled(true);
finish();
break;
case 30:
case 32:
case 34:
case 37:
// Earlier password/word tests replace the original multiline
// editor with a single-line EDIT. Restore the snippet fixture:
// a single-line control cannot insert the tested Enter action.
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
{
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
GetModuleHandleW(nullptr), nullptr);
SetWindowSubclass(field, observeEnter, 1, 0);
}
if (!captured)
{
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
@@ -300,9 +417,16 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
app->settings.autoCorrect = stage == 35;
app->settings.autoLanguages = {L"en", L"ru"};
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
app->settings.switchLayout = false;
app->autoCorrector = ks::AutoCorrector{};
app->autoPredictor = ks::AutoCorrector{};
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
enterPresses = 0;
copyRequests = 0;
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
temporaryClipboardExcluded = true;
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"prefix ", false);
{
@@ -329,6 +453,10 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
case 33:
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
require(copyRequests == 2 && copyFailuresRemaining == 0,
"snippet retries a silently dropped copy without duplicating input");
require(temporaryClipboardExcluded,
"temporary snippet clipboard contents are excluded from Windows history");
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
stage = 34;
@@ -352,6 +480,15 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
break;
case 36:
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
stage = 37;
break;
case 38:
require(copyRequests == 3, "copy retries are bounded to three attempts");
require(text() == L"prefix ;r x" && enterPresses == 0,
"exhausted copy retries preserve trigger and following input without Enter");
require(ks::readClipboard(app->window).text() == sentinel,
"exhausted copy retries restore the clipboard");
copyFailuresRemaining = 0;
finish();
break;
case 20:
@@ -448,6 +585,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} // namespace
int main(int argc, char **argv)
{
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
@@ -463,9 +601,22 @@ int main(int argc, char **argv)
ks::showLogs(instance);
HWND viewer = instance.logWindow;
HWND rows = GetDlgItem(viewer, 602);
if (argc > 2 && std::string(argv[2]) == "--preview")
{
MSG message{};
while (IsWindow(viewer) && GetMessageW(&message, nullptr, 0, 0) > 0)
{
TranslateMessage(&message); DispatchMessageW(&message);
}
CoUninitialize();
return 0;
}
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(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable columns");
require(GetDlgItem(viewer, 607) && GetDlgItem(viewer, 608) && GetDlgItem(viewer, 609) &&
GetDlgItem(viewer, 610) && GetDlgItem(viewer, 611),
"log viewer exposes program, command, result, problems filter and detail panel");
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
SetFocus(rows);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
@@ -475,10 +626,11 @@ int main(int argc, char **argv)
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");
require(IsWindow(viewer), "refresh keeps log viewer open");
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");
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
"previous log or explicit 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",
@@ -503,7 +655,46 @@ int main(int argc, char **argv)
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");
ks::DiagnosticJson operation{{"attempt", "1"}, {"process", "alpha.exe"},
{"command", "Convert / cycle"}, {"source", "English"}, {"target", "Ukrainian"},
{"outcome", "Paste verified"}, {"problem", false}, {"original", "ghbdsn"},
{"expected", ks::utf8(L"привіт")}, {"actual", ks::utf8(L"привіт")}};
ks::clearDiagnosticLog(false);
const auto successfulOperation = operation;
for (int attempt = 0; attempt < 1000; ++attempt)
ks::diagnosticOperation(operation.dump());
require(!std::filesystem::exists(logRoot / L"kswitcher.log"),
"one thousand successful operations create no log file");
operation["attempt"] = "2"; operation["process"] = "beta.exe";
operation["outcome"] = "Original text remained"; operation["problem"] = true;
operation["actual"] = operation["original"];
ks::diagnosticOperation(operation.dump());
auto problems = ks::parseOperationLog(ks::readDiagnosticLog(false));
require(problems.size() == 1 && problems[0].process == L"beta.exe" &&
problems[0].details.find(L"Completed operation") != std::wstring::npos &&
problems[0].details.find(L"Paste verified") != std::wstring::npos,
"failure writes one row with bounded preceding successful context");
// Pre-existing successful records remain viewable and filterable.
ks::log(L"Operation " + ks::wide(successfulOperation.dump()));
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
require(ListView_GetItemCount(rows) == 2, "one visible row per recorded operation");
CheckDlgButton(viewer, 610, BST_CHECKED);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(610, BN_CLICKED), 0);
require(ListView_GetItemCount(rows) == 1, "only problems hides verified operations");
SendDlgItemMessageW(viewer, 607, CB_SETCURSEL, 1, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(607, CBN_SELCHANGE), 0);
require(ListView_GetItemCount(rows) == 0, "program filter combines with problems filter");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(612, BN_CLICKED), 0);
require(ListView_GetItemCount(rows) == 2, "reset restores all operation rows");
NMLISTVIEW sort{}; sort.hdr = {rows, 602, LVN_COLUMNCLICK}; sort.iSubItem = 1;
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&sort);
wchar_t firstProgram[64]{};
ListView_GetItemText(rows, 0, 1, firstProgram, 64);
require(std::wstring(firstProgram) == L"alpha.exe", "clicking program column sorts rows");
require(GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0, "selected operation populates detail panel");
}
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
auto savedClipboard = ks::readClipboard(viewer);
instance.window = viewer;
try
+99
View File
@@ -24,6 +24,105 @@ int main()
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
"new screenshot editor starts with the pencil tool");
{
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
auto mouse=[&](UINT message,POINT p,bool shift=false) {
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
};
mouse(WM_LBUTTONDOWN,{40,40},true);
mouse(WM_MOUSEMOVE,{100,50},true);
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
mouse(WM_MOUSEMOVE,{100,90},true);
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
"50 pixel deviation keeps the current axis");
mouse(WM_MOUSEMOVE,{100,91},true);
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
"51 pixel deviation turns vertically and preserves the horizontal segment");
mouse(WM_MOUSEMOVE,{70,140});
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
"releasing Shift resumes freehand in the same stroke");
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
mouse(WM_MOUSEMOVE,{140,150},true);
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
"pressing Shift mid-stroke anchors at the last drawn point");
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
mouse(WM_MOUSEMOVE,{150,200},true);
mouse(WM_LBUTTONUP,{150,210},true);
SetKeyboardState(savedKeys);
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
c.marks.clear(); c.selected=-1;
for(int sx:{-1,1}) for(int sy:{-1,1}) {
mouse(WM_LBUTTONDOWN,{160,120},true);
mouse(WM_MOUSEMOVE,{160+sx,120},true);
for(int step=2;step<=90;++step)
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
c.marks.clear(); c.selected=-1;
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
for(int step=1;step<=90;++step)
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
c.marks.clear(); c.selected=-1;
}
for(int sx:{-1,1}) for(int sy:{-1,1}) {
mouse(WM_LBUTTONDOWN,{160,120},true);
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
"Shift snaps to exact 45 degree diagonals in all four directions");
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
c.marks.clear(); c.selected=-1;
}
mouse(WM_LBUTTONDOWN,{40,40},true);
mouse(WM_MOUSEMOVE,{100,40},true);
mouse(WM_MOUSEMOVE,{160,100},true);
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
"large deviation turns a horizontal segment diagonally without losing its corner");
mouse(WM_MOUSEMOVE,{200,100},true);
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
"small perpendicular deviation keeps the diagonal axis");
mouse(WM_MOUSEMOVE,{260,120},true);
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
"diagonal deviation beyond 50 pixels turns back to horizontal");
mouse(WM_LBUTTONUP,{260,120},true);
c.marks.clear(); c.selected=-1;
for(int sx:{-1,1}) for(int sy:{-1,1}) {
POINT start{sx>0?310L:9L,120};
mouse(WM_LBUTTONDOWN,start,true);
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
auto end=c.stroke.to;
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
std::abs(end.x-start.x)==std::abs(end.y-start.y),
"diagonal stays at 45 degrees and inside image boundaries");
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
c.marks.clear(); c.selected=-1;
}
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
mouse(WM_LBUTTONDOWN,{40,40});
mouse(WM_MOUSEMOVE,{100,70});
mouse(WM_LBUTTONUP,{120,90});
SetKeyboardState(savedKeys);
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
c.marks[0].to.x==120 && c.marks[0].to.y==90,
"Ctrl keeps its existing drawing behavior without constraining the pencil");
c.marks.clear(); c.selected=-1;
}
for(int size:{32,48,64,96}) {
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
ICONINFO info{};
+29
View File
@@ -2,6 +2,7 @@
#include "ocr.h"
#include "richtext.h"
#include "storage.h"
#include "diagnostics.h"
#include <algorithm>
#include <iostream>
#include <map>
@@ -44,6 +45,34 @@ int main(int argc, char **argv)
}
try
{
{
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
{"problem", true}, {"verification", "read-back-via-accessibility"},
{"original", "hello\n\"quoted\""}, {"expected", ks::utf8(L"привіт")}, {"actual", "hello"},
{"context", "copy\npaste"}};
auto encoded = L"2026-10-31 12:00:00 Operation " + ks::wide(record.dump());
auto rows = ks::parseOperationLog(encoded);
require(rows.size() == 1 && rows[0].process == L"editor.exe" && rows[0].problem,
"structured operation is one filterable row");
require(rows[0].details.find(L"привіт") != std::wstring::npos &&
rows[0].details.find(L"copy\r\npaste") != std::wstring::npos,
"operation details preserve Unicode and normalize multiline text");
record["problem"] = false;
record["outcome"] = "Paste verified";
rows = ks::parseOperationLog(encoded + L"\n2026-10-31 12:00:01 Operation " + ks::wide(record.dump()));
require(rows.size() == 2 && !rows[1].problem, "verified operations remain distinguishable from problems");
rows = ks::parseOperationLog(L"old incident\n first stage\n second stage\n2026-10-31 Operation {broken");
require(rows.size() == 2 && rows[0].details.find(L"second stage") != std::wstring::npos &&
rows[1].details.find(L"{broken") != std::wstring::npos,
"legacy context is grouped and malformed records remain inspectable");
require(ks::conversionReadbackOutcome(L"new", L"old", L"new") == L"Paste verified",
"expected readback verifies paste");
require(ks::conversionReadbackOutcome(L"old", L"old", L"new") == L"Original text remained",
"original readback identifies unchanged editor text");
require(ks::conversionReadbackOutcome(L"other", L"old", L"new") == L"Readback differs",
"unexpected readback is never reported as success");
}
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
"diagnostic word preserved");
require(ks::diagnosticText(L"\"\\\n\r\t") ==
+8
View File
@@ -10,6 +10,12 @@ try {
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
Assert ($null -ne $binaryVersion) 'executable has a stable version'
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
@@ -24,6 +30,8 @@ try {
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'