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
55 changed files with 136 additions and 3894 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~ActivationInboxTests|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:
+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.
@@ -30,7 +30,7 @@ and isolated boundary evidence in ADR-0005. Explorer and installed lifecycle
acceptance remain required; literal process arguments alone are not association
proof.
Store at most20 successful absolute paths in the private settings file,
Store at most20 successful absolute paths in the private AppKit settings file,
deduplicated with Windows path comparison. Update only after a parsed snapshot
commits. Clear history removes entries without deleting files or closing readers.
Keep paths out of activity logs/telemetry. Each preference save merges only fields
-138
View File
@@ -1,138 +0,0 @@
# ADR-0006: Settings file protocol
- **Status:** Accepted
- **Date:** 2026-11-10
## Context
One `settings.json` per edition is shared by every reader window and by every
process that uses the same data folder: an installed copy and a portable copy, or
two builds started from different folders, are separate processes over one file.
AppKit's `SettingsStore` returns defaults whenever the file cannot be read and writes
a whole snapshot through one fixed temporary name with no lock. With real files and
real processes this lost data in two ways: a load during a sharing violation returned
defaults that the next save wrote back, and two processes saving in turn erased each
other's changes.
The files beside `settings.json` are now a contract between application versions that
may run at the same time, so the protocol is recorded here.
## Decision
`AppSettingsStore` in the core library owns the file. AppKit still defines the shell
preferences (`ShellSettings`); only persistence moved.
**Change, not snapshot.** `Update` takes a function from the stored settings to the
new settings. It runs under the lock against what is on disk at that moment, so a
caller cannot write back a stale copy of a value it did not change.
**Lock.** `settings.json.lock` is opened with no sharing for the duration of an
update. It is never deleted: deleting a lock file leaves a window in which a second
opener is refused with access denied. The operating system releases the handle if
the process dies. A lock that stays held for every attempt refuses the update.
**Read for an update.** The file is opened for reading while denying writers, and
that handle is held until the rename. A program rewriting the file in place therefore
reads as busy, not as invalid content, and none can start between the read and the
rename. An empty file is waited on for the same attempts, because a program that
clears a file before rewriting it leaves it empty for a moment.
**Read for display.** The same open is tried first. If another handle has write
access, the file is read beside it instead of waiting, since such a handle is usually
idle. Content read that way which does not parse is retried and, if it never parses,
reported as unreadable rather than corrupt: it cannot be judged while it can still be
written. An empty file reads as defaults at once.
**What a read can find.**
| Found | Used for display | On update |
| --- | --- | --- |
| No file | Defaults | The file is created |
| Empty file | Defaults | Replaced after the wait; nothing kept aside |
| Valid settings | The stored values | Replaced |
| Some invalid properties | Valid properties kept, invalid ones as defaults | Replaced; a copy of the original is kept aside |
| Not settings at all | Defaults | Replaced; a copy of the original is kept aside, unless it is over 16 MiB |
| Unreadable or being written | Defaults | Refused; nothing is written |
A trailing comma, a comment and a quoted number are valid: they are what a hand edit
commonly leaves behind. Text the JSON reader cannot decode, such as an unpaired
surrogate escape, makes its property invalid. Repair works per top-level property:
one bad entry in a list costs that list. A file over 16 MiB is not loaded.
**Write.** The new content is written to `settings.json.<32 hex digits>.tmp` and
flushed to disk. That file is then renamed over `settings.json` in one step with the
Windows rename that replaces an open file (`FileRenameInfoEx` with POSIX semantics).
At no point is there no `settings.json`: a reader sees the old file or the new one,
and a process killed at any moment leaves one of them. Handles that were open on the
old file and allow deletion go on reading it. `File.Replace` is not used because it
is two renames with the file missing between them, and `File.Move` with overwrite is
not used because it refuses while any handle is open on the destination.
The rename is given the path in the form Windows takes as written, so a data folder
whose path is longer than 260 characters, or whose name is not well-formed UTF-16,
behaves like any other.
On a volume that does not implement that rename, the store closes its own read
handle and falls back to `File.Move` with overwrite. If the file did not exist when
it was read, the temporary file is renamed into place without overwriting. A failed
update leaves the stored file as it was and removes its temporary file.
**Kept-aside copies.** Before invalid content is replaced it is copied to a temporary
file. Once the save has succeeded the copy is renamed to `settings.json.corrupt`, or
to the first free of `settings.json.corrupt-2` to `settings.json.corrupt-9`; the last
name is overwritten once all exist. A save that fails removes its temporary copy and
leaves every earlier copy as it was, and the invalid file is still in place. The copy
is a courtesy for recovering a hand edit, with these limits:
- a file over 16 MiB is replaced without a copy, since it was never this
application's settings and copying it would stall the window;
- a copy that cannot be written, for example on a full disk, or cannot be given its
name, is removed and reported, and the save goes ahead, because refusing would
block every later change too;
- a process killed between the save and the rename of the copy loses that copy.
Nothing reads the copies back. They contain whatever the file contained, including
recent-document paths, so clearing recent files deletes them.
**Abandoned temporary files.** A process killed between its write and its rename
leaves a temporary file, and possibly a temporary copy. The next update, holding the
lock, removes files that match this store's own name pattern and no others.
**Waiting.** Only a sharing violation is waited out: at most 20 attempts, 50 ms
apart, for the lock, the read and the rename. Access denied is treated as permanent
and reported at once, so a read-only profile does not stall a window. The fallback
move is the exception: it answers access denied while any handle is open on the
file, a reader's included, so there access denied is waited out as well, unless the
file carries the read-only attribute.
**Reporting.** Faults are reported by kind with the exception type and HRESULT. No
path and no operating-system message text reaches the log.
## Consequences
- An update is a synchronous call on the window thread. It normally takes a few
milliseconds and at most about one second per contended step.
- The JSON written is byte-compatible with earlier releases, so a downgrade reads it.
- The rename needs Windows 10 version 1607 or later and a file system that supports
it; NTFS does. Other volumes use the fallback, which is not safe against a kill
between closing the file and the move completing.
- An earlier release running against the same data folder still has its own defect:
it takes no lock, and when a read fails it falls back to defaults and may save
them. This store cannot prevent that. It also makes that release's saves fail more
often while both run, because the earlier release cannot replace a file this store
has open. Run one version per data folder.
- The store keeps no in-memory copy. Windows hold their own view of the settings and
merge only the fields they changed (ADR-0004).
## Options considered
- **A named mutex instead of a lock file.** Rejected: a session-local name does not
cover two sessions of one user on the same profile, and a global name can be
created first by another account on the machine.
- **Renaming the invalid file away instead of copying it.** Rejected: between that
rename and the new file arriving there is no settings file, and a kill there loses
the profile.
- **Fixing AppKit's store instead.** Still worthwhile for other applications, but the
read-modify-write API is a different shape, and this application needed the fix
without waiting for a package release.
+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:
+2 -6
View File
@@ -34,7 +34,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Remote resources | Denied by CSP and resource interception |
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. Settings changes are read-modify-write under a lock file beside `settings.json` and are renamed into place in one step, so the file is never absent; a file that is unreadable or being written by another program is never overwritten, a copy of invalid content is kept aside under a bounded set of names and removed when recent files are cleared, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths |
| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight |
| Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token |
@@ -43,7 +43,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Threat | Mitigation and evidence |
| --- | --- |
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser malicious-script and reader acceptance passed on 77dd04d |
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access. Reserved device names and forbidden characters are one written-out rule (`WindowsFileNames`), independent of the running Windows version: every device name in any letter case, with any extension, and with spaces before the extension |
| Image traversal, device/ADS paths or encoded separators | Core path-policy tests pass; denied before filesystem access |
| Junction/symlink escape or check/reopen race | Check normalized physical path on opened image handle; read same handle; real junction rejection test passes |
| Remote image tracking / resource exfiltration | No-referrer header, no document networking; actual listening-server browser denial passed on 77dd04d |
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
@@ -426,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 -7
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.
@@ -73,11 +73,5 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
- **Open in editor is disabled:** hover for the reason, then configure an installed editor in Settings.
- **The viewer is not a default option:** use Setup rather than a portable copy, then reopen Windows Default Apps.
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
- **A setting could not be saved:** another program is using `settings.json`, or the file or folder is read-only. Nothing is overwritten while the file cannot be read; try the change again.
- **Settings were edited by hand:** a trailing comma, a comment or a quoted number is accepted; the app rewrites the file in its own format on the next save, so comments are not kept. A syntax error such as a missing comma or quote makes the whole file unreadable: every setting starts from its default, and the next save keeps a copy of your file as described below. A setting whose value is not valid is ignored and the other settings are kept. When either happens, the next save keeps a copy of the file as it was, named `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`). Those copies contain everything the file contained; **File → Recent files → Clear recent files** deletes them.
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",
@@ -5,6 +5,7 @@
using KofTwentyTwo.AppKit;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core;
@@ -72,7 +73,7 @@ public static class AppIdentity
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
public static AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile);
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
@@ -202,9 +202,8 @@ public sealed class AppSettings : ShellSettings
_ = Themes.ReaderTheme.ParseCompatible(json);
return true;
}
catch(Exception exception) when(exception is JsonException or InvalidOperationException)
catch(JsonException)
{
// A string the JSON reader cannot decode, such as an unpaired surrogate escape, is invalid content too.
return false;
}
}
@@ -3,17 +3,13 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.
/// Reading tolerates what a hand edit commonly leaves behind: a trailing comma, a comment, a quoted number.
/// </summary>
[JsonSourceGenerationOptions(WriteIndented = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, NumberHandling = JsonNumberHandling.AllowReadingFromString)]
/// <summary>Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.</summary>
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(AppSettings))]
public sealed partial class AppSettingsContext : JsonSerializerContext
{
@@ -1,534 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// The settings file shared by every window and every process of one edition. A change is
/// applied to the latest stored settings under a cross-process lock and renamed into place in
/// one step, so two instances cannot erase each other's change and the file is never absent.
/// A file that cannot be read, or that another program is writing, is never treated as empty;
/// content that is invalid while nobody is writing it is repaired where possible and a copy of
/// the original is kept aside. ADR-0006 describes the files involved.
/// </summary>
public sealed class AppSettingsStore
{
/// <summary>Attempts for the lock, a read, and the final rename before the operation gives up.</summary>
public const int MaximumAttempts = 20;
/// <summary>A larger file is not this application's settings and is never loaded into memory.</summary>
public const int MaximumBytes = 16 * 1024 * 1024;
/// <summary>Copies of invalid files kept aside before the last name is reused.</summary>
public const int MaximumKeptAside = 9;
/// <summary>The pause between attempts while another handle is in the way.</summary>
public static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
private const int SharingViolation = unchecked((int)0x80070020);
private static readonly JsonDocumentOptions s_lenient = new() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip };
private readonly Action<SettingsFault, Exception?> _report;
private readonly Action<TimeSpan> _wait;
private readonly Func<string, string, bool> _renameOver;
/// <summary>Creates the store; faults go to <paramref name="report"/>, and tests supply <paramref name="wait"/> to count attempts instead of sleeping.</summary>
public AppSettingsStore(string path, Action<SettingsFault, Exception?>? report = null, Action<TimeSpan>? wait = null)
: this(path, report, wait, FileSwap.TryRenameOver)
{
}
/// <summary>Creates the store over a given rename, so tests can stand in for a volume that cannot rename over an open file.</summary>
internal AppSettingsStore(string path, Action<SettingsFault, Exception?>? report, Action<TimeSpan>? wait, Func<string, string, bool> renameOver)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
Path = System.IO.Path.GetFullPath(path);
_report = report ?? ((_, _) => { });
_wait = wait ?? Thread.Sleep;
_renameOver = renameOver;
}
/// <summary>The settings file, as an absolute path.</summary>
public string Path { get; }
/// <summary>
/// Reads the stored settings for display. A missing, empty, invalid or unreadable file yields
/// defaults and is left untouched. A handle that another program merely holds with write
/// access does not delay the read.
/// </summary>
public AppSettings Load()
{
_ = TryRead(forUpdate: false, out AppSettings settings, out _);
return settings;
}
/// <summary>
/// Applies one change to the latest stored settings and saves the result. Returns the saved
/// settings, or null when nothing was written: the lock stayed busy, the file could not be
/// read or was being written by another program, or the write failed. The stored file is
/// unchanged in every null case.
/// </summary>
public AppSettings? Update(Func<AppSettings, AppSettings> change)
{
ArgumentNullException.ThrowIfNull(change);
FileStream? stored = null;
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
using FileStream? gate = Acquire();
if(gate is null)
{
return null;
}
// The read handle denies writers until the rename, so the content judged here is the content replaced.
SettingsRead read = TryRead(forUpdate: true, out AppSettings current, out stored);
if(read == SettingsRead.Unreadable)
{
return null;
}
AppSettings edited = change(current);
edited.Sanitize();
RemoveAbandonedTemporaryFiles();
string? copy = read is SettingsRead.Corrupt or SettingsRead.Repaired ? CopyAside(stored!) : null;
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored, copy);
if(copy is not null)
{
KeepAside(copy);
}
return edited;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
_report(SettingsFault.WriteFailed, exception);
return null;
}
finally
{
stored?.Dispose();
}
}
/// <summary>
/// Removes the copies of invalid files that saves have kept aside. They hold whatever the
/// file held, including the recent-document history, so clearing that history removes them.
/// </summary>
public void DeleteKeptAside()
{
for(int slot = 1; slot <= MaximumKeptAside; slot++)
{
TryDelete(KeptAsideName(slot));
}
}
/// <summary>
/// Holds an exclusive handle beside the settings file; the operating system releases it if the
/// process dies. A lock that stays held, or cannot be opened for another I/O reason, is busy.
/// </summary>
private FileStream? Acquire()
{
try
{
FileStream? gate = null;
Retry(() => gate = new FileStream(Path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None));
return gate;
}
catch(IOException exception)
{
_report(SettingsFault.Busy, exception);
return null;
}
}
/// <summary>
/// Reads the file, waiting out other handles a bounded number of times, and reports what it
/// found. For an update the open handle is returned so writers stay out until the rename.
/// </summary>
private SettingsRead TryRead(bool forUpdate, out AppSettings settings, out FileStream? stored)
{
settings = new AppSettings();
stored = null;
SettingsRead? read = null;
for(int attempt = 1; read is null; attempt++)
{
if(attempt > 1)
{
_wait(RetryDelay);
}
read = Attempt(forUpdate, attempt == MaximumAttempts, ref settings, ref stored);
}
if(read is SettingsRead.Corrupt or SettingsRead.Repaired)
{
_report(read == SettingsRead.Corrupt ? SettingsFault.Corrupt : SettingsFault.Repaired, null);
}
settings.Sanitize();
return read.Value;
}
/// <summary>
/// One read. Returns null to try again: another handle is in the way, the file is empty while
/// a save is waiting for whoever cleared it, or the content read beside a writer did not parse.
/// On the last attempt it always answers.
/// </summary>
private SettingsRead? Attempt(bool forUpdate, bool last, ref AppSettings settings, ref FileStream? stored)
{
FileStream? stream = null;
bool keep = false;
try
{
stream = Open(forUpdate, out bool writersExcluded);
SettingsRead read = Parse(stream, ref settings);
if(read == SettingsRead.Empty && forUpdate && !last)
{
// A program that clears the file before rewriting it is about to write.
return null;
}
if(read == SettingsRead.Corrupt && !writersExcluded)
{
// Content read while another program can write may simply be half written.
return last ? Unreadable(null) : null;
}
keep = forUpdate;
stored = keep ? stream : null;
return read;
}
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
{
return SettingsRead.Missing;
}
catch(IOException exception) when(IsTransient(exception) && !last)
{
return null;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
return Unreadable(exception);
}
finally
{
if(!keep)
{
stream?.Dispose();
}
}
}
/// <summary>Reports a file whose content could not be established and answers that it must not be replaced.</summary>
private SettingsRead Unreadable(Exception? exception)
{
_report(SettingsFault.Unreadable, exception);
return SettingsRead.Unreadable;
}
/// <summary>
/// Opens the file denying writers, so a program rewriting it in place reads as busy rather
/// than as invalid content. A read for display falls back to reading beside a handle that has
/// write access, because such a handle is usually idle; the caller then knows the content may
/// be half written.
/// </summary>
private FileStream Open(bool forUpdate, out bool writersExcluded)
{
writersExcluded = true;
try
{
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
}
catch(IOException exception) when(IsTransient(exception) && !forUpdate)
{
writersExcluded = false;
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
}
}
/// <summary>Reads at most <see cref="MaximumBytes"/> and classifies the content; an invalid property costs that property, never the whole profile.</summary>
private static SettingsRead Parse(FileStream stream, ref AppSettings settings)
{
if(stream.Length == 0)
{
return SettingsRead.Empty;
}
if(stream.Length > MaximumBytes)
{
return SettingsRead.Corrupt;
}
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
string json = reader.ReadToEnd();
try
{
if(JsonSerializer.Deserialize(json, AppSettingsContext.Default.AppSettings) is { } loaded)
{
settings = loaded;
return SettingsRead.Loaded;
}
}
catch(Exception exception) when(IsInvalidContent(exception))
{
if(Salvage(json) is { } repaired)
{
settings = repaired;
return SettingsRead.Repaired;
}
}
return SettingsRead.Corrupt;
}
/// <summary>Keeps every stored property that is valid on its own and drops the ones that are not; null when the file is not a JSON object at all.</summary>
private static AppSettings? Salvage(string json)
{
try
{
if(JsonNode.Parse(json, documentOptions: s_lenient) is not JsonObject stored)
{
return null;
}
foreach(string name in stored.Select(property => property.Key).ToArray())
{
try
{
_ = new JsonObject { [name] = stored[name]?.DeepClone() }.Deserialize(AppSettingsContext.Default.AppSettings);
}
catch(Exception exception) when(IsInvalidContent(exception))
{
stored.Remove(name);
}
}
return stored.Deserialize(AppSettingsContext.Default.AppSettings);
}
catch(Exception exception) when(IsInvalidContent(exception) || exception is ArgumentException)
{
// Not JSON, or an object that repeats a property name.
return null;
}
}
/// <summary>
/// What the JSON reader throws for content that is not valid settings: a syntax or type error,
/// or text it cannot decode, such as an unpaired surrogate escape.
/// </summary>
private static bool IsInvalidContent(Exception exception) => exception is JsonException or InvalidOperationException;
/// <summary>
/// Copies an invalid file to a temporary name before it is replaced and returns that name.
/// The copy is a courtesy, so the save goes ahead without one, with the reason reported and
/// null returned, when the file is past the size bound (it was never this application's
/// settings) or the copy cannot be written. A copy left behind by a save that never finished
/// is removed with the other abandoned temporary files.
/// </summary>
private string? CopyAside(FileStream stored)
{
if(stored.Length > MaximumBytes)
{
_report(SettingsFault.NotKeptAside, null);
return null;
}
string temporary = TemporaryName();
try
{
using(var copy = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
stored.Position = 0;
stored.CopyTo(copy);
copy.Flush(flushToDisk: true);
}
return temporary;
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
TryDelete(temporary);
_report(SettingsFault.NotKeptAside, exception);
return null;
}
}
/// <summary>
/// Gives the copy of a replaced invalid file its lasting name: the first free kept-aside name,
/// or the last one once all are taken. This happens after the save, so a save that fails
/// never disturbs an earlier copy. The copy is a courtesy: one that cannot be placed is
/// removed and reported, and the save stands.
/// </summary>
private void KeepAside(string copy)
{
int slot = 1;
while(slot < MaximumKeptAside && (File.Exists(KeptAsideName(slot)) || Directory.Exists(KeptAsideName(slot))))
{
slot++;
}
try
{
File.Move(copy, KeptAsideName(slot), overwrite: true);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
TryDelete(copy);
_report(SettingsFault.NotKeptAside, exception);
}
}
/// <summary>The kept-aside names: <c>.corrupt</c>, then <c>.corrupt-2</c> to <c>.corrupt-9</c>.</summary>
private string KeptAsideName(int slot) => slot == 1 ? Path + ".corrupt" : Path + ".corrupt-" + slot.ToString(CultureInfo.InvariantCulture);
/// <summary>A name beside the settings file that only this store uses, so an abandoned one can be recognized and removed.</summary>
private string TemporaryName() => Path + "." + Guid.NewGuid().ToString("N") + ".tmp";
/// <summary>
/// Writes beside the file and flushes to disk, then renames over it in one step. A failure
/// leaves the stored file as it was, and neither the temporary file nor the copy of the
/// invalid original behind.
/// </summary>
private void Replace(byte[] content, FileStream? stored, string? copy)
{
string temporary = TemporaryName();
bool replaced = false;
try
{
using(var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
stream.Write(content);
stream.Flush(flushToDisk: true);
}
if(stored is null)
{
// No overwrite: a file that appeared since the read is not this instance's to replace.
File.Move(temporary, Path);
}
else
{
bool renamed = false;
Retry(() => renamed = _renameOver(temporary, Path));
if(!renamed)
{
// This volume cannot rename over an open file, so the file is closed first. This
// move is refused while any handle is open on the file, a reader's included.
stored.Dispose();
Retry(() => File.Move(temporary, Path, overwrite: true), IsBlockedByAHandle);
}
}
replaced = true;
}
finally
{
if(!replaced)
{
TryDelete(temporary);
if(copy is not null)
{
TryDelete(copy);
}
}
}
}
/// <summary>A writer that died before its rename left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
private void RemoveAbandonedTemporaryFiles()
{
string prefix = System.IO.Path.GetFileName(Path) + ".";
foreach(string file in Directory.EnumerateFiles(System.IO.Path.GetDirectoryName(Path)!, prefix + "*.tmp"))
{
string name = System.IO.Path.GetFileName(file);
if(name.Length == prefix.Length + 36 && Guid.TryParseExact(name.AsSpan(prefix.Length, 32), "N", out _))
{
TryDelete(file);
}
}
}
/// <summary>
/// Repeats an operation only while another handle is in the way, a bounded number of times;
/// the last failure is thrown. <paramref name="inTheWay"/> says which failures mean that:
/// a sharing violation unless the operation reports an open handle differently.
/// </summary>
private void Retry(Action operation, Func<Exception, bool>? inTheWay = null)
{
inTheWay ??= IsTransient;
for(int attempt = 1; ; attempt++)
{
try
{
operation();
return;
}
catch(Exception exception) when(attempt < MaximumAttempts && inTheWay(exception))
{
_wait(RetryDelay);
}
}
}
/// <summary>A sharing violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
private static bool IsTransient(Exception exception) => exception is IOException && exception.HResult == SharingViolation;
/// <summary>
/// What a move over the settings file fails with while another handle is open on it. Unlike
/// an open or a rename, that move answers access denied, which therefore has to be waited
/// out. A read-only file gives the same answer for good, so it is told apart by its attribute.
/// </summary>
private bool IsBlockedByAHandle(Exception exception)
=> IsTransient(exception) || (exception is UnauthorizedAccessException && !new FileInfo(Path).IsReadOnly);
/// <summary>Removes a file if it is there and can be removed; a folder with that name, or a file in use, is left.</summary>
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
// Cleanup only: the operation this was part of has already succeeded or failed.
}
}
}
@@ -19,6 +19,12 @@ public sealed class DocumentResourcePolicy
".md", ".markdown", ".mdown", ".mkd", ".txt",
};
private static readonly HashSet<string> s_deviceNames = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³",
};
private readonly string _prefix;
@@ -32,16 +38,6 @@ public sealed class DocumentResourcePolicy
/// <summary>The image file types served to the document surface.</summary>
internal static IReadOnlyCollection<string> ImageExtensions => s_imageExtensions;
/// <summary>The document file types a link may open in a new reader.</summary>
internal static IReadOnlyCollection<string> DocumentExtensions => s_documentExtensions;
/// <summary>The only top-level document URI the browser host may serve.</summary>
public Uri DocumentUri { get; }
@@ -97,7 +93,7 @@ public sealed class DocumentResourcePolicy
private static bool IsSafeSegment(string segment)
{
return segment.Length > 0 && segment is not ("." or "..") && !segment.EndsWith(' ') && !segment.EndsWith('.') &&
!segment.Any(character => char.IsControl(character) || WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal)) &&
!WindowsFileNames.IsReservedDeviceName(segment);
!segment.Any(character => char.IsControl(character) || "<>:\\|?*".Contains(character, StringComparison.Ordinal)) &&
!s_deviceNames.Contains(segment.Split('.')[0]);
}
}
@@ -59,11 +59,6 @@ public sealed class MarkdownRenderer
/// <summary>The configured sanitizer, for comparing its allowlists with an independent list.</summary>
internal HtmlSanitizer Policy => _sanitizer;
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
public string Render(string markdown, FrontmatterMode frontmatter = FrontmatterMode.Hide) => Render(markdown, frontmatter, null);
-106
View File
@@ -1,106 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// Replaces a file by renaming another over it in a single step. The destination is never
/// absent, not even for an instant, and the rename works while the destination is open through
/// handles that allow deletion; those handles go on reading the content they opened.
/// <see cref="File.Move(string, string, bool)"/> refuses when any handle is open on the
/// destination, and <see cref="File.Replace(string, string, string?)"/> is two renames with the
/// destination missing between them, so neither can do this.
/// </summary>
internal static partial class FileSwap
{
private const uint DeleteAccess = 0x00010000;
private const uint ShareAll = 0x00000007;
private const uint OpenExisting = 3;
private const int FileRenameInfoEx = 22;
private const uint ReplaceIfExists = 0x1;
private const uint PosixSemantics = 0x2;
private const int AccessDenied = 5;
/// <summary>
/// Renames <paramref name="source"/> over <paramref name="destination"/>. Returns false when the
/// volume cannot rename over an open file, as file systems other than NTFS may not. Throws
/// <see cref="IOException"/> with the Windows error as its HRESULT when the rename is refused,
/// for example by a handle on the destination that does not allow deletion, and
/// <see cref="UnauthorizedAccessException"/> when access is denied.
/// </summary>
public static bool TryRenameOver(string source, string destination)
{
using SafeFileHandle handle = CreateFile(Verbatim(source), DeleteAccess, ShareAll, 0, OpenExisting, 0, 0);
if(handle.IsInvalid)
{
throw Failure(Marshal.GetLastPInvokeError());
}
// FILE_RENAME_INFO: flags, root directory handle, name length in bytes, then the name itself.
// The name is copied as stored: a file name may hold text that is not well-formed UTF-16.
byte[] name = MemoryMarshal.AsBytes(Verbatim(destination).AsSpan()).ToArray();
int lengthOffset = 2 * IntPtr.Size;
int nameOffset = lengthOffset + sizeof(int);
byte[] rename = new byte[nameOffset + name.Length + sizeof(char)];
BitConverter.TryWriteBytes(rename, ReplaceIfExists | PosixSemantics);
BitConverter.TryWriteBytes(rename.AsSpan(lengthOffset), name.Length);
name.CopyTo(rename, nameOffset);
if(SetFileInformationByHandle(handle, FileRenameInfoEx, rename, rename.Length))
{
return true;
}
int error = Marshal.GetLastPInvokeError();
return IsUnsupported(error) ? false : throw Failure(error);
}
/// <summary>
/// The form of a full path that Windows takes as written, without the 260-character limit the
/// plain form has in a process that has not opted in to long paths. The managed file calls add
/// it themselves; a direct Windows call has to.
/// </summary>
internal static string Verbatim(string fullPath)
{
if(fullPath.StartsWith(@"\\?\", StringComparison.Ordinal) || fullPath.StartsWith(@"\\.\", StringComparison.Ordinal))
{
return fullPath;
}
return fullPath.StartsWith(@"\\", StringComparison.Ordinal) ? string.Concat(@"\\?\UNC\", fullPath.AsSpan(2)) : @"\\?\" + fullPath;
}
/// <summary>The errors a volume answers with when it does not implement this kind of rename: invalid function, not supported, invalid parameter.</summary>
internal static bool IsUnsupported(int error) => error is 1 or 50 or 87;
/// <summary>The exception a managed file operation would throw for the same Windows error.</summary>
private static Exception Failure(int error)
{
string message = new Win32Exception(error).Message;
return error == AccessDenied ? new UnauthorizedAccessException(message) : new IOException(message, unchecked((int)0x80070000) | error);
}
/// <summary>Opens the file to be renamed with delete access, which a managed file stream cannot request.</summary>
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
private static partial SafeFileHandle CreateFile(string path, uint access, uint share, nint security, uint disposition, uint flags, nint template);
/// <summary>Applies the rename to the open file.</summary>
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, byte[] information, int size);
}
@@ -8,8 +8,6 @@
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<!-- Include the SDK tool dependency in the lock used by single-file CLI publishing. -->
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<!-- Source-generated P/Invoke marshalling (FileSwap) compiles to unsafe code. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
@@ -1,24 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core;
/// <summary>Why the settings file could not be used as stored; reported without paths or operating-system message text.</summary>
public enum SettingsFault
{
/// <summary>The file does not hold settings at all; defaults are used and the next save keeps a copy of the file aside.</summary>
Corrupt,
/// <summary>Some stored properties were invalid and read as their defaults; the next save keeps a copy of the original aside.</summary>
Repaired,
/// <summary>The file could not be read, or another program was writing it; it is left untouched and nothing is written over it.</summary>
Unreadable,
/// <summary>Another instance kept the settings lock for every attempt; the change was not saved.</summary>
Busy,
/// <summary>The change could not be written; the stored file is unchanged.</summary>
WriteFailed,
/// <summary>The invalid original could not be copied aside; the change was saved without the copy.</summary>
NotKeptAside,
}
@@ -1,24 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core;
/// <summary>What reading the settings file found; only <see cref="Unreadable"/> forbids writing over it.</summary>
internal enum SettingsRead
{
/// <summary>Valid stored settings.</summary>
Loaded,
/// <summary>No file yet; defaults apply and the first save creates it.</summary>
Missing,
/// <summary>A file with no content, as a program that clears a file before rewriting it leaves it; defaults apply and a save replaces it.</summary>
Empty,
/// <summary>Valid properties were kept and invalid ones dropped; a save keeps a copy of the original aside.</summary>
Repaired,
/// <summary>Not settings at all; defaults apply and a save keeps a copy of the file aside.</summary>
Corrupt,
/// <summary>The content is unknown, so it must not be replaced.</summary>
Unreadable,
}
@@ -6,7 +6,6 @@
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -16,24 +15,8 @@ namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Validates Darin's version-one theme format before generating passive document CSS.</summary>
public sealed class ReaderTheme
{
/// <summary>The largest theme file accepted for import; bounds the read before the complete JSON text is allocated.</summary>
/// <summary>Bounds schema input before a native file read allocates the complete JSON text.</summary>
public const int MaximumCharacters = 128 * 1024;
/// <summary>
/// The largest theme the app keeps. Renaming or editing rewrites the whole theme, and JSON
/// writes some characters (emoji, no-break spaces, private-use glyphs) as six-character
/// escapes, so a kept theme can be several times the size of the file it was imported from.
/// The bound leaves room for that, so a theme that could be imported can always be renamed.
/// </summary>
public const int MaximumStoredCharacters = 8 * MaximumCharacters;
/// <summary>
/// How a renamed or edited theme is written back. Quote marks, angle brackets and most
/// non-ASCII letters are written as themselves instead of as six-character escapes, which
/// keeps ordinary CSS and text close to its imported size. Theme JSON is stored and exported,
/// never placed in a page.
/// </summary>
private static readonly JsonSerializerOptions s_compact = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
private static readonly string[] s_colors = ["background", "text", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "link", "codeBackground", "codeText", "blockquoteBorder", "blockquoteBackground", "highlightBackground", "highlightText"];
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
private static readonly string s_baseStyles = LoadBaseStyles();
@@ -76,7 +59,7 @@ public sealed class ReaderTheme
{
JsonNode source = JsonNode.Parse(Json)!;
source["name"] = name;
return ParseCompatible(source.ToJsonString(s_compact));
return ParseCompatible(source.ToJsonString());
}
@@ -120,7 +103,7 @@ public sealed class ReaderTheme
}
source["colors"]![key] = value?.Trim();
}
return ParseCompatible(source.ToJsonString(s_compact));
return ParseCompatible(source.ToJsonString());
}
@@ -167,14 +150,13 @@ public sealed class ReaderTheme
private static ReaderTheme ParseSchema(string json, bool allowNewer)
{
ArgumentNullException.ThrowIfNull(json);
if(json.Length > MaximumStoredCharacters)
if(json.Length > MaximumCharacters)
{
throw new JsonException("The theme is too large.");
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
}
using JsonDocument document = ParseDocument(json);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
JsonElement root = document.RootElement;
RequireObject(root);
RequireReadableText(root);
int schemaVersion = 1;
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out schemaVersion) || schemaVersion < 1 || !allowNewer && schemaVersion != 1))
{
@@ -296,68 +278,6 @@ public sealed class ReaderTheme
/// <summary>Reads the theme text as JSON; text that is not well-formed UTF-16 is an invalid theme like any other malformed input.</summary>
private static JsonDocument ParseDocument(string json)
{
try
{
return JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
}
catch(ArgumentException exception)
{
throw new JsonException("The theme contains text that cannot be decoded.", exception);
}
}
/// <summary>
/// Checks every property name and string in the theme once, including ones this version does
/// not read, because a kept theme is later renamed, edited and re-serialized as a whole. An
/// unpaired surrogate escape is valid JSON syntax with no string value, and a repeated
/// property name has no single value; both are invalid themes.
/// </summary>
private static void RequireReadableText(JsonElement root)
{
try
{
var pending = new Stack<JsonElement>();
pending.Push(root);
while(pending.TryPop(out JsonElement element))
{
if(element.ValueKind == JsonValueKind.Object)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach(JsonProperty property in element.EnumerateObject())
{
if(!names.Add(property.Name))
{
throw new JsonException("The theme repeats a property: " + property.Name);
}
pending.Push(property.Value);
}
}
else if(element.ValueKind == JsonValueKind.Array)
{
foreach(JsonElement item in element.EnumerateArray())
{
pending.Push(item);
}
}
else if(element.ValueKind == JsonValueKind.String)
{
_ = element.GetString();
}
}
}
catch(InvalidOperationException exception)
{
throw new JsonException("The theme contains text that cannot be decoded.", exception);
}
}
/// <summary>Prevents wrong-shaped JSON from reaching property accessors.</summary>
private static void RequireObject(JsonElement element)
{
@@ -33,13 +33,14 @@ public static class ThemeJson
public static string SuggestedFileName(ReaderTheme theme)
{
ArgumentNullException.ThrowIfNull(theme);
string stem = string.Concat(theme.Name.Select(character => WindowsFileNames.InvalidCharacters.Contains(character, StringComparison.Ordinal) ? '_' : character))
string stem = string.Concat(theme.Name.Select(character => "<>:\"/\\|?*".Contains(character, StringComparison.Ordinal) ? '_' : character))
.Replace(' ', '-').ToLowerInvariant().Trim('.', '-');
if(stem.Length == 0)
{
stem = "theme";
}
if(WindowsFileNames.IsReservedDeviceName(stem))
string device = stem.Split('.')[0];
if(device is "con" or "prn" or "aux" or "nul" || device.Length == 4 && (device.StartsWith("com", StringComparison.Ordinal) || device.StartsWith("lpt", StringComparison.Ordinal)) && device[3] is >= '1' and <= '9' or '¹' or '²' or '³')
{
stem = "theme-" + stem;
}
@@ -1,39 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// The Windows file-name rules this application relies on, written out instead of asked of the
/// running platform: the characters a name cannot contain and the names that address a device.
/// </summary>
public static class WindowsFileNames
{
/// <summary>Printable characters that cannot appear in a Windows file or folder name. Control characters are also forbidden; callers refuse those themselves.</summary>
public const string InvalidCharacters = "<>:\"/\\|?*";
private static readonly HashSet<string> s_devices = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
};
/// <summary>
/// Whether a single name addresses a device under the legacy Win32 rule: any letter case, and
/// ignoring everything after the first dot and any spaces before it, so <c>nul.txt</c> and
/// <c>COM1 .log</c> count. Current Windows versions resolve fewer of these spellings to a
/// device; the rule is applied in full on every version so the answer never depends on it.
/// </summary>
public static bool IsReservedDeviceName(string name)
{
ArgumentNullException.ThrowIfNull(name);
int dot = name.IndexOf('.', StringComparison.Ordinal);
return s_devices.Contains((dot < 0 ? name : name[..dot]).TrimEnd(' '));
}
}
@@ -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",
+7 -16
View File
@@ -107,14 +107,12 @@ public partial class App
/// <summary>Records a successfully committed path in the latest stored history under the settings lock, then refreshes every recent menu.</summary>
/// <summary>Records a successfully committed path through the latest store snapshot, then refreshes every recent menu.</summary>
internal bool RecordRecentDocument(string path)
{
bool saved = AppIdentity.SettingsStore.Update(settings =>
{
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
return settings;
}) is not null;
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
settings.RecentFiles = Core.Documents.RecentDocuments.Record(settings.RecentFiles, path);
bool saved = AppIdentity.SettingsStore.Save(settings);
RefreshRecentMenus();
return saved;
}
@@ -124,16 +122,9 @@ public partial class App
/// <summary>Clears only the local history, leaving open documents, reading preferences and files intact.</summary>
internal bool ClearRecentDocuments()
{
bool saved = AppIdentity.SettingsStore.Update(settings =>
{
settings.RecentFiles = [];
return settings;
}) is not null;
if(saved)
{
// Copies of an invalid settings file kept beside it would still list the history.
AppIdentity.SettingsStore.DeleteKeptAside();
}
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
settings.RecentFiles = [];
bool saved = AppIdentity.SettingsStore.Save(settings);
RefreshRecentMenus();
return saved;
}
+3 -8
View File
@@ -5,6 +5,7 @@
using KofTwentyTwo.AppKit;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
using Velopack.Locators;
@@ -30,8 +31,8 @@ internal static class AppIdentity
/// <summary>Each edition owns its own persisted preferences and recent document list; faults are logged by kind, never with a path or message text.</summary>
public static Core.AppSettingsStore SettingsStore { get; } = new(Paths.SettingsFile, ReportSettingsFault);
/// <summary>Each edition owns its own persisted preferences and recent document list.</summary>
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
@@ -55,12 +56,6 @@ internal static class AppIdentity
/// <summary>A settings fault is an operational event: the kind, exception type and HRESULT identify it without disclosing the profile.</summary>
private static void ReportSettingsFault(Core.SettingsFault fault, Exception? exception)
=> App.Log.Warning(FormattableString.Invariant($"Settings file fault: {fault}; exception={exception?.GetType().Name ?? "none"}; HRESULT=0x{exception?.HResult ?? 0:X8}."));
/// <summary>Daily activity files use the edition's own path and identity prefix.</summary>
public static IActivityLog CreateLog() => FileActivityLog.ForApp(Info, Paths);
}
@@ -48,8 +48,8 @@ public sealed partial class MainWindow
Core.AppSettings draft = Core.SettingsChanges.Copy(before);
draft.ConfiguredEditors = choices;
draft.ExternalEditor = choices.FirstOrDefault()?.Executable ?? "";
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
if(merged is null)
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
if(!AppIdentity.SettingsStore.Save(merged))
{
StatusText.Text = "Could not save editor configuration.";
return;
@@ -19,12 +19,9 @@ public sealed partial class MainWindow
{
return false;
}
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
{
current.Print = options.Sanitize();
return current;
});
if(settings is null)
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
settings.Print = options.Sanitize();
if(!AppIdentity.SettingsStore.Save(settings))
{
return false;
}
@@ -61,8 +61,8 @@ public sealed partial class MainWindow
draft.ContentWidthEnabled = appearance.LimitWidth;
draft.ContentWidthPx = appearance.WidthPixels;
draft.GlobalCss = appearance.GlobalCss;
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, draft));
if(merged is null)
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, draft);
if(!AppIdentity.SettingsStore.Save(merged))
{
return false;
}
@@ -79,16 +79,14 @@ public sealed partial class MainWindow
{
return false;
}
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
{
current.ShowSplashScreen = showSplash;
current.SplashMilliseconds = milliseconds;
current.CheckForUpdatesOnStartup = checkUpdates;
current.ShowOutlineByDefault = showOutline;
current.ShowSourceByDefault = showSource;
return current;
});
if(settings is null)
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
settings.ShowSplashScreen = showSplash;
settings.SplashMilliseconds = milliseconds;
settings.CheckForUpdatesOnStartup = checkUpdates;
settings.ShowOutlineByDefault = showOutline;
settings.ShowSourceByDefault = showSource;
settings.Sanitize();
if(!AppIdentity.SettingsStore.Save(settings))
{
return false;
}
@@ -122,13 +120,10 @@ public sealed partial class MainWindow
{
return false;
}
Core.AppSettings? settings = AppIdentity.SettingsStore.Update(current =>
{
current.WrapOutlineEntries = wrap;
current.ShowOutlineDepthMarkers = depthMarkers;
return current;
});
if(settings is null)
Core.AppSettings settings = AppIdentity.SettingsStore.Load();
settings.WrapOutlineEntries = wrap;
settings.ShowOutlineDepthMarkers = depthMarkers;
if(!AppIdentity.SettingsStore.Save(settings))
{
return false;
}
@@ -56,8 +56,8 @@ public sealed partial class MainWindow
/// <summary>Failed persistence does not apply an unsaved theme draft; sibling fields untouched by this edit retain their latest values.</summary>
private async Task<bool> CommitThemeDraftAsync(Core.AppSettings before, Core.AppSettings edited)
{
Core.AppSettings? merged = AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, before, edited));
if(merged is null)
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), before, edited);
if(!AppIdentity.SettingsStore.Save(merged))
{
StatusText.Text = "Could not save document themes.";
return false;
+2 -1
View File
@@ -522,7 +522,8 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
private void Save()
{
if(AppIdentity.SettingsStore.Update(current => Core.SettingsChanges.Merge(current, _lastSavedSettings, _settings)) is not null)
Core.AppSettings merged = Core.SettingsChanges.Merge(AppIdentity.SettingsStore.Load(), _lastSavedSettings, _settings);
if(AppIdentity.SettingsStore.Save(merged))
{
_lastSavedSettings = Core.SettingsChanges.Copy(_settings);
}
@@ -90,8 +90,8 @@ public class AppSettingsTests
public void SaveAndLoad_KeepsTheTheme()
{
using var temp = new TempDirectory();
var store = new AppSettingsStore(temp.File("settings.json"));
Assert.NotNull(store.Update(_ => new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
Assert.True(store.Save(new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light", Frontmatter = "Metadata", UserThemes = [Themes.ThemeCatalog.Find("GitHub Light").Json] }));
Assert.Equal("Dark", store.Load().Theme);
Assert.Equal("GitHub Light", store.Load().DocumentTheme);
Assert.Equal(Documents.FrontmatterMode.Metadata, store.Load().FrontmatterKind);
File diff suppressed because it is too large. Load diff
@@ -1,217 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Each resource-policy check is exercised alone: a request that differs from a valid one in a
/// single component must be denied by that component's check, not by an unrelated one.
/// </summary>
public sealed class DocumentResourcePolicyBoundaryTests
{
/// <summary>The valid baseline every single-component variation below is derived from.</summary>
[Fact]
public void GetRelativePath_OwnDocumentRequest_IsAccepted()
{
var policy = new DocumentResourcePolicy();
Assert.Equal("image.png", policy.GetImageRelativePath(Vary(policy, "image.png", _ => { })));
Assert.Equal("child.md", policy.GetDocumentRelativePath(Vary(policy, "child.md", _ => { })));
}
/// <summary>Plain HTTP on the same host, port and path is not the document's origin.</summary>
[Fact]
public void GetRelativePath_OtherScheme_IsDenied()
{
var policy = new DocumentResourcePolicy();
Uri image = Vary(policy, "image.png", builder =>
{
builder.Scheme = "http";
builder.Port = 443;
});
Assert.Equal(443, image.Port);
Assert.Null(policy.GetImageRelativePath(image));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
{
builder.Scheme = "http";
builder.Port = 443;
})));
}
/// <summary>Another port on the same host is another origin.</summary>
[Fact]
public void GetRelativePath_OtherPort_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Port = 444)));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Port = 8443)));
}
/// <summary>Credentials in the request never reach a resource.</summary>
[Fact]
public void GetRelativePath_UserInformation_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.UserName = "user")));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder =>
{
builder.UserName = "user";
builder.Password = "secret";
})));
}
/// <summary>Another host is denied, including a subdomain of the viewer's host and a host that merely starts with it.</summary>
[Theory]
[InlineData("example.com")]
[InlineData("sub.viewer.markdownviewr.invalid")]
[InlineData("viewer.markdownviewr.invalid.example.com")]
public void GetRelativePath_OtherHost_IsDenied(string host)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(Vary(policy, "image.png", builder => builder.Host = host)));
Assert.Null(policy.GetDocumentRelativePath(Vary(policy, "child.md", builder => builder.Host = host)));
}
/// <summary>The served image types and the openable document types are exactly these sets.</summary>
[Fact]
public void Extensions_ConfiguredSets_AreExactlyTheListedTypes()
{
Assert.Equal([".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"], DocumentResourcePolicy.ImageExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal([".markdown", ".md", ".mdown", ".mkd", ".txt"], DocumentResourcePolicy.DocumentExtensions.Order(StringComparer.Ordinal), StringComparer.Ordinal);
}
/// <summary>The document's own path prefix is matched exactly; another letter case is another document.</summary>
[Fact]
public void GetRelativePath_PrefixInAnotherLetterCase_IsDenied()
{
var policy = new DocumentResourcePolicy();
string shouted = policy.DocumentUri.AbsoluteUri.Replace("/documents/", "/DOCUMENTS/", StringComparison.Ordinal);
Assert.NotEqual(policy.DocumentUri.AbsoluteUri, shouted, StringComparer.Ordinal);
Assert.Null(policy.GetImageRelativePath(new Uri(new Uri(shouted), "image.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(new Uri(shouted), "child.md")));
Assert.Null(policy.GetHeadingIndex(new Uri(shouted + "#mv-heading-0"), 3));
Assert.Equal(0, policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + "#mv-heading-0"), 3));
}
/// <summary>Image types are served in any letter case; documents, pages, scripts and executables are not images.</summary>
[Theory]
[InlineData(".png", true)]
[InlineData(".jpg", true)]
[InlineData(".jpeg", true)]
[InlineData(".gif", true)]
[InlineData(".bmp", true)]
[InlineData(".webp", true)]
[InlineData(".svg", true)]
[InlineData(".avif", true)]
[InlineData(".ico", true)]
[InlineData(".PNG", true)]
[InlineData(".md", false)]
[InlineData(".markdown", false)]
[InlineData(".txt", false)]
[InlineData(".html", false)]
[InlineData(".htm", false)]
[InlineData(".js", false)]
[InlineData(".css", false)]
[InlineData(".json", false)]
[InlineData(".pdf", false)]
[InlineData(".tiff", false)]
[InlineData(".exe", false)]
[InlineData(".lnk", false)]
[InlineData(".url", false)]
[InlineData("", false)]
public void GetImageRelativePath_Extension_ServesOnlyImageTypes(string extension, bool served)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(served ? "file" + extension : null, policy.GetImageRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
}
/// <summary>Document types open in a new reader in any letter case; pages, images, scripts and executables do not.</summary>
[Theory]
[InlineData(".md", true)]
[InlineData(".markdown", true)]
[InlineData(".mdown", true)]
[InlineData(".mkd", true)]
[InlineData(".txt", true)]
[InlineData(".MD", true)]
[InlineData(".html", false)]
[InlineData(".htm", false)]
[InlineData(".png", false)]
[InlineData(".svg", false)]
[InlineData(".json", false)]
[InlineData(".pdf", false)]
[InlineData(".exe", false)]
[InlineData(".bat", false)]
[InlineData(".cmd", false)]
[InlineData(".ps1", false)]
[InlineData(".lnk", false)]
[InlineData(".url", false)]
[InlineData(".mdx", false)]
[InlineData("", false)]
public void GetDocumentRelativePath_Extension_OpensOnlyDocumentTypes(string extension, bool opened)
{
var policy = new DocumentResourcePolicy();
Assert.Equal(opened ? "file" + extension : null, policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "file" + extension)));
}
/// <summary>Each character Windows forbids in a name is refused on its own, for images and documents.</summary>
[Theory]
[InlineData("%3C")]
[InlineData("%3E")]
[InlineData("%3A")]
[InlineData("%22")]
[InlineData("%5C")]
[InlineData("%7C")]
[InlineData("%3F")]
[InlineData("%2A")]
public void GetRelativePath_ForbiddenCharacter_IsDenied(string encoded)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "images/a" + encoded + "b.png")));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, "im" + encoded + "ages/ab.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/a" + encoded + "b.md")));
}
/// <summary>The generated heading fragment is matched exactly; another letter case is not a heading target.</summary>
[Theory]
[InlineData("#MV-HEADING-0")]
[InlineData("#Mv-Heading-0")]
public void GetHeadingIndex_OtherSpelling_IsNotAHeadingTarget(string fragment)
{
var policy = new DocumentResourcePolicy();
Assert.Null(policy.GetHeadingIndex(new Uri(policy.DocumentUri.AbsoluteUri + fragment), 3));
}
/// <summary>Builds this document's own resource request and changes exactly one component of it.</summary>
private static Uri Vary(DocumentResourcePolicy policy, string file, Action<UriBuilder> change)
{
var builder = new UriBuilder(new Uri(policy.DocumentUri, file));
change(builder);
return builder.Uri;
}
}
@@ -76,24 +76,21 @@ public sealed class EditorConfigurationTests
/// <summary>Atomic persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
/// <summary>Atomic AppKit persistence retains named IDs, order and folder mode using the trimmed serializer.</summary>
[Fact]
public void SaveAndLoad_EditorList_PreservesIdentityOrderAndMode()
{
using var temporary = new TempDirectory();
var store = new AppSettingsStore(temporary.File("settings.json"));
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temporary.File("settings.json"), AppSettingsContext.Default.AppSettings);
var first = new EditorConfiguration(Guid.NewGuid(), "Folder editor", @"C:\Editor\folder.exe", true);
var second = new EditorConfiguration(Guid.NewGuid(), "File editor", @"C:\Editor\file.exe");
Assert.NotNull(store.Update(_ => new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
Assert.True(store.Save(new AppSettings { ConfiguredEditors = [first, second], ExternalEditor = @"C:\Other\old.exe" }));
AppSettings loaded = store.Load();
Assert.Equal(new[] { first, second }, loaded.ConfiguredEditors);
Assert.Equal(first.Executable, loaded.ExternalEditor);
Assert.NotNull(store.Update(settings =>
{
settings.ConfiguredEditors = [];
settings.ExternalEditor = "";
return settings;
}));
loaded.ConfiguredEditors = [];
loaded.ExternalEditor = "";
Assert.True(store.Save(loaded));
Assert.Empty(store.Load().ConfiguredEditors);
}
@@ -1,134 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Renaming one real file over another, with other real handles open on the destination.</summary>
public sealed class FileSwapTests
{
/// <summary>A full path is given the form Windows takes as written: a drive path, a network path, and paths that already have it.</summary>
[Theory]
[InlineData(@"C:\data\settings.json", @"\\?\C:\data\settings.json")]
[InlineData(@"\\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
[InlineData(@"\\?\C:\data\settings.json", @"\\?\C:\data\settings.json")]
[InlineData(@"\\?\UNC\server\share\settings.json", @"\\?\UNC\server\share\settings.json")]
[InlineData(@"\\.\C:\data\settings.json", @"\\.\C:\data\settings.json")]
public void Verbatim_FullPath_IsPrefixedOnce(string path, string expected)
{
Assert.Equal(expected, FileSwap.Verbatim(path));
}
/// <summary>The rename replaces the destination and removes the source name.</summary>
[Fact]
public void TryRenameOver_ExistingDestination_ReplacesIt()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("stored.json"), "old");
File.WriteAllText(temp.File("new.tmp"), "new");
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
Assert.False(File.Exists(temp.File("new.tmp")));
}
/// <summary>A reader that allows deletion does not stop the rename, and goes on reading the content it opened.</summary>
[Fact]
public void TryRenameOver_DestinationOpenByAReaderThatAllowsDeletion_ReplacesItUnderTheReader()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("stored.json"), "old content");
File.WriteAllText(temp.File("new.tmp"), "new");
using(var reader = new StreamReader(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)))
{
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
Assert.Equal("old content", reader.ReadToEnd());
}
Assert.Equal(["stored.json"], Directory.GetFiles(temp.Path).Select(file => Path.GetFileName(file)!), StringComparer.Ordinal);
}
/// <summary>A handle that does not allow deletion refuses the rename with a sharing violation, and both files stay as they were.</summary>
[Fact]
public void TryRenameOver_DestinationOpenWithoutDeleteSharing_IsASharingViolation()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("stored.json"), "old");
File.WriteAllText(temp.File("new.tmp"), "new");
using(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
{
IOException refused = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
Assert.Equal(unchecked((int)0x80070020), refused.HResult);
}
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
Assert.Equal("new", File.ReadAllText(temp.File("new.tmp")));
}
/// <summary>A read-only destination is access denied, as it is for a managed move.</summary>
[Fact]
public void TryRenameOver_ReadOnlyDestination_IsAccessDenied()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("stored.json"), "old");
File.WriteAllText(temp.File("new.tmp"), "new");
File.SetAttributes(temp.File("stored.json"), FileAttributes.ReadOnly);
try
{
Assert.Throws<UnauthorizedAccessException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
}
finally
{
File.SetAttributes(temp.File("stored.json"), FileAttributes.Normal);
}
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
}
/// <summary>A source that does not exist is reported with the Windows error for a missing file.</summary>
[Fact]
public void TryRenameOver_MissingSource_IsAnIoFailure()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("stored.json"), "old");
IOException missing = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("absent.tmp"), temp.File("stored.json")));
Assert.Equal(unchecked((int)0x80070002), missing.HResult);
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
}
/// <summary>A destination that does not exist yet is simply created by the rename.</summary>
[Fact]
public void TryRenameOver_NoDestinationYet_RenamesTheSource()
{
using var temp = new TempDirectory();
File.WriteAllText(temp.File("new.tmp"), "new");
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
}
/// <summary>Only the errors a volume gives for an unimplemented rename mean "use another way"; every other error is a failure.</summary>
[Theory]
[InlineData(1, true)]
[InlineData(50, true)]
[InlineData(87, true)]
[InlineData(0, false)]
[InlineData(2, false)]
[InlineData(5, false)]
[InlineData(32, false)]
[InlineData(17, false)]
public void IsUnsupported_WindowsError_SeparatesAnUnimplementedRenameFromAFailure(int error, bool unsupported)
=> Assert.Equal(unsupported, FileSwap.IsUnsupported(error));
}
@@ -124,15 +124,12 @@ public sealed class ReaderThemeTests
/// <summary>Null input is refused, and a theme is kept up to the stored size limit exactly and refused one character past it.</summary>
/// <summary>Null and oversized input are rejected before parser work.</summary>
[Fact]
public void Parse_OutsideSizeContract_RejectsInput()
{
Assert.Throws<ArgumentNullException>(() => ReaderTheme.Parse(null!));
string valid = ThemeCatalog.Find("Nord").Json;
Assert.Equal("Nord", ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters)).Name);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(valid.PadRight(ReaderTheme.MaximumStoredCharacters + 1)));
Assert.Equal(8 * 128 * 1024, ReaderTheme.MaximumStoredCharacters);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(new string(' ', 128 * 1024 + 1)));
}
@@ -1,220 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using Ganss.Xss;
using MarkdownViewr.Windows.Core.Documents;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// The elements, attributes and link schemes authored HTML may keep. The configured policy is
/// compared with the lists below as sets, so any change to it has to be made here as well, and
/// the rendering tests show the policy doing what the lists say.
/// </summary>
public sealed class SanitizerAllowlistTests
{
private static readonly string[] s_allowedTags =
[
"a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "ins", "kbd", "li", "mark", "ol", "p", "pre",
"s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul",
];
private static readonly string[] s_otherTags =
[
"acronym", "address", "applet", "area", "article", "aside", "audio", "base", "basefont", "bdi", "bdo", "bgsound", "big",
"blink", "button", "canvas", "center", "cite", "data", "datalist", "dfn", "dialog", "dir", "embed", "fencedframe",
"fieldset", "figcaption", "figure", "font", "footer", "form", "header", "hgroup", "iframe", "keygen", "label", "legend",
"link", "listing", "main", "map", "marquee", "math", "menu", "menuitem", "meta", "meter", "nav", "nobr", "noembed",
"noframes", "noscript", "object", "optgroup", "option", "output", "param", "picture", "plaintext", "portal", "progress",
"q", "rb", "rp", "rt", "rtc", "ruby", "samp", "script", "search", "section", "select", "selectedcontent", "slot", "source",
"strike", "style", "svg", "template", "textarea", "time", "title", "track", "tt", "u", "var", "video", "wbr", "xmp",
"custom-element",
];
private static readonly string[] s_allowedAttributes =
[
"align", "alt", "checked", "class", "colspan", "disabled", "height", "href", "rowspan", "src", "title", "type", "width",
];
private static readonly string[] s_otherAttributes =
[
"accesskey", "action", "allow", "as", "autocapitalize", "autofocus", "autoplay", "background", "bgcolor", "border",
"cellpadding", "cellspacing", "charset", "cite", "color", "content", "contenteditable", "controls", "coords",
"crossorigin", "data", "data-mv-body", "data-mv-source-fixed", "data-x", "datetime", "dir", "download", "draggable",
"enctype", "enterkeyhint", "face", "for", "form", "formaction", "formmethod", "formtarget", "headers", "hidden",
"http-equiv", "id", "inert", "inputmode", "integrity", "is", "ismap", "itemprop", "label", "lang", "list", "loading",
"longdesc", "loop", "lowsrc", "manifest", "max", "maxlength", "media", "method", "min", "multiple", "muted", "name",
"nonce", "novalidate", "nowrap", "onclick", "onerror", "onfocus", "onload", "onmouseover", "onpointerdown", "ontoggle",
"onwheel", "open", "part", "pattern", "ping", "placeholder", "popover", "popovertarget", "poster", "preload", "readonly",
"referrerpolicy", "rel", "required", "reversed", "role", "sandbox", "scope", "selected", "shape", "size", "sizes", "slot",
"span", "spellcheck", "srcdoc", "srclang", "srcset", "start", "step", "tabindex", "target", "translate", "usemap",
"valign", "value", "wrap", "xlink:href", "xml:lang", "xmlns", "aria-label",
];
/// <summary>The policy the renderer configures is exactly these elements, attributes and schemes, with no styling or data attributes.</summary>
[Fact]
public void Constructor_ConfiguredPolicy_AllowsExactlyTheListedMarkup()
{
HtmlSanitizer policy = new MarkdownRenderer().Policy;
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), policy.AllowedTags.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(s_allowedAttributes.Order(StringComparer.Ordinal), policy.AllowedAttributes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(["http", "https", "mailto"], policy.AllowedSchemes.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Empty(policy.AllowedCssProperties);
Assert.Empty(policy.AllowedAtRules);
Assert.False(policy.AllowDataAttributes);
Assert.False(policy.AllowCssCustomProperties);
}
/// <summary>Every allowed element survives in a document that nests each one where HTML permits it.</summary>
[Fact]
public void Render_EveryAllowedElement_SurvivesSanitation()
{
const string Markup = """
<div><p><a href="x.md">a</a><abbr>b</abbr><b>c</b><br><code>d</code><del>e</del><em>f</em><i>g</i><img src="i.png" alt="h"><input type="checkbox"><ins>j</ins><kbd>k</kbd><mark>l</mark><s>m</s><small>n</small><span>o</span><strong>p</strong><sub>q</sub><sup>r</sup></p><blockquote>s</blockquote><hr><pre>t</pre><h1>1</h1><h2>2</h2><h3>3</h3><h4>4</h4><h5>5</h5><h6>6</h6><dl><dt>u</dt><dd>v</dd></dl><details><summary>w</summary>x</details><ul><li>y</li></ul><ol><li>z</li></ol><table><caption>c</caption><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table></div>
""";
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
Assert.Equal(s_allowedTags.Order(StringComparer.Ordinal), ElementNames(document), StringComparer.Ordinal);
}
/// <summary>No other element survives between allowed elements; its text may remain but the element does not.</summary>
[Fact]
public void Render_EveryOtherElement_IsRemoved()
{
var renderer = new MarkdownRenderer();
Assert.All(s_otherTags, tag =>
{
using IHtmlDocument document = Parse(renderer.Render($"<div><{tag} title=\"t\">inner</{tag}><span>after</span></div>"));
Assert.Empty(ElementNames(document).Except(["div", "span"], StringComparer.Ordinal));
});
Assert.Empty(s_allowedTags.Intersect(s_otherTags, StringComparer.Ordinal));
}
/// <summary>Table parts that only exist inside a table, and are not allowed, are removed there while the allowed parts stay.</summary>
[Fact]
public void Render_TablePartsOutsideTheAllowlist_AreRemovedInsideATable()
{
const string Markup = "<table><colgroup><col span=\"2\"></colgroup><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>";
using IHtmlDocument document = Parse(new MarkdownRenderer().Render(Markup));
Assert.Empty(document.Body!.QuerySelectorAll("colgroup,col,tfoot"));
Assert.NotNull(document.Body.QuerySelector("table > thead > tr > th"));
Assert.NotNull(document.Body.QuerySelector("table > tbody > tr > td"));
}
/// <summary>Exactly the allowed attributes survive on an allowed element; identity, behaviour, styling and data attributes do not.</summary>
[Fact]
public void Render_EveryAttribute_KeepsExactlyTheAllowedOnes()
{
string attributes = string.Join(' ', s_allowedAttributes.Concat(s_otherAttributes).Select(name => string.Equals(name, "class", StringComparison.Ordinal) ? "class=\"frontmatter\"" : name + "=\"1\"")) + " style=\"color:red\"";
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<div><span {attributes}>text</span></div>"));
IElement span = Assert.Single(document.Body!.QuerySelectorAll("span"));
Assert.Equal(s_allowedAttributes, span.Attributes.Select(attribute => attribute.Name).Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Empty(s_allowedAttributes.Intersect(s_otherAttributes, StringComparer.Ordinal));
}
/// <summary>Only the fixed styling classes and bounded code-language classes survive, spelled exactly.</summary>
[Theory]
[InlineData("frontmatter fm-key fm-value task-list task-list-item", "frontmatter fm-key fm-value task-list task-list-item")]
[InlineData("language-csharp hljs keyword", "language-csharp")]
[InlineData("LANGUAGE-csharp Language-go language-rust", "language-rust")]
[InlineData("Frontmatter FM-KEY", null)]
[InlineData("language- language-a;b language-<x>", null)]
public void Render_ClassNames_KeepsOnlyTheStylingClasses(string authored, string? kept)
{
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<span class=\"{authored}\">text</span>"));
Assert.Equal(kept, Assert.Single(document.Body!.QuerySelectorAll("span")).GetAttribute("class"));
}
/// <summary>A code-language class is kept up to 80 characters and dropped one character past that.</summary>
[Fact]
public void Render_CodeLanguageClassAtAndPastTheLengthLimit_IsBoundedExactly()
{
string atLimit = "language-" + new string('x', 71);
Assert.Equal(80, atLimit.Length);
using IHtmlDocument kept = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}\">text</span>"));
Assert.Equal(atLimit, Assert.Single(kept.Body!.QuerySelectorAll("span")).GetAttribute("class"));
using IHtmlDocument dropped = Parse(new MarkdownRenderer().Render($"<span class=\"{atLimit}x\">text</span>"));
Assert.Null(Assert.Single(dropped.Body!.QuerySelectorAll("span")).GetAttribute("class"));
}
/// <summary>The attribute that carries source positions is allowed only during a source-aware render, whether that render finishes or fails.</summary>
[Fact]
public void RenderWithSources_AfterTheRender_NoLongerAllowsTheSourceAttribute()
{
var renderer = new MarkdownRenderer();
var sources = new DocumentSourceMap("# Title");
Assert.Contains("Title", renderer.RenderWithSources("# Title", FrontmatterMode.Hide, sources), StringComparison.Ordinal);
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
Assert.Throws<ArgumentOutOfRangeException>(() => renderer.RenderWithSources("# Title", (FrontmatterMode)99, sources));
Assert.DoesNotContain(sources.AttributeName, renderer.Policy.AllowedAttributes);
}
/// <summary>Only web and mail links keep a target; every other scheme, including ones Windows would hand to another application, is removed.</summary>
[Theory]
[InlineData("http://example.com/a", true)]
[InlineData("https://example.com/a", true)]
[InlineData("mailto:someone@example.com", true)]
[InlineData("child.md", true)]
[InlineData("#section", true)]
[InlineData("HTTPS://example.com/a", true)]
[InlineData("ftp://example.com/a", false)]
[InlineData("file:///C:/private.txt", false)]
[InlineData("FILE:///C:/private.txt", false)]
[InlineData("javascript:run()", false)]
[InlineData("vbscript:run()", false)]
[InlineData("data:text/html,<p>x</p>", false)]
[InlineData("blob:https://example.com/1", false)]
[InlineData("about:blank", false)]
[InlineData("ws://example.com", false)]
[InlineData("tel:5551234", false)]
[InlineData("sms:5551234", false)]
[InlineData("ms-settings:defaultapps", false)]
[InlineData("ms-msdt:/id", false)]
[InlineData("ms-word:ofe|u|https://example.com/a.docx", false)]
[InlineData("search-ms:query=x", false)]
[InlineData("shell:startup", false)]
[InlineData("res://ieframe.dll/x", false)]
[InlineData("view-source:https://example.com", false)]
[InlineData("onenote:x", false)]
public void Render_LinkScheme_KeepsOnlyWebAndMailTargets(string target, bool kept)
{
using IHtmlDocument document = Parse(new MarkdownRenderer().Render($"<a href=\"{target}\">link</a>"));
IElement link = Assert.Single(document.Body!.QuerySelectorAll("a"));
Assert.Equal(kept, link.HasAttribute("href"));
}
/// <summary>The distinct element names in the sanitized body, in ordinal order.</summary>
private static string[] ElementNames(IHtmlDocument document)
=> document.Body!.QuerySelectorAll("*").Select(element => element.LocalName).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
/// <summary>Parses renderer output the way the browser surface does.</summary>
private static IHtmlDocument Parse(string html) => new HtmlParser().ParseDocument("<!doctype html><html><body>" + html + "</body></html>");
}
@@ -35,10 +35,6 @@ public sealed class SecurityFuzzTests
"[section](#heading) ![image](images/hello%20world.png)\n", "| A | B |\n|---|---|\n| x | y |\n",
"- [x] Task\n> Quote\n", "😀 café\0\t\r\n", "<!-- <code> -->", "</code></pre></div>",
"</style><script>execute()</script>", "&lt;script&gt;literal&lt;/script&gt;", "\"'<>:=[]()\\/!?%",
"<video src='x.mp4' autoplay onplay='execute()'><source src='y.mp4'></video><audio src='x.mp3' controls></audio>",
"<embed src='x.swf'><object data='x.swf'><param name='a' value='b'></object><math><mi xlink:href='javascript:execute()'>x</mi></math>",
"<link rel='stylesheet' href='https://outside.invalid/x.css'><meta http-equiv='refresh' content='0;url=https://outside.invalid'><base href='https://outside.invalid/'>",
"<textarea autofocus onfocus='execute()'>text</textarea><select><option>one</option></select><a href='x.md' target='_blank' name='n' ping='https://outside.invalid'>x</a>",
];
@@ -246,12 +242,11 @@ public sealed class SecurityFuzzTests
/// <summary>Uses the parsed DOM's executable capabilities as the oracle, not the renderer's private allowlist.</summary>
private static void AssertPassive(IHtmlDocument document)
{
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta,link,audio,video,source,param,textarea,select,option"));
Assert.Empty(document.QuerySelectorAll("script,svg,math,iframe,object,embed,form,button,style,base,meta[http-equiv],audio,video"));
foreach(IElement element in document.QuerySelectorAll("*"))
{
Assert.DoesNotContain(element.Attributes, attribute => attribute.Name.StartsWith("on", StringComparison.OrdinalIgnoreCase));
Assert.False(element.HasAttribute("srcdoc") || element.HasAttribute("srcset") || element.HasAttribute("style"));
Assert.False(element.HasAttribute("target") || element.HasAttribute("ping") || element.HasAttribute("name") || element.HasAttribute("autofocus"));
foreach(string name in new[] { "href", "src" })
{
string? value = element.GetAttribute(name);
@@ -1,431 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.Json.Nodes;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using MarkdownViewr.Windows.Core.Documents;
using MarkdownViewr.Windows.Core.Editors;
using MarkdownViewr.Windows.Core.Printing;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>
/// Validation of text that comes from a document, a settings file, an imported theme or the
/// browser surface: each check is pinned by an input that only that check rejects.
/// </summary>
public sealed class UntrustedInputValidationTests
{
/// <summary>Metadata keys and values are display text: markup in either is escaped, never parsed.</summary>
[Fact]
public void RenderMetadata_MarkupInKeyAndValue_IsEscapedText()
{
const string Key = "<b>key</b>";
const string Value = "<img src=x onerror=run()> & \"quoted\"";
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(Key + ": " + Value));
Assert.Equal(Key, Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
Assert.Equal(Value, Assert.Single(parsed.QuerySelectorAll("td.fm-value")).TextContent);
Assert.Empty(parsed.QuerySelectorAll("b,img"));
}
/// <summary>A line that starts with its colon still has a key cell, so the table keeps two columns.</summary>
[Fact]
public void RenderMetadata_LeadingColon_KeepsAnEmptyKeyCell()
{
using IHtmlDocument parsed = new HtmlParser().ParseDocument(DocumentFrontmatter.RenderMetadata(": value"));
Assert.Equal("", Assert.Single(parsed.QuerySelectorAll("th.fm-key")).TextContent);
IElement value = Assert.Single(parsed.QuerySelectorAll("td.fm-value"));
Assert.Equal("value", value.TextContent);
Assert.False(value.HasAttribute("colspan"));
}
/// <summary>The private source marker is new for every render, so an authored attribute cannot predict it.</summary>
[Fact]
public void AttributeName_EachSourceMap_IsUnpredictable()
{
string first = new DocumentSourceMap("# Title").AttributeName;
string second = new DocumentSourceMap("# Title").AttributeName;
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", first);
Assert.Matches("^data-mv-source-[0-9a-f]{32}$", second);
Assert.NotEqual(first, second, StringComparer.Ordinal);
}
/// <summary>Raw HTML headings map to their source at every level, including the sixth.</summary>
[Theory]
[InlineData(1)]
[InlineData(5)]
[InlineData(6)]
public void SourceHeadings_RawHtmlHeadingLevel_MapsItsSource(int level)
{
string heading = $"<h{level}>Raw</h{level}>";
string source = "Intro\n\n" + heading + "\n\nAfter\n";
var outline = new DocumentOutline(source);
Assert.Equal(level, Assert.Single(outline.Headings).Level);
DocumentSourceHeading mapped = Assert.Single(outline.SourceHeadings);
Assert.Equal(heading, source[mapped.Start..mapped.End]);
}
/// <summary>A level the HTML standard does not define is not a heading and gets no source mapping.</summary>
[Fact]
public void SourceHeadings_RawHtmlLevelSeven_IsNotAHeading()
{
var outline = new DocumentOutline("<h7>Not a heading</h7>\n");
Assert.Empty(outline.Headings);
Assert.Empty(outline.SourceHeadings);
}
/// <summary>Loading repairs every stored list and object, not only the ones the window happens to read first.</summary>
[Fact]
public void Sanitize_CorruptPrintEditorsAndThemeLists_RepairsEachProperty()
{
var valid = new EditorConfiguration(Guid.NewGuid(), "Editor", @"C:\Editor\editor.exe");
var settings = new AppSettings
{
Print = new PrintOptions(Theme: "Bad\u0001Theme", PaddingPixels: 5000),
ConfiguredEditors = [valid, new EditorConfiguration(Guid.NewGuid(), "Script", @"C:\Editor\run.cmd"), new EditorConfiguration(Guid.Empty, "Empty", @"C:\Editor\other.exe")],
DisabledThemes = ["Nord", "Nord", "Bad\u0001Name", ""],
DeletedBuiltInThemes = ["Dracula", "Dracula", "Bad\u0002Name"],
ThemeOrder = ["Nord", "Nord", "Bad\u0003Name"],
DocumentTheme = "Bad\u0001Theme",
};
settings.Sanitize();
Assert.Equal("Clean Printing", settings.Print.Theme);
Assert.Equal(96, settings.Print.PaddingPixels);
Assert.Equal([valid], settings.ConfiguredEditors);
Assert.Equal(valid.Executable, settings.ExternalEditor);
Assert.Equal(["Nord"], settings.DisabledThemes);
Assert.Equal(["Dracula"], settings.DeletedBuiltInThemes);
Assert.Equal(["Nord"], settings.ThemeOrder);
Assert.Equal("Catppuccin Mocha", settings.DocumentTheme);
}
/// <summary>A missing print object, as an older file or a hand edit leaves it, becomes the defaults.</summary>
[Fact]
public void Sanitize_MissingPrintObject_UsesDefaults()
{
var settings = new AppSettings { Print = null! };
settings.Sanitize();
Assert.Equal(new PrintOptions(), settings.Print);
}
/// <summary>Editing a theme cannot silently raise it to a newer schema; that needs the import confirmation.</summary>
[Fact]
public void SaveEdited_SchemaVersionRaised_RequiresConfirmation()
{
ReaderTheme original = ThemeCatalog.Find(null);
JsonNode raised = JsonNode.Parse(original.Json)!;
raised["schemaVersion"] = 2;
var edited = ReaderTheme.ParseCompatible(raised.ToJsonString());
Assert.Equal(1, original.SchemaVersion);
Assert.Equal(2, edited.SchemaVersion);
Assert.Throws<InvalidOperationException>(() => ThemeManagement.SaveEdited(new AppSettings(), original, edited));
}
/// <summary>An already confirmed newer-schema theme stays editable at its own version.</summary>
[Fact]
public void SaveEdited_SameNewerSchema_IsSaved()
{
JsonNode newer = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
newer["schemaVersion"] = 2;
newer["name"] = "Owned future";
var theme = ReaderTheme.ParseCompatible(newer.ToJsonString());
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme, approveNewerSchema: true);
AppSettings saved = ThemeManagement.SaveEdited(imported, theme, theme);
Assert.Equal("Owned future", saved.DocumentTheme);
Assert.Equal(2, ThemeManagement.Active(saved).SchemaVersion);
}
/// <summary>A copy of a theme whose name is already at the limit still has a name the theme parser accepts.</summary>
[Fact]
public void CopyTheme_LongestNameTakenTwice_StaysWithinTheNameLimit()
{
ReaderTheme longest = ThemeCatalog.Find(null).Rename(new string('N', 80));
AppSettings settings = ThemeManagement.Import(new AppSettings(), longest);
ReaderTheme firstCopy = ThemeManagement.CopyTheme(settings, longest);
settings = ThemeManagement.Import(settings, firstCopy);
ReaderTheme secondCopy = ThemeManagement.CopyTheme(settings, longest);
Assert.Equal(80, firstCopy.Name.Length);
Assert.Equal(80, secondCopy.Name.Length);
Assert.EndsWith(" (2)", secondCopy.Name, StringComparison.Ordinal);
Assert.Equal(secondCopy.Name, ReaderTheme.ParseCompatible(secondCopy.Json).Name);
}
/// <summary>Editor and document paths are accepted up to the Windows limit and refused one character past it.</summary>
[Fact]
public void IsExecutablePath_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('e', 32767 - 7) + ".exe";
string pastLimit = @"C:\" + new string('e', 32768 - 7) + ".exe";
Assert.Equal(32767, atLimit.Length);
Assert.True(EditorLaunch.IsExecutablePath(atLimit));
Assert.False(EditorLaunch.IsExecutablePath(pastLimit));
string document = @"C:\" + new string('d', 32767 - 6) + ".md";
Assert.Equal(32767, document.Length);
Assert.Equal(document, Assert.Single(EditorLaunch.Create(@"C:\Editor\editor.exe", document).ArgumentList));
Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", document + "x"));
}
/// <summary>Recent paths are kept up to the Windows limit and dropped one character past it.</summary>
[Fact]
public void Sanitize_RecentPathAtAndPastTheWindowsLimit_IsBoundedExactly()
{
string atLimit = @"C:\" + new string('d', 32767 - 6) + ".md";
Assert.Equal([atLimit], RecentDocuments.Sanitize([atLimit, atLimit + "x"]));
}
/// <summary>A find term is accepted at its limit and refused one character past it.</summary>
[Fact]
public void Constructor_FindTermAtAndPastTheLimit_IsBoundedExactly()
{
string atLimit = new('q', DocumentFind.MaximumQueryCharacters);
Assert.Equal(atLimit, new DocumentFind(atLimit + " tail", atLimit).Query);
Assert.Throws<ArgumentException>(() => new DocumentFind("source", atLimit + "q"));
}
/// <summary>The viewport result is read up to its size cap and ignored past it, even when it would parse.</summary>
[Fact]
public void FromBrowserResult_ViewportAtAndPastTheSizeCap_IsBoundedExactly()
{
string atCap = "[0.25," + new string(' ', 128 - 11) + "0.75]";
Assert.Equal(128, atCap.Length);
Assert.Equal(0.75, DocumentViewport.FromBrowserResult(atCap).VerticalFraction);
Assert.Equal(default, DocumentViewport.FromBrowserResult(atCap.Replace("[", "[ ", StringComparison.Ordinal)));
}
/// <summary>The scroll-state result is read up to its size cap for the heading count and ignored past it.</summary>
[Theory]
[InlineData(0)]
[InlineData(3)]
public void FromBrowserResult_ScrollStateAtAndPastTheSizeCap_IsBoundedExactly(int headings)
{
int cap = headings * 128 + 512;
string atCap = "[1,2,[],[]" + new string(' ', cap - 11) + "]";
Assert.Equal(cap, atCap.Length);
Assert.NotNull(DocumentScrollState.FromBrowserResult(atCap, headings));
Assert.Null(DocumentScrollState.FromBrowserResult(atCap.Replace("[1,", "[ 1,", StringComparison.Ordinal), headings));
}
/// <summary>The scroll-state result has exactly four parts; a longer array is not the application's own query result.</summary>
[Theory]
[InlineData("[1,2,[]]")]
[InlineData("[1,2,[],[],[]]")]
[InlineData("[1,2,[],[],0]")]
public void FromBrowserResult_ScrollStateWithWrongNumberOfParts_IsRejected(string json)
=> Assert.Null(DocumentScrollState.FromBrowserResult(json, 2));
/// <summary>Print styles are built from repaired options, so a corrupt padding value cannot reach the stylesheet.</summary>
[Theory]
[InlineData(double.NaN, "padding:0px")]
[InlineData(double.PositiveInfinity, "padding:0px")]
[InlineData(5000, "padding:96px")]
[InlineData(-3, "padding:0px")]
public void Build_CorruptPadding_UsesTheRepairedValue(double padding, string expected)
{
ReaderTheme theme = ThemeCatalog.Find(null);
string css = PrintStyles.Build(new PrintOptions(PaddingPixels: padding), theme, [theme], 1);
Assert.Contains(expected, css, StringComparison.Ordinal);
Assert.DoesNotContain("NaN", css, StringComparison.Ordinal);
Assert.DoesNotContain("Infinity", css, StringComparison.Ordinal);
Assert.DoesNotContain("∞", css, StringComparison.Ordinal);
}
/// <summary>
/// Text the JSON reader cannot decode, an unpaired surrogate escape, makes the theme invalid
/// wherever it appears: in a value the theme reads, in a property name, or in a value this
/// version ignores but would later re-serialize. So does a repeated property name.
/// </summary>
[Theory]
[InlineData("\"name\": \"Nord\"", "\"name\": \"\\uD83D\"")]
[InlineData("\"background\": \"#2e3440\"", "\"background\": \"\\uD800\"")]
[InlineData("\"body\": \"System\"", "\"body\": \"A\\uDC00B\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"\\uD83D\": 1")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"\\uD83D\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [1, [\"ok\", {\"deep\": \"\\uDFFF\"}]]")]
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"\\uD800x\": \"y\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"name\": \"Nord\"")]
[InlineData("\"body\": \"System\"", "\"body\": \"System\", \"body\": \"Other\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"extra\": {\"a\": 1, \"a\": 2}")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"na\\u006de\": \"Nord\"")]
[InlineData("\"name\": \"Nord\"", "\"name\": \"Nord\", \"notes\": [\"\\uD83D\"]")]
public void Parse_UndecodableTextOrRepeatedProperty_IsRejectedAsAnInvalidTheme(string valid, string invalid)
{
string original = ThemeCatalog.Find("Nord").Json;
Assert.Contains(valid, original, StringComparison.Ordinal);
string json = original.Replace(valid, invalid, StringComparison.Ordinal);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
var settings = new AppSettings { UserThemes = [json, original] };
settings.Sanitize();
Assert.Equal([original], settings.UserThemes);
}
/// <summary>
/// A theme that parses can be renamed, copied, edited and imported under a new name with its
/// extra properties intact: those operations re-serialize the whole document. Names that differ
/// only in case, and the same name in two different objects, are not repeats.
/// </summary>
[Fact]
public void Parse_AcceptedThemeWithExtraProperties_SurvivesRenameCopyEditAndImport()
{
string json = ThemeCatalog.Find("Nord").Json.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"Name\": \"other case\", \"note\": \"caf\\u00e9 \\ud83d\\ude00\", \"future\": {\"name\": \"inner\", \"list\": [1, \"two\", null]}", StringComparison.Ordinal);
var theme = ReaderTheme.Parse(json);
Assert.Equal("Renamed", theme.Rename("Renamed").Name);
AppSettings imported = ThemeManagement.Import(new AppSettings(), theme);
Assert.Contains(ThemeManagement.All(imported), item => item.Name.StartsWith("Nord (", StringComparison.Ordinal));
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(imported, theme).Name, StringComparison.Ordinal);
ReaderTheme edited = theme.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
Assert.Equal("Edited", edited.Name);
Assert.Contains("caf", edited.Json, StringComparison.Ordinal);
Assert.Contains("\"Name\":\"other case\"", edited.Json, StringComparison.Ordinal);
Assert.Contains("\"name\":\"inner\"", edited.Json, StringComparison.Ordinal);
}
/// <summary>
/// Renaming, copying and editing write characters as themselves, so a theme near the size
/// limit whose text is full of quote marks, angle brackets or non-ASCII characters stays
/// within the limit instead of growing sixfold and being refused.
/// </summary>
[Theory]
[InlineData("a>b+c{font:'x'}")]
[InlineData("/* \u6f22\u5b57 caf\u00e9 & <tag> */")]
public void Rename_ThemeNearTheSizeLimit_DoesNotGrowPastIt(string fragment)
{
string css = string.Concat(Enumerable.Repeat(fragment, 60_000 / fragment.Length));
string original = ThemeCatalog.Find("Nord").Json;
Assert.Contains("\"name\": \"Nord\"", original, StringComparison.Ordinal);
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"Nord\", \"note\": \"" + css + "\"", StringComparison.Ordinal);
var theme = ReaderTheme.Parse(json);
ReaderTheme renamed = theme.Rename("Renamed");
Assert.True(renamed.Json.Length <= json.Length);
Assert.Contains(fragment, renamed.Json, StringComparison.Ordinal);
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "");
Assert.True(edited.Json.Length <= json.Length);
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), theme).Name, StringComparison.Ordinal);
}
/// <summary>
/// JSON writes emoji, no-break and ideographic spaces and private-use glyphs as six-character
/// escapes whatever the encoder. A theme as large as an import may be, made of nothing else,
/// can still be renamed, copied and edited: the stored limit leaves room for the growth.
/// </summary>
[Theory]
[InlineData("😀")]
[InlineData(" ")]
[InlineData(" ")]
[InlineData("")]
public void Rename_ThemeAtTheImportLimitMadeOfEscapedCharacters_IsKept(string character)
{
string original = ThemeCatalog.Find("Nord").Json;
string opening = "\"name\": \"Nord\", \"note\": \"";
int room = ReaderTheme.MaximumCharacters - original.Length - (opening.Length - "\"name\": \"Nord\"".Length) - 1;
string filler = string.Concat(Enumerable.Repeat(character, room / character.Length)).PadRight(room, 'x');
string json = original.Replace("\"name\": \"Nord\"", opening + filler + "\"", StringComparison.Ordinal);
Assert.Equal(ReaderTheme.MaximumCharacters, json.Length);
var theme = ReaderTheme.Parse(json);
ReaderTheme renamed = theme.Rename("A considerably longer name than the one it had");
Assert.InRange(renamed.Json.Length, json.Length + 1, ReaderTheme.MaximumStoredCharacters);
Assert.Equal(renamed.Json.Length, renamed.Rename(renamed.Name).Json.Length);
Assert.EndsWith(" Copy", ThemeManagement.CopyTheme(new AppSettings(), renamed).Name, StringComparison.Ordinal);
ReaderTheme edited = renamed.Edit("Edited", new Dictionary<string, string>(StringComparer.Ordinal), new Dictionary<string, double>(StringComparer.Ordinal), new Dictionary<string, string>(StringComparer.Ordinal), "body { color: red }");
Assert.Equal("Edited", edited.Name);
var settings = new AppSettings { UserThemes = [edited.Json] };
settings.Sanitize();
Assert.Equal([edited.Json], settings.UserThemes);
}
/// <summary>A string that is not well-formed UTF-16 is refused as an invalid theme, and a stored one is dropped, rather than failing as a programming error.</summary>
[Fact]
public void Parse_TextWithAnUnpairedSurrogate_IsRejectedAsAnInvalidTheme()
{
string original = ThemeCatalog.Find("Nord").Json;
string json = original.Replace("\"name\": \"Nord\"", "\"name\": \"No\uD83Drd\"", StringComparison.Ordinal);
Assert.DoesNotContain("\"name\": \"Nord\"", json, StringComparison.Ordinal);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(json));
Assert.Throws<JsonException>(() => ReaderTheme.ParseCompatible(json));
var settings = new AppSettings { UserThemes = [json, original] };
settings.Sanitize();
Assert.Equal([original], settings.UserThemes);
}
/// <summary>Only the exact lower-case channel name selects the development identity.</summary>
[Theory]
[InlineData("DEV")]
[InlineData("Dev")]
[InlineData("dev ")]
[InlineData("development")]
public void ForChannel_OtherSpellingOfDev_KeepsTheStableIdentity(string channel)
=> Assert.Equal("markdownviewr-windows", AppIdentity.ForChannel(channel).Id);
/// <summary>A colour that is not six hexadecimal digits after a hash, or a label containing a control character, is refused.</summary>
[Theory]
[InlineData("colors.background", "\";ffffff\"")]
[InlineData("colors.background", "\"#fffffff\"")]
[InlineData("colors.background", "\"#ffffffff\"")]
[InlineData("colors.background", "\"#fffff\"")]
[InlineData("colors.background", "\"ffffff#\"")]
[InlineData("colors.text", "\"#12345g\"")]
[InlineData("name", "\"Theme\\u0001Name\"")]
[InlineData("name", "\"Theme\\u007fName\"")]
[InlineData("fonts.heading", "\"Font\\u0001Name\"")]
[InlineData("fonts.code", "\"Font\\u009bName\"")]
public void Parse_MalformedColorOrLabel_IsRejected(string path, string json)
{
JsonNode root = JsonNode.Parse(ThemeCatalog.Find(null).Json)!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent[parts[^1]] = JsonNode.Parse(json);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
}
@@ -1,125 +0,0 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Documents;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>The written-out Windows name rules, and the two places that depend on them.</summary>
public sealed class WindowsFileNamesTests
{
private static readonly string[] s_devices =
[
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³",
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
];
/// <summary>A device is a device in any letter case, with any extension, and with spaces before the extension or at the end.</summary>
[Fact]
public void IsReservedDeviceName_EveryDeviceSpelling_IsReserved()
{
Assert.All(Spellings(), spelling =>
{
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".png"), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + ".tar.md"), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " .png"), spelling);
Assert.True(WindowsFileNames.IsReservedDeviceName(spelling + " "), spelling);
});
Assert.Equal(96, Spellings().Count());
}
/// <summary>Names that only resemble a device stay ordinary file names; only spaces are ignored before the extension.</summary>
[Theory]
[InlineData("")]
[InlineData("console.md")]
[InlineData("COM10.png")]
[InlineData("COM")]
[InlineData("LPT.txt")]
[InlineData("xCON.png")]
[InlineData("CON-notes.md")]
[InlineData("CON-.md")]
[InlineData("null.md")]
[InlineData(" NUL.png")]
[InlineData("NUL\u00A0.png")]
[InlineData("NUL\t.png")]
[InlineData("readme.NUL")]
[InlineData("COM⁴.png")]
[InlineData("CLOCK$")]
[InlineData("CLOCK$.txt")]
public void IsReservedDeviceName_OrdinaryName_IsNotReserved(string name) => Assert.False(WindowsFileNames.IsReservedDeviceName(name));
/// <summary>A missing name is a contract error, not an ordinary file.</summary>
[Fact]
public void IsReservedDeviceName_Null_RejectsInput() => Assert.Throws<ArgumentNullException>(() => WindowsFileNames.IsReservedDeviceName(null!));
/// <summary>The printable characters Windows forbids in a name; control characters are refused separately by each caller.</summary>
[Fact]
public void InvalidCharacters_Constant_ListsThePrintableForbiddenCharacters() => Assert.Equal("\"*/:<>?\\|", new string(WindowsFileNames.InvalidCharacters.Order().ToArray()));
/// <summary>No image or linked-document request can name a device in any spelling, as the file or as a folder on the way to it.</summary>
[Fact]
public void GetRelativePath_DeviceSegment_IsDenied()
{
var policy = new DocumentResourcePolicy();
Assert.All(Spellings(), spelling =>
{
string segment = Uri.EscapeDataString(spelling);
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + ".png")));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "%20.png")));
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, segment + "/picture.png")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".md")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, segment + ".tar.md")));
Assert.Null(policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/" + segment + "/child.md")));
});
Assert.Equal("console.png", policy.GetImageRelativePath(new Uri(policy.DocumentUri, "console.png")));
Assert.Equal("docs/com10/child.md", policy.GetDocumentRelativePath(new Uri(policy.DocumentUri, "docs/com10/child.md")));
}
/// <summary>A theme named after a device, in any case, never becomes the suggested export file name.</summary>
[Fact]
public void SuggestedFileName_EveryDeviceName_IsPrefixed()
{
Assert.All(s_devices, device =>
{
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device)));
Assert.Equal("theme-" + device.ToLowerInvariant() + ".json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device.ToLowerInvariant())));
Assert.Equal("theme-" + device.ToLowerInvariant() + ".backup.json", ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(device + ".backup")));
});
}
/// <summary>Leading and trailing dots and hyphens are dropped from a suggested name, and a name made only of them falls back to a plain one.</summary>
[Theory]
[InlineData("-Edge-", "edge.json")]
[InlineData(".Dotted.", "dotted.json")]
[InlineData("--", "theme.json")]
[InlineData(" spaced ", "spaced.json")]
[InlineData("-con-", "theme-con.json")]
public void SuggestedFileName_LeadingAndTrailingSeparators_AreTrimmed(string name, string expected)
=> Assert.Equal(expected, ThemeJson.SuggestedFileName(ThemeCatalog.Find(null).Rename(name)));
/// <summary>Each device name in upper, lower and mixed case.</summary>
private static IEnumerable<string> Spellings()
=> s_devices.SelectMany(device => new[] { device, device.ToLowerInvariant(), char.ToUpperInvariant(device[0]) + device[1..].ToLowerInvariant() });
}
@@ -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);