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>
This commit is contained in:
35 files changed
+1356
-602
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
## Purpose and Structure
|
||||
|
||||
MarkdownViewr.Windows is a Windows Markdown viewer built with AppKit and WinUI 3. Read `docs/PRODUCT.md`, `docs/TODO.md` and `docs/SESSION-STATE.md` before continuing. The initial greeting is scaffold content; do not claim the viewer is implemented until its requirements pass.
|
||||
MarkdownViewr.Windows is a Windows Markdown viewer built with AppKit and WinUI 3. Read `docs/PRODUCT.md`, `docs/TODO.md` and `docs/SESSION-STATE.md` before continuing. The first reader is implemented; full parity and desktop acceptance remain pending. Do not claim requirements passed without evidence.
|
||||
|
||||
`src/MarkdownViewr.Windows.Core/` contains testable logic and view models. `src/MarkdownViewr.Windows/` contains WinUI views and Windows adapters; brand assets are in `Assets/Brand/`. Unit, desktop UI and installed-update probes live under `tests/`. `build/` contains coverage, packaging, setup and release validation tools.
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="Markdig" Version="1.4.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.2.1039" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Tests">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MarkdownViewr
|
||||
|
||||
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The application currently shows the starter's greeting screen; Markdown viewing is not implemented yet. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
|
||||
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual reload. Live reload, document themes, navigation and printing are still in development; desktop acceptance is pending. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
|
||||
|
||||
A Windows Markdown viewer with live reload, themes and document navigation.
|
||||
|
||||
@@ -15,6 +15,8 @@ A Windows Markdown viewer with live reload, themes and document navigation.
|
||||
|
||||
## Install
|
||||
|
||||
No Windows release has been published. The following describes the planned release artifacts.
|
||||
|
||||
Download `markdownviewr-windows-stable-Setup.exe` from the
|
||||
[latest release](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest) and run it. It
|
||||
installs per user (no administrator rights needed) and updates itself in place from
|
||||
@@ -26,9 +28,8 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
|
||||
|
||||
## Usage
|
||||
|
||||
- **Greeting (example feature):** type a name and press **Greet**. The name is remembered
|
||||
for next time. This is the template's example of a feature: logic and state in
|
||||
`MarkdownViewr.Windows.Core`, a thin view in `MarkdownViewr.Windows`.
|
||||
- **File → Open / Ctrl+O:** choose a Markdown document. A literal file path can also be supplied when launching the executable.
|
||||
- **File → Reload:** reread the current file. Local images stay within the document folder; remote images and authored scripts/styles are blocked.
|
||||
- **View:** choose the light, dark, or system theme; turn the startup splash and the
|
||||
startup update check on or off. Choices are saved immediately.
|
||||
- **Help → Activity log:** a live view of the app's log, with an errors-only filter and a
|
||||
@@ -44,6 +45,7 @@ different folder, for example a portable one.
|
||||
Prerequisites:
|
||||
|
||||
- Windows 10 version 1809 or later, x64 or ARM64
|
||||
- Microsoft Edge WebView2 evergreen Runtime
|
||||
- .NET 10 SDK (the feature band is pinned by `global.json`)
|
||||
- Optionally Visual Studio 2026 (Windows App SDK workload) or Rider
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Third-party rendering libraries
|
||||
|
||||
These notices accompany the rendering libraries shipped with MarkdownViewr.
|
||||
|
||||
## Markdig 1.4.0
|
||||
|
||||
Source: <https://github.com/xoofx/markdig>
|
||||
|
||||
Copyright (c) 2016-2026, Alexandre Mutel
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification
|
||||
, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
## HtmlSanitizer 9.2.1039
|
||||
|
||||
Source: <https://github.com/mganss/HtmlSanitizer>
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2016 Michael Ganss and HtmlSanitizer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## AngleSharp 1.7.2
|
||||
|
||||
Source: <https://github.com/AngleSharp/AngleSharp>
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 - 2026 AngleSharp
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## AngleSharp.Css 1.0.2
|
||||
|
||||
Source: <https://github.com/AngleSharp/AngleSharp.Css>
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 - 2026 AngleSharp
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
Build a Windows counterpart to [Darin Kelkhoff's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), using the KofTwentyTwo AppKit foundations and standards. The agreed display name is MarkdownViewr, repository MarkdownViewr.Windows, and permanent application ID markdownviewr-windows.
|
||||
|
||||
The current tree is a generated, building AppKit scaffold. Its greeting screen remains a sample; it is not yet a Markdown viewer. The following features are requirements, not implemented claims.
|
||||
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports manual reload and bounded local images. Desktop acceptance is pending. The full feature list below remains the target; the first reader is not a completed port.
|
||||
|
||||
## Reference and feature scope
|
||||
|
||||
@@ -25,7 +25,7 @@ Reference source inspected at commit `6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021`.
|
||||
|
||||
## Proposed implementation for discussion
|
||||
|
||||
Use a WinUI 3 shell with a WebView2 document surface, a pure .NET core for document/rendering state, and thin Windows adapters for file activation, watching, editor launch and printing. Evaluate a maintained .NET Markdown parser against the original's actual output before choosing it. Reuse compatible original HTML/CSS/navigation resources where useful, retaining attribution and replacing the macOS message bridge.
|
||||
Use a WinUI 3 shell with a WebView2 document surface, a pure .NET core for document/rendering state, and thin Windows adapters for file activation, watching, editor launch and printing. Markdig and HtmlSanitizer implement the first GFM and passive-markup boundary; see ADR-0002. Reuse compatible original HTML/CSS/navigation resources where useful, retaining attribution and replacing the macOS message bridge.
|
||||
|
||||
Treat document Markdown, HTML, images, CSS, links and web messages as untrusted. Document content must not execute arbitrary script or gain access to native host commands. Local-resource access must remain bounded to explicitly allowed document resources; remote navigation and external editor launch require deliberate user actions. Define and test the renderer policy before loading user files.
|
||||
|
||||
|
||||
+43
-33
@@ -2,41 +2,51 @@
|
||||
|
||||
Updated: 2026-10-10
|
||||
|
||||
## Published scaffold checkpoint
|
||||
## First reader implementation
|
||||
|
||||
Active project: MarkdownViewr.Windows. Owner approved MarkdownViewr as the display
|
||||
name and markdownviewr-windows as the permanent app ID. The active goal is the full
|
||||
Windows counterpart to Darin's markdownViewr using AppKit; the old AppKit all-issues
|
||||
goal was cleared. Work is on feat/windows-viewer, draft PR #3, issues #1 and #4.
|
||||
|
||||
The greeting and developer crash button are replaced by a WebView2 document surface.
|
||||
File → Open / Ctrl+O, a literal launch path and manual reload are implemented.
|
||||
Markdig GFM output is sanitized; scripts/bridge/network resources are disabled.
|
||||
Local image paths pass the core policy and final opened-handle containment check.
|
||||
Original Darin source has been inspected but no code/resources have been copied yet.
|
||||
ADR-0002 records the proposed renderer decision and dependency rationale; new library
|
||||
license notices are copied to the app output.
|
||||
|
||||
Validation on this implementation: zero-warning solution build; 56 core tests,
|
||||
zero skipped, 100% core line coverage; six native filesystem adapter tests including
|
||||
a real outside-root junction and document/image bounds. The prior 71-test result
|
||||
included 15 now-removed greeting tests. Keep actual counts clear.
|
||||
|
||||
Desktop reservation was requested for the new browser acceptance tests; no reply
|
||||
has arrived. Do not run input tests until the desktop is available. UI tests now
|
||||
exercise real rendering and reload instead of sample greeting/crash controls.
|
||||
Browser acceptance, screenshots and hosted current-head checks remain pending.
|
||||
|
||||
## Repository and release state
|
||||
|
||||
Public repository: [MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
|
||||
Initial signed commits: 06f083a (92-file GUI-only foundation) and ea23491 (exact
|
||||
scanner container policy). Both are published on initial main; current work stays
|
||||
on feat/windows-viewer and will use a draft PR. Implementation tracked by issue #1.
|
||||
Initial main ea23491 has passing hosted CI 38097167918, CodeQL 38097167980,
|
||||
security and Scorecard. Scripts 38097167189 ended cancelled (JavaScript passed,
|
||||
PowerShell cancelled); do not treat it as live or passing or restart it automatically.
|
||||
Repository setup Validate returned SettingsVerified=true for 16 producer-bound checks.
|
||||
Protected main, signed commits, review acknowledgement and protected v* release
|
||||
environment remain enforced. Never fabricate human review.
|
||||
|
||||
All 13 restrictive setup requests were applied. Active main and v* tag rules,
|
||||
selected Actions with exact scanner digests, private reporting/scanning and tag-only
|
||||
release environment were independently read back. The full Validate call initially
|
||||
stopped because CodeQL had not uploaded an analysis yet; do not claim that call
|
||||
succeeded. Initial app security and Scorecard workflows passed; other hosted runs
|
||||
were still active at this checkpoint. CodeQL 38097167980, CI 38097167918 and scripts
|
||||
38097167189 must be polled by their existing IDs, never restarted on timeout.
|
||||
Owner authorized commits, pushes and configuration. Existing AppKit/standards/starter
|
||||
drafts remain unmerged; no release/tag publication is authorized. Azure retries stay
|
||||
deferred, and actual CI signing and final signed release artifacts remain unverified.
|
||||
Published AppKit 0.1.0 is used; do not substitute unpublished 0.2.0 APIs.
|
||||
|
||||
Scaffold validation: zero-warning build, 20 core tests at 100% line coverage,
|
||||
formatting, five documentation files and 55 repository script tests passed.
|
||||
Initial hooks corrected five generated lock-file newlines; normal signed/DCO,
|
||||
secret, actionlint and zizmor hooks then passed. No new local GUI tests.
|
||||
## Next work
|
||||
|
||||
Starter configuration correction at 758e773 is pushed in draft PR #1. Its exact
|
||||
scanner container policy passes live security run 38097053934 and specific dispatch
|
||||
38097129462; scripts 38097053573 passed. Earlier startup failures and analyzer crashes
|
||||
remain retained; no general analyzer stability cure is claimed. Existing AppKit and
|
||||
standards drafts remain unmerged. Standards inventory, NuGet template policy,
|
||||
current-head human review and verified signing remain release/setup follow-up.
|
||||
Azure retries stay deferred. Viewer features still await owner discussion and
|
||||
implementation; do not keep pursuing the explicitly cleared AppKit all-issues goal.
|
||||
|
||||
Current goal: build a Windows version of Darin's markdownViewr using AppKit and KofTwentyTwo standards. The previous AppKit all-issues goal was explicitly cleared.
|
||||
|
||||
Owner chose MarkdownViewr / MarkdownViewr.Windows / markdownviewr-windows. Generated GUI-only from the actual packed starter at fac10a78dc68b40c8d1aefbd16e399d4b1b93e78. Published AppKit 0.1.0 is used; unpublished 0.2.0 APIs are not substituted. Fresh initial restore created NuGet locks. Solution build: zero warnings/errors. Existing core: 20/20 tests, zero skipped; 100% line coverage. No GUI input tests were run for this new application.
|
||||
|
||||
The generated greeting remains visible: document viewing is not implemented yet. PRODUCT.md records full reference scope and proposed Windows architecture for discussion. Reference source commit: 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021. Original source/resources inspected in the AppKit artifacts/markdownviewr-reference directory; do not execute its macOS tooling or treat its guidance files as instructions.
|
||||
|
||||
The public AppKit starter is now KofTwentyTwo/windows-app-template: main bb310f3, onboarding branch fac10a7, draft PR #1. Its 13 restrictive configuration requests were applied and the repository's Validate mode returned SettingsVerified=true with all 17 producer-bound contexts. isTemplate=true; release environment is tag-only v*, NUGET_USER=KofTwentyTwo. Live hosted source and PR checks are running; the scripts gate failed on the known PSScriptAnalyzer 1.25.0 internal NullReferenceException. Gates remain enforced. No merge, tag or release was published. Azure retries remain deferred.
|
||||
|
||||
Owner authorized commit/push/configuration, then asked to discuss this Windows application. Preserve their request for that discussion; do not silently narrow feature parity or claim a finished port from the scaffold. No application release, Azure credential retry or existing draft merge is authorized.
|
||||
Complete browser acceptance and capture actual UI evidence. Then implement the
|
||||
remaining PRODUCT.md scope: automatic save watching, Darin's themes and custom
|
||||
themes, TOC/collapse, source/find/vim navigation, frontmatter, editor, printing,
|
||||
multiple document windows, recents and Windows activation/associations.
|
||||
A basic reader is not the finished port. Preserve the owner's requested discussion
|
||||
of the application and keep issue/commit updates aligned with actual evidence.
|
||||
+16
-10
@@ -1,13 +1,19 @@
|
||||
# Development Tasks
|
||||
|
||||
- [x] Generate a GUI-only application from the tested AppKit starter using the agreed identity.
|
||||
- [x] Restore published AppKit 0.1.0 packages; build with zero warnings; pass 20 existing core tests and the 100% coverage gate.
|
||||
- [x] Inspect pinned markdownViewr source and capture the full Windows feature requirements in PRODUCT.md.
|
||||
- [x] Generate and publish the GUI-only AppKit application with the approved identity.
|
||||
- [x] Configure/read back repository protections and establish the initial hosted CI baseline.
|
||||
- [x] Inspect pinned markdownViewr source and record the full parity scope in PRODUCT.md.
|
||||
- [x] Implement GFM parsing, passive HTML sanitation and the isolated resource policy (ADR-0002).
|
||||
- [x] Replace the greeting/crash controls with file opening, rendered Markdown and manual reload.
|
||||
- [x] Test physical image containment, including junction escapes, and input size limits.
|
||||
- [x] Update the threat model, renderer attributions and shipped library notices.
|
||||
- [ ] Complete actual browser/desktop acceptance and screenshots for the first reader.
|
||||
- [ ] Discuss the desired first release and Windows-specific behavior with the owner.
|
||||
- [ ] Select and document the renderer and untrusted-document security policy.
|
||||
- [ ] Replace the sample greeting with file opening and rendered Markdown.
|
||||
- [ ] Implement and test all document, theme, navigation, source, find, frontmatter, editor and printing requirements in PRODUCT.md.
|
||||
- [ ] Update the threat model, attribution, screenshots, user documentation and agent guidance as features land.
|
||||
- [ ] Establish hosted repository gates and standards inventory; resolve inherited analyzer instability without suppressing errors.
|
||||
- [ ] Adopt the reviewed/published structured AppKit API and signing builder when available.
|
||||
- [ ] Verify actual signed stable/dev installers and the exact shipped bytes before any release publication.
|
||||
- [ ] Add automatic reload on saves/atomic replacements with preserved scroll and deletion recovery.
|
||||
- [ ] Port Darin's built-in themes; add editable user themes, custom CSS and code font settings.
|
||||
- [ ] Add TOC, section collapse, source pane, synchronized navigation, find and vim keys.
|
||||
- [ ] Add YAML frontmatter modes, safe configured editor launch and printing options.
|
||||
- [ ] Add multiple document windows, recent files and Windows activation/file associations.
|
||||
- [ ] Verify hosted checks on each actual implementation head; scripts baseline ended cancelled.
|
||||
- [ ] Finish standards inventory and reviewed/published AppKit/signing API adoption when available.
|
||||
- [ ] Verify actual signed stable/dev installers and exact shipped bytes before release publication.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-0002: GFM rendering inside an isolated WebView2 document surface
|
||||
|
||||
- **Status:** Proposed; implemented on the viewer topic branch, pending review
|
||||
- **Date:** 2026-10-10
|
||||
- **Decider:** Project maintainer
|
||||
- **Tracking:** [reader milestone #4](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/4)
|
||||
|
||||
## Context
|
||||
|
||||
Darin's document layout needs HTML/CSS, GFM tables, tasks and useful raw HTML.
|
||||
Documents can contain hostile markup, resource paths and links. A Markdown parser
|
||||
alone does not provide a native application security boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Markdig 1.4.0 (BSD-2-Clause) for GFM and HtmlSanitizer 9.2.1039 (MIT) for
|
||||
an explicit passive-markup allowlist. AngleSharp and AngleSharp.Css are their MIT
|
||||
dependencies. Remove the unused sample greeting and CommunityToolkit dependency.
|
||||
Versions and transitive hashes are committed in the NuGet locks.
|
||||
|
||||
Host the sanitized result in WinUI WebView2. Serve an intercepted logical HTTPS
|
||||
document, with a random resource namespace for each open; no local HTTP listener.
|
||||
The document response carries a deny-by-default CSP and no-referrer/no-store headers.
|
||||
Only application-generated CSS is allowed; authored styles, IDs and classes are removed.
|
||||
|
||||
Disable scripts, web messages, host objects, frames, downloads, popups and permission
|
||||
requests. Reject all browser resources except the exact document and allowlisted
|
||||
local image requests. External HTTP(S) navigation requires a real user gesture
|
||||
and opens in Windows' external handler. Other links remain blocked in this slice.
|
||||
|
||||
Decode resource paths once; reject traversal, alternate streams, devices and Windows
|
||||
filename aliases. Before reading an image, resolve its opened handle with
|
||||
GetFinalPathNameByHandleW and compare it against the physical document directory.
|
||||
Read from that same handle, preventing a check/reopen race. Remote images are blocked.
|
||||
Bound documents to 16 Mi characters and individual images to 16 MiB.
|
||||
|
||||
## Options considered
|
||||
|
||||
| Option | Benefit | Cost |
|
||||
| --- | --- | --- |
|
||||
| Markdig, sanitization and WebView2 | Maintained parsing; compatible HTML/CSS surface | Requires explicit browser/filesystem containment |
|
||||
| Parser plus unrestricted HTML | Small initial implementation | Exposes script, resource and native navigation risks |
|
||||
| Entirely native text layout | Avoids an embedded browser | Reimplements tables, themes, print and navigation |
|
||||
|
||||
## Consequences
|
||||
|
||||
The first slice supports reading, a picker, literal launch arguments and manual reload.
|
||||
It does not yet reproduce Darin's themes, syntax colors or navigation. Any later trusted
|
||||
JavaScript or native message bridge requires a revised CSP, origin/nonce/schema checks
|
||||
and tests before enabling it. Custom CSS must not introduce network or file access.
|
||||
|
||||
## Verification and follow-up
|
||||
|
||||
Core regression tests cover GFM, hostile HTML and resource paths at 100% core line
|
||||
coverage. Native adapter tests cover BOM decoding, byte limits, exact image bytes and
|
||||
a real junction pointing outside the document folder. Browser/UI acceptance is a
|
||||
separate gate; these unit tests do not prove browser behavior or release signing.
|
||||
|
||||
- [ ] Review this decision and complete document UI acceptance.
|
||||
- [ ] Add save watching, navigation, themes, source/find and print from PRODUCT.md.
|
||||
- [ ] Verify WebView2 runtime deployment and exact signed installed release artifacts.
|
||||
|
||||
## References
|
||||
|
||||
- [Markdig](https://github.com/xoofx/markdig)
|
||||
- [HtmlSanitizer](https://github.com/mganss/HtmlSanitizer)
|
||||
- [Microsoft WebView2 security guidance](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/security)
|
||||
- [GetFinalPathNameByHandleW](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew)
|
||||
@@ -1,92 +1,60 @@
|
||||
# Threat model: MarkdownViewr
|
||||
|
||||
<!-- K22-SDLC-03. Update in any pull request that changes the attack surface, and review
|
||||
in every MINOR/MAJOR release checklist (K22-SEC-30). Method: data flow + STRIDE. -->
|
||||
- **Version reviewed:** first reader implementation
|
||||
- **Last reviewed:** 2026-10-10
|
||||
- **Method:** data flow and STRIDE; review again for each attack-surface change.
|
||||
|
||||
- **Version reviewed:** template readiness work · **Last reviewed:** 2026-10-10
|
||||
## Product and boundaries
|
||||
|
||||
## 1. What the product is and does
|
||||
A per-user WinUI application reads user-selected local Markdown. AppKit supplies
|
||||
settings, logging, crash handling and Velopack updates. The UI-free core parses and
|
||||
sanitizes Markdown. WebView2 displays the sanitized result through an intercepted
|
||||
HTTPS origin. No application HTTP server or scriptable command-line head exists.
|
||||
|
||||
A Windows desktop application installed per user (no administrator rights) with
|
||||
Velopack, updating itself from its GitHub Releases. It runs entirely on the user's
|
||||
machine: it keeps preferences and logs in the user's profile and has no server
|
||||
component. The template's example feature (a greeting) handles only text the user types.
|
||||
Document text, HTML, links, images and filesystem aliases are untrusted. The user
|
||||
selects the document; this authorizes reading it and passive images within its
|
||||
physical directory. It does not authorize native commands or remote resource access.
|
||||
|
||||
## 2. Actors and components
|
||||
## Interfaces and controls
|
||||
|
||||
| Actor or component | Description | Trust level |
|
||||
| Interface | Control |
|
||||
| --- | --- |
|
||||
| Picker / literal process argument | Read-only bounded snapshot; no shell interpolation |
|
||||
| Markdown / raw HTML | Explicit tag/attribute/scheme allowlist; disabled checkbox inputs; no authored scripts, CSS, IDs or classes |
|
||||
| Browser surface | Scripts, messages, host objects, permissions, downloads, frames and popups disabled; restrictive CSP |
|
||||
| Local images | Per-document URI prefix; one decode; filename/extension policy; final open-handle containment; 16 MiB limit |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## Threats and verification
|
||||
|
||||
| Threat | Mitigation and evidence |
|
||||
| --- | --- |
|
||||
| Document script or native-command execution | Sanitizer regressions pass; host disables scripts/bridge; actual browser acceptance pending |
|
||||
| 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; browser network acceptance pending |
|
||||
| Oversized input / image denial of service | Pre-parse character bound and image byte bound; native limit tests pass; performance acceptance remains |
|
||||
| Settings corruption / UI exception | AppKit shell sanitation and crash handling; inherited foundation tests |
|
||||
| Tampered installer/update | Fail-closed signing/provenance/checksum release pipeline; real Azure CI authorization and signed artifacts remain unverified |
|
||||
| Privileged repository review abuse | Trusted review workflow only; current-SHA human acknowledgement remains required |
|
||||
|
||||
## Remaining risks and work
|
||||
|
||||
This is a first reader, not a completed port. User themes, external editor commands,
|
||||
save watching, print and any future browser bridge need their own controls and tests.
|
||||
Local links to other documents and mailto links are currently blocked. WebView2 uses
|
||||
the installed evergreen runtime; installed deployment acceptance remains required.
|
||||
|
||||
Historical EX-0002 does not authorize unsigned production publication. Azure setup
|
||||
and real final-artifact signature validation are deferred; an active profile alone
|
||||
does not establish working release signing.
|
||||
|
||||
## Review log
|
||||
|
||||
| Date | Reviewer | Changes |
|
||||
| --- | --- | --- |
|
||||
| User | The person running the app | Trusted for their own data |
|
||||
| WinUI shell (`MarkdownViewr.Windows.exe`) | Views and startup plumbing | Runs as the user |
|
||||
| Core (`MarkdownViewr.Windows.Core`) | Logic, settings, view models | Runs as the user |
|
||||
| Command line (`markdownviewr-windows.exe`) | Optional scriptable head over Core | Runs as the user |
|
||||
| AppKit packages | Shell services: logging, settings, secrets, updates, splash, About | Third-party code, pinned and attested |
|
||||
| Velopack update service | Checks GitHub Releases, downloads and applies packages | Runs as the user |
|
||||
| GitHub Releases | Update feed and download source | Untrusted network data |
|
||||
|
||||
## 3. Data flow and trust boundaries
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user([User]) --> shell[MarkdownViewr.Windows.exe]
|
||||
user --> cli[markdownviewr-windows.exe]
|
||||
shell --> core[MarkdownViewr.Windows.Core]
|
||||
cli --> core
|
||||
core --> data[("%LOCALAPPDATA%\markdownviewr-windows<br/>settings.json · logs")]
|
||||
shell -- HTTPS --> gh[(GitHub Releases)]
|
||||
subgraph machine [Trust boundary: the user's machine and account]
|
||||
shell
|
||||
cli
|
||||
core
|
||||
data
|
||||
end
|
||||
```
|
||||
|
||||
## 4. External interfaces
|
||||
|
||||
| Interface | Direction | Data | Authenticated? | Validated where? |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Window input (text boxes, menus) | In | User text | The signed-in user | `GreetingService.Greet` (length limit), each feature's Core service |
|
||||
| Command line arguments | In | Command, options | The signed-in user | `CliApp.Run` (unknown commands and options rejected) |
|
||||
| `settings.json` | In/out | Preferences | File ACLs of the user profile | `SettingsStore` sanitizes on load; corrupt files fall back to defaults |
|
||||
| Log files | Out | Operational events, no secrets | File ACLs | Logging API takes messages only |
|
||||
| Data-folder environment variable | In | A folder path | The signed-in user | Used as a path only |
|
||||
| Update feed (GitHub Releases over HTTPS) | In | Release metadata and packages | TLS; package hashes checked by Velopack | Velopack |
|
||||
| Windows Credential Manager | In/out | Secrets, if a feature stores any | The user's Windows login | AppKit `CredentialManagerVault` |
|
||||
|
||||
## 5. Assets
|
||||
|
||||
The integrity of the installed program and its updates (an attacker who controls them
|
||||
runs code as the user), the user's preferences and any stored secrets, and availability
|
||||
of the app.
|
||||
|
||||
## 6. Threats (STRIDE)
|
||||
|
||||
| # | Boundary / component | Category | Threat | Likelihood | Impact | Mitigation | Status |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| T1 | Update feed | Tampering | A malicious update is served to installed copies | Low | Critical | Updates only from this repository's immutable, attested GitHub Releases over HTTPS; Velopack verifies package hashes; Authenticode signing once EX-0002 closes | Mitigated (signing pending) |
|
||||
| T2 | Download | Spoofing | A tampered installer on a mirror | Medium | High | README verification steps: `SHA256SUMS`, `gh attestation verify`, Authenticode | Mitigated |
|
||||
| T3 | Settings file | Tampering | A crafted `settings.json` crashes the app or sets invalid values | Low | Low | Source-generated deserialization, `Sanitize` on load, defaults on failure | Mitigated |
|
||||
| T4 | Logs | Information disclosure | Secrets or personal data written to logs | Low | Medium | Log calls take plain messages; secrets go only to Credential Manager (K22-SEC-71); review | Mitigated |
|
||||
| T5 | Install folder | Elevation of privilege | DLL planting next to the exe | Low | High | Per-user install under `%LOCALAPPDATA%`, owned by the user; no loading from the working directory | Mitigated |
|
||||
| T6 | Shell | Denial of service | An unhandled exception closes the app with unsaved work | Medium | Low | AppKit crash net logs and survives UI exceptions; one-dialog-at-a-time guard | Mitigated |
|
||||
| T7 | Input | Denial of service | Very long input slows or breaks the UI | Low | Low | Length limits in Core services | Mitigated |
|
||||
| T8 | Repository administration | Tampering | Setup weakens protections, trusts another check producer or targets another repository | Low | High | Offline preview; exact public target/admin checks; verify official Actions identity and every context binding in apply/readback; current-PR checks; refuse conflicting rules and broad release refs; preserve reviewed plan | Local regression tests pass; live bootstrap pending |
|
||||
| T9 | PR review workflow | Elevation of privilege | Untrusted PR code runs with a checks write token | Low | High | Comment-triggered workflow executes trusted workflow SHA only; no PR checkout; human current-SHA acknowledgement required | Local tests pass; hosted enforcement pending |
|
||||
| T10 | Package-manager distributions | Tampering | Manifest points at wrong bytes or an unsafe executable | Low | High | Stable-only generation; ZIP identity/layout checks; actual SHA256 and provenance verification; exclude helper shims; candidate review before feed publication | Local tests and real archive/schema checks pass; feed lifecycle pending |
|
||||
| T11 | Installed update smoke | Tampering | A test overwrites a real app or deletes unrelated files | Low | Medium | Unique test package ID; fresh artifacts-only output; app identity check; literal arguments; offline URL allowlist; exact process path; root/manifest checks before uninstall | Local runtime/boundary tests pass; hosted CI pending |
|
||||
|
||||
## 7. Accepted risks
|
||||
|
||||
- EX-0002 tracks historical unsigned Windows release risk. The current application
|
||||
release preflight rejects unsigned production publication. Verified deployment,
|
||||
scoped CI authorization and final-artifact signature acceptance remain required;
|
||||
completed publisher validation alone does not prove CI signing.
|
||||
|
||||
## 8. Review log
|
||||
|
||||
| Date | Version | Reviewer | Changes |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-09 | template baseline | maintainer | Initial model from the Windows desktop reference architecture |
|
||||
| 2026-10-10 | repository setup | Codex (GPT-6) | Added administration and review workflow trust boundaries; live proof pending |
|
||||
| 2026-10-10 | check producer readback | Codex (GPT-6) | Verify official Actions identity/bindings without mutating caller plans; clarify signing gate versus historical risk |
|
||||
| 2026-10-10 | Codex (GPT-6) | Replaced template-only interfaces with actual document and browser boundaries; recorded separate unit/native/browser evidence |
|
||||
@@ -22,7 +22,7 @@ public static class AppIdentity
|
||||
{
|
||||
Id = "markdownviewr-windows",
|
||||
DisplayName = "MarkdownViewr",
|
||||
Tagline = "Built on the KofTwentyTwo Windows app template",
|
||||
Tagline = "A Windows Markdown reader",
|
||||
Description = "A Windows Markdown viewer with live reload, themes and document navigation.",
|
||||
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/MarkdownViewr.Windows"),
|
||||
Author = "James Maes",
|
||||
@@ -38,7 +38,8 @@ public static class AppIdentity
|
||||
Attributions =
|
||||
[
|
||||
new("KofTwentyTwo.AppKit", "MIT License"),
|
||||
new("CommunityToolkit.Mvvm", "MIT License"),
|
||||
new("Markdig", "BSD-2-Clause License"),
|
||||
new("HtmlSanitizer / AngleSharp / AngleSharp.Css", "MIT License"),
|
||||
new("Velopack", "MIT License"),
|
||||
new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"),
|
||||
new(".NET Runtime", "MIT License"),
|
||||
|
||||
@@ -8,22 +8,7 @@ using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The app's preferences: AppKit's shell settings (theme, splash, update checks) plus the
|
||||
/// app's own. Add properties here, repair them in <see cref="Sanitize"/>, and they are
|
||||
/// saved by <see cref="AppIdentity.SettingsStore"/>.
|
||||
/// </summary>
|
||||
/// <summary>Persistent AppKit shell preferences; document preferences will extend this type.</summary>
|
||||
public sealed class AppSettings : ShellSettings
|
||||
{
|
||||
/// <summary>The name last greeted, offered again at the next start.</summary>
|
||||
public string LastName { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>Repairs the shell settings, then restores a missing name (JSON can supply null).</summary>
|
||||
public override void Sanitize()
|
||||
{
|
||||
base.Sanitize();
|
||||
LastName ??= "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>Limits browser resource requests to passive images belonging to one opened document.</summary>
|
||||
public sealed class DocumentResourcePolicy
|
||||
{
|
||||
private static readonly HashSet<string> s_imageExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".avif", ".ico",
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates an isolated logical resource namespace for this document; no HTTP server is started.</summary>
|
||||
public DocumentResourcePolicy()
|
||||
{
|
||||
_prefix = $"/documents/{Guid.NewGuid():N}/";
|
||||
DocumentUri = new Uri("https://viewer.markdownviewr.invalid" + _prefix + "index.html");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The only top-level document URI the browser host may serve.</summary>
|
||||
public Uri DocumentUri { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a decoded relative image path, or denies the request before filesystem access.</summary>
|
||||
public string? GetImageRelativePath(Uri request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if(!request.IsAbsoluteUri || !string.Equals(request.Scheme, "https", StringComparison.Ordinal) || !string.Equals(request.Host, DocumentUri.Host, StringComparison.Ordinal) || request.Port != 443 ||
|
||||
request.UserInfo.Length != 0 || !request.AbsolutePath.StartsWith(_prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string relative = Uri.UnescapeDataString(request.AbsolutePath[_prefix.Length..]);
|
||||
if(!s_imageExtensions.Contains(Path.GetExtension(relative)) || relative.Split('/').Any(segment => !IsSafeSegment(segment)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rejects traversal, device names, separators, controls and Windows filename aliases.</summary>
|
||||
private static bool IsSafeSegment(string segment)
|
||||
{
|
||||
return segment.Length > 0 && segment is not ("." or "..") && !segment.EndsWith(' ') && !segment.EndsWith('.') &&
|
||||
!segment.Any(character => char.IsControl(character) || "<>:\\|?*".Contains(character, StringComparison.Ordinal)) &&
|
||||
!s_deviceNames.Contains(segment.Split('.')[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Html.Dom;
|
||||
using Ganss.Xss;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.EmphasisExtras;
|
||||
using Markdig.Extensions.Tables;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
/// <summary>Renders GitHub Flavored Markdown into passive HTML for the isolated document surface.</summary>
|
||||
public sealed class MarkdownRenderer
|
||||
{
|
||||
/// <summary>Bounds parser work before an opened document can consume unbounded memory.</summary>
|
||||
public const int MaximumCharacters = 16 * 1024 * 1024;
|
||||
|
||||
private static readonly MarkdownPipeline s_pipeline = new MarkdownPipelineBuilder()
|
||||
.UsePipeTables(new PipeTableOptions { UseGfmRules = true })
|
||||
.UseTaskLists()
|
||||
.UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
|
||||
.UseAutoLinks()
|
||||
.Build();
|
||||
|
||||
private readonly HtmlSanitizer _sanitizer;
|
||||
|
||||
|
||||
|
||||
/// <summary>Allows document formatting without scripts, CSS, forms or native host capabilities.</summary>
|
||||
public MarkdownRenderer()
|
||||
{
|
||||
_sanitizer = new HtmlSanitizer();
|
||||
_sanitizer.AllowedTags.Clear();
|
||||
_sanitizer.AllowedTags.UnionWith(["a", "abbr", "b", "blockquote", "br", "caption", "code", "dd", "del", "details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "kbd", "li", "ol", "p", "pre", "s", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]);
|
||||
_sanitizer.AllowedAttributes.Clear();
|
||||
_sanitizer.AllowedAttributes.UnionWith(["href", "src", "alt", "title", "align", "colspan", "rowspan", "width", "height", "type", "checked", "disabled"]);
|
||||
_sanitizer.AllowedSchemes.Clear();
|
||||
_sanitizer.AllowedSchemes.UnionWith(["http", "https", "mailto"]);
|
||||
_sanitizer.AllowedCssProperties.Clear();
|
||||
_sanitizer.AllowedAtRules.Clear();
|
||||
_sanitizer.PostProcessNode += MakeInputsPassive;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Preserves useful markup while sanitizing every parser result, including authored raw HTML.</summary>
|
||||
public string Render(string markdown)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(markdown);
|
||||
if(markdown.Length > MaximumCharacters)
|
||||
{
|
||||
throw new ArgumentException("The document exceeds the 16 Mi-character rendering limit.", nameof(markdown));
|
||||
}
|
||||
return _sanitizer.Sanitize(Markdown.ToHtml(markdown, s_pipeline));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Task markers are display-only checkboxes; authored inputs cannot become active controls.</summary>
|
||||
private static void MakeInputsPassive(object? sender, PostProcessNodeEventArgs args)
|
||||
{
|
||||
if(args.Node is IHtmlInputElement input)
|
||||
{
|
||||
input.Type = "checkbox";
|
||||
input.IsDisabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Greetings;
|
||||
|
||||
/// <summary>
|
||||
/// The template's example feature: a greeting that depends on the time of day. It shows
|
||||
/// the shape every feature takes: UI-free logic in Core, the clock injected through
|
||||
/// <see cref="TimeProvider"/> so tests are deterministic (K22-TEST-20), and input
|
||||
/// validated where it enters (K22-CODE-22). Replace it with the app's real features.
|
||||
/// </summary>
|
||||
public sealed class GreetingService
|
||||
{
|
||||
/// <summary>Longest name accepted, so a pasted document cannot become a greeting.</summary>
|
||||
public const int MaxNameLength = 100;
|
||||
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the service over <paramref name="time"/>; the system clock when null.</summary>
|
||||
public GreetingService(TimeProvider? time = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// "Good morning, Ada!" (or afternoon, or evening, by local time). A blank name greets
|
||||
/// "there".
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">The name is longer than <see cref="MaxNameLength"/>.</exception>
|
||||
public string Greet(string? name)
|
||||
{
|
||||
string trimmed = name?.Trim() ?? "";
|
||||
if(trimmed.Length > MaxNameLength)
|
||||
{
|
||||
throw new ArgumentException($"A name can be at most {MaxNameLength} characters.", nameof(name));
|
||||
}
|
||||
|
||||
return $"{PartOfDay()}, {(trimmed.Length == 0 ? "there" : trimmed)}!";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Morning before noon, afternoon before six, evening after.</summary>
|
||||
private string PartOfDay()
|
||||
{
|
||||
int hour = _time.GetLocalNow().Hour;
|
||||
return hour switch
|
||||
{
|
||||
< 12 => "Good morning",
|
||||
< 18 => "Good afternoon",
|
||||
_ => "Good evening",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Greetings;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation state for the greeting screen. It lives in Core, not in the WinUI project,
|
||||
/// so it is tested headlessly; the window binds to it with x:Bind and holds no logic.
|
||||
/// </summary>
|
||||
public sealed partial class GreetingViewModel : ObservableObject
|
||||
{
|
||||
private readonly GreetingService _greetings;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly SettingsStore<AppSettings> _store;
|
||||
private readonly IActivityLog _log;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the view model, starting from the name saved last time.</summary>
|
||||
public GreetingViewModel(GreetingService greetings, AppSettings settings, SettingsStore<AppSettings> store, IActivityLog log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(greetings);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
_greetings = greetings;
|
||||
_settings = settings;
|
||||
_store = store;
|
||||
_log = log;
|
||||
Name = settings.LastName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The name typed by the user.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string Name { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>The greeting shown, or the reason the name was rejected.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string Message { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
/// <summary>Greets <see cref="Name"/>, remembers it for next time, and logs the event.</summary>
|
||||
[RelayCommand]
|
||||
private void Greet()
|
||||
{
|
||||
try
|
||||
{
|
||||
Message = _greetings.Greet(Name);
|
||||
}
|
||||
catch(ArgumentException ex)
|
||||
{
|
||||
Message = ex.Message;
|
||||
_log.Warning($"Greeting rejected: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.LastName = Name.Trim();
|
||||
if(!_store.Save(_settings))
|
||||
{
|
||||
_log.Warning("Could not save the last greeted name.");
|
||||
}
|
||||
_log.Info("Greeted the user.");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="KofTwentyTwo.AppKit" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,4 +18,9 @@
|
||||
<InternalsVisibleTo Include="MarkdownViewr.Windows.Core.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="HtmlSanitizer" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -2,11 +2,15 @@
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"HtmlSanitizer": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
"requested": "[9.2.1039, )",
|
||||
"resolved": "9.2.1039",
|
||||
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "1.7.2",
|
||||
"AngleSharp.Css": "1.0.2"
|
||||
}
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "Direct",
|
||||
@@ -14,6 +18,12 @@
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Markdig": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.4.0, )",
|
||||
"resolved": "1.4.0",
|
||||
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
|
||||
},
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.296, )",
|
||||
@@ -25,6 +35,19 @@
|
||||
"requested": "[10.0.12, )",
|
||||
"resolved": "10.0.12",
|
||||
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
|
||||
},
|
||||
"AngleSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.7.2",
|
||||
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
|
||||
},
|
||||
"AngleSharp.Css": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.2",
|
||||
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "[1.5.0, 2.0.0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/win-arm64": {},
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Hosts passive Markdown without network access, authored scripts or native capabilities.</summary>
|
||||
internal sealed class DocumentBrowser
|
||||
{
|
||||
private const string SecurityHeaders = "Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nCache-Control: no-store";
|
||||
private const string Styles = """
|
||||
:root { color-scheme: light dark; font: 16px/1.65 system-ui, sans-serif; }
|
||||
body { max-width: 900px; margin: 0 auto; padding: 32px; overflow-wrap: anywhere; }
|
||||
h1,h2,h3,h4,h5,h6 { line-height: 1.25; margin-top: 1.5em; }
|
||||
pre { padding: 16px; overflow: auto; background: light-dark(#f3f4f6,#22272e); border-radius: 6px; }
|
||||
code,kbd { font-family: Consolas, monospace; }
|
||||
table { border-collapse: collapse; } th,td { border: 1px solid #888; padding: 6px 12px; }
|
||||
blockquote { margin-left: 0; padding-left: 20px; border-left: 4px solid #888; }
|
||||
img { max-width: 100%; height: auto; } a { color: light-dark(#0969da,#58a6ff); }
|
||||
""";
|
||||
|
||||
private readonly WebView2 _view;
|
||||
private readonly Action<string> _status;
|
||||
private CoreWebView2Environment? _environment;
|
||||
private Core.Documents.DocumentResourcePolicy _policy = new();
|
||||
private DocumentFiles? _files;
|
||||
private byte[] _html = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Attaches a native status callback; the document itself cannot invoke host commands.</summary>
|
||||
public DocumentBrowser(WebView2 view, Action<string> status)
|
||||
{
|
||||
_view = view;
|
||||
_status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates an isolated per-user browser profile and installs deny-by-default handlers before navigation.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, Path.Combine(AppIdentity.Paths.DataRoot, "WebView2"), null);
|
||||
await _view.EnsureCoreWebView2Async(_environment);
|
||||
CoreWebView2 core = _view.CoreWebView2;
|
||||
core.Settings.IsScriptEnabled = false;
|
||||
core.Settings.AreHostObjectsAllowed = false;
|
||||
core.Settings.IsWebMessageEnabled = false;
|
||||
core.Settings.AreDefaultScriptDialogsEnabled = false;
|
||||
core.Settings.AreDevToolsEnabled = false;
|
||||
core.Settings.AreDefaultContextMenusEnabled = false;
|
||||
core.PermissionRequested += (_, args) => args.State = CoreWebView2PermissionState.Deny;
|
||||
core.DownloadStarting += (_, args) => args.Cancel = true;
|
||||
core.NewWindowRequested += (_, args) => args.Handled = true;
|
||||
core.FrameNavigationStarting += (_, args) => args.Cancel = true;
|
||||
core.NavigationStarting += NavigationStarting;
|
||||
core.NavigationCompleted += (_, args) =>
|
||||
{
|
||||
if(!args.IsSuccess)
|
||||
{
|
||||
_status("The document surface could not be displayed.");
|
||||
}
|
||||
};
|
||||
core.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All, CoreWebView2WebResourceRequestSourceKinds.All);
|
||||
core.WebResourceRequested += ResourceRequested;
|
||||
Show(null, "<h1>MarkdownViewr</h1><p>Choose File → Open to read a Markdown document.</p>", "MarkdownViewr");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Replaces the document capability and navigates to an intercepted HTTPS response, with no HTTP server.</summary>
|
||||
public void Show(DocumentFiles? files, string sanitizedHtml, string title)
|
||||
{
|
||||
_files = files;
|
||||
_policy = new Core.Documents.DocumentResourcePolicy();
|
||||
_html = Encoding.UTF8.GetBytes($"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{WebUtility.HtmlEncode(title)}</title><style>{Styles}</style></head><body>{sanitizedHtml}</body></html>""");
|
||||
_view.CoreWebView2.Navigate(_policy.DocumentUri.AbsoluteUri);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Allows only the current document and fragment navigation; external links require an actual user gesture.</summary>
|
||||
private async void NavigationStarting(CoreWebView2 sender, CoreWebView2NavigationStartingEventArgs args)
|
||||
{
|
||||
if(Uri.TryCreate(args.Uri, UriKind.Absolute, out Uri? uri) &&
|
||||
string.Equals(uri.GetLeftPart(UriPartial.Path), _policy.DocumentUri.AbsoluteUri, StringComparison.Ordinal) &&
|
||||
uri.Query.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
args.Cancel = true;
|
||||
if(args.IsUserInitiated && uri is not null && uri.UserInfo.Length == 0 &&
|
||||
!string.Equals(uri.Host, _policy.DocumentUri.Host, StringComparison.OrdinalIgnoreCase) && uri.Scheme is "https" or "http")
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!await global::Windows.System.Launcher.LaunchUriAsync(uri))
|
||||
{
|
||||
_status("Windows could not open this link.");
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(exception is ArgumentException or COMException)
|
||||
{
|
||||
_status("Windows could not open this link.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_status("This link is outside the current document.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Every browser resource receives either the document, a verified local image, or an empty denial response.</summary>
|
||||
private void ResourceRequested(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args)
|
||||
{
|
||||
byte[] bytes = [];
|
||||
string type = "text/plain";
|
||||
int status = 403;
|
||||
if(string.Equals(args.Request.Method, "GET", StringComparison.Ordinal) && Uri.TryCreate(args.Request.Uri, UriKind.Absolute, out Uri? uri))
|
||||
{
|
||||
if(uri == _policy.DocumentUri && args.ResourceContext == CoreWebView2WebResourceContext.Document)
|
||||
{
|
||||
bytes = _html;
|
||||
type = "text/html; charset=utf-8";
|
||||
status = 200;
|
||||
}
|
||||
else if(_files is not null && args.ResourceContext == CoreWebView2WebResourceContext.Image &&
|
||||
_policy.GetImageRelativePath(uri) is string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = _files.ReadImage(path);
|
||||
type = Path.GetExtension(path).ToUpperInvariant() switch
|
||||
{
|
||||
".SVG" => "image/svg+xml",
|
||||
".JPG" or ".JPEG" => "image/jpeg",
|
||||
".PNG" => "image/png",
|
||||
".GIF" => "image/gif",
|
||||
".WEBP" => "image/webp",
|
||||
".BMP" => "image/bmp",
|
||||
".AVIF" => "image/avif",
|
||||
_ => "image/x-icon",
|
||||
};
|
||||
status = 200;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
_status("One or more local images could not be loaded.");
|
||||
}
|
||||
}
|
||||
}
|
||||
var stream = new MemoryStream(bytes, writable: false);
|
||||
args.Response = _environment!.CreateWebResourceResponse(stream.AsRandomAccessStream(), status, status == 200 ? "OK" : "Forbidden", $"Content-Type: {type}\r\n{SecurityHeaders}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Reads a bounded document snapshot and serves images through verified open file handles.</summary>
|
||||
internal sealed partial class DocumentFiles
|
||||
{
|
||||
private const int MaximumImageBytes = 16 * 1024 * 1024;
|
||||
private readonly string _directory;
|
||||
private readonly string _physicalRoot;
|
||||
|
||||
|
||||
|
||||
/// <summary>Retains the selected directory and the physical root of the document actually opened.</summary>
|
||||
private DocumentFiles(string path, string physicalPath, string markdown)
|
||||
{
|
||||
_directory = Path.GetDirectoryName(path)!;
|
||||
_physicalRoot = Path.GetDirectoryName(physicalPath)! + Path.DirectorySeparatorChar;
|
||||
Markdown = markdown;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The bounded snapshot parsed into the document surface.</summary>
|
||||
public string Markdown { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Loads at most the parser limit plus one character, using the same handle for identity and contents.</summary>
|
||||
public static DocumentFiles Open(string path)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
using SafeFileHandle handle = File.OpenHandle(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
string physicalPath = GetPhysicalPath(handle);
|
||||
using var stream = new FileStream(handle, FileAccess.Read);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
char[] buffer = new char[Core.Documents.MarkdownRenderer.MaximumCharacters + 1];
|
||||
int count = reader.ReadBlock(buffer, 0, buffer.Length);
|
||||
if(count > Core.Documents.MarkdownRenderer.MaximumCharacters)
|
||||
{
|
||||
throw new IOException("This document exceeds the 16 Mi-character limit.");
|
||||
}
|
||||
return new DocumentFiles(fullPath, physicalPath, new string(buffer, 0, count));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Checks the final image handle against the physical document root before reading any image bytes.</summary>
|
||||
public byte[] ReadImage(string relativePath)
|
||||
{
|
||||
using SafeFileHandle handle = File.OpenHandle(Path.Combine(_directory, relativePath), FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
if(!GetPhysicalPath(handle).StartsWith(_physicalRoot, StringComparison.Ordinal))
|
||||
{
|
||||
throw new IOException("This image resolves outside the document folder.");
|
||||
}
|
||||
using var stream = new FileStream(handle, FileAccess.Read);
|
||||
if(stream.Length > MaximumImageBytes)
|
||||
{
|
||||
throw new IOException("This image exceeds the 16 MiB limit.");
|
||||
}
|
||||
byte[] bytes = new byte[checked((int)stream.Length)];
|
||||
stream.ReadExactly(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Uses Windows normalization on the open handle; aliases and reparse points are resolved before access.</summary>
|
||||
private static unsafe string GetPhysicalPath(SafeFileHandle handle)
|
||||
{
|
||||
const int Capacity = 32768;
|
||||
char* buffer = stackalloc char[Capacity];
|
||||
uint count = GetFinalPathNameByHandle(handle, buffer, Capacity, 0);
|
||||
if(count == 0 || count >= Capacity)
|
||||
{
|
||||
throw new IOException("Could not verify the opened file's physical location.", Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()));
|
||||
}
|
||||
return new string(buffer, 0, checked((int)count));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Retrieves the normalized DOS path of an already opened file, including resolved reparse points.</summary>
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true)]
|
||||
private static unsafe partial uint GetFinalPathNameByHandle(SafeFileHandle handle, char* path, uint capacity, uint flags);
|
||||
}
|
||||
@@ -12,10 +12,18 @@
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<MenuBar>
|
||||
<MenuBarItem Title="File">
|
||||
<MenuFlyoutItem x:Name="OpenMenuItem" Text="Open..." Click="Open_Click">
|
||||
<MenuFlyoutItem.KeyboardAccelerators>
|
||||
<KeyboardAccelerator Key="O" Modifiers="Control" />
|
||||
</MenuFlyoutItem.KeyboardAccelerators>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem x:Name="ReloadMenuItem" Text="Reload" Click="Reload_Click" IsEnabled="False" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="View">
|
||||
@@ -34,18 +42,7 @@
|
||||
</MenuBarItem>
|
||||
</MenuBar>
|
||||
|
||||
<!-- The example feature, bound to GreetingViewModel in MarkdownViewr.Windows.Core. Replace it with
|
||||
the app's real content. -->
|
||||
<StackPanel Grid.Row="1" Spacing="12" Padding="32" MaxWidth="560" HorizontalAlignment="Left">
|
||||
<TextBlock Text="MarkdownViewr" Style="{ThemeResource TitleTextBlockStyle}" />
|
||||
<TextBlock TextWrapping="Wrap" Text="A Windows Markdown viewer with live reload, themes and document navigation." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox x:Name="NameBox" Width="260" PlaceholderText="Your name" Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button x:Name="GreetButton" Content="Greet" Command="{x:Bind ViewModel.GreetCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="GreetingText" Text="{x:Bind ViewModel.Message, Mode=OneWay}" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<Button x:Name="ThrowButton" Content="Throw an unhandled exception" Click="Throw_Click" />
|
||||
<TextBlock x:Name="StatusText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<WebView2 x:Name="DocumentView" Grid.Row="1" Loaded="DocumentView_Loaded" AutomationProperties.Name="Markdown document" />
|
||||
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="16,8" Text="Starting the document surface..." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -7,6 +7,7 @@ using KofTwentyTwo.AppKit.Settings;
|
||||
using KofTwentyTwo.AppKit.Updates;
|
||||
using KofTwentyTwo.AppKit.WinUI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.Storage.Pickers;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
@@ -19,16 +20,20 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
private readonly AppSettings _settings = AppIdentity.SettingsStore.Load();
|
||||
private readonly UpdateCoordinator _updates;
|
||||
private readonly DocumentBrowser _browser;
|
||||
private Task? _browserInitialization;
|
||||
private string? _documentPath;
|
||||
private bool _loaded;
|
||||
|
||||
|
||||
|
||||
/// <summary>Sizes and brands the window, creates the view model, shows the splash, and schedules the quiet startup update check.</summary>
|
||||
/// <summary>Sizes and brands the window, creates the document host, shows the splash, and schedules the quiet startup update check.</summary>
|
||||
public MainWindow()
|
||||
{
|
||||
ViewModel = new GreetingViewModel(new GreetingService(), _settings, AppIdentity.SettingsStore, App.Log);
|
||||
InitializeComponent();
|
||||
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message);
|
||||
Title = AppIdentity.Info.DisplayName;
|
||||
this.ResizeForDpi(900, 600);
|
||||
this.ResizeForDpi(1100, 760);
|
||||
this.SetMinimumSize(640, 420);
|
||||
this.SetAppIcon(AppIdentity.Info);
|
||||
|
||||
@@ -43,7 +48,11 @@ public sealed partial class MainWindow : Window
|
||||
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
|
||||
|
||||
SplashOverlay.ShowOver(RootGrid, AppIdentity.Info, _settings);
|
||||
Closed += (_, _) => LogWindow.CloseCurrent(); // a log-only process would linger otherwise
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
DocumentView.Close();
|
||||
LogWindow.CloseCurrent();
|
||||
};
|
||||
|
||||
if(_settings.CheckForUpdatesOnStartup)
|
||||
{
|
||||
@@ -54,11 +63,6 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>The greeting screen's state; x:Bind targets it.</summary>
|
||||
public GreetingViewModel ViewModel { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the splash to clear (dialogs need the window's XamlRoot), then runs the
|
||||
/// quiet startup update check, which only speaks up when an update exists.
|
||||
@@ -71,6 +75,96 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>Initializes the browser once and opens a document supplied as a literal process argument.</summary>
|
||||
private async void DocumentView_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_loaded = true;
|
||||
_browserInitialization ??= _browser.InitializeAsync();
|
||||
try
|
||||
{
|
||||
await _browserInitialization;
|
||||
StatusText.Text = "Choose File → Open to read a Markdown document.";
|
||||
string[] arguments = Environment.GetCommandLineArgs();
|
||||
if(arguments.Length == 2)
|
||||
{
|
||||
await OpenDocumentAsync(arguments[1]);
|
||||
}
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or System.Runtime.InteropServices.COMException)
|
||||
{
|
||||
StatusText.Text = "The document surface could not start. Check that the Microsoft Edge WebView2 Runtime is installed.";
|
||||
App.Log.Error("The document surface could not start.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Shows the Windows picker in this window; cancellation leaves the current document intact.</summary>
|
||||
private async void Open_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var picker = new FileOpenPicker();
|
||||
WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(this));
|
||||
picker.FileTypeFilter.Add(".md");
|
||||
picker.FileTypeFilter.Add(".markdown");
|
||||
picker.FileTypeFilter.Add(".txt");
|
||||
global::Windows.Storage.StorageFile? file = await picker.PickSingleFileAsync();
|
||||
if(file is not null)
|
||||
{
|
||||
await OpenDocumentAsync(file.Path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reopens the selected file explicitly; automatic save watching will build on this path.</summary>
|
||||
private async void Reload_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(_documentPath is not null)
|
||||
{
|
||||
await OpenDocumentAsync(_documentPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads and parses outside the UI thread; a failed open preserves the previous document.</summary>
|
||||
private async Task OpenDocumentAsync(string path)
|
||||
{
|
||||
OpenMenuItem.IsEnabled = false;
|
||||
ReloadMenuItem.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
_browserInitialization ??= _browser.InitializeAsync();
|
||||
await _browserInitialization;
|
||||
(DocumentFiles files, string html) = await Task.Run(() =>
|
||||
{
|
||||
var document = DocumentFiles.Open(path);
|
||||
return (document, new Core.Documents.MarkdownRenderer().Render(document.Markdown));
|
||||
});
|
||||
_browser.Show(files, html, Path.GetFileName(path));
|
||||
_documentPath = Path.GetFullPath(path);
|
||||
Title = Path.GetFileName(path) + " — " + AppIdentity.Info.DisplayName;
|
||||
StatusText.Text = "Opened " + Path.GetFileName(path);
|
||||
App.Log.Info("Opened a Markdown document.");
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or ArgumentException or System.Runtime.InteropServices.COMException)
|
||||
{
|
||||
StatusText.Text = "Could not open this document: " + exception.Message;
|
||||
App.Log.Warning("Could not open a Markdown document.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
OpenMenuItem.IsEnabled = true;
|
||||
ReloadMenuItem.IsEnabled = _documentPath is not null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>File, Exit.</summary>
|
||||
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
@@ -119,15 +213,6 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>Throws on purpose: proves the crash net logs the exception and the app keeps running. Remove it from a real app.</summary>
|
||||
private void Throw_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
|
||||
throw new InvalidOperationException("Deliberately unhandled exception from the template.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
|
||||
private void Save()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<Platforms>x86;x64;ARM64</Platforms>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<!-- LibraryImport generates the file-handle path verification stub. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<!-- Velopack must run before any XAML starts, so the app supplies its own Main (Program.cs). -->
|
||||
@@ -23,6 +25,7 @@
|
||||
reach the build and publish output, since the unpackaged app has no MSIX step. -->
|
||||
<Content Include="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="..\..\THIRD-PARTY-NOTICES.md" Link="THIRD-PARTY-NOTICES.md" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -41,7 +44,6 @@
|
||||
<!-- The app's own namespaces are global usings: their sort position against other
|
||||
usings depends on the app's name, which the template replaces. -->
|
||||
<Using Include="MarkdownViewr.Windows.Core" />
|
||||
<Using Include="MarkdownViewr.Windows.Core.Greetings" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Unpackaged WinUI publishes omit the app PRI/XBF items (WindowsAppSDK#6720).
|
||||
|
||||
@@ -54,6 +54,19 @@
|
||||
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
|
||||
}
|
||||
},
|
||||
"AngleSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.7.2",
|
||||
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
|
||||
},
|
||||
"AngleSharp.Css": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.2",
|
||||
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "[1.5.0, 2.0.0)"
|
||||
}
|
||||
},
|
||||
"Microsoft.Web.WebView2": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.3719.77",
|
||||
@@ -175,21 +188,32 @@
|
||||
"markdownviewr.windows.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )"
|
||||
"HtmlSanitizer": "[9.2.1039, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )",
|
||||
"Markdig": "[1.4.0, )"
|
||||
}
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"HtmlSanitizer": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
"requested": "[9.2.1039, )",
|
||||
"resolved": "9.2.1039",
|
||||
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "1.7.2",
|
||||
"AngleSharp.Css": "1.0.2"
|
||||
}
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Markdig": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.4.0, )",
|
||||
"resolved": "1.4.0",
|
||||
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
|
||||
}
|
||||
},
|
||||
"net10.0-windows10.0.19041/win-arm64": {
|
||||
|
||||
@@ -49,25 +49,24 @@ public class AppIdentityTests
|
||||
/// <summary>Tests of the app's settings.</summary>
|
||||
public class AppSettingsTests
|
||||
{
|
||||
/// <summary>Sanitize: a null name from JSON becomes empty, and the shell settings are repaired too.</summary>
|
||||
/// <summary>Sanitize: invalid shell preferences fall back to their supported defaults.</summary>
|
||||
[Fact]
|
||||
public void Sanitize_RepairsNullsAndShellSettings()
|
||||
public void Sanitize_RepairsShellSettings()
|
||||
{
|
||||
var settings = new AppSettings { LastName = null!, Theme = "Purple" };
|
||||
var settings = new AppSettings { Theme = "Purple" };
|
||||
settings.Sanitize();
|
||||
Assert.Equal("", settings.LastName);
|
||||
Assert.Equal("System", settings.Theme);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Round trip: the name survives a save and a load.</summary>
|
||||
/// <summary>Round trip: the shell theme survives a save and a load.</summary>
|
||||
[Fact]
|
||||
public void SaveAndLoad_KeepTheName()
|
||||
public void SaveAndLoad_KeepsTheTheme()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
Assert.True(store.Save(new AppSettings { LastName = "Ada" }));
|
||||
Assert.Equal("Ada", store.Load().LastName);
|
||||
Assert.True(store.Save(new AppSettings { Theme = "Dark" }));
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Verifies document image isolation and Windows path rejection before any native file access.</summary>
|
||||
public sealed class DocumentResourcePolicyTests
|
||||
{
|
||||
/// <summary>Each opened document has a distinct, inert HTTPS namespace.</summary>
|
||||
[Fact]
|
||||
public void Constructor_DifferentDocuments_UsesDistinctNamespaces()
|
||||
{
|
||||
var first = new DocumentResourcePolicy();
|
||||
var second = new DocumentResourcePolicy();
|
||||
Assert.NotEqual(first.DocumentUri, second.DocumentUri);
|
||||
Assert.Equal("https", first.DocumentUri.Scheme);
|
||||
Assert.Equal("viewer.markdownviewr.invalid", first.DocumentUri.Host);
|
||||
Assert.EndsWith("/index.html", first.DocumentUri.AbsolutePath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Supported local images retain their relative spelling and are decoded only once.</summary>
|
||||
[Theory]
|
||||
[InlineData("images/photo.PNG", "images/photo.PNG")]
|
||||
[InlineData("images/hello%20world.jpg", "images/hello world.jpg")]
|
||||
[InlineData("images/%252e%252e.png", "images/%2e%2e.png")]
|
||||
[InlineData("diagram.svg#view", "diagram.svg")]
|
||||
[InlineData("image.webp?cache=1", "image.webp")]
|
||||
public void GetImageRelativePath_AllowedImage_ReturnsOnceDecodedPath(string path, string expected)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Equal(expected, policy.GetImageRelativePath(new Uri(policy.DocumentUri, path)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Other origins, ports, credentials and document namespaces have no resource access.</summary>
|
||||
[Theory]
|
||||
[InlineData("http://viewer.markdownviewr.invalid/image.png")]
|
||||
[InlineData("https://example.com/image.png")]
|
||||
[InlineData("https://viewer.markdownviewr.invalid:444/image.png")]
|
||||
[InlineData("https://user@viewer.markdownviewr.invalid/image.png")]
|
||||
[InlineData("file:///C:/private.png")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
public void GetImageRelativePath_ForeignRequest_DeniesAccess(string url)
|
||||
{
|
||||
Assert.Null(new DocumentResourcePolicy().GetImageRelativePath(new Uri(url)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Requests cannot cross into another opened document.</summary>
|
||||
[Fact]
|
||||
public void GetImageRelativePath_OtherDocument_DeniesAccess()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
var other = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(other.DocumentUri, "image.png")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Non-images, traversal, device names, encoded separators and filename aliases are refused.</summary>
|
||||
[Theory]
|
||||
[InlineData("index.html")]
|
||||
[InlineData("script.js")]
|
||||
[InlineData("../private.png")]
|
||||
[InlineData("%2e%2e/private.png")]
|
||||
[InlineData("images/%2e%2e%2f%2e%2e%2fprivate.png")]
|
||||
[InlineData("images/%5cprivate.png")]
|
||||
[InlineData("images/C%3a/private.png")]
|
||||
[InlineData("images/%00private.png")]
|
||||
[InlineData("images/%0aprivate.png")]
|
||||
[InlineData("images/a%3f.png")]
|
||||
[InlineData("images/a%2a.png")]
|
||||
[InlineData("images/a%3c.png")]
|
||||
[InlineData("images/a%7c.png")]
|
||||
[InlineData("images//private.png")]
|
||||
[InlineData("images/folder%20/private.png")]
|
||||
[InlineData("images/folder./private.png")]
|
||||
[InlineData("CON.png")]
|
||||
[InlineData("images/LPT1.png")]
|
||||
[InlineData("images/COM%C2%B9.png")]
|
||||
public void GetImageRelativePath_UnsafePath_DeniesAccess(string path)
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri(policy.DocumentUri, path)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Relative request URIs and absent requests have explicit refusal behavior.</summary>
|
||||
[Fact]
|
||||
public void GetImageRelativePath_RelativeAndNull_RejectsInvalidRequests()
|
||||
{
|
||||
var policy = new DocumentResourcePolicy();
|
||||
Assert.Null(policy.GetImageRelativePath(new Uri("image.png", UriKind.Relative)));
|
||||
Assert.Throws<ArgumentNullException>(() => policy.GetImageRelativePath(null!));
|
||||
}
|
||||
}
|
||||
@@ -3,59 +3,9 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using KofTwentyTwo.AppKit.Logging;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>A clock fixed at one local time, in a UTC zone so local time equals the given time.</summary>
|
||||
internal sealed class FixedTimeProvider(int hour) : TimeProvider
|
||||
{
|
||||
/// <summary>Today at the given hour.</summary>
|
||||
public override DateTimeOffset GetUtcNow() => new(2026, 3, 15, hour, 30, 0, TimeSpan.Zero);
|
||||
|
||||
|
||||
|
||||
/// <summary>UTC, so GetLocalNow returns GetUtcNow unchanged.</summary>
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An activity log that records entries in memory.</summary>
|
||||
internal sealed class RecordingLog : IActivityLog
|
||||
{
|
||||
/// <summary>Entries as "LEVEL message".</summary>
|
||||
public List<string> Entries { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LogDirectory => "";
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string CurrentLogFilePath => "";
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Info(string message) => Entries.Add("INFO " + message);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Warning(string message) => Entries.Add("WARN " + message);
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A per-test temporary directory, deleted on dispose.</summary>
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using KofTwentyTwo.AppKit.Settings;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Tests of the example greeting feature's logic.</summary>
|
||||
public class GreetingServiceTests
|
||||
{
|
||||
/// <summary>Greet: the part of the day follows the local hour, including the boundaries.</summary>
|
||||
[Theory]
|
||||
[InlineData(0, "Good morning, Ada!")]
|
||||
[InlineData(11, "Good morning, Ada!")]
|
||||
[InlineData(12, "Good afternoon, Ada!")]
|
||||
[InlineData(17, "Good afternoon, Ada!")]
|
||||
[InlineData(18, "Good evening, Ada!")]
|
||||
[InlineData(23, "Good evening, Ada!")]
|
||||
public void Greet_FollowsTheTimeOfDay(int hour, string expected)
|
||||
{
|
||||
Assert.Equal(expected, new GreetingService(new FixedTimeProvider(hour)).Greet("Ada"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Greet: a blank or missing name greets "there"; surrounding spaces are trimmed.</summary>
|
||||
[Theory]
|
||||
[InlineData(null, "Good morning, there!")]
|
||||
[InlineData(" ", "Good morning, there!")]
|
||||
[InlineData(" Ada ", "Good morning, Ada!")]
|
||||
public void Greet_HandlesBlankAndPaddedNames(string? name, string expected)
|
||||
{
|
||||
Assert.Equal(expected, new GreetingService(new FixedTimeProvider(9)).Greet(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Greet: a name longer than the limit is rejected.</summary>
|
||||
[Fact]
|
||||
public void Greet_RejectsOverlongNames()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => new GreetingService().Greet(new string('x', GreetingService.MaxNameLength + 1)));
|
||||
Assert.Equal("name", ex.ParamName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Tests of the greeting screen's view model.</summary>
|
||||
public sealed class GreetingViewModelTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _temp = new();
|
||||
private readonly RecordingLog _log = new();
|
||||
|
||||
|
||||
|
||||
/// <summary>Deletes the temporary settings folder.</summary>
|
||||
public void Dispose() => _temp.Dispose();
|
||||
|
||||
|
||||
|
||||
/// <summary>A view model over a settings store in the temporary folder.</summary>
|
||||
private GreetingViewModel Create(AppSettings settings, string? settingsPath = null)
|
||||
{
|
||||
var store = new SettingsStore<AppSettings>(settingsPath ?? _temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
return new GreetingViewModel(new GreetingService(new FixedTimeProvider(20)), settings, store, _log);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Construction: starts from the name saved last time.</summary>
|
||||
[Fact]
|
||||
public void StartsWithTheSavedName()
|
||||
{
|
||||
Assert.Equal("Grace", Create(new AppSettings { LastName = "Grace" }).Name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Greet: shows the greeting, saves the trimmed name, and logs it.</summary>
|
||||
[Fact]
|
||||
public void Greet_ShowsSavesAndLogs()
|
||||
{
|
||||
var settings = new AppSettings();
|
||||
GreetingViewModel model = Create(settings);
|
||||
var changed = new List<string?>();
|
||||
model.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
model.Name = " Ada ";
|
||||
model.GreetCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Good evening, Ada!", model.Message);
|
||||
Assert.Equal("Ada", settings.LastName);
|
||||
Assert.Contains(changed, name => string.Equals(name, nameof(GreetingViewModel.Message), StringComparison.Ordinal));
|
||||
Assert.Equal("Ada", new SettingsStore<AppSettings>(_temp.File("settings.json"), AppSettingsContext.Default.AppSettings).Load().LastName);
|
||||
Assert.Equal(["INFO Greeted the user."], _log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Greet: an overlong name shows the reason, warns, and saves nothing.</summary>
|
||||
[Fact]
|
||||
public void Greet_RejectsAndWarns()
|
||||
{
|
||||
var settings = new AppSettings { LastName = "Kept" };
|
||||
GreetingViewModel model = Create(settings);
|
||||
model.Name = new string('x', GreetingService.MaxNameLength + 1);
|
||||
model.GreetCommand.Execute(null);
|
||||
|
||||
Assert.StartsWith("A name can be at most", model.Message, StringComparison.Ordinal);
|
||||
Assert.Equal("Kept", settings.LastName);
|
||||
Assert.StartsWith("WARN Greeting rejected", Assert.Single(_log.Entries), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Greet: a failed settings save is logged, and the greeting still shows.</summary>
|
||||
[Fact]
|
||||
public void Greet_LogsAFailedSave()
|
||||
{
|
||||
string notADirectory = _temp.File("file");
|
||||
File.WriteAllText(notADirectory, "");
|
||||
GreetingViewModel model = Create(new AppSettings(), Path.Combine(notADirectory, "settings.json"));
|
||||
model.Name = "Ada";
|
||||
model.GreetCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Good evening, Ada!", model.Message);
|
||||
Assert.Equal(["WARN Could not save the last greeted name.", "INFO Greeted the user."], _log.Entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Construction: every dependency is required.</summary>
|
||||
[Fact]
|
||||
public void Constructor_ValidatesArguments()
|
||||
{
|
||||
var store = new SettingsStore<AppSettings>(_temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
|
||||
var service = new GreetingService();
|
||||
var settings = new AppSettings();
|
||||
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(null!, settings, store, _log));
|
||||
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, null!, store, _log));
|
||||
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, settings, null!, _log));
|
||||
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, settings, store, null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using MarkdownViewr.Windows.Core.Documents;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Verifies useful document output and hostile-markup boundaries before browser integration.</summary>
|
||||
public sealed class MarkdownRendererTests
|
||||
{
|
||||
/// <summary>GFM content retains headings, tables, task markers, strikethrough and fenced code.</summary>
|
||||
[Fact]
|
||||
public void Render_GfmDocument_PreservesRequiredFormatting()
|
||||
{
|
||||
const string Markdown = "# Heading\n\n| Name | Value |\n| --- | --- |\n| A | B |\n\n- [x] Done\n- [ ] Pending\n\n~~Old~~\n\n```csharp\nvar x = 1;\n```\n\nhttps://example.com\n";
|
||||
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(Markdown));
|
||||
Assert.Equal("Heading", document.QuerySelector("h1")!.TextContent);
|
||||
Assert.Equal("B", document.QuerySelectorAll("td")[1].TextContent);
|
||||
Assert.Equal("Old", document.QuerySelector("del")!.TextContent);
|
||||
Assert.Contains("var x = 1;", document.QuerySelector("pre code")!.TextContent, StringComparison.Ordinal);
|
||||
Assert.Equal("https://example.com", document.QuerySelector("a")!.GetAttribute("href"));
|
||||
IElement[] inputs = document.QuerySelectorAll("input").ToArray();
|
||||
Assert.Equal(2, inputs.Length);
|
||||
Assert.All(inputs, input => Assert.True(Assert.IsAssignableFrom<IHtmlInputElement>(input).IsDisabled));
|
||||
Assert.True(Assert.IsAssignableFrom<IHtmlInputElement>(inputs[0]).IsChecked);
|
||||
Assert.False(Assert.IsAssignableFrom<IHtmlInputElement>(inputs[1]).IsChecked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Useful authored HTML and relative resources survive without active styling or identity attributes.</summary>
|
||||
[Fact]
|
||||
public void Render_SafeHtml_PreservesContentAndRelativeResources()
|
||||
{
|
||||
const string Markdown = "<details><summary>More</summary><p id='native-host' class='shell' style='color:red'>Content</p></details>\n\n\n\n[Section](#heading)";
|
||||
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(Markdown));
|
||||
Assert.Equal("More", document.QuerySelector("summary")!.TextContent);
|
||||
IElement paragraph = document.QuerySelector("details p")!;
|
||||
Assert.Equal("Content", paragraph.TextContent);
|
||||
Assert.Empty(paragraph.Attributes);
|
||||
Assert.Equal("images/example.png", document.QuerySelector("img")!.GetAttribute("src"));
|
||||
Assert.Equal("#heading", document.QuerySelector("a")!.GetAttribute("href"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Malformed and active markup cannot introduce executable nodes or host-facing attributes.</summary>
|
||||
[Theory]
|
||||
[InlineData("<script>window.chrome.webview.postMessage('run');</script><p onclick='evil()'>Safe</p>")]
|
||||
[InlineData("<svg><script>alert(1)</script><a xlink:href='javascript:evil()'>Bad</a></svg>")]
|
||||
[InlineData("<iframe srcdoc='<script>evil()</script>'></iframe><object data='file:///C:/private'></object>")]
|
||||
[InlineData("<form action='https://example.com'><button>Send</button><input type='password' oninput='evil()'></form>")]
|
||||
[InlineData("<meta http-equiv='refresh' content='0;url=file:///C:/private'><base href='file:///C:/private'>")]
|
||||
[InlineData("<style>@import url(https://example.com);</style><img src='image.png' onerror='evil()' srcset='https://example.com 2x'>")]
|
||||
public void Render_ActiveMarkup_RemovesExecutableElementsAndAttributes(string markdown)
|
||||
{
|
||||
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(markdown));
|
||||
Assert.Empty(document.QuerySelectorAll("script,svg,iframe,object,embed,form,button,style,base,meta[http-equiv]"));
|
||||
foreach(IElement element in document.Body!.QuerySelectorAll("*"))
|
||||
{
|
||||
Assert.DoesNotContain(element.Attributes, attribute => attribute.Name.StartsWith("on", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(element.HasAttribute("srcdoc"));
|
||||
Assert.False(element.HasAttribute("srcset"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Unsafe URI schemes are rejected after HTML entity/control-character decoding.</summary>
|
||||
[Theory]
|
||||
[InlineData("<a href='javascript:alert(1)'>Click</a>")]
|
||||
[InlineData("<a href='JaVaScRiPt:alert(1)'>Click</a>")]
|
||||
[InlineData("<a href='java	script:alert(1)'>Click</a>")]
|
||||
[InlineData("<a href='vbscript:evil()'>Click</a>")]
|
||||
[InlineData("<a href='file:///C:/private'>Click</a>")]
|
||||
[InlineData("<img src='data:image/svg+xml,%3Csvg%20onload=evil()%3E'>")]
|
||||
public void Render_UnsafeUrls_RemovesResourceCapabilities(string markdown)
|
||||
{
|
||||
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render(markdown));
|
||||
Assert.All(document.QuerySelectorAll("a,img"), element =>
|
||||
{
|
||||
Assert.False(element.HasAttribute("href"));
|
||||
Assert.False(element.HasAttribute("src"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Document-authored input types are normalized to passive checkbox markers.</summary>
|
||||
[Fact]
|
||||
public void Render_AuthoredInput_ForcesDisabledCheckbox()
|
||||
{
|
||||
IHtmlDocument document = new HtmlParser().ParseDocument(new MarkdownRenderer().Render("<input type='text' checked>"));
|
||||
IHtmlInputElement input = Assert.IsAssignableFrom<IHtmlInputElement>(document.QuerySelector("input"));
|
||||
Assert.Equal("checkbox", input.Type);
|
||||
Assert.True(input.IsDisabled);
|
||||
Assert.True(input.IsChecked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Missing documents fail explicitly; empty documents render as empty content.</summary>
|
||||
[Fact]
|
||||
public void Render_NullAndEmpty_UsesExplicitContract()
|
||||
{
|
||||
var renderer = new MarkdownRenderer();
|
||||
Assert.Throws<ArgumentNullException>(() => renderer.Render(null!));
|
||||
Assert.Equal("", renderer.Render(""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The size guard rejects over-limit input before parsing it.</summary>
|
||||
[Fact]
|
||||
public void Render_OverLimit_RejectsBeforeParsing()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new MarkdownRenderer().Render(new string('a', MarkdownRenderer.MaximumCharacters + 1)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A shared renderer handles concurrent documents without cross-document state.</summary>
|
||||
[Fact]
|
||||
public async Task Render_ConcurrentDocuments_DoesNotLeakState()
|
||||
{
|
||||
var renderer = new MarkdownRenderer();
|
||||
Task<string>[] tasks = Enumerable.Range(0, 24).Select(index => Task.Run(() => renderer.Render($"# Document {index}"))).ToArray();
|
||||
string[] output = await Task.WhenAll(tasks);
|
||||
for(int index = 0; index < output.Length; index++)
|
||||
{
|
||||
Assert.Equal($"<h1>Document {index}</h1>\n", output[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@
|
||||
<ItemGroup>
|
||||
<!-- The app's own namespaces are global usings: their sort position against other
|
||||
usings depends on the app's name, which the template replaces. -->
|
||||
<Using Include="MarkdownViewr.Windows.Core.Greetings" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -41,6 +41,19 @@
|
||||
"resolved": "4.0.0",
|
||||
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
|
||||
},
|
||||
"AngleSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.7.2",
|
||||
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
|
||||
},
|
||||
"AngleSharp.Css": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.2",
|
||||
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "[1.5.0, 2.0.0)"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.10.1",
|
||||
@@ -102,21 +115,32 @@
|
||||
"markdownviewr.windows.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )"
|
||||
"HtmlSanitizer": "[9.2.1039, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )",
|
||||
"Markdig": "[1.4.0, )"
|
||||
}
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"HtmlSanitizer": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
"requested": "[9.2.1039, )",
|
||||
"resolved": "9.2.1039",
|
||||
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "1.7.2",
|
||||
"AngleSharp.Css": "1.0.2"
|
||||
}
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Markdig": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.4.0, )",
|
||||
"resolved": "1.4.0",
|
||||
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
|
||||
/// <summary>Launches the app against a fresh temporary data folder and waits for its main window.</summary>
|
||||
public AppSession(bool showSplash = false)
|
||||
public AppSession(bool showSplash = false, string? document = null)
|
||||
{
|
||||
ExePath = ResolveExePath();
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), AppIdentity.Info.Id + "-uitests", Guid.NewGuid().ToString("N"));
|
||||
@@ -43,6 +43,12 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
|
||||
startInfo.Environment[AppIdentity.Info.DataDirectoryVariable] = DataDirectory;
|
||||
if(document is not null)
|
||||
{
|
||||
string path = Path.Combine(DataDirectory, "document.md");
|
||||
File.WriteAllText(path, document);
|
||||
startInfo.ArgumentList.Add(path);
|
||||
}
|
||||
|
||||
_dpi = new DpiAwarenessScope();
|
||||
try
|
||||
|
||||
@@ -10,7 +10,7 @@ using Xunit.Abstractions;
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests of the real app: the AppKit shell pieces and the example feature.
|
||||
/// End-to-end tests of the real app: the AppKit shell pieces and the document surface.
|
||||
/// UI tests share one desktop, so they never run in parallel.
|
||||
/// </summary>
|
||||
[Collection("Desktop")]
|
||||
@@ -50,30 +50,6 @@ public class AppTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Crash net: an unhandled exception is logged and the app keeps running.</summary>
|
||||
[Fact]
|
||||
public void UnhandledException_IsLoggedAndTheAppSurvives()
|
||||
{
|
||||
using var session = new AppSession();
|
||||
try
|
||||
{
|
||||
session.MouseClickElement("ThrowButton");
|
||||
_output.WriteLine(session.LastMouseInput);
|
||||
session.WaitFor(
|
||||
() => session.ReadLogs().Contains("Deliberately unhandled exception from the template", StringComparison.Ordinal) ? session.MainWindow : null,
|
||||
"the crash net's log entry");
|
||||
}
|
||||
catch(TimeoutException exception)
|
||||
{
|
||||
AutomationElement? status = session.FindInApp(cf => cf.ByAutomationId("StatusText"));
|
||||
throw new TimeoutException($"{exception.Message} {session.LastMouseInput} Observed status: '{status?.Properties.Name.ValueOrDefault}'. App logs: {session.ReadLogs()}", exception);
|
||||
}
|
||||
Assert.False(session.App.HasExited);
|
||||
Assert.Contains("Unhandled UI exception", session.ReadLogs(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Activity log: opens a window showing the log.</summary>
|
||||
[Fact]
|
||||
public void ActivityLog_OpensAWindowShowingTheLog()
|
||||
@@ -124,33 +100,31 @@ public class AppTests
|
||||
|
||||
|
||||
|
||||
/// <summary>Example feature: greeting a name shows the greeting and saves the name.</summary>
|
||||
/// <summary>The launched document appears in the actual browser accessibility tree.</summary>
|
||||
[Fact]
|
||||
public void Greet_ShowsTheGreetingAndSavesTheName()
|
||||
public void OpenDocument_GfmDocument_ShowsRenderedContent()
|
||||
{
|
||||
using var session = new AppSession();
|
||||
session.EnterText("NameBox", "Ada");
|
||||
session.ClickElement("GreetButton");
|
||||
string? observedGreeting = null;
|
||||
try
|
||||
{
|
||||
session.WaitFor(
|
||||
() =>
|
||||
{
|
||||
AutomationElement? text = session.FindInApp(cf => cf.ByAutomationId("GreetingText"));
|
||||
observedGreeting = text?.Properties.Name.ValueOrDefault;
|
||||
return observedGreeting?.EndsWith(", Ada!", StringComparison.Ordinal) == true ? text : null;
|
||||
},
|
||||
"the greeting");
|
||||
}
|
||||
catch(TimeoutException exception)
|
||||
{
|
||||
throw new TimeoutException($"{exception.Message} Observed greeting: '{observedGreeting}'. App logs: {session.ReadLogs()}", exception);
|
||||
}
|
||||
session.WaitFor(
|
||||
() => session.ReadSettings().Contains("\"LastName\": \"Ada\"", StringComparison.Ordinal) ? session.MainWindow : null,
|
||||
"settings.json to record the name");
|
||||
using var session = new AppSession(document: "# Reader acceptance\n\n| Column | Value |\n| --- | --- |\n| One | Two |\n\n- [x] Finished\n\n<script>document.body.textContent='UNSAFE EXECUTION'</script>");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Reader acceptance")), "the rendered document heading", TimeSpan.FromSeconds(30));
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Two")), "the rendered table cell");
|
||||
Assert.Null(session.FindInApp(cf => cf.ByName("UNSAFE EXECUTION")));
|
||||
Assert.Contains("Opened a Markdown document.", session.ReadLogs(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reload uses the actual source file again instead of preserving an obsolete rendered snapshot.</summary>
|
||||
[Fact]
|
||||
public void Reload_ChangedDocument_ShowsNewContent()
|
||||
{
|
||||
using var session = new AppSession(document: "# Before reload");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Before reload")), "the initial heading", TimeSpan.FromSeconds(30));
|
||||
File.WriteAllText(Path.Combine(session.DataDirectory, "document.md"), "# After reload");
|
||||
session.InvokeMenuItem("File", "ReloadMenuItem");
|
||||
session.WaitFor(() => session.FindInApp(cf => cf.ByName("After reload")), "the reloaded heading");
|
||||
Assert.Null(session.FindInApp(cf => cf.ByName("Before reload")));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <summary>Exercises bounded snapshots and physical resource containment without launching the GUI.</summary>
|
||||
public sealed class DocumentFilesTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "markdownviewr-files-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates a private document folder and a sibling outside the resource boundary.</summary>
|
||||
public DocumentFilesTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "document"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "outside"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>File loading honors the BOM and reads the selected document snapshot.</summary>
|
||||
[Fact]
|
||||
public void Open_UnicodeDocument_ReadsTheSnapshot()
|
||||
{
|
||||
string path = Path.Combine(_root, "document", "read me.md");
|
||||
File.WriteAllText(path, "# Café", Encoding.Unicode);
|
||||
var files = DocumentFiles.Open(path);
|
||||
Assert.Equal("# Café", files.Markdown);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Ordinary relative images inside the selected folder retain their exact bytes.</summary>
|
||||
[Fact]
|
||||
public void ReadImage_InsideDocumentFolder_ReturnsExactBytes()
|
||||
{
|
||||
DocumentFiles files = CreateDocument();
|
||||
byte[] expected = [1, 2, 3, 4];
|
||||
File.WriteAllBytes(Path.Combine(_root, "document", "image.png"), expected);
|
||||
Assert.Equal(expected, files.ReadImage("image.png"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Physical containment also rejects traversal if a caller bypasses the earlier URI policy.</summary>
|
||||
[Fact]
|
||||
public void ReadImage_OutsideDocumentFolder_ThrowsBeforeReading()
|
||||
{
|
||||
DocumentFiles files = CreateDocument();
|
||||
File.WriteAllBytes(Path.Combine(_root, "outside", "secret.png"), [1, 2, 3]);
|
||||
Assert.Throws<IOException>(() => files.ReadImage(Path.Combine("..", "outside", "secret.png")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An allowed-looking image path cannot use a directory junction to escape the document root.</summary>
|
||||
[Fact]
|
||||
public void ReadImage_JunctionOutsideDocumentFolder_ThrowsBeforeReading()
|
||||
{
|
||||
DocumentFiles files = CreateDocument();
|
||||
string link = Path.Combine(_root, "document", "images");
|
||||
File.WriteAllBytes(Path.Combine(_root, "outside", "secret.png"), [1, 2, 3]);
|
||||
var start = new ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true };
|
||||
start.ArgumentList.Add("/c");
|
||||
start.ArgumentList.Add("mklink");
|
||||
start.ArgumentList.Add("/J");
|
||||
start.ArgumentList.Add(link);
|
||||
start.ArgumentList.Add(Path.Combine(_root, "outside"));
|
||||
using Process process = Process.Start(start)!;
|
||||
Assert.True(process.WaitForExit(10000), "Junction creation did not finish.");
|
||||
Assert.Equal(0, process.ExitCode);
|
||||
try
|
||||
{
|
||||
Assert.Throws<IOException>(() => files.ReadImage(Path.Combine("images", "secret.png")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(link);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A document above the parser's bound is rejected before parsing.</summary>
|
||||
[Fact]
|
||||
public void Open_OverCharacterLimit_Throws()
|
||||
{
|
||||
string path = Path.Combine(_root, "document", "large.md");
|
||||
File.WriteAllText(path, new string('x', Core.Documents.MarkdownRenderer.MaximumCharacters + 1));
|
||||
Assert.Throws<IOException>(() => DocumentFiles.Open(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Oversized images do not allocate an unbounded browser response.</summary>
|
||||
[Fact]
|
||||
public void ReadImage_OverByteLimit_Throws()
|
||||
{
|
||||
DocumentFiles files = CreateDocument();
|
||||
string path = Path.Combine(_root, "document", "large.png");
|
||||
using(var stream = new FileStream(path, FileMode.CreateNew))
|
||||
{
|
||||
stream.SetLength(16 * 1024 * 1024 + 1);
|
||||
}
|
||||
Assert.Throws<IOException>(() => files.ReadImage("large.png"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the source file and returns its established physical resource capability.</summary>
|
||||
private DocumentFiles CreateDocument()
|
||||
{
|
||||
string path = Path.Combine(_root, "document", "read.md");
|
||||
File.WriteAllText(path, "# Document");
|
||||
return DocumentFiles.Open(path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Deletes only the unique temporary folder owned by this test instance.</summary>
|
||||
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
point K22_UITEST_EXE at the exe to test. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,4 +30,9 @@
|
||||
<Using Include="MarkdownViewr.Windows.Core" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exercise the actual native file adapter without loading WinUI or reserving the desktop. -->
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\DocumentFiles.cs" Link="WindowsAdapters\DocumentFiles.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -45,6 +45,19 @@
|
||||
"resolved": "4.0.0",
|
||||
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
|
||||
},
|
||||
"AngleSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.7.2",
|
||||
"contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g=="
|
||||
},
|
||||
"AngleSharp.Css": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.2",
|
||||
"contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "[1.5.0, 2.0.0)"
|
||||
}
|
||||
},
|
||||
"FlaUI.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
@@ -188,21 +201,32 @@
|
||||
"markdownviewr.windows.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )"
|
||||
"HtmlSanitizer": "[9.2.1039, )",
|
||||
"KofTwentyTwo.AppKit": "[0.1.0, )",
|
||||
"Markdig": "[1.4.0, )"
|
||||
}
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"HtmlSanitizer": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[8.4.2, )",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||
"requested": "[9.2.1039, )",
|
||||
"resolved": "9.2.1039",
|
||||
"contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==",
|
||||
"dependencies": {
|
||||
"AngleSharp": "1.7.2",
|
||||
"AngleSharp.Css": "1.0.2"
|
||||
}
|
||||
},
|
||||
"KofTwentyTwo.AppKit": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.1.0, )",
|
||||
"resolved": "0.1.0",
|
||||
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
|
||||
},
|
||||
"Markdig": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.4.0, )",
|
||||
"resolved": "1.4.0",
|
||||
"contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user