Compare commits

..
Author SHA1 Message Date
james.maesandCodex GPT-6 baee14f492 docs: prepare user-focused MarkdownViewr product page
Add real reader screenshots, user and developer guides, current contribution/support links, and verified OpenSSF enrollment evidence. Refs #31, #33, #34.

Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-12 12:39:59 -05:00
james.maesandCodex b1dc8743b8 feat(release): enable verified Windows signing
Use the published standards 1.1.0 builder with the six-file owned signing catalog. Verified Azure configuration, local final signed distribution and current application acceptance are recorded; production OIDC/signatures remain required before publication.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-12 11:18:59 -05:00
james.maesandCodex 313d25888d docs(release): record verified Azure identity and signing proof
Document the existing signing resource, isolated secretless CI trust and profile-scoped permission, protected release variable readback, and real SHA256/timestamped six-binary verification. Record completed tooltip acceptance while keeping CI and final distribution signing unproved.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-12 09:01:30 -05:00
James MaesandGPT-6 321816c1c9 fix(toolbar): attach disabled-editor tooltip to its native owner
Use the same ToolTip instance in ToolTipService and the hover timer, check the loaded target, and exercise actual hover/text/exit with UI-error assertions. Remove completed timing diagnostics while preserving all prior cases/assertions/deadlines. Refs #32, #28.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-12 05:44:02 -05:00
James MaesandGPT-6 de70220b83 test(ui): retain timing evidence for published acceptance
Measure fixture launch, slow lookups, owned closure and Appearance PDF phases in existing hosted artifacts. Preserve every case, assertion and deadline. Refs #21, #22.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-12 00:47:15 -05:00
James MaesandCodex d0526ef2f3 test(settings): activate the owned window before native picker actions
Retain original picker assertions and deadlines; include invocation evidence on failure. Hosted causation/acceptance remains required.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-12 00:15:14 -05:00
James MaesandCodex e6fd2818ff test(security): gate seeded parser campaigns and record assurance
Refs #30, #31. Add real parser properties and replay evidence; retain unenrolled badge and signing requirements without bypasses.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 23:31:46 -05:00
James MaesandCodex 82256f362e docs: record application review and remaining release evidence
Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 22:50:30 -05:00
James MaesandCodex 3b01199afc fix(themes): avoid reserved Unicode export filenames
Prefix COM/LPT superscript device names in theme-export suggestions, matching Microsoft's Windows naming rules and existing ASCII/device handling. Six new existing filename cases pass; 668 core tests retain 100% measured line coverage and formatting passes.

Hold the push until the full final PR review is complete. Actual preceding b542 CI is terminal with native/published109 and installed108 per channel, zero skips. No signing/publication or genuine human review claimed.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 22:27:22 -05:00
James MaesandCodex b542dcd1f5 feat(documents): open bounded local links in native readers
Require user-initiated current-namespace Markdown/text links, verify the opened target handle against the source physical folder, and retain that grant for all reloads. Preserve source readers and duplicate focus without arbitrary shell or protocol dispatch.

Validation: 662 core tests and 100% measured coverage, 10 actual document/image filesystem cases, zero-warning Release and formatting. Prepared physical link/duplicate/watcher/junction-reload acceptance; 109 native cases discovered. Prior exact4d full distributed CI is terminal success.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 21:46:44 -05:00
James MaesandCodex 4d346565ea fix(rendering): preserve authored code and format raw HTML text
Reproduce four authored-code delimiter failures, protect actual code/pre regions, and apply enabled formatting only to raw visible text after original source positions are captured. Preserve attributes, comments, entities and the reference metadata/subscript boundary. Keep original MIT attribution and extend real browser acceptance.

Validation: 642 core tests and 100% measured line coverage, zero-warning Release build, full formatting. Previous c3c735e CI is terminal with native/published106 and installed105 per channel, zero skips. New hosted acceptance remains required.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 20:54:15 -05:00
James MaesandCodex a5bccf862a docs(signing): record verified gclo release reference
Verify the existing gclo release checksum and exact executable with Microsoft SignTool, record its full publisher subject, and retain the distinction from unconfigured MarkdownViewr signing. Update actual native placement acceptance.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 20:24:17 -05:00
James MaesandCodex c4047c746a docs(release): reconcile remaining work with verified acceptance
Add a current release checklist backed by actual distributed test cases and fresh placement screenshots, preserve all historical scope, and record the pending shared builder decision.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 20:21:22 -05:00
James MaesandCodex 216830e94f docs(release): record complete distributed acceptance and tenant discovery
Retain actual terminal native, published and stable/dev installed results. Distinguish public directory discovery from still-unverified Azure signing resources; preserve current hosted placement acceptance.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 20:13:42 -05:00
James MaesandCodex c3c735edd6 fix(settings): keep the full window inside its monitor work area
Center the bounded Settings window rather than retaining an offscreen cascading position. Record actual26/26 focused and106/106 native success and preserve current distributed acceptance.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 19:51:04 -05:00
James MaesandCodex 9d5c0d1d51 docs(release): retain terminal palette and repeat-click failures
Record exact1bfab22 focused/native outcomes and actual published timeout evidence before pushing the prepared metadata and native realization corrections.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 19:28:30 -05:00
James MaesandCodex b1f8af75a6 test(themes): realize offscreen choices with native scrolling
Navigate the owned virtualized list before selecting its exact theme automation ID, preserving every value assertion and the original lookup deadline.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 19:23:42 -05:00
James MaesandCodex 49f0556bf9 fix(navigation): retain heading identity on Contents row content
Preserve the trusted heading Tag after adding native depth-marker layout, so repeat ItemClick can navigate an already selected heading. Retain original physical assertions and correct client-versus-screen diagnostic interpretation.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 19:18:04 -05:00
James MaesandCodex 1bfab22835 feat(settings): preview theme palettes and clarify saved defaults
Use accessible native palette/state rows and one default radio choice inside the existing copied draft. Close every owned test window before the unchanged FlaUI exit fallback to avoid intentional sibling-window waits.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 19:12:06 -05:00
James MaesandCodex 24a464eac6 feat(settings): show effective Markdown default-viewer status
Read the Windows selected ProgID with bounded Unicode interop and exact installed ownership, refresh General without changing defaults, and show full-title tooltips only for actual native truncation with physical acceptance prepared.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:55:16 -05:00
James MaesandCodex b2ecc8c6ef docs(release): record terminal reader regression evidence
Retain actual published98/100 results and distinguish unreached installed lifecycle checks from the prepared source/Printing correction.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:44:06 -05:00
James MaesandCodex 7fa9f7735f feat(settings): share printing defaults with the output dialog
Add native Printing preferences with Apply/Restore and a shared validated options editor. Checked saves refresh all reader baselines, preserve concurrent Settings drafts and update the available named themes.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:42:32 -05:00
James MaesandCodex ec78b5020a fix(reader): retain cross-block headings and native click targets
Reproduce multiline inline closing tags in the following HTML block. Preserve complete token spans, place Contents tooltips away from rows and locate the observed exact picker edit through owned enumeration.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:40:03 -05:00
James MaesandCodex b5e519eddf docs(release): retain installed registration and coexistence evidence
Record exact hosted53755ab acceptance and distinguish unsigned fixture proof from remaining production signing and Windows chooser checks.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:27:45 -05:00
James MaesandCodex aeeb28dd5d feat(settings): add native Contents presentation preferences
Move new-window panel defaults and file-type guidance into General. Add full heading wrapping, optional depth markers and sibling-safe saved presentation, retaining the native depth control and exact heading titles.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:26:16 -05:00
James MaesandCodex 5803fc1b5a fix(source): retain complete inline heading syntax when folded
Pair actual inline heading nodes and exercise multiline headers, code/comment lookalikes and implicit closes. Record the Mac settings comparison and exact validation evidence.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 18:18:56 -05:00
James MaesandCodex 53755ab144 test(print): retain published fixture startup failure evidence
Retain owned activity logs, screenshot and accessibility tree when the original print fixtures cannot render their first heading. Add per-case console progress to full published/installed runs. All assertions, cases, startup and process deadlines remain unchanged.

Retained bd3d72a has full native 99/99 and focused 21/21; published log shows two startup failures before the 600-second timeout. Build/format, 17 relevant tooling tests, all configured analyzer rules and shared Markdown lint pass.

Refs #22

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 17:52:13 -05:00
James MaesandCodex 489af757e7 docs(release): record pinned rendering fidelity evidence
Verify immutable upstream template/CSS-help blob hashes and record matching shared screen declarations plus native Contents/source CSS boundaries. Retain full native 99/99 and focused 21/21 on bd3d72a while preserving the live installed run. Shared standards Markdown lint policy passes.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 17:46:07 -05:00
James MaesandCodex 5de41252f1 test(release): verify simultaneous stable and dev installations
Keep both actual editions installed and open during AppKit updates, verify isolated profile logs and registrations plus unchanged sibling process/bytes/settings, and require stable uninstall to preserve dev. Gate every smoke uninstall on the canonical artifact boundary and actual package manifest.

All 64 tooling tests and every configured analyzer rule on changed scripts pass. Real hosted coexistence execution remains pending; no local desktop input used.

Refs #14

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 17:39:42 -05:00
James MaesandCodex bd3d72abb8 fix(source): retain complete multiline HTML headings when folding
Use parser token spans for raw heading ends, preserving authored multiline attributes, nested title markup and closing syntax. Three newline regressions reproduce the previous header loss; all 589 core tests and required 100% line coverage pass.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 17:32:31 -05:00
James MaesandCodex 764c7ac7fb feat(release): prepare 1.0 and verify installed file registrations
Refs #14 and #10. Add read-only real install/update/uninstall/default-choice assertions and raw HTML heading/fold/find/reload acceptance. Prior9994eda is terminal green across native, published and both installed channels.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 17:18:50 -05:00
James MaesandCodex c89ce5cbb7 chore(source): remove temporary native diagnostic probes
Refs #17 and #27. Exact9994eda passes20 focused and98 native cases; keep its live published/installed run intact before pushing this clean candidate.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 16:43:08 -05:00
James MaesandCodex 9994eda630 fix(source): use content coordinates for offscreen scroll anchors
Refs #17. Retain bounded geometry probes until unchanged native and published acceptance verifies the correction.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 16:31:16 -05:00
James MaesandGPT-6 6374611537 fix(source): repaint on explicit shell theme mode changes
A System-to-Light mode change can retain ActualTheme, so repaint the source from the actual shell command as well as resolved-theme events. Extend real native palette coverage through System-to-Light on already highlighted text, and require three consecutive attribute observations to catch the proven correct-then-plain color reset. Keep original colors, selection and read-only assertions.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 16:00:47 -05:00
James MaesandGPT-6 b15fcbce5f fix(source): include scroll offsets in native caret hit testing
The actual focused regression returns character2 at verticaloffset5297.759. WinUI exposes the full text extent as RichEdit client space, so add current horizontal and vertical offsets to the client inset before native hit-testing. Keep the original physical source alignment assertions and bounds.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:56:55 -05:00
James MaesandGPT-6 5fa6754f0a fix(source): paint tokens after native foreground propagation
The published trace shows the native heading changing from #75628B to #1F1F1F after painting with the same snapshot, generation and six tokens. Await the owned low-priority dispatcher boundary after setting control brushes, then apply default and semantic native ranges. Recheck stale/disposal guards; preserve actual color, pointer, selection and read-only assertions.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:46:04 -05:00
James MaesandGPT-6 91877f7688 fix(source): select the visible native caret before focus
The hosted probe shows that offscreen range rectangles are zero before adding the scroll offset, so binary searching them selects character zero and native focus reveals the old caret. Hit-test the actual visible native text instead. Outline DOM probes prove nested hidden state survives parent expansion; await the corresponding real UIA peer transition with the existing bound and identical absence assertions.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:37:34 -05:00
James MaesandGPT-6 04c1da8289 test(reader): retain native failure evidence before geometry queries
Preserve source alignment failures when UIA glyph queries throw E_UNEXPECTED. Capture opt-in native color propagation and generated collapsed-section indices to distinguish the remaining source and outline defects; keep all acceptance assertions intact.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:29:10 -05:00
James MaesandCodex dbda6d48da test(installation): require channel-owned file registrations
Mark native tests as published or installed from the actual smoke harness's
verified manifest/channel/update boundary, saving and restoring the marker.
Installed Settings must find this exact executable's expected registration
and enable Default Apps; a missing registration plus disabled button cannot
silently pass. Published fixtures must remain unregistered.

The marker changes no application behavior and creates no registry state.
Windows user selection, Explorer and lifecycle acceptance remain required.

Validation: zero-warning Release, format, 55 script boundary cases and both
script analyzer partitions; actual installed execution pending.
Refs #14

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:15:12 -05:00
James MaesandCodex efe14af2fa fix(source): repaint after native theme propagation
Initial native source foreground is intermittently reset to#1F1F1F following
ThemeLight even when its real UIA Toggle action executes. Queue semantic paint
after native theme propagation instead of painting inside ActualThemeChanged.
Preserve parser/range generation guards, native selection and read-only state.

Keep exact native foreground and physical hover regressions; focused14/15 does
not override current full93/97 proof. Include the newly observed nested outline
variant in focused CI, retaining all existing cases and release gates.

Validation: zero-warning Release and full format; exact hosted proof pending.
Refs #16, #27

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:12:02 -05:00
James MaesandCodex 2fe5853720 fix(activation): release accepted broken pipe connections
A synchronous broken read clears IsConnected before cleanup; skipping Disconnect
leaves the native instance busy and prevents later file activations. Track each
accepted connection and release it even after the managed Broken transition.

Add a deterministic real current-user pipe regression: defer the actual server
continuation until its peer closes, then assert the next real acknowledgement.
The unfixed next connection times out10seconds; all4 cases and25 stress runs /
100tests pass after the fix. No retries, deadlines or assertions were relaxed.

Include pipe cases in the required focused job. Retain numeric source focus
coordinates behind the existing test-only trace opt-in for the sole remaining
focused source alignment failure; remove that tagged probe before release.

Validation: zero-warning Release build, full format verification and actual
100-case pipe stress. Prior9fdfc2d focused14/15; distributed proof pending.

Refs #1, #10, #14, #16, #17

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 15:08:15 -05:00
James MaesandCodex 9fdfc2d981 fix(reader): respect heading insets and native menu actions
Compare heading navigation against its actual CSS scroll target so repeated
commands advance while preserving Darin's original scroll inset. Assert the
exact native 16px landing position.

Use WinUI's supported Toggle action instead of a focus-sensitive mouse
fallback; physical source hover assertions remain. Navigate the native picker
before entering all four quoted filenames to avoid its observed input limit.
Include every remaining native failure in the focused required CI check.

Validation: zero-warning Release build, full format verification, 55 Pester
boundary tests and both analyzer partitions pass. Prior 8abbd4d native run is
93/97; this correction awaits exact-head native/published/installed proof.

Refs #1, #10, #16, #17, #21, #27

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 14:56:57 -05:00
James MaesandGPT-6 8abbd4d43c fix(reader): expose the reading scroller and preserve native focus
Name and focus the original document scroll region for real browser UIA
scrolling. Defer duplicate-reader activation until menus finish closing.
Use visible menu peers and preserve Settings focus during UIA invocation.

Retain exact font-growth, PDF width/edge, source-color and palette assertions.
Give the font fixture a clear rounding margin and center its narrow print
body; sample the real empty reading gutter. Record actual failure values.

Validation: zero-warning Release build;586 core tests and100% measured line
coverage; native84/97 on the prior head;75 real pipe stress tests pass.
Current hosted/published/installed and production signatures remain pending.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 14:39:15 -05:00
James MaesandGPT-6 05bf612779 fix(reader): correct print width and native acceptance boundaries
Reset the outer custom body width for full-page printing and align fixed
Contents/Print controls with the standard toolbar hit targets. Use the
observed native Open picker filename control and visible outline-depth peer.

Keep the exact existing pixel, width, scroll and font-growth assertions;
retain native failure geometry and run a focused hosted regression group.
Record current74/97 native acceptance and the passing source-hover proof.

Validation: zero-warning Release build;586 core tests;100% measured line
coverage; full formatting verification. Current distributed acceptance and
production signing remain pending.

Co-Authored-By: GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 14:19:38 -05:00
James MaesandCodex 0ed608e09c feat(reader): isolate editions and refine native review behavior
Use separate stable/dev package IDs and compose native services from the actual
installed channel after Velopack startup. Keep titles, settings, logs and update
identity separate; installed fixtures now pack distinct IDs and assert edition
titles without touching production installs. Add a reproducible gclo-family
Markdown icon and WinUI open-source credit with complete vendor notices retained.

Hosted0eb3a0e reproduced the source-hover bug: heading color changes from
#75628B to #000000 at the first pointer assertion. Test the native visual-state
foreground hypothesis by removing only ContentElement.Foreground animations;
keep native chrome and actual character-color assertions. Add a focused hosted
regression with retained TRX alongside the unchanged full acceptance scope.

Record exact current failures and immutable GitHub OIDC claims. Prepare deployed
Azure account/profile/full-subject inspection and profile-scoped signer actions;
no Azure mutation or actual production signature is claimed.

Validation: zero-warning Release, 586 core tests/100% measured lines, formatting,
55 script tests and both changed-script analyzer partitions pass locally. Native
candidate/edition acceptance and all 25 current full-suite failures remain open.

Refs #1, #14, #16, #24, #27, #29

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 14:00:47 -05:00
James MaesandCodex 0eb3a0efbd feat(reader): complete configuration and Windows integration
Add cancel-safe native Settings pages, configurable typography/CSS/themes,
file registration and activation, theme-aware source and proportional layout,
toolbar/footer/editor guidance and open-source credits. Preserve attributed
Darin Kelkhoff document styles and literal passive rendering. Retain verbatim
vendor notices with scoped newline/whitespace preservation; secret, security
and other hooks remain enabled.

Validation: zero-warning Release build, 582 core tests with 100% measured line
coverage, 25 Windows adapter tests, formatting and self-contained publish pass.
Current native/distributed acceptance and source-hover diagnosis remain pending;
no production release-readiness claim.

Refs #1, #6, #11, #14, #16, #17, #18, #19, #20, #21, #23, #24, #25, #26, #27, #28

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 13:40:39 -05:00
James MaesandCodex 841798c750 test(documents): drive owned native controls after navigation
Reacquire browser glyphs, click unsupported menu providers physically and retain exact picker diagnostics. Keep individual waits unchanged; allow the measured expanded full suite to emit its TRX within a bounded 600-second process budget.

Refs #1, #14

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 13:20:03 -05:00
James MaesandCodex 1cfc07f900 docs: record full print evidence and window milestone
Refs #1, #12, #14

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 04:46:02 -05:00
James MaesandCodex e48a08cd56 feat(documents): add native windows and private recent files
Refs #1, #14

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 04:36:28 -05:00
James MaesandCodex bf4fcdc26a test(print): measure precise font scale in actual PDFs
Refs #12, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 04:25:11 -05:00
James MaesandCodex 6e17674494 test(reader): retain actual zoom failure geometry
Refs #12, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 04:15:28 -05:00
James MaesandCodex d0a225d420 test(print): verify actual font and layout options
Compare real PDF glyph scaling and padding, user-theme content width, optional border rows and full-page output through native controls. Verify actual paired find/source selection and screen paint after successful exports. Preserve original print cases and retain generated PDF/PNG evidence.

Refs #12

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 04:01:02 -05:00
James MaesandCodex 300e14526c chore(print): retain verified acceptance and remove probes
Original hosted print cases pass: both cancels, actual default/themed PDF pixels and unchanged rendered/source/collapse state. Remove the temporary workflow and numeric probes; retain original regressions and require full clean native/published/stable/dev gates. Record measured standards readiness and remaining inventory/review/signing work.

Refs #12, #1

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:47:33 -05:00
James MaesandCodex ec5f98fbf4 test(print): await the complete native PDF filename
The hosted native edit reports a typed prefix while queued input is still processing. Wait for exact full readback before Save rather than asserting an incomplete snapshot; keep PDF output and reading-state assertions unchanged.

Refs #12

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:42:17 -05:00
James MaesandCodex 6bfcf0c52e fix(print): restore reading position after native PDF output
Hosted evidence confirms PDF generation moves the same screen glyph by 41 pixels. Restore validated vertical geometry after the awaited PDF operation only on the same ready navigation; assert actual glyph/view rectangles instead of a stale 200-percent UIA range.

Refs #12

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:38:04 -05:00
James MaesandCodex 39f4ea3983 test(print): compare actual glyphs with scroll-provider state
The first viewport trace reports an impossible 200 percent before printing. Pair each print stage with actual heading and browser rectangles before choosing a restoration change.

Refs #12

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:29:43 -05:00
James MaesandCodex eba7a752fd test(print): measure settled viewport around PDF output
Record fixture-only viewport stages and await stable native layout before taking the unchanged-state baseline. Preserve the original PDF pixel and after-print state assertions.

Refs #12

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:25:15 -05:00
James MaesandCodex 41a687d17c test(print): drive committed native PDF destinations
Use the established find control identities. Exercise real filename typing with exact readback to diagnose a saved PDF missing from its expected fixture path.

Refs #12

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:19:42 -05:00
James MaesandCodex 28c295f3fe feat(print): add native printing and independent print styles
Keep reading and print styles in separate media sheets; preserve viewport, source, collapse, find and window font zoom. Add saved reference print choices, Windows print UI and explicit PDF save through the installed App SDK picker. Core363/100% lines, build/format/docs and55 script tests pass. Three hosted native/output cases are pending; they cancel physical print UI and rasterize owned PDFs using Windows, without a new renderer dependency.

Refs #12, #1. ADR-0003 proposed for maintainer review.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 03:12:17 -05:00
James MaesandCodex 2639e2d8d2 test(source): retire diagnostics after the original regression passes
Original source viewport test passed on 690be07 in run 38121389088. Remove temporary workflow, clicked-content/focus probes and geometry traces; keep the actual browser/source/physical-wheel regression and full required suites. Preserve historical session notes and keep startup state concise.

Refs #10, #11, #13.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 02:32:08 -05:00
James MaesandCodex 690be072d9 fix(navigation): resolve the heading from clicked native content
WinUI ItemClick supplies the TextBlock content rather than the ListViewItem. Attach the numeric heading identity to both native elements and accept the tagged clicked content. The original physical repeat-navigation regression reproduces the mismatch; no source scroll mapping or assertion changes.

Refs #10, #11.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 02:26:35 -05:00
James MaesandCodex dcddefb9c7 test(navigation): trace the native repeated activation boundary
Refs #10, #11. Physical repetition also fails; trace the opted-in numeric ItemClick boundary and assert actual browser alignment before source alignment.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 02:18:33 -05:00
James MaesandCodex 016c7453a4 test(navigation): activate the selected heading with physical input
Refs #10, #11. Preserve the original glyph and wheel assertions; distinguish native ItemClick from the UIA selection path.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 02:13:28 -05:00
James MaesandCodex 2bf1d14570 fix(navigation): reactivate an already selected outline heading
The original source regression now passes actual native and browser middle-heading alignment after bounded physical wheel input. It then reproduces a same-heading click that never jumps back because selection is unchanged. Handle native ItemClick as well as SelectionChanged; preserve keyboard selection jumps and bounded numeric renderer commands.

Zero-warning build, format, docs and normal hooks pass. The existing regression is retained unchanged; hosted full/native/installed proof remains required.

Refs #9, #10, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 02:04:26 -05:00
James MaesandCodex 22a33937b6 test(source): bound physical wheel input per native event
Measured input moves source and reader correctly, but a 31.8-detent event moves only one 580px viewport. Send at most four detents per event, read settled native movement after each, and retain exact glyph adjustment and all original bidirectional/wheel assertions. No application change.

Zero-warning build, format, docs and normal hooks pass. Existing 330 core tests/100% measured coverage and 55 script tests remain; hosted proof pending.

Refs #10, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 01:54:55 -05:00
James MaesandCodex 05c4bc91b9 test(source): drive measured physical wheel scrolling
The UIA range accepted min0/max7370 and value2455.883 but did not reveal the middle glyph or produce a new source geometry frame. Exercise the user gesture instead: measure one settled physical wheel detent, approach the text region, then align from actual native glyphs. Retain all source/reader alignment and wheel assertions; no application change.

330 core tests/100% measured lines retained; zero-warning build, format, documentation and hooks pass. Hosted proof remains pending.

Refs #10, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 01:44:12 -05:00
James MaesandCodex 06a1213eff test(source): retain native scrollbar range diagnostics
The expanded native suite passes 48/49 including actual zoom glyph/key/toolbar/reload acceptance. Preserve the original source failure and record native scrollbar min/max/read-only/value plus owned geometry before another driver change. No app change or relaxed assertions.

Refs #10, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 01:35:08 -05:00
James MaesandCodex 0f13849017 feat(reader): add window-local font zoom and native controls
Adapt pinned reference font-size scaling, 1.1 steps and 30–500% bounds without changing line height or persisted theme JSON. Ctrl+plus/equal/minus/0 retain owned reading focus; native menu and accessible percentage toolbar share the command. Navigation retains viewport/collapse and existing find replay.

Source focus alignment passes on 1c4d1ad. Drive the actual native scrollbar after the observed no-op text-range request, retaining exact source/reader glyph and physical-wheel assertions. Separate optional diagnostics from required CI.

330 core tests/100% measured lines, zero-warning build, format, docs, all 55 script tests and normal hooks pass. Hosted 49 native/published and 48 installed tests remain pending; no local GUI input.

Refs #10, #11, #13

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 01:28:00 -05:00
James MaesandCodex 1c4d1adaca fix(source): align the visible caret before native focus
Hosted diagnostics show GettingFocus captures the correct 5311 offset but later pointer/caret processing resets it to zero. Position only an unselected read-only caret in the visible text before focus; retain active find and viewport guards. Exact full UI acceptance remains required.

Allow the enlarged full published/installed suites a bounded five minutes and retain process output before reporting timeout failure. An owned headless timeout probe and both script-analyzer passes passed.

Refs #10, #11

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 01:12:44 -05:00
James MaesandCodex 82898bd278 test(source): isolate viewport diagnostics without masking timeouts
Keep full required UI suites intact. Run the exact failing source case on a read-only hosted Windows job and print owned focus logs before diagnostic range reads.

Refs #10, #11

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 01:06:42 -05:00
James MaesandCodex f650e36642 feat(reader): manage editor lists and preserve source focus
Add stable named editor choices, bounded legacy migration, copied add/remove/reorder drafts and native file/folder launch menus. Preserve one literal process argument and add actual hosted selection/persistence/probe tests. Restore the synchronized source viewport after native focus/caret revelation, with actual glyph assertions retained.

Refs #10, #11

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 00:53:21 -05:00
James MaesandCodex 2dc509dcfa test(source): verify native movement before reader alignment
Physically focus the owned native source before its text-range scroll request and require actual source glyph alignment before reverse synchronization. Retain original heading bounds and physical-wheel assertions; trace actual source offset changes numerically.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 00:36:26 -05:00
James MaesandCodex 90b5406fe4 fix(source): map original offsets to native CR text
Hosted source selections exposed a two-character drift after two newlines. Preserve canonical offsets and normalize only the native projection to one CR per break; retain exact viewport, fold and find assertions.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 00:28:30 -05:00
James MaesandCodex ddb98ac286 feat(source): mirror folds and synchronize reading positions
Preserve canonical source/find snapshots while projecting literal nested folds and pairing native/browser heading geometry. Add bounded numeric frame validation and actual hosted viewport/fold/find/reload acceptance.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-11 00:20:40 -05:00
James MaesandCodex b0f3b8638f perf(source): index original line boundaries once
Avoid scanning the remaining original text for every heading on a long raw HTML line; verify 300 distinct same-line anchors. Record full native/published/installed vim acceptance while retaining full source synchronization scope.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 23:53:48 -05:00
James MaesandCodex a0d8fe73aa feat(source): map sanitized headings to original syntax
Carry parser-derived UTF-16 provenance through sanitation using fresh private markers that are stripped before presentation. Cover Setext, fences, raw HTML, nesting, duplicates, Unicode, frontmatter and forged attributes. Source scroll and collapse UI remains pending.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 23:49:19 -05:00
James MaesandCodex 6bf125d7fd test(navigation): focus the reader with physical input
The first vim producer retained all 41 existing successes but UIA focus left native logical focus empty or on the outline button. Use the proven real MOVE/Down/Up path inside exact owned browser bounds, keeping real viewport, heading and query-typing assertions unchanged.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 23:39:46 -05:00
James MaesandCodex dfc41a78bd feat(navigation): add native vim reading commands
Route bounded reading commands through the owned UI thread with text-focus, modifier, layout and timed-prefix guards. Add physical viewport, heading and query-typing acceptance and preserve reference attribution.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 23:31:02 -05:00
James MaesandCodex 5a9af6328f fix(navigation): reveal same-document fragment targets
Route same-document SourceChanged events through an exact-document and bounded-heading policy. Rearm repeated links after recollapse; retain physical click and actual reveal outcomes. Core 206 tests/100% lines and build/format/docs pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 23:07:20 -05:00
James MaesandCodex 9edd982e41 fix(tests): account for the composed browser hit provider
Require the physical pointer inside the exact hyperlink bounds and its owned browser provider. Retain the real click and collapsed heading/body reveal outcomes; record the proven native Ctrl+F acceptance.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 22:57:21 -05:00
James MaesandCodex 574b57432a feat(editor): configure literal external document launch
Persist a validated editor executable, expose native configuration and launch commands, and preserve the viewer on missing files or launch errors. Add bounded argument tests and hosted actual-process fixtures; editor-list and folder parity remain tracked in issue #11.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 22:55:12 -05:00
James MaesandCodex 2292603569 fix(input): route owned browser shortcut messages
Limit the Ctrl+F adapter to this foreground window and UI thread. Keep physical keyboard/link assertions and retain exact focus, pointer and fixture screenshot evidence.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 22:51:10 -05:00
James MaesandCodex 7d7c712c63 fix(find): register the window shortcut before menus open
Keep the physical browser-focus shortcut test and identify collapsed browser headings separately from outline labels. Correct ADR spacing and record the terminal acceptance evidence.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 22:41:19 -05:00
James MaesandCodex 4f4280921c feat(find): pair native document and source search
Add one Ctrl+F toolbar with literal source positions, native browser highlights, shared counts and wrapping navigation. Serialize query/reload updates, restore section choices and keep scripts/messages/host objects disabled. Add real keyboard, selection, viewport, hostile-query/reload and fragment-link acceptance.

Refs: #1, #9, #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 22:30:13 -05:00
James MaesandCodex 4bf0b81b85 fix(ui): expose source and inject native pointer movement
Declare the native source surface before the embedded browser without changing its visual column. Replace cursor-only warping with checked SendInput movement after hosted traces showed drag start/completion without deltas; retain physical mode and resize assertions and add source-divider acceptance.

Refs: #8, #9, #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 22:00:22 -05:00
James MaesandCodex 9ff820f477 feat(source): show literal snapshots beside the reader
Persist source visibility and width, refresh the committed snapshot on reload, and keep the reader usable with both panes open. Add hosted literal/read-only/reload acceptance and opt-in native event traces for the two outstanding pointer failures.

Refs: #8, #9, #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 21:50:34 -05:00
James MaesandCodex d59dba2c96 fix(tests): process pointer states before releasing native input
Refs #8, #9. Keep mode/table/resize assertions and timeouts; settle actual mouse down/move/up and retain resize diagnostics.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 21:31:19 -05:00
James MaesandCodex 443b4928ee fix(ui): target submenu input and expose the resize surface
Refs #8, #9. Keep settings/table/physical-resize assertions; verify pointer identity and dismiss the depth popup before dragging.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 21:23:09 -05:00
James MaesandCodex ce9afc6237 fix(ci): isolate casing analysis from competing rule lookups
Refs #5. Preserve every configured rule and fail closed; verify both partitions with negative fixtures. All 13 files and a bounded 20-case race probe pass locally.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 21:18:55 -05:00
James MaesandCodex 5c3988e012 feat(navigation): add native outline and section controls
Refs #9, #8. Preserve exact frontmatter assertions with fixture diagnostics; 161 core tests and 100% line coverage pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 21:15:47 -05:00
James MaesandCodex a141929820 fix(frontmatter): expose metadata table semantics
Refs #8. Add a caption and row headers; keep the actual accessible-table assertion unchanged.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 21:03:18 -05:00
James MaesandCodex 3b6ebf83d0 feat(frontmatter): add persisted metadata display modes
Refs #8, #7. Also verify the native code-font size control.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:58:14 -05:00
James MaesandCodex d3530d106f chore(ci): report analyzer files and engine exceptions
Refs #5. Keep every configured rule and propagate analyzer failures.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:58:10 -05:00
James MaesandCodex 30ca0943c0 feat(themes): edit and restore persistent document themes
Refs #7

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:44:51 -05:00
James MaesandCodex 77dd04da96 fix(accessibility): declare native status before the browser peer
Refs #6, #7

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:33:36 -05:00
James MaesandCodex fa0df784b3 fix(window): keep document status inside the working display
Refs #6

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:27:28 -05:00
James MaesandCodex 0507ca5ac4 feat(themes): port the original document palettes
Refs #7

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:22:31 -05:00
James MaesandCodex e7b6985a34 test(accessibility): retain native status layout evidence
Refs #6

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:20:23 -05:00
James MaesandCodex 23928bc614 fix(accessibility): expose document status to automation
Refs #6

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:15:01 -05:00
James MaesandCodex 665ece7fa4 test(documents): diagnose deletion recovery status
Refs #6

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 20:11:02 -05:00
James MaesandCodex 09d14e6b78 feat(documents): reload saves and retain the reading position
Refs #6

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 19:59:49 -05:00
James MaesandCodex f1756d21ed feat(reader): open and render isolated Markdown documents
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 19:35:37 -05:00
James MaesandCodex 85004f1d1f docs: record Windows viewer bootstrap and repository identity
Preserve the generated repository name in setup examples and record actual foundation validation, hosted run IDs and remaining viewer/release requirements.

Refs: #1

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
2026-10-10 19:06:41 -05:00
42 changed files with 540 additions and 1200 deletions

No files matched your search

-50
View File
@@ -1,50 +0,0 @@
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
windows:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
secrets: inherit
with:
commands: |
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror --no-restore
# The WinUI app project is built directly so the format workspace can load its generated XAML code.
dotnet build src/MarkdownViewr.Windows/MarkdownViewr.Windows.csproj -p:Platform=x64 -warnaserror --no-restore
$env:Platform = 'x64'
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
Remove-Item Env:Platform
dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --logger trx --results-directory TestResults
pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core
# PowerShell build scripts: analyzer and Pester boundary tests with pinned modules saved to the job's temp directory.
$modules = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "psmodules-$env:GITHUB_RUN_ID"
New-Item -ItemType Directory -Force -Path $modules | Out-Null
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
Import-Module (Join-Path $modules PSScriptAnalyzer/1.25.0/PSScriptAnalyzer.psd1)
Import-Module (Join-Path $modules Pester/5.7.1/Pester.psd1)
foreach ($file in Get-ChildItem -LiteralPath build -Recurse -File | Where-Object Extension -In '.ps1', '.psm1', '.psd1') {
# Casing runs in its own pass: command metadata races other rules in 1.25.0 (PSScriptAnalyzer#1708).
$findings = @(
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -IncludeRule PSUseCorrectCasing
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -ExcludeRule PSUseCorrectCasing
)
if ($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $($file.Name)" }
}
# The Pester suites assert on child processes' exit codes themselves.
$PSNativeCommandUseErrorActionPreference = $false
$pester = New-PesterConfiguration
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/GitHubRepository.Tests.ps1', 'build/Distribution.Tests.ps1', 'build/InstalledUpdates.Tests.ps1', 'build/PackageBytes.Tests.ps1', 'build/ReleaseSigning.Tests.ps1')
$pester.Run.PassThru = $true
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
scripts:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
with:
node-version: '24'
install: npm ci --ignore-scripts
commands: |
npm run lint
npm test
npm audit signatures
-55
View File
@@ -1,55 +0,0 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
/** Requires genuine passing CI on the exact complete tree merged for this release. */
async function run({ github, context, core })
{
const repository = context.repo;
const { data: source } = await github.rest.repos.getCommit({ ...repository, ref: context.sha });
if(!source.commit.verification.verified)
{
throw new Error("The release commit signature is not verified.");
}
const pulls = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit,
{ ...repository, commit_sha: context.sha, per_page: 100 });
const merged = pulls.filter(pull => pull.merged_at && pull.state === "closed"
&& pull.base.ref === "main" && pull.merge_commit_sha === context.sha);
if(merged.length !== 1)
{
throw new Error("Expected one merged main PR for the tagged release commit.");
}
const pull = merged[0];
const { data: tested } = await github.rest.repos.getCommit({ ...repository, ref: pull.head.sha });
if(tested.commit.tree.sha !== source.commit.tree.sha)
{
throw new Error("The release tree differs from the tested PR tree.");
}
const { data: rules } = await github.request("GET /repos/{owner}/{repo}/rules/branches/{branch}",
{ ...repository, branch: "main" });
// Personal acknowledgement is enforced at merge; technical validation is reused here.
const required = rules.filter(rule => rule.type === "required_status_checks")
.flatMap(rule => rule.parameters.required_status_checks)
.filter(check => check.context !== "review / automated");
if(required.length === 0)
{
throw new Error("No required technical checks were found.");
}
const checks = await github.paginate(github.rest.checks.listForRef,
{ ...repository, ref: pull.head.sha, per_page: 100 });
for(const requirement of required)
{
const records = checks.filter(check => check.name === requirement.context
&& check.app?.id === (requirement.integration_id ?? 15368));
records.sort((left, right) => right.id - left.id);
if(records[0]?.status !== "completed" || records[0]?.conclusion !== "success")
{
throw new Error(`Required tested-source check is not successful: ${requirement.context}`);
}
}
core.info(`Reused ${required.length} genuine passing checks from PR #${pull.number} at ${pull.head.sha}; exact release tree ${source.commit.tree.sha}.`);
return { head: pull.head.sha, tree: source.commit.tree.sha, checks: required.length };
}
module.exports = { run };
@@ -1,43 +0,0 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { run } = require("./assert-release-source.cjs");
/** Exercises release-tree, check-provider and latest-result boundaries. */
test("reuses only verified identical-source passing CI", async () =>
{
for(const failure of [null, "signature", "tree", "merge", "provider", "pending", "failed", "missing", "latest", "rules"])
{
const check = { id: 1, name: "ci / ui-tests", app: { id: failure === "provider" ? 7 : 15368 },
status: failure === "pending" ? "in_progress" : "completed",
conclusion: failure === "failed" ? "failure" : "success" };
const github = {
rest: {
repos: { getCommit: async ({ ref }) => ({ data: { commit: {
verification: { verified: failure !== "signature" },
tree: { sha: failure === "tree" && ref === "head" ? "other" : "tree" },
} } }), listPullRequestsAssociatedWithCommit: "pulls" },
checks: { listForRef: "checks" },
},
request: async () => ({ data: failure === "rules" ? [] : [{ type: "required_status_checks",
parameters: { required_status_checks: [{ context: "ci / ui-tests", integration_id: 15368 }, { context: "review / automated" }] } }] }),
paginate: async method => method === "pulls" ? (failure === "merge" ? [] : [{ number: 1, state: "closed",
merged_at: "today", base: { ref: "main" }, merge_commit_sha: "release", head: { sha: "head" } }])
: failure === "missing" ? [] : failure === "latest" ? [check, { ...check, id: 2, conclusion: "failure" }] : [check],
};
const operation = run({ github, context: { repo: { owner: "owner", repo: "repo" }, sha: "release" }, core: { info: () =>
{} } });
if(failure)
{
await assert.rejects(operation);
}
else
{
assert.deepEqual(await operation, { head: "head", tree: "tree", checks: 1 });
}
}
});
+2 -3
View File
@@ -44,7 +44,7 @@ jobs:
shell: pwsh
run: >-
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~Outline_NestedCollapse'
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
--logger 'trx;LogFileName=reader-regressions.trx'
--results-directory artifacts/reader-regressions
- name: Retain native regression evidence
@@ -63,8 +63,7 @@ jobs:
installed-updates:
name: installed-updates
runs-on: windows-latest
# Three complete UI suites plus packaging, installation and update probes.
timeout-minutes: 60
timeout-minutes: 45
permissions:
contents: read
steps:
+9
View File
@@ -41,12 +41,21 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet build tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 -warnaserror --no-restore
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Restore and build activation adapter targets
shell: pwsh
run: |
dotnet restore tests/MarkdownViewr.Windows.UiTests -p:Platform=x64 --locked-mode
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet build tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 -warnaserror --no-restore
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify seeded parser security properties
shell: pwsh
run: |
Write-Output "Fuzz seeds22023/99173, five targets, $env:K22_FUZZ_ITERATIONS iterations per seed/target."
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=fuzz.trx' --results-directory artifacts/fuzz
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=activation-fuzz.trx' --results-directory artifacts/fuzz
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Retain synthetic campaign failures and results
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+33 -11
View File
@@ -38,8 +38,6 @@ jobs:
environment: release
permissions:
contents: read
checks: read
pull-requests: read
steps:
- name: Checkout main and tag history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -63,16 +61,41 @@ jobs:
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
run: ./build/Assert-ReleaseSigning.ps1
- name: Require passing CI for the exact merged release tree
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { run } = require('./.github/scripts/assert-release-source.cjs');
await run({ github, context, core });
gates:
needs: verify
uses: ./.github/workflows/ci.yml
permissions:
contents: read
security:
needs: verify
uses: ./.github/workflows/security.yml
permissions:
contents: read
security-events: write
codeql:
needs: verify
uses: ./.github/workflows/codeql.yml
permissions:
contents: read
security-events: write
scripts:
needs: verify
uses: ./.github/workflows/scripts.yml
permissions:
contents: read
fuzzing:
needs: verify
uses: ./.github/workflows/fuzz.yml
permissions:
contents: read
release:
needs: verify
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@70b555bd5658273090769991bfc4c9b6681dde18 # v1.2.1
needs: [verify, gates, security, codeql, scripts, fuzzing]
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@93b1d6163a4c466ae443e98b62786fc4d80d33bd # v1.1.0
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations; Azure OIDC when signing
@@ -90,7 +113,6 @@ jobs:
test-projects: |
tests/MarkdownViewr.Windows.Core.Tests
sign: true
msi: true
signing-files: |
app/MarkdownViewr.Windows.exe
app/MarkdownViewr.Windows.dll
+1 -1
View File
@@ -54,7 +54,7 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>1.0.4</VersionPrefix>
<VersionPrefix>1.0.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
+9 -7
View File
@@ -4,7 +4,9 @@ Read Markdown comfortably, keep up with changes, and turn your notes into someth
MarkdownViewr is a dedicated Windows reader for project documentation, notes, and README files. Open a document, choose a look you like, and focus on the words.
**[Download Windows installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Setup.exe) · [User guide](docs/user-guide.md) · [Ask a question](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions)**
**[Release downloads](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases) · [User guide](docs/user-guide.md) · [Ask a question](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions)**
> The first Windows release is being prepared. There are no published Windows downloads yet; the Releases page will contain the stable installer and development previews when publication completes.
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15417/badge)](https://www.bestpractices.dev/en/projects/15417)
@@ -27,17 +29,17 @@ Contents and Source start hidden. Turn them on when needed, or change their new-
## Download and install
Windows packages support **Windows 10 version 1809 or later and Windows 11, x64**. Microsoft Edge WebView2 Runtime is required. No .NET SDK is needed to use a release.
The planned Windows packages support **Windows 10 version 1809 or later and Windows 11, x64**. Microsoft Edge WebView2 Runtime is required. No .NET SDK is needed to use a release.
| Edition | Choose it for | Package |
| --- | --- | --- |
| Stable | Everyday reading | [Download EXE installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Setup.exe) · [MSI installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable.msi) |
| Development preview | Trying upcoming changes | [Download dev EXE installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev-Setup.exe) · [MSI installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev.msi) |
| Portable | Running without installation | [Download portable ZIP](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Portable.zip) |
| Stable | Everyday reading | `markdownviewr-windows-stable-Setup.exe` |
| Development preview | Trying upcoming changes | `markdownviewr-windows-dev-Setup.exe` |
| Portable | Running without installation | `markdownviewr-windows-stable-Portable.zip` |
Download a package from [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases). Run Setup for a per-user installation without administrator rights, or extract the portable ZIP before launching the app.
Once available, download a package from [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases). Run Setup for a per-user installation without administrator rights, or extract the portable ZIP before launching the app.
Installed and portable editions check for updates through **Help → Check for updates**. Stable and development editions keep separate settings and update channels, so they can coexist.
Installed editions check for updates through **Help → Check for updates**. Stable and development editions keep separate settings and update channels, so they can coexist. Portable copies do not self-update.
## Quick start
+3 -2
View File
@@ -50,10 +50,11 @@ minor receives security fixes for 6 months.
Public Windows releases require Authenticode signing of the final shipped binaries
and installers. Publication fails closed until the approved Azure signing identity
and expected publisher, trust and timestamp are verified. Unsigned test fixtures are not releases.
and expected publisher, trust and timestamp are verified. No verified signed
MarkdownViewr release has been published yet; unsigned test fixtures are not releases.
Verify final signatures, `SHA256SUMS` and provenance attestations as described in the
[Developer Guide](DEVELOPERS.md#releases-and-verification) and
[README](README.md#verifying-a-release) and
[release signing instructions](docs/release-signing.md). No historical exception
is claimed as permission to publish unsigned application releases.
+4 -4
View File
@@ -47,17 +47,17 @@ Describe 'Required checks match standalone workflow jobs' `
Describe 'Generated repository administration' `
{
It 'allows the digest-pinned security and SBOM containers without permitting arbitrary container actions' `
It 'allows the digest-pinned security containers without permitting arbitrary container actions' `
{
$plan = Get-GitHubRepositoryPlan -Repository 'Example/Notes'
$policy = ($plan.Requests | Where-Object Path -Like '*/actions/permissions/selected-actions').Body
$policy.patterns_allowed.Count | Should -Be 4
$policy.patterns_allowed.Count | Should -Be 3
$policy.patterns_allowed | Should -Contain 'KofTwentyTwo/standards/*@*'
$containers = @($policy.patterns_allowed | Where-Object { $_.StartsWith('docker://', [StringComparison]::Ordinal) })
$containers.Count | Should -Be 3
$containers.Count | Should -Be 2
foreach($container in $containers)
{
$container | Should -Match '^docker://ghcr\.io/(gitleaks/gitleaks:v8\.30\.1|aquasecurity/trivy:0\.75\.0|anchore/syft:v1\.54\.1)@sha256:[0-9a-f]{64}$'
$container | Should -Match '^docker://ghcr\.io/(gitleaks/gitleaks:v8\.30\.1|aquasecurity/trivy:0\.75\.0)@sha256:[0-9a-f]{64}$'
$container | Should -Not -Match '\*'
}
}
+1 -2
View File
@@ -79,8 +79,7 @@ function Get-GitHubRepositoryPlan
# Exact container actions used by the published v0.1.2 security builder.
patterns_allowed = @('KofTwentyTwo/standards/*@*',
'docker://ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f',
'docker://ghcr.io/aquasecurity/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa',
'docker://ghcr.io/anchore/syft:v1.54.1@sha256:3eb5379ba7b409c3f4069b686110527af0c47df993fa5c10d13e7cf34f49b1aa')
'docker://ghcr.io/aquasecurity/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa')
}
},
@{ Method = 'PUT'; Path = "$root/environments/release"; Body = @{ deployment_branch_policy = @{ protected_branches = $false; custom_branch_policies = $true } } },
+1 -17
View File
@@ -13,27 +13,11 @@ Describe 'Installed update smoke boundaries' `
$script:SmokeArtifacts = Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'
$script:SmokeAppId = [regex]::Match((Get-Content -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/MarkdownViewr.Windows.Core/AppIdentity.cs') -Raw), '(?m)^\s*Id\s*=\s*"([a-z0-9][a-z0-9-]*)"').Groups[1].Value
$ast = [Management.Automation.Language.Parser]::ParseFile($script:SmokeHarness, [ref]$null, [ref]$null)
foreach($name in @('Assert-SmokeCliVersion', 'Get-SmokeDefaultChoiceSnapshot', 'Get-SmokeFileRegistration', 'Assert-SmokeFileRegistrationRemoved', 'Assert-SmokeInstallationOwner', 'Invoke-SmokeProcess'))
foreach($name in @('Assert-SmokeCliVersion', 'Get-SmokeDefaultChoiceSnapshot', 'Get-SmokeFileRegistration', 'Assert-SmokeFileRegistrationRemoved', 'Assert-SmokeInstallationOwner'))
{
$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true)
. ([scriptblock]::Create($function.Extent.Text))
}
$script:repositoryRoot = $TestDrive
$script:outputRoot = $TestDrive
$script:vpk = 'not-a-fixture-tool'
$script:smokeProcessNumber = 0
}
It 'terminates a hung child process and fails the smoke gate' `
{
$shell = (Get-Process -Id $PID).Path
{ Invoke-SmokeProcess -File $shell -Arguments @('-NoProfile', '-Command', 'Start-Sleep -Seconds 30') -TimeoutSeconds 1 } | Should -Throw '*Process timed out*'
}
It 'rejects a failing child process and retains its output' `
{
$shell = (Get-Process -Id $PID).Path
{ Invoke-SmokeProcess -File $shell -Arguments @('-NoProfile', '-Command', "Write-Output 'fixture failure'; exit 7") -TimeoutSeconds 10 } | Should -Throw '*Process failed (7)*fixture failure*'
}
It 'requires an actual matching manifest inside the resolved artifact boundary before uninstall' `
+2 -4
View File
@@ -435,9 +435,7 @@ try
$env:K22_UITEST_CHANNEL = 'stable'
$env:K22_UITEST_INSTALLED = '0'
$env:K22_UITEST_EXE = Join-Path $buildRoot "publish/0.1.0/$mainExe"
# The full 111-test UI suite takes 572 seconds on a passing hosted runner.
# Allow bounded host variation without changing any individual test deadline.
Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--logger', 'console;verbosity=normal', '--logger', 'trx;LogFileName=published-ui.trx', '--results-directory', $outputRoot) -TimeoutSeconds 900
Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--logger', 'console;verbosity=normal', '--logger', 'trx;LogFileName=published-ui.trx', '--results-directory', $outputRoot) -TimeoutSeconds 600
foreach($initial in @($versions[0], $versions[2]))
{
$initialPackageId = $packageId + '-' + $initial.channel
@@ -502,7 +500,7 @@ try
$updatedRegistration | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $outputRoot "$channel-registration-after.json")
$env:K22_UITEST_INSTALLED = '1'
$env:K22_UITEST_EXE = $currentExe
Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--filter', 'FullyQualifiedName!~CheckForUpdates_InALooseBuild', '--logger', 'console;verbosity=normal', '--logger', "trx;LogFileName=installed-$channel-ui.trx", '--results-directory', $outputRoot) -TimeoutSeconds 900
Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--filter', 'FullyQualifiedName!~CheckForUpdates_InALooseBuild', '--logger', 'console;verbosity=normal', '--logger', "trx;LogFileName=installed-$channel-ui.trx", '--results-directory', $outputRoot) -TimeoutSeconds 600
$records.Add(@{ kind = 'installed'; channel = $channel; before = $before; after = $after; restarted = $true; appSha256 = $installedHash })
}
finally
+1 -1
View File
@@ -7,7 +7,7 @@ which calls the shared `release-velopack.yml` workflow in KofTwentyTwo/standards
## Workflow baseline
The signing release builder is pinned to standards v1.1.1 (`96cbdf208a15c9c524676bd15ff9e323228a952b`); other shared gates retain v0.1.2. The release caller checks main ancestry and reruns build, coverage, formatting, UI tests, security scans and CodeQL before publication.
The signing release builder is pinned to standards v1.1.0 (`93b1d6163a4c466ae443e98b62786fc4d80d33bd`); other shared gates retain v0.1.2. The release caller checks main ancestry and reruns build, coverage, formatting, UI tests, security scans and CodeQL before publication.
The AppKit dependency locks now match the published 0.1.0 packages. Before changing any package version, regenerate the affected locks and verify a locked restore with a clean package cache.
## Versioning and channels
+1 -30
View File
@@ -1,35 +1,6 @@
# Session State
Updated: 2026-10-13
## Release source and verification
Stable1.0.2 and dev1.0.2-beta.1 EXE/ZIP releases are public and immutable at
source18d8ae950cab445672d216d387a956b964d2c6c1, builder083d903. Actual downloaded
signatures/publisher/trust/timestamps, package payloads, checksums, CycloneDX
SBOM attestations, source/builder provenance and updater feeds verify.
Production runs38392743207 and38392743312 succeeded. Failed older refs remain.
The maintainer renewed the MSI request on2026-10-13; earlier MSI deferral is
superseded. Prepare signed per-user MSI alongside EXE/ZIP for stable/dev1.0.3.
MSI payload verification and focused native install/launch/uninstall must pass
before publication. Source behaviors, dependencies and Azure trust are unchanged.
Prevent duplicate release test passes by requiring the exact complete Git tree
of a genuinely merged main PR whose latest required technical checks all passed
under their required provider identities. Reject unverified source signatures,
different trees, missing/failed/pending checks and API failures. Reuse real CI
proof; do not post fabricated results or weaken branch protection. Fresh release
build/core coverage, signing and installer/distribution checks remain required.
The owner explicitly authorized finishing releases without further human-review
requests. Record the release-specific waiver truthfully; restore the original
manual-review requirement immediately after each authorized merge. The next
development prefix is1.0.4. OpenSSF15417 remains70%, not passing. Preserve owner
previews/profiles and unrelated worktrees. Track actual publication/proof in
[release checklist](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/33).
## Historical release snapshots
Updated: 2026-10-12
## Current release status - 2026-10-12
+1 -11
View File
@@ -1,16 +1,6 @@
# Development Tasks
## Release and follow-up work
- [x] Publish/verify signed stable1.0.2 and dev1.0.2-beta.1 EXE/ZIP releases (#33).
- [x] Prepare exact-tree/provider-bound reuse of genuine source CI; boundary tests and real17-check source proof pass (#38).
- [ ] Publish the native MSI builder, pin it and enable signed MSI in the caller (#36).
- [ ] Pass exact-head CI, record the existing release waiver, merge and restore all original branch checks.
- [ ] Publish1.0.3 stable/dev MSI+EXE+ZIP; verify actual MSI install/launch/uninstall and all final payload signatures, hashes, SBOMs and provenance.
- [x] Set next development prefix to1.0.4 and prepare user download links.
- Complete remaining OpenSSF evidence; saved70% is not a passing badge (#31).
## Prior release checklist
## Current release checklist
- [x] Correct the disabled-editor tooltip exception; accepted application code321816c.
- [x] Final signing caller b1dc874 / CI38276579007 passes all hosted checks, including published111 and stable/dev110 acceptance each, zero failures/skips.
@@ -33,22 +33,10 @@ crossing processes; the public SDK launch envelope cannot carry sender CWD.
Deliver requests through a current-user-only .NET named pipe. Accept only a
source-generated bounded schema: at most128 absolute paths, a1MiB frame and an
optional fixed invalid-path notification. Reject unknown fields; no command or
script operation exists. A sender waits at most2 seconds for the reader's pipe
and each side at most10 seconds for one exchange, with at most16 requests held
before XAML attaches its dispatcher. Malformed/disconnected peers cannot stop
later valid requests. App shutdown cancels and disposes the listener, releasing
the pipe name before the instance key. No dependencies or generic RPC layer are added.
The request, the schema and the inbox live in the core library
(`Core/Activation`), where the unit tests and the coverage gate reach them.
Forwarding is best effort; opening the document is not. A launch whose request
the running reader did not take (no listener, a refusal, or a deadline) opens
its documents in its own process. A reader that cannot create its inbox, because
the pipe name is already held by a reader that is still closing or by another
program, runs without one; later launches then open their own documents. An
argument that cannot name a document is dropped and reported in the reader
window while the usable arguments still open.
script operation exists. Connection/read/acknowledgement waits are bounded to
10seconds, with at most16 requests queued before XAML attaches its dispatcher.
Malformed/disconnected peers cannot stop later valid requests. App shutdown
cancels and disposes the listener. No dependencies or generic RPC layer are added.
The foreground launcher permits the primary to focus an existing document.
Dispatch all window creation on the native UI thread; duplicate paths include
+2 -2
View File
@@ -35,8 +35,8 @@ signing service. Never export a PFX or put administrative passwords in CI.
## Enable the reviewed builder
The release caller pins standards v1.1.1 at
`96cbdf208a15c9c524676bd15ff9e323228a952b`. Its reviewed builder supports the
The release caller pins standards v1.1.0 at
`93b1d6163a4c466ae443e98b62786fc4d80d33bd`. Its reviewed builder supports the
explicit ownership catalog and verifies final shipped bytes/signatures. The
configured catalog is:
+1 -1
View File
@@ -11,7 +11,7 @@
"reason": "Untrusted Markdown/HTML/frontmatter, source ranges/folds, resource URLs, imported theme JSON and activation JSON are parser boundaries. Seeded mutation targets verify unsafe acceptance as well as exceptions.",
"targets": [
"tests/MarkdownViewr.Windows.Core.Tests/SecurityFuzzTests.cs",
"tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs"
"tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs"
],
"command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz",
"workflow": ".github/workflows/fuzz.yml",
+2 -1
View File
@@ -30,7 +30,8 @@ Run from the repository root with the pinned .NET 10 SDK on Windows:
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
$env:K22_FUZZ_ITERATIONS = '500'
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --filter Category=Fuzz
# Weekly and before every MINOR/MAJOR release, repeat the command with 10000.
dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --filter Category=Fuzz
# Weekly and before every MINOR/MAJOR release, repeat both commands with 10000.
$env:K22_FUZZ_ITERATIONS = '10000'
```
-11
View File
@@ -324,13 +324,6 @@ relative/control-containing/null/overlong paths and more than128 paths; frames
are bounded to1MiB, connection/read waits to10seconds and pre-XAML queuing to16
requests. Normal document size/resource/sanitizer boundaries still apply.
The pipe name is derived from the executable and data folders, so another
program running as any user on the machine can create that name first. That
cannot expose a document or redirect one: both ends require a pipe owned by the
current user. It could only deny forwarding, so a reader that cannot create its
inbox starts without one, and a launch whose request is not taken opens its own
documents.
Malformed/disconnected requests are rejected and the listener continues; owned
shutdown cancels the pipe. Fixed notifications and failure kind/HRESULT logging
do not expose paths or payloads. UI-dispatched duplicate activation preserves
@@ -433,7 +426,3 @@ actual parsers. Independent properties detect unsafe acceptance as well as crash
Synthetic inputs never execute scripts, start native processes or touch real profiles.
The record in [assurance.json](assurance.json) does not claim coverage-guided
instrumentation, Best Practices enrollment or verified production signatures.
## Release validation reuse
Release tags must be on main and have a verified commit signature. Reused source CI must belong to the genuinely merged main PR, match the entire tagged Git tree and have successful latest required technical checks from the configured provider identities. Missing, stale, failed or pending proof fails publication. This avoids repeating identical UI tests; it does not trust arbitrary PR artifacts or remove branch checks. Fresh build/coverage, Azure signing, MSI installation and exact distributed-payload verification remain release gates.
+1 -5
View File
@@ -60,7 +60,7 @@ The installer preserves your current defaults. Windows requires you to choose th
## Updates and local data
Stable and development editions, including portable copies, update within their own channels using **Help → Check for updates**. They have separate settings, recent files, and logs.
Installed stable and development editions update within their own channels using **Help → Check for updates**. They have separate settings, recent files, and logs. Portable copies require a fresh download.
Settings and logs are stored locally under `%LOCALAPPDATA%\markdownviewr-windows` or `%LOCALAPPDATA%\markdownviewr-windows-dev`. **Help → Activity log** opens the log viewer and provides a shortcut to its folder.
@@ -75,7 +75,3 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md).
## MSI installation
The MSI installs for the current Windows user, alongside the EXE and portable download options. Stable and dev have separate package identities. Use the same installer format for an existing edition; install the other channel separately to try previews. Uninstall an MSI installation through Windows Settings → Apps → Installed apps. The installer registers supported Markdown types and preserves your existing default choices.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.403",
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
},
"scripts": {
"lint": "eslint .github/scripts eslint.config.mjs --max-warnings 0",
"test": "node --test .github/scripts/review-gate.test.cjs .github/scripts/assert-release-source.test.cjs"
"test": "node --test .github/scripts/review-gate.test.cjs"
},
"devDependencies": {
"@eslint/js": "10.0.1",
@@ -1,214 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
using System.IO.Pipes;
using System.Text.Json;
namespace MarkdownViewr.Windows.Core.Activation;
/// <summary>
/// The running reader's inbox for documents a later launch asks it to open. One named pipe,
/// open only to the current Windows user, carries one length-prefixed <see cref="ActivationRequest"/>
/// per connection and answers with one byte: accepted or not.
/// </summary>
public sealed class ActivationInbox : IDisposable
{
/// <summary>Requests held while the reader is still starting; a further one is refused so the sender can report it.</summary>
public const int MaximumPendingRequests = 16;
/// <summary>How long a sender waits for the reader's pipe to exist and be free.</summary>
public static readonly TimeSpan ConnectDeadline = TimeSpan.FromSeconds(2);
/// <summary>How long either side waits for the other to finish one request.</summary>
public static readonly TimeSpan ExchangeDeadline = TimeSpan.FromSeconds(10);
private readonly Lock _gate = new();
private readonly Queue<ActivationRequest> _pending = new();
private readonly CancellationTokenSource _closed = new();
private readonly NamedPipeServerStream _pipe;
private readonly Action<Exception> _report;
private readonly TimeProvider _time;
private readonly Task _listener;
private Func<ActivationRequest, bool>? _dispatch;
private int _disposed;
/// <summary>
/// Creates the pipe and starts listening. Throws when the pipe name is already taken, by a
/// reader that has not finished closing or by another program; the caller then runs without
/// an inbox. Deadlines run on <paramref name="time"/>, the system clock unless a test supplies its own.
/// </summary>
public ActivationInbox(string key, Action<Exception> report, TimeProvider? time = null)
{
ArgumentNullException.ThrowIfNull(report);
_report = report;
_time = time ?? TimeProvider.System;
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
_listener = ListenAsync();
}
/// <summary>Completes when the listener has stopped after <see cref="Dispose"/>.</summary>
public Task Completion => _listener;
/// <summary>
/// Connects the inbox to the reader once its windows can open documents, delivering first the
/// requests that arrived while it was starting. The dispatcher returns false when it cannot
/// take a request.
/// </summary>
public void Attach(Func<ActivationRequest, bool> dispatch)
{
ArgumentNullException.ThrowIfNull(dispatch);
lock(_gate)
{
_dispatch = dispatch;
while(_pending.TryDequeue(out ActivationRequest? request))
{
if(!dispatch(request))
{
_report(new IOException("The reader could not accept activation."));
}
}
}
}
/// <summary>Sends one request to the running reader and returns whether it accepted it.</summary>
public static Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
=> SendAsync(key, request, TimeProvider.System, cancellationToken);
/// <summary>Sends one request with its deadlines on the given clock. Throws when no reader answers in time or the request is too large to send.</summary>
internal static async Task<bool> SendAsync(string key, ActivationRequest request, TimeProvider time, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
request.Validate();
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
{
throw new ArgumentException("The activation payload is too large.", nameof(request));
}
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
using(var deadline = new CancellationTokenSource(ConnectDeadline, time))
using(var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token))
{
await client.ConnectAsync(connecting.Token);
}
using var exchangeDeadline = new CancellationTokenSource(ExchangeDeadline, time);
using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exchangeDeadline.Token);
byte[] length = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
await client.WriteAsync(length, exchanging.Token);
await client.WriteAsync(payload, exchanging.Token);
await client.FlushAsync(exchanging.Token);
byte[] response = new byte[1];
await client.ReadExactlyAsync(response, exchanging.Token);
return response[0] == 1;
}
/// <summary>Serves one connection at a time until disposed; a peer that disconnects, stalls or sends an invalid request is reported and does not stop the next one.</summary>
private async Task ListenAsync()
{
while(!_closed.IsCancellationRequested)
{
bool connected = false;
try
{
await _pipe.WaitForConnectionAsync(_closed.Token);
connected = true;
using var deadline = new CancellationTokenSource(ExchangeDeadline, _time);
using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token, deadline.Token);
bool accepted = Accept(await ReadRequestAsync(exchanging.Token));
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, exchanging.Token);
await _pipe.FlushAsync(exchanging.Token);
}
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or OperationCanceledException or ObjectDisposedException)
{
if(!_closed.IsCancellationRequested)
{
_report(exception);
}
}
finally
{
// Under the lock Dispose takes, so the pipe cannot be disposed between the check and the call.
lock(_gate)
{
if(connected && !_closed.IsCancellationRequested)
{
// A failed read clears IsConnected, but the connection still has to be released before the next peer.
_pipe.Disconnect();
}
}
}
}
}
/// <summary>Reads one bounded, length-prefixed request and checks it against the schema and the path rules.</summary>
private async Task<ActivationRequest> ReadRequestAsync(CancellationToken cancellationToken)
{
byte[] length = new byte[4];
await _pipe.ReadExactlyAsync(length, cancellationToken);
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
{
throw new InvalidDataException("Invalid activation length.");
}
byte[] payload = new byte[size];
await _pipe.ReadExactlyAsync(payload, cancellationToken);
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
request.Validate();
return request;
}
/// <summary>Hands the request to the reader, or holds it until the reader attaches; false when neither is possible.</summary>
private bool Accept(ActivationRequest request)
{
lock(_gate)
{
if(_dispatch is not null)
{
return _dispatch(request);
}
if(_pending.Count >= MaximumPendingRequests)
{
return false;
}
_pending.Enqueue(request);
return true;
}
}
/// <summary>Stops listening and releases the pipe name.</summary>
public void Dispose()
{
if(Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock(_gate)
{
_closed.Cancel();
_pipe.Dispose();
}
_closed.Dispose();
}
}
@@ -1,93 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
namespace MarkdownViewr.Windows.Core.Activation;
/// <summary>
/// The documents one launch asks to open: bounded, literal, absolute paths. This is the whole
/// message a second launch sends to the running reader; it carries no commands.
/// </summary>
public sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
{
/// <summary>The most documents one launch may open.</summary>
public const int MaximumPaths = 128;
/// <summary>The largest serialized request the running reader accepts.</summary>
public const int MaximumPayloadBytes = 1024 * 1024;
private const int MaximumPathCharacters = 32767;
/// <summary>
/// Resolves each argument against the launching process's folder, so a relative path keeps
/// its meaning when the running reader has a different working folder. An argument that is
/// not a usable path is dropped and flagged; the usable ones still open.
/// </summary>
public static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
{
ArgumentNullException.ThrowIfNull(paths);
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
if(candidates.Length > MaximumPaths)
{
throw new ArgumentException("Too many document paths.", nameof(paths));
}
string?[] resolved = candidates.Select(path => Resolve(path, workingDirectory)).ToArray();
return new ActivationRequest(resolved.OfType<string>().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), resolved.Contains(null));
}
/// <summary>Rejects a request that did not come from <see cref="Create"/>: a relative, empty, overlong or control-bearing path, or too many of them.</summary>
public void Validate()
{
if(Paths is null || Paths.Length > MaximumPaths || !Paths.All(IsUsable))
{
throw new InvalidDataException("Invalid document activation paths.");
}
}
/// <summary>
/// Names the single-instance group: one per executable folder and data folder, so stable and
/// development editions, loose builds and test profiles never forward to each other.
/// </summary>
public static string InstanceKey(string executableDirectory, string dataDirectory)
{
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
}
/// <summary>The absolute form of one argument, or null when it cannot name a document.</summary>
private static string? Resolve(string? path, string workingDirectory)
{
if(string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
string full = Path.GetFullPath(path, workingDirectory);
return IsUsable(full) ? full : null;
}
catch(Exception exception) when(exception is ArgumentException or NotSupportedException or IOException)
{
return null;
}
}
/// <summary>A path the reader may be asked to open: absolute, bounded, and free of control characters.</summary>
private static bool IsUsable(string? path)
=> !string.IsNullOrEmpty(path) && path.Length <= MaximumPathCharacters && Path.IsPathFullyQualified(path) && !path.Any(char.IsControl);
}
@@ -32,9 +32,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.14, )",
"resolved": "10.0.14",
"contentHash": "YlTamXppEQls6DYWt7RdUuwWDb0lyw/vXCaGeQpLCpsLmfwGuCkOz/vIGgMTV7ECsJcavkCOkxZXv9s8rdAHkQ=="
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"AngleSharp": {
"type": "Transitive",
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
using System.IO.Pipes;
using System.Text.Json;
namespace MarkdownViewr.Windows;
/// <summary>Bounded current-user IPC delivers sender-canonicalized document paths to the SDK's chosen primary instance.</summary>
internal sealed class ActivationInbox : IDisposable
{
private readonly Lock _gate = new();
private readonly Queue<ActivationRequest> _pending = new();
private readonly CancellationTokenSource _closed = new();
private readonly NamedPipeServerStream _pipe;
private readonly Action<Exception> _report;
private readonly Task _listener;
private Func<ActivationRequest, bool>? _dispatch;
private int _disposed;
/// <summary>The primary owns the pipe before XAML starts; only the same Windows user can connect.</summary>
internal ActivationInbox(string key, Action<Exception> report)
{
_report = report;
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
_listener = ListenAsync();
}
/// <summary>The owned listener completes on disposal; tests and shutdown diagnostics can observe failures instead of losing a task.</summary>
internal Task Completion => _listener;
/// <summary>Attaches the native UI dispatcher after launch, delivering bounded requests received while XAML initialized.</summary>
internal void Attach(Func<ActivationRequest, bool> dispatch)
{
lock(_gate)
{
_dispatch = dispatch;
while(_pending.TryDequeue(out ActivationRequest? request))
{
if(!dispatch(request))
{
_report(new IOException("The reader could not accept activation."));
}
}
}
}
/// <summary>Sends one fixed request with a bounded connection/acknowledgement wait and preserves the real server acknowledgement.</summary>
internal static async Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
{
request.Validate();
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
{
throw new ArgumentException("The activation payload is too large.", nameof(request));
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await client.ConnectAsync(timeout.Token);
byte[] length = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
await client.WriteAsync(length, timeout.Token);
await client.WriteAsync(payload, timeout.Token);
await client.FlushAsync(timeout.Token);
byte[] response = new byte[1];
await client.ReadExactlyAsync(response, timeout.Token);
return response[0] == 1;
}
/// <summary>A disconnected or malformed peer cannot stop later activations; each request has a bounded read deadline.</summary>
private async Task ListenAsync()
{
while(!_closed.IsCancellationRequested)
{
bool connected = false;
try
{
await _pipe.WaitForConnectionAsync(_closed.Token);
connected = true;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
byte[] length = new byte[4];
await _pipe.ReadExactlyAsync(length, timeout.Token);
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
{
throw new InvalidDataException("Invalid activation length.");
}
byte[] payload = new byte[size];
await _pipe.ReadExactlyAsync(payload, timeout.Token);
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
request.Validate();
bool accepted;
lock(_gate)
{
accepted = _dispatch is null ? QueueBeforeLaunch(request) : _dispatch(request);
}
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, timeout.Token);
await _pipe.FlushAsync(timeout.Token);
}
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or ArgumentException or OperationCanceledException or ObjectDisposedException)
{
if(!_closed.IsCancellationRequested)
{
_report(exception);
}
}
finally
{
if(connected && !_closed.IsCancellationRequested)
{
// A broken read clears IsConnected, but the owned native connection still requires Disconnect.
try
{
_pipe.Disconnect();
}
catch(ObjectDisposedException) when(_closed.IsCancellationRequested)
{
// Shutdown may release the handle after the cancellation check.
}
}
}
}
}
/// <summary>A startup burst can queue at most16 requests; the peer is told to retry if the native reader is busy.</summary>
private bool QueueBeforeLaunch(ActivationRequest request)
{
if(_pending.Count >= 16)
{
return false;
}
_pending.Enqueue(request);
return true;
}
/// <summary>Application shutdown cancels pending connections and releases the owned current-user pipe.</summary>
public void Dispose()
{
if(Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_closed.Cancel();
_pipe.Dispose();
_closed.Dispose();
}
}
@@ -6,9 +6,10 @@
using System.Text.Json.Serialization;
namespace MarkdownViewr.Windows.Core.Activation;
namespace MarkdownViewr.Windows;
/// <summary>The fixed schema of an activation message; a field it does not define is an error, so the message cannot grow into a command protocol.</summary>
/// <summary>Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol.</summary>
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
[JsonSerializable(typeof(ActivationRequest))]
internal sealed partial class ActivationJsonContext : JsonSerializerContext;
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
namespace MarkdownViewr.Windows;
/// <summary>Only bounded literal absolute document paths cross the current-user activation pipe; no native commands are accepted.</summary>
internal sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
{
internal const int MaximumPaths = 128;
internal const int MaximumPayloadBytes = 1024 * 1024;
/// <summary>Canonicalizes at the sender, preserving relative-path meaning even when the existing reader has a different working directory.</summary>
internal static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
{
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
if(candidates.Length > MaximumPaths)
{
throw new ArgumentException("Too many document paths.", nameof(paths));
}
var request = new ActivationRequest(candidates.Select(path => Path.GetFullPath(path, workingDirectory)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
request.Validate();
return request;
}
/// <summary>Malformed, oversized or relative pipe input cannot become an unbounded launch or be interpreted using the receiver's directory.</summary>
internal void Validate()
{
if(Paths is null || Paths.Length > MaximumPaths || Paths.Any(path => string.IsNullOrEmpty(path) || path.Length > 32767 || !Path.IsPathFullyQualified(path) || path.Any(char.IsControl)))
{
throw new InvalidDataException("Invalid document activation paths.");
}
}
/// <summary>Profile and executable directories define distinct stable/dev, development-build and review/test instance identities.</summary>
internal static string InstanceKey(string executableDirectory, string dataDirectory)
{
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
}
}
@@ -3,8 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Activation;
namespace MarkdownViewr.Windows;
@@ -4,7 +4,6 @@
*/
using System.Runtime.InteropServices;
using MarkdownViewr.Windows.Core.Activation;
using Microsoft.Windows.AppLifecycle;
using Windows.ApplicationModel.Activation;
+15 -44
View File
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Activation;
using Microsoft.UI.Dispatching;
using Microsoft.Windows.AppLifecycle;
using Velopack;
@@ -17,21 +16,17 @@ namespace MarkdownViewr.Windows;
/// </summary>
public static class Program
{
/// <summary>The running reader's inbox; null in a process that could not create one or that opened its documents itself.</summary>
/// <summary>The primary's bounded current-user inbox is attached by App only after the native reader initializes.</summary>
internal static ActivationInbox? Inbox { get; private set; }
/// <summary>The documents this launch was asked to open, resolved against its own working folder.</summary>
/// <summary>The initial canonical request preserves sender-relative paths and SDK rich file activation.</summary>
internal static ActivationRequest InitialRequest { get; private set; } = new([]);
/// <summary>
/// Runs the installer hooks, then either hands this launch's documents to the reader that is
/// already running or becomes a reader. A launch whose documents the running reader could not
/// take opens them itself, so a double-clicked file always opens.
/// </summary>
/// <summary>Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request.</summary>
[STAThread]
private static int Main()
{
@@ -44,12 +39,12 @@ public static class Program
WinRT.ComWrappersSupport.InitializeComWrappers();
InitialRequest = DesktopActivation.ReadInitialRequest();
string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!);
var instance = AppInstance.FindOrRegisterForKey(key);
if(!instance.IsCurrent && Forward(key, instance.ProcessId))
var primary = AppInstance.FindOrRegisterForKey(key);
if(!primary.IsCurrent)
{
return 0;
return ForwardToPrimary(key, primary.ProcessId);
}
ActivationInbox? inbox = instance.IsCurrent ? OpenInbox(key) : null;
using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure);
Inbox = inbox;
try
{
@@ -62,54 +57,30 @@ public static class Program
finally
{
Inbox = null;
// The pipe name is released before the instance key, so the next reader to register can create its own inbox.
inbox?.Dispose();
if(instance.IsCurrent)
{
instance.UnregisterKey();
}
primary.UnregisterKey();
}
return 0;
}
/// <summary>
/// Creates the inbox, or returns null when the pipe name is taken. The reader then runs
/// without one: later launches find nobody listening and open their documents themselves.
/// </summary>
private static ActivationInbox? OpenInbox(string key)
{
try
{
return new ActivationInbox(key, DesktopActivation.ReportFailure);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
DesktopActivation.ReportFailure(exception);
return null;
}
}
/// <summary>Hands this launch's documents to the running reader; false when it did not take them.</summary>
private static bool Forward(string key, uint processId)
/// <summary>Current-user .NET pipe delivery avoids the SDK STA redirection deadlock and resolves relative paths before crossing processes.</summary>
private static int ForwardToPrimary(string key, uint processId)
{
try
{
DesktopActivation.AllowPrimaryForeground(processId);
// A plain pipe is used because the SDK's own redirection deadlocks an STA entry point.
if(ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult())
bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult();
if(!delivered)
{
return true;
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
}
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
return delivered ? 0 : 1;
}
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException)
{
DesktopActivation.ReportFailure(exception);
return 1;
}
return false;
}
}
@@ -1,343 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Text;
using MarkdownViewr.Windows.Core.Activation;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Real current-user pipes between a real inbox and real senders. Deadlines run on a manual
/// clock, so no test waits for one and none can be reached early by a slow machine.
/// </summary>
public sealed class ActivationInboxTests
{
private static readonly TimeSpan s_safetyNet = TimeSpan.FromSeconds(30);
/// <summary>A request sent while the reader is starting is held until it attaches; afterwards each request gets the reader's own answer.</summary>
[Fact]
public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
{
string key = NewKey();
var received = new ConcurrentQueue<ActivationRequest>();
var errors = new ConcurrentQueue<Exception>();
using var inbox = new ActivationInbox(key, errors.Enqueue);
var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
using var safety = new CancellationTokenSource(s_safetyNet);
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
Assert.Empty(received);
inbox.Attach(value =>
{
received.Enqueue(value);
return true;
});
Assert.Equal(request.Paths, Assert.Single(received).Paths);
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
Assert.Equal(2, received.Count);
inbox.Attach(_ => false);
Assert.False(await ActivationInbox.SendAsync(key, request, safety.Token));
Assert.Equal(2, received.Count);
Assert.Empty(errors);
}
/// <summary>While the reader is starting it holds a bounded number of requests and refuses the next, which the sender is told.</summary>
[Fact]
public async Task Send_MoreThanTheBoundBeforeTheReaderAttaches_RefusesTheExtraRequest()
{
string key = NewKey();
var received = new ConcurrentQueue<ActivationRequest>();
using var inbox = new ActivationInbox(key, _ => { });
using var safety = new CancellationTokenSource(s_safetyNet);
for(int index = 0; index < ActivationInbox.MaximumPendingRequests; index++)
{
Assert.True(await ActivationInbox.SendAsync(key, ActivationRequest.Create([$"held{index}.md"], Path.GetTempPath()), safety.Token));
}
Assert.False(await ActivationInbox.SendAsync(key, ActivationRequest.Create(["one too many.md"], Path.GetTempPath()), safety.Token));
inbox.Attach(value =>
{
received.Enqueue(value);
return true;
});
Assert.Equal(Enumerable.Range(0, ActivationInbox.MaximumPendingRequests).Select(index => $"held{index}.md"), received.Select(request => Path.GetFileName(Assert.Single(request.Paths))), StringComparer.Ordinal);
Assert.Equal(16, ActivationInbox.MaximumPendingRequests);
}
/// <summary>A held request the reader then cannot take is reported, not silently dropped.</summary>
[Fact]
public async Task Attach_HeldRequestTheReaderRefuses_IsReported()
{
string key = NewKey();
var errors = new ConcurrentQueue<Exception>();
using var inbox = new ActivationInbox(key, errors.Enqueue);
using var safety = new CancellationTokenSource(s_safetyNet);
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
inbox.Attach(_ => false);
Assert.IsType<IOException>(Assert.Single(errors));
Assert.Throws<ArgumentNullException>(() => inbox.Attach(null!));
Assert.Throws<ArgumentNullException>(() => new ActivationInbox(NewKey(), null!));
}
/// <summary>
/// The pipe name belongs to one inbox at a time. A second one fails to start, so its process
/// can run without an inbox, and the name is free again once the first is disposed.
/// </summary>
[Fact]
public async Task Constructor_NameAlreadyTaken_ThrowsUntilTheOwnerIsDisposed()
{
string key = NewKey();
using var safety = new CancellationTokenSource(s_safetyNet);
Task stopped;
using(var owner = new ActivationInbox(key, _ => { }))
{
Assert.ThrowsAny<IOException>(() => new ActivationInbox(key, _ => { }));
stopped = owner.Completion;
}
await stopped.WaitAsync(safety.Token);
using var successor = new ActivationInbox(key, _ => { });
successor.Attach(_ => true);
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
}
/// <summary>A frame with a zero, negative or oversized length, an unknown field, a relative path or a null body is rejected; the next valid sender is still served.</summary>
[Fact]
public async Task Listen_MalformedPeers_RejectsAndRecovers()
{
string key = NewKey();
var received = new ConcurrentQueue<ActivationRequest>();
var errors = new ConcurrentQueue<Exception>();
using var inbox = new ActivationInbox(key, errors.Enqueue);
inbox.Attach(value =>
{
received.Enqueue(value);
return true;
});
using var safety = new CancellationTokenSource(s_safetyNet);
await SendFrame(key, ActivationRequest.MaximumPayloadBytes + 1, [], safety.Token);
await SendFrame(key, 0, [], safety.Token);
await SendFrame(key, -1, [], safety.Token);
foreach(string body in new[] { "{\"Paths\":[],\"Command\":\"execute\"}", "{\"Paths\":[\"relative.md\"]}", "null" })
{
byte[] payload = Encoding.UTF8.GetBytes(body);
await SendFrame(key, payload.Length, payload, safety.Token);
}
var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
Assert.Equal(request.Paths, Assert.Single(received).Paths);
// Each frame is refused for its own reason: the three lengths before any payload is read, then the schema, the path rule and the empty body.
Type[] expected = [typeof(InvalidDataException), typeof(InvalidDataException), typeof(InvalidDataException), typeof(System.Text.Json.JsonException), typeof(InvalidDataException), typeof(InvalidDataException)];
Assert.Equal(expected, errors.Select(error => error.GetType()));
Assert.Equal(1024 * 1024, ActivationRequest.MaximumPayloadBytes);
}
/// <summary>A frame of exactly the largest size is read and accepted.</summary>
[Fact]
public async Task Listen_FrameOfExactlyTheLargestSize_IsAccepted()
{
string key = NewKey();
var received = new ConcurrentQueue<ActivationRequest>();
var errors = new ConcurrentQueue<Exception>();
using var inbox = new ActivationInbox(key, errors.Enqueue);
inbox.Attach(value =>
{
received.Enqueue(value);
return true;
});
using var safety = new CancellationTokenSource(s_safetyNet);
byte[] payload = new byte[ActivationRequest.MaximumPayloadBytes];
Array.Fill(payload, (byte)' ');
"{\"Paths\":[\"C:\\\\largest.md\"]}"u8.CopyTo(payload);
Assert.Equal(1, await SendFrame(key, payload.Length, payload, safety.Token));
Assert.Equal([@"C:\largest.md"], Assert.Single(received).Paths);
Assert.Empty(errors);
}
/// <summary>A sender never puts an invalid or oversized request on the pipe.</summary>
[Fact]
public async Task Send_InvalidOrOversizedRequest_IsRefusedBeforeConnecting()
{
string key = NewKey();
using var safety = new CancellationTokenSource(s_safetyNet);
await Assert.ThrowsAsync<InvalidDataException>(() => ActivationInbox.SendAsync(key, new ActivationRequest(["relative.md"]), safety.Token));
await Assert.ThrowsAsync<ArgumentNullException>(() => ActivationInbox.SendAsync(key, null!, safety.Token));
string longPath = @"C:\" + new string('a', 9000);
var oversized = new ActivationRequest(Enumerable.Range(0, ActivationRequest.MaximumPaths).Select(index => longPath + index.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray());
oversized.Validate();
await Assert.ThrowsAsync<ArgumentException>(() => ActivationInbox.SendAsync(key, oversized, safety.Token));
}
/// <summary>With no reader listening, a sender gives up at its connect deadline instead of waiting forever.</summary>
[Fact]
public async Task Send_NoReaderListening_GivesUpAtTheConnectDeadline()
{
var clock = new ManualTimeProvider();
using var safety = new CancellationTokenSource(s_safetyNet);
Task<bool> sending = ActivationInbox.SendAsync(NewKey(), new ActivationRequest([]), clock, safety.Token);
await clock.NextTimerAsync(safety.Token);
Assert.False(sending.IsCompleted);
clock.Elapse();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sending);
Assert.False(safety.IsCancellationRequested);
Assert.Equal(TimeSpan.FromSeconds(2), ActivationInbox.ConnectDeadline);
}
/// <summary>A peer that connects and sends nothing is dropped at the exchange deadline, and the next sender is served.</summary>
[Fact]
public async Task Listen_PeerThatSendsNothing_IsDroppedAtTheDeadline()
{
string key = NewKey();
var clock = new ManualTimeProvider();
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
using var inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception), clock);
inbox.Attach(_ => true);
using var safety = new CancellationTokenSource(s_safetyNet);
await using(var silent = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
{
await silent.ConnectAsync(safety.Token);
await clock.NextTimerAsync(safety.Token);
Assert.False(reported.Task.IsCompleted);
clock.Elapse();
Assert.IsAssignableFrom<OperationCanceledException>(await reported.Task.WaitAsync(safety.Token));
}
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
Assert.Equal(TimeSpan.FromSeconds(10), ActivationInbox.ExchangeDeadline);
}
/// <summary>A sender that disconnects without a request is reported and does not block the next one; disposing twice is harmless and frees the name.</summary>
[Fact]
public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
{
string key = NewKey();
var errors = new ConcurrentQueue<Exception>();
using var safety = new CancellationTokenSource(s_safetyNet);
Task completed;
using(var inbox = new ActivationInbox(key, errors.Enqueue))
{
inbox.Attach(_ => true);
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
{
await client.ConnectAsync(safety.Token);
}
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
Assert.Single(errors);
completed = inbox.Completion;
inbox.Dispose();
inbox.Dispose();
}
await completed.WaitAsync(safety.Token);
Assert.Single(errors);
using var replacement = new ActivationInbox(key, _ => { });
replacement.Attach(_ => true);
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
}
/// <summary>A peer that has already closed when the reader turns to it is released before the next sender connects.</summary>
[Fact]
public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
{
string key = NewKey();
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
var received = new ConcurrentQueue<ActivationRequest>();
var connectionContext = new DeferredConnectionContext();
SynchronizationContext? previous = SynchronizationContext.Current;
ActivationInbox inbox;
try
{
SynchronizationContext.SetSynchronizationContext(connectionContext);
inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
}
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
}
using ActivationInbox ownedInbox = inbox;
inbox.Attach(value =>
{
received.Enqueue(value);
return true;
});
using var safety = new CancellationTokenSource(s_safetyNet);
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
{
await client.ConnectAsync(safety.Token);
}
(SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(safety.Token);
// Resume the listener only after the peer has closed, so its first read finds a broken pipe.
var resume = Task.Run(() => callback(state), safety.Token);
Assert.IsAssignableFrom<IOException>(await reported.Task.WaitAsync(safety.Token));
var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
Assert.Equal(request.Paths, Assert.Single(received).Paths);
await resume.WaitAsync(safety.Token);
}
/// <summary>A pipe name no other test or process uses.</summary>
private static string NewKey() => "k22-mv-tests-" + Guid.NewGuid().ToString("N");
/// <summary>Sends one raw frame and returns the reader's answer byte, or -1 when the reader closed the connection without answering.</summary>
private static async Task<int> SendFrame(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
{
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await client.ConnectAsync(cancellationToken);
byte[] length = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
byte[] response = new byte[1];
try
{
await client.WriteAsync(length, cancellationToken);
await client.WriteAsync(payload, cancellationToken);
await client.FlushAsync(cancellationToken);
return await client.ReadAsync(response, cancellationToken) == 1 ? response[0] : -1;
}
catch(IOException)
{
// Windows may report a broken pipe instead of end-of-stream once the reader has rejected the frame.
return -1;
}
}
/// <summary>Captures the listener's first continuation so a test decides when it resumes.</summary>
private sealed class DeferredConnectionContext : SynchronizationContext
{
/// <summary>The captured continuation and its state.</summary>
internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Holds the first posted continuation instead of running it.</summary>
public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
}
}
@@ -1,105 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Activation;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>What one launch may ask the running reader to open, and which launches share a reader.</summary>
public sealed class ActivationRequestTests
{
/// <summary>Relative arguments resolve against the launching folder; shell characters stay literal and duplicate paths collapse without regard to case.</summary>
[Fact]
public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
{
string directory = Path.Combine(Path.GetTempPath(), "sender space");
var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
Assert.False(request.HasInvalidPath);
Assert.Empty(ActivationRequest.Create([], directory).Paths);
Assert.False(ActivationRequest.Create([], directory).HasInvalidPath);
}
/// <summary>An argument that cannot name a document is dropped and flagged; the documents beside it still open.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("bad\0name.md")]
[InlineData("line\nbreak.md")]
public void Create_UnusableArgumentBesideUsableOnes_KeepsTheUsableOnesAndFlagsTheRest(string unusable)
{
string directory = Path.Combine(Path.GetTempPath(), "sender");
var request = ActivationRequest.Create(["first.md", unusable, "second.md"], directory);
Assert.Equal(new[] { Path.Combine(directory, "first.md"), Path.Combine(directory, "second.md") }, request.Paths);
Assert.True(request.HasInvalidPath);
request.Validate();
}
/// <summary>A launch may name at most the bounded number of documents; one more is refused outright.</summary>
[Fact]
public void Create_PathCountAtAndPastTheBound_IsBoundedExactly()
{
string directory = Path.GetTempPath();
IEnumerable<string> names = Enumerable.Range(0, ActivationRequest.MaximumPaths + 1).Select(index => $"document{index}.md");
Assert.Equal(ActivationRequest.MaximumPaths, ActivationRequest.Create(names.Take(ActivationRequest.MaximumPaths), directory).Paths.Length);
Assert.Throws<ArgumentException>(() => ActivationRequest.Create(names, directory));
Assert.Throws<ArgumentNullException>(() => ActivationRequest.Create(null!, directory));
Assert.Equal(128, ActivationRequest.MaximumPaths);
}
/// <summary>A request that did not come from Create is refused: relative, empty, control-bearing, overlong or too many paths.</summary>
[Fact]
public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
{
Assert.Throws<InvalidDataException>(() => new ActivationRequest(null!).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["relative.md"]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest([""]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest([null!]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths + 1).ToArray()).Validate());
new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths).ToArray()).Validate();
}
/// <summary>A path is accepted up to the Windows limit and refused one character past it.</summary>
[Fact]
public void Validate_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('a', 32767 - 3);
Assert.Equal(32767, atLimit.Length);
new ActivationRequest([atLimit]).Validate();
Assert.Throws<InvalidDataException>(() => new ActivationRequest([atLimit + "a"]).Validate());
}
/// <summary>
/// One reader serves one executable folder and one data folder. Spelling does not matter:
/// case and a trailing separator name the same folders. Any other folder is another reader,
/// even one whose name has the same length.
/// </summary>
[Fact]
public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
{
string root = Path.Combine(Path.GetTempPath(), "viewer");
string data = Path.Combine(Path.GetTempPath(), "profile");
string key = ActivationRequest.InstanceKey(root, data);
Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
Assert.Equal(key, ActivationRequest.InstanceKey(root.ToUpperInvariant(), data + Path.DirectorySeparatorChar));
Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, Path.Combine(Path.GetTempPath(), "profilf")), StringComparer.Ordinal);
Assert.NotEqual(key, ActivationRequest.InstanceKey(data, root), StringComparer.Ordinal);
Assert.Matches("^k22-markdownviewr-[0-9A-F]{64}$", key);
}
}
@@ -1,104 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// A clock whose timers fire only when a test says so, so a deadline can be reached without
/// waiting for it and can never be reached by a slow machine.
/// </summary>
internal sealed class ManualTimeProvider : TimeProvider
{
private readonly Lock _gate = new();
private readonly List<ManualTimer> _timers = [];
private TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Records the timer instead of scheduling it.</summary>
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ManualTimer(callback, state);
lock(_gate)
{
_timers.Add(timer);
_created.TrySetResult();
}
return timer;
}
/// <summary>Completes once code under test has created a timer since the last call.</summary>
public Task NextTimerAsync(CancellationToken cancellationToken)
{
Task created;
lock(_gate)
{
created = _created.Task;
if(created.IsCompleted)
{
_created = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
return created.WaitAsync(cancellationToken);
}
/// <summary>Fires every timer created so far that has not been disposed, as if each deadline had passed.</summary>
public void Elapse()
{
ManualTimer[] pending;
lock(_gate)
{
pending = _timers.ToArray();
_timers.Clear();
}
foreach(ManualTimer timer in pending)
{
timer.Fire();
}
}
/// <summary>A timer that runs its callback once, on request, unless it was disposed first.</summary>
private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer
{
private int _disposed;
/// <summary>Runs the callback unless the owner has already let go of the timer.</summary>
public void Fire()
{
if(Volatile.Read(ref _disposed) == 0)
{
callback(state);
}
}
/// <summary>Rescheduling is accepted and ignored: only <see cref="Elapse"/> fires a timer.</summary>
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
/// <summary>Marks the timer dead so a later <see cref="Elapse"/> skips it.</summary>
public void Dispose() => Interlocked.Exchange(ref _disposed, 1);
/// <summary>Asynchronous disposal has nothing to wait for.</summary>
public ValueTask DisposeAsync()
{
Dispose();
return ValueTask.CompletedTask;
}
}
}
@@ -6,10 +6,9 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using MarkdownViewr.Windows.Core.Activation;
namespace MarkdownViewr.Windows.Core.Tests;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>Fuzzes the real generated activation schema and path validator without creating a window, pipe, process or profile.</summary>
[Trait("Category", "Fuzz")]
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Text;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>Real current-user pipes prove bounded native delivery without taking desktop input or launching an app.</summary>
public sealed class ActivationInboxTests
{
/// <summary>A startup request survives until a reader dispatcher attaches; rejected dispatch returns a negative acknowledgement.</summary>
[Fact]
public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
{
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
var received = new ConcurrentQueue<ActivationRequest>();
var errors = new ConcurrentQueue<Exception>();
using var inbox = new ActivationInbox(key, errors.Enqueue);
var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
Assert.Empty(received);
inbox.Attach(value => { received.Enqueue(value); return true; });
Assert.Equal(request.Paths, Assert.Single(received).Paths);
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
Assert.Equal(2, received.Count);
inbox.Attach(_ => false);
Assert.False(await ActivationInbox.SendAsync(key, request, deadline.Token));
Assert.Equal(2, received.Count);
Assert.Empty(errors);
}
/// <summary>Oversized frames, unknown JSON fields and relative pipe paths are rejected, then a real valid peer still connects.</summary>
[Fact]
public async Task Listen_MalformedPeers_RejectsAndRecovers()
{
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
var received = new ConcurrentQueue<ActivationRequest>();
var errors = new ConcurrentQueue<Exception>();
using var inbox = new ActivationInbox(key, errors.Enqueue);
inbox.Attach(value => { received.Enqueue(value); return true; });
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await SendMalformed(key, ActivationRequest.MaximumPayloadBytes + 1, [], deadline.Token);
byte[] unknown = Encoding.UTF8.GetBytes("{\"Paths\":[],\"Command\":\"execute\"}");
await SendMalformed(key, unknown.Length, unknown, deadline.Token);
byte[] relative = Encoding.UTF8.GetBytes("{\"Paths\":[\"relative.md\"]}");
await SendMalformed(key, relative.Length, relative, deadline.Token);
var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
Assert.Equal(request.Paths, Assert.Single(received).Paths);
Assert.Equal(3, errors.Count);
}
/// <summary>A disconnected sender never blocks the next activation and shutdown releases the pipe without leaking an owned connection.</summary>
[Fact]
public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
{
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
var errors = new ConcurrentQueue<Exception>();
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
Task completed;
using(var inbox = new ActivationInbox(key, errors.Enqueue))
{
inbox.Attach(_ => true);
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
{
await client.ConnectAsync(deadline.Token);
}
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
Assert.Single(errors);
completed = inbox.Completion;
inbox.Dispose();
inbox.Dispose();
}
await completed.WaitAsync(deadline.Token);
using var replacement = new ActivationInbox(key, _ => { });
replacement.Attach(_ => true);
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
}
/// <summary>An already-closed peer must release its native connection before another real activation.</summary>
[Fact]
public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
{
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
var received = new ConcurrentQueue<ActivationRequest>();
var connectionContext = new DeferredConnectionContext();
SynchronizationContext? previous = SynchronizationContext.Current;
ActivationInbox inbox;
try
{
SynchronizationContext.SetSynchronizationContext(connectionContext);
inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
}
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
}
using ActivationInbox ownedInbox = inbox;
inbox.Attach(value => { received.Enqueue(value); return true; });
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
{
await client.ConnectAsync(deadline.Token);
}
(SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(deadline.Token);
// Resume the real server after the real peer closes, exercising the synchronous broken-read path.
var resume = Task.Run(() => callback(state), deadline.Token);
Assert.IsAssignableFrom<IOException>(await reported.Task.WaitAsync(deadline.Token));
var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
Assert.Equal(request.Paths, Assert.Single(received).Paths);
await resume.WaitAsync(deadline.Token);
}
/// <summary>Defers only the first real server continuation, making the already-closed native peer schedule reproducible.</summary>
private sealed class DeferredConnectionContext : SynchronizationContext
{
internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Captures the actual asynchronous continuation without changing pipe bytes, handles or acknowledgements.</summary>
public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
}
/// <summary>Sends an intentionally invalid frame and waits for the actual server to close it before starting the next peer.</summary>
private static async Task SendMalformed(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
{
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await client.ConnectAsync(cancellationToken);
byte[] length = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
await client.WriteAsync(length, cancellationToken);
await client.WriteAsync(payload, cancellationToken);
await client.FlushAsync(cancellationToken);
byte[] response = new byte[1];
try
{
Assert.Equal(0, await client.ReadAsync(response, cancellationToken));
}
catch(IOException)
{
// Windows may report a broken pipe rather than EOF after the server rejects the malformed frame.
}
}
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.UiTests;
/// <summary>Sender-relative paths, Unicode, duplicate launches and stable/dev/profile isolation retain their actual meanings.</summary>
public sealed class ActivationRequestTests
{
/// <summary>Canonicalization belongs to the sender, including literal shell characters and case-insensitive duplicate paths.</summary>
[Fact]
public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
{
string directory = Path.Combine(Path.GetTempPath(), "sender space");
var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
Assert.False(request.HasInvalidPath);
Assert.Empty(ActivationRequest.Create([], directory).Paths);
}
/// <summary>Malformed messages cannot open relative paths or allocate an unbounded number of document windows.</summary>
[Fact]
public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
{
Assert.Throws<InvalidDataException>(() => new ActivationRequest(null!).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["relative.md"]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest([""]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\" + new string('a', 32768)]).Validate());
Assert.Throws<ArgumentException>(() => ActivationRequest.Create(Enumerable.Repeat("one.md", ActivationRequest.MaximumPaths + 1), Path.GetTempPath()));
}
/// <summary>Equivalent directory spellings share an instance, while distinct review profiles and channel installation roots do not.</summary>
[Fact]
public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
{
string root = Path.Combine(Path.GetTempPath(), "viewer");
string data = Path.Combine(Path.GetTempPath(), "profile");
string key = ActivationRequest.InstanceKey(root, data);
Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
Assert.StartsWith("k22-markdownviewr-", key, StringComparison.Ordinal);
Assert.DoesNotContain(root, key, StringComparison.OrdinalIgnoreCase);
}
}
@@ -150,12 +150,6 @@ public partial class AppTests
CloseSettings(session);
InvokeDocumentMenu(session, sibling, "SourceToggle", menuName: "View");
AssertSavedPrintingDefaults(session, options);
session.WaitFor(() =>
{
string settings = session.ReadSettings();
using var saved = JsonDocument.Parse(settings);
return saved.RootElement.GetProperty("ShowSource").GetBoolean() ? sibling : null;
}, "the sibling source toggle to finish saving before the print-cancel snapshot");
string savedSettings = session.ReadSettings();
InvokeDocumentMenu(session, primary, "PrintMenuItem");
AssertPrintOptionControls(session, options);
@@ -1032,8 +1032,6 @@ public partial class AppTests
SendReadingKey(VirtualKeyShort.KEY_J);
SendReadingKey(VirtualKeyShort.KEY_K);
SendReadingKey(VirtualKeyShort.KEY_G);
// FlaUI's input helper sleeps briefly; it does not acknowledge native text processing.
session.WaitFor(() => string.Equals(query.AsTextBox().Text, "jkg", StringComparison.Ordinal) ? query : null, "the complete literal keyboard query 'jkg'");
Assert.Equal("jkg", query.AsTextBox().Text);
WaitForFindCount(session, "0 of 0");
Assert.True(scroller.Patterns.Scroll.Pattern.VerticalScrollPercent.Value < 10);
@@ -36,6 +36,9 @@
<Compile Include="..\..\src\MarkdownViewr.Windows\DocumentWatcher.cs" Link="WindowsAdapters\DocumentWatcher.cs" />
<Compile Include="..\..\src\MarkdownViewr.Windows\FileTypeRegistration.cs" Link="WindowsAdapters\FileTypeRegistration.cs" />
<Compile Include="..\..\src\MarkdownViewr.Windows\ShellFileAssociations.cs" Link="WindowsAdapters\ShellFileAssociations.cs" />
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationRequest.cs" Link="WindowsAdapters\ActivationRequest.cs" />
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationJsonContext.cs" Link="WindowsAdapters\ActivationJsonContext.cs" />
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationInbox.cs" Link="WindowsAdapters\ActivationInbox.cs" />
</ItemGroup>
</Project>