From c42e5333e957ffa38e1e09d6bea195f1f18b396e Mon Sep 17 00:00:00 2001 From: kamilkaczmareksolutions Date: Sun, 4 Oct 2026 12:59:10 +0200 Subject: [PATCH 01/13] feat(windows): jail local images to the open document A Windows slice has to show a relative image and refuse a path that leaves the document folder. The decision is a pure function so the tests can name the refusal. Signed-off-by: kamilkaczmareksolutions --- .../ExternalNavigationPolicyTests.cs | 34 ++++ .../ImagePolicyTests.cs | 74 +++++++++ .../MarkdownReaderTests.cs | 21 +++ .../PreviewMD.Windows.Core.Tests.csproj | 17 ++ .../PreviewSessionTests.cs | 73 ++++++++ .../RendererChecksumTests.cs | 63 +++++++ .../PreviewMD.Windows.Core/DocumentSession.cs | 52 ++++++ .../DocumentSessionController.cs | 25 +++ .../ExternalNavigationPolicy.cs | 34 ++++ .../PreviewMD.Windows.Core/ImagePolicy.cs | 156 ++++++++++++++++++ .../PreviewMD.Windows.Core/MarkdownReader.cs | 25 +++ .../PreviewMD.Windows.Core.csproj | 9 + .../PreviewMD.Windows.Core/PreviewSession.cs | 83 ++++++++++ .../PreviewMD.Windows.Core/RenderPayload.cs | 62 +++++++ .../RendererChecksum.cs | 84 ++++++++++ 15 files changed, 812 insertions(+) create mode 100644 ports/windows/PreviewMD.Windows.Core.Tests/ExternalNavigationPolicyTests.cs create mode 100644 ports/windows/PreviewMD.Windows.Core.Tests/ImagePolicyTests.cs create mode 100644 ports/windows/PreviewMD.Windows.Core.Tests/MarkdownReaderTests.cs create mode 100644 ports/windows/PreviewMD.Windows.Core.Tests/PreviewMD.Windows.Core.Tests.csproj create mode 100644 ports/windows/PreviewMD.Windows.Core.Tests/PreviewSessionTests.cs create mode 100644 ports/windows/PreviewMD.Windows.Core.Tests/RendererChecksumTests.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/DocumentSession.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/DocumentSessionController.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/ExternalNavigationPolicy.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/ImagePolicy.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/MarkdownReader.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/PreviewMD.Windows.Core.csproj create mode 100644 ports/windows/PreviewMD.Windows.Core/PreviewSession.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/RenderPayload.cs create mode 100644 ports/windows/PreviewMD.Windows.Core/RendererChecksum.cs diff --git a/ports/windows/PreviewMD.Windows.Core.Tests/ExternalNavigationPolicyTests.cs b/ports/windows/PreviewMD.Windows.Core.Tests/ExternalNavigationPolicyTests.cs new file mode 100644 index 0000000..0cd6270 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/ExternalNavigationPolicyTests.cs @@ -0,0 +1,34 @@ +using PreviewMD.Windows.Core; +using Xunit; + +namespace PreviewMD.Windows.Core.Tests; + +public class ExternalNavigationPolicyTests +{ + [Fact] + public void HttpAndMailtoLaunchExternally() + { + Assert.True(ExternalNavigationPolicy.ShouldLaunchExternally("http://example.com/notes")); + Assert.True(ExternalNavigationPolicy.ShouldLaunchExternally("mailto:adam@jesion.pl")); + Assert.True(ExternalNavigationPolicy.ShouldLaunchExternally("https://example.com/image.png")); + } + + [Fact] + public void AssetsShellFileAndLocalImageDoNotLaunchExternally() + { + Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("https://previewmd.assets/index.html")); + Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("https://previewmd.assets/index.html#section")); + Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("file:///C:/notes/other.md")); + Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("previewmd-local-image://resource?source=pic.png")); + Assert.False(ExternalNavigationPolicy.ShouldLaunchExternally("other.md")); + } + + [Fact] + public void OnlyTheAssetsShellDocumentStays() + { + Assert.True(ExternalNavigationPolicy.IsAssetsShell("https://previewmd.assets/index.html")); + Assert.True(ExternalNavigationPolicy.IsAssetsShell("https://previewmd.assets/index.html#section")); + Assert.False(ExternalNavigationPolicy.IsAssetsShell("https://previewmd.assets/other.md")); + Assert.False(ExternalNavigationPolicy.IsAssetsShell("file:///C:/notes/other.md")); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core.Tests/ImagePolicyTests.cs b/ports/windows/PreviewMD.Windows.Core.Tests/ImagePolicyTests.cs new file mode 100644 index 0000000..c673854 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/ImagePolicyTests.cs @@ -0,0 +1,74 @@ +using PreviewMD.Windows.Core; +using Xunit; + +namespace PreviewMD.Windows.Core.Tests; + +public class ImagePolicyTests +{ + private const string Root = @"C:\previewmd-slice-root\notes"; + + [Fact] + public void ParentTraversalOutsideTheRootEscapesTheJail() + { + var decision = ImagePolicy.Decide("../pic.png", Root, _ => new FileFacts(true, 4, ".png")); + + Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), decision); + } + + [Fact] + public void RelativePngUnderTheRootIsAllowed() + { + var decision = ImagePolicy.Decide( + "pic.png", + Root, + path => path == @"C:\previewmd-slice-root\notes\pic.png" + ? new FileFacts(true, 4, ".png") + : null); + + var allowed = Assert.IsType(decision); + Assert.Equal(@"C:\previewmd-slice-root\notes\pic.png", allowed.Path); + } + + [Fact] + public void EncodedCustomSchemeSourceResolvesUnderTheRoot() + { + var decision = ImagePolicy.Decide( + "previewmd-local-image://resource?source=Renderer%2Fbadges%2Fmacos%2Fbadge.svg", + @"C:\previewmd-slice-root\WebAssets", + _ => new FileFacts(true, 80, ".svg")); + + var allowed = Assert.IsType(decision); + Assert.Equal(@"C:\previewmd-slice-root\WebAssets\Renderer\badges\macos\badge.svg", allowed.Path); + } + + [Fact] + public void SvgBadgeUnderTheRootIsAllowed() + { + var decision = ImagePolicy.Decide( + "Renderer/badges/macos/badge.svg", + @"C:\previewmd-slice-root\WebAssets", + _ => new FileFacts(true, 80, ".svg")); + + var allowed = Assert.IsType(decision); + Assert.Equal(@"C:\previewmd-slice-root\WebAssets\Renderer\badges\macos\badge.svg", allowed.Path); + } + + [Fact] + public void MarkdownExtensionIsUnsupported() + { + var decision = ImagePolicy.Decide("readme.md", Root, _ => new FileFacts(true, 20, ".md")); + + Assert.Equal(new ImageDecision.Refused(ImageRefusal.UnsupportedType), decision); + } + + [Fact] + public void LengthAboveOneHundredMegabytesIsTooLarge() + { + var decision = ImagePolicy.Decide( + "big.png", + Root, + _ => new FileFacts(true, 100L * 1024 * 1024 + 1, ".png")); + + Assert.Equal(new ImageDecision.Refused(ImageRefusal.FileTooLarge), decision); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core.Tests/MarkdownReaderTests.cs b/ports/windows/PreviewMD.Windows.Core.Tests/MarkdownReaderTests.cs new file mode 100644 index 0000000..d05a447 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/MarkdownReaderTests.cs @@ -0,0 +1,21 @@ +using System.Text; +using PreviewMD.Windows.Core; +using Xunit; + +namespace PreviewMD.Windows.Core.Tests; + +public class MarkdownReaderTests +{ + [Fact] + public void ReadStripsTheBomAndNormalizesNewlines() + { + var body = Encoding.UTF8.GetBytes("a\r\nb\rc"); + var bytes = new byte[body.Length + 3]; + bytes[0] = 0xEF; + bytes[1] = 0xBB; + bytes[2] = 0xBF; + body.CopyTo(bytes, 3); + + Assert.Equal("a\nb\nc", MarkdownReader.Read(bytes)); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core.Tests/PreviewMD.Windows.Core.Tests.csproj b/ports/windows/PreviewMD.Windows.Core.Tests/PreviewMD.Windows.Core.Tests.csproj new file mode 100644 index 0000000..48f1080 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/PreviewMD.Windows.Core.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + enable + enable + false + PreviewMD.Windows.Core.Tests + + + + + + + + + + diff --git a/ports/windows/PreviewMD.Windows.Core.Tests/PreviewSessionTests.cs b/ports/windows/PreviewMD.Windows.Core.Tests/PreviewSessionTests.cs new file mode 100644 index 0000000..6c3c37f --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/PreviewSessionTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using PreviewMD.Windows.Core; +using Xunit; + +namespace PreviewMD.Windows.Core.Tests; + +public class PreviewSessionTests +{ + [Fact] + public async Task OpeningASecondFileReplacesTheFirstPath() + { + var directory = Path.Combine(Path.GetTempPath(), "previewmd-replace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var first = Path.Combine(directory, "first.md"); + var second = Path.Combine(directory, "second.md"); + var showcase = Path.Combine(directory, "showcase.md"); + await File.WriteAllTextAsync(first, "first-body"); + await File.WriteAllTextAsync(second, "second-body"); + await File.WriteAllTextAsync(showcase, "showcase-body"); + var renderer = new RecordingRenderer(); + var session = new PreviewSession(renderer, showcase, directory); + + try + { + await session.OpenFileAsync(first); + await session.OpenFileAsync(second); + + var file = Assert.IsType(session.Current); + Assert.Equal(second, file.AbsolutePath); + Assert.Equal("second-body", file.Markdown); + Assert.Equal(directory, file.ImageRoot); + Assert.Equal(2, file.Revision); + Assert.Equal(2, renderer.Payloads.Count); + + using var json = JsonDocument.Parse(RenderPayloadJson.Serialize(renderer.Payloads[1])); + var payload = json.RootElement; + Assert.Equal(second, payload.GetProperty("documentID").GetString()); + Assert.Equal("second-body", payload.GetProperty("markdown").GetString()); + Assert.Equal(2, payload.GetProperty("revision").GetInt32()); + Assert.False(payload.GetProperty("editable").GetBoolean()); + Assert.Equal("system", payload.GetProperty("theme").GetString()); + Assert.Equal("modern", payload.GetProperty("readingStyle").GetString()); + Assert.Equal(JsonValueKind.Null, payload.GetProperty("customReadingPreset").ValueKind); + Assert.False(payload.GetProperty("systemDark").GetBoolean()); + Assert.Equal(820, payload.GetProperty("readingWidth").GetInt32()); + Assert.False(payload.GetProperty("readingWidthIsFluid").GetBoolean()); + Assert.False(payload.GetProperty("paperCanvas").GetBoolean()); + Assert.Equal(1, payload.GetProperty("zoom").GetDouble()); + Assert.Equal("", payload.GetProperty("searchText").GetString()); + Assert.Equal(JsonValueKind.Null, payload.GetProperty("outlineTarget").ValueKind); + Assert.Equal(0, payload.GetProperty("externalChanges").GetArrayLength()); + Assert.Equal(JsonValueKind.Null, payload.GetProperty("externalChangeSelection").ValueKind); + Assert.Equal(0, payload.GetProperty("topInset").GetDouble()); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private sealed class RecordingRenderer : IDocumentRenderer + { + public bool SystemDark => false; + + public List Payloads { get; } = new(); + + public Task RenderAsync(RenderPayload payload, CancellationToken cancellationToken) + { + Payloads.Add(payload); + return Task.CompletedTask; + } + } +} diff --git a/ports/windows/PreviewMD.Windows.Core.Tests/RendererChecksumTests.cs b/ports/windows/PreviewMD.Windows.Core.Tests/RendererChecksumTests.cs new file mode 100644 index 0000000..390133f --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/RendererChecksumTests.cs @@ -0,0 +1,63 @@ +using PreviewMD.Windows.Core; +using Xunit; + +namespace PreviewMD.Windows.Core.Tests; + +public class RendererChecksumTests +{ + [Fact] + public void VerifyThrowsWhenOneCopiedByteDiffers() + { + var root = Directory.CreateTempSubdirectory("previewmd-checksum-"); + var copied = Path.Combine(root.FullName, "renderer"); + var badges = Path.Combine(copied, "badges"); + Directory.CreateDirectory(badges); + var file = Path.Combine(badges, "one.svg"); + File.WriteAllBytes(file, new byte[] { 1, 2, 3, 4 }); + var manifest = Path.Combine(root.FullName, "renderer.sha256"); + + try + { + var text = RendererChecksum.Format(copied); + Assert.Equal( + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a badges/one.svg\n", + text); + File.WriteAllText(manifest, text); + var bytes = File.ReadAllBytes(file); + bytes[0] = 9; + File.WriteAllBytes(file, bytes); + + var exception = Assert.Throws(() => RendererChecksum.Verify(copied, manifest)); + Assert.Equal("renderer checksum mismatch for badges/one.svg", exception.Message); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public void BundledRendererMatchesTheManifest() + { + var repo = RepoRoot(); + var directory = Path.Combine(repo, "Sources", "PreviewMD", "Resources", "Renderer"); + var manifest = Path.Combine(repo, "ports", "windows", "renderer.sha256"); + var expected = File.ReadAllText(manifest).Replace("\r\n", "\n", StringComparison.Ordinal); + + Assert.Equal(expected, RendererChecksum.Format(directory)); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var renderer = Path.Combine(dir.FullName, "Sources", "PreviewMD", "Resources", "Renderer"); + if (File.Exists(Path.Combine(dir.FullName, "Package.swift")) && Directory.Exists(renderer)) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("The PreviewMD repo root was not found from the test output."); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core/DocumentSession.cs b/ports/windows/PreviewMD.Windows.Core/DocumentSession.cs new file mode 100644 index 0000000..1594a86 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/DocumentSession.cs @@ -0,0 +1,52 @@ +namespace PreviewMD.Windows.Core; + +public abstract class DocumentSession +{ + private DocumentSession() { } + + public abstract T Match(Func empty, Func file, Func showcase); + + public sealed class Empty : DocumentSession + { + public override T Match(Func empty, Func file, Func showcase) => empty(this); + } + + public sealed class File : DocumentSession + { + public File(string absolutePath, string markdown, int revision, string imageRoot) + { + AbsolutePath = absolutePath; + Markdown = markdown; + Revision = revision; + ImageRoot = imageRoot; + } + + public string AbsolutePath { get; } + + public string Markdown { get; } + + public int Revision { get; } + + public string ImageRoot { get; } + + public override T Match(Func empty, Func file, Func showcase) => file(this); + } + + public sealed class Showcase : DocumentSession + { + public Showcase(string markdown, int revision, string imageRoot) + { + Markdown = markdown; + Revision = revision; + ImageRoot = imageRoot; + } + + public string Markdown { get; } + + public int Revision { get; } + + public string ImageRoot { get; } + + public override T Match(Func empty, Func file, Func showcase) => showcase(this); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core/DocumentSessionController.cs b/ports/windows/PreviewMD.Windows.Core/DocumentSessionController.cs new file mode 100644 index 0000000..682444e --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/DocumentSessionController.cs @@ -0,0 +1,25 @@ +namespace PreviewMD.Windows.Core; + +internal sealed class DocumentSessionController +{ + private int _revision; + + public DocumentSession Current { get; private set; } = new DocumentSession.Empty(); + + public DocumentSession ReplaceFile(string path, string markdown) + { + var fullPath = Path.GetFullPath(path); + var imageRoot = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The document has no parent directory."); + Current = new DocumentSession.File(fullPath, markdown, NextRevision(), imageRoot); + return Current; + } + + public DocumentSession ReplaceShowcase(string markdown, string imageRoot) + { + Current = new DocumentSession.Showcase(markdown, NextRevision(), Path.GetFullPath(imageRoot)); + return Current; + } + + private int NextRevision() => ++_revision; +} diff --git a/ports/windows/PreviewMD.Windows.Core/ExternalNavigationPolicy.cs b/ports/windows/PreviewMD.Windows.Core/ExternalNavigationPolicy.cs new file mode 100644 index 0000000..fb12113 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/ExternalNavigationPolicy.cs @@ -0,0 +1,34 @@ +namespace PreviewMD.Windows.Core; + +public static class ExternalNavigationPolicy +{ + public static bool ShouldLaunchExternally(string? uri) + { + if (string.IsNullOrWhiteSpace(uri)) + return false; + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed)) + return false; + + if (parsed.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) + || parsed.Scheme.Equals("mailto", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!parsed.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + return false; + + return !parsed.Host.Equals("previewmd.assets", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsAssetsShell(string? uri) + { + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed)) + return false; + if (!parsed.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + return false; + if (!parsed.Host.Equals("previewmd.assets", StringComparison.OrdinalIgnoreCase)) + return false; + + var path = parsed.AbsolutePath; + return path == "/" || path.Equals("/index.html", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core/ImagePolicy.cs b/ports/windows/PreviewMD.Windows.Core/ImagePolicy.cs new file mode 100644 index 0000000..f865f93 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/ImagePolicy.cs @@ -0,0 +1,156 @@ +using System.Security; + +namespace PreviewMD.Windows.Core; + +public readonly record struct FileFacts(bool IsRegularFile, long Length, string Extension); + +public enum ImageRefusal +{ + InvalidRequest, + EscapesJail, + NotRegularFile, + UnsupportedType, + FileTooLarge, +} + +public abstract record ImageDecision +{ + private ImageDecision() { } + + public sealed record Allowed(string Path) : ImageDecision; + + public sealed record Refused(ImageRefusal Reason) : ImageDecision; +} + +public static class ImagePolicy +{ + public const long MaximumBytes = 100L * 1024 * 1024; + + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "svg", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + }; + + public static ImageDecision Decide(string encodedSource, string imageRoot, Func inspect) + { + ArgumentNullException.ThrowIfNull(inspect); + if (!TryDecodeSource(encodedSource, out var source) || string.IsNullOrWhiteSpace(imageRoot)) + return new ImageDecision.Refused(ImageRefusal.InvalidRequest); + + string fullPath; + try + { + var rootFull = Path.GetFullPath(imageRoot); + fullPath = Resolve(source, rootFull); + var rootPrefix = rootFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) + return new ImageDecision.Refused(ImageRefusal.EscapesJail); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException or IOException) + { + return new ImageDecision.Refused(ImageRefusal.InvalidRequest); + } + + var facts = inspect(fullPath); + if (facts is null || !facts.Value.IsRegularFile) + return new ImageDecision.Refused(ImageRefusal.NotRegularFile); + + var extension = facts.Value.Extension.Trim(); + if (extension.StartsWith('.')) + extension = extension[1..]; + if (!AllowedExtensions.Contains(extension)) + return new ImageDecision.Refused(ImageRefusal.UnsupportedType); + + if (facts.Value.Length > MaximumBytes) + return new ImageDecision.Refused(ImageRefusal.FileTooLarge); + + return new ImageDecision.Allowed(fullPath); + } + + private static string Resolve(string source, string rootFull) + { + if (source.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate(source, UriKind.Absolute, out var fileUri) || !fileUri.IsFile) + throw new ArgumentException("The file URI is invalid."); + return Path.GetFullPath(fileUri.LocalPath); + } + + if (HasNonFileScheme(source)) + throw new ArgumentException("The image source is not a local path."); + + return Path.GetFullPath(Path.Combine(rootFull, source)); + } + + private static bool HasNonFileScheme(string source) + { + var colon = source.IndexOf(':'); + if (colon <= 0) + return false; + if (colon == 1 && Path.IsPathRooted(source)) + return false; + return true; + } + + private static bool TryDecodeSource(string? encodedSource, out string source) + { + source = ""; + if (string.IsNullOrWhiteSpace(encodedSource)) + return false; + + var value = encodedSource.Trim(); + if (value.Contains("://", StringComparison.Ordinal)) + { + const string marker = "source="; + var index = value.IndexOf(marker, StringComparison.Ordinal); + if (index < 0) + return false; + value = value[(index + marker.Length)..]; + var amp = value.IndexOf('&'); + if (amp >= 0) + value = value[..amp]; + var hash = value.IndexOf('#'); + if (hash >= 0) + value = value[..hash]; + } + + try + { + source = Uri.UnescapeDataString(value); + } + catch (UriFormatException) + { + return false; + } + + return source.Length > 0; + } +} + +public static class ImageInspection +{ + public static FileFacts? Read(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists) + return null; + var isDirectory = (info.Attributes & FileAttributes.Directory) != 0; + var length = isDirectory ? 0 : info.Length; + return new FileFacts(!isDirectory, length, info.Extension); + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or SecurityException) + { + return null; + } + } +} diff --git a/ports/windows/PreviewMD.Windows.Core/MarkdownReader.cs b/ports/windows/PreviewMD.Windows.Core/MarkdownReader.cs new file mode 100644 index 0000000..dae8e86 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/MarkdownReader.cs @@ -0,0 +1,25 @@ +using System.Text; + +namespace PreviewMD.Windows.Core; + +public static class MarkdownReader +{ + public static string Read(byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + var start = 0; + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + start = 3; + + var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + var text = utf8.GetString(bytes, start, bytes.Length - start); + return text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal); + } + + public static string ReadFile(string path) + { + ArgumentNullException.ThrowIfNull(path); + return Read(File.ReadAllBytes(path)); + } +} diff --git a/ports/windows/PreviewMD.Windows.Core/PreviewMD.Windows.Core.csproj b/ports/windows/PreviewMD.Windows.Core/PreviewMD.Windows.Core.csproj new file mode 100644 index 0000000..11c5ad8 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/PreviewMD.Windows.Core.csproj @@ -0,0 +1,9 @@ + + + net8.0 + enable + enable + PreviewMD.Windows.Core + PreviewMD.Windows.Core + + diff --git a/ports/windows/PreviewMD.Windows.Core/PreviewSession.cs b/ports/windows/PreviewMD.Windows.Core/PreviewSession.cs new file mode 100644 index 0000000..eec6f81 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/PreviewSession.cs @@ -0,0 +1,83 @@ +namespace PreviewMD.Windows.Core; + +public interface IDocumentRenderer +{ + bool SystemDark { get; } + + Task RenderAsync(RenderPayload payload, CancellationToken cancellationToken); +} + +public sealed class PreviewSession +{ + private readonly DocumentSessionController _controller = new(); + private readonly IDocumentRenderer _renderer; + private readonly string _showcasePath; + private readonly string _showcaseImageRoot; + private readonly SemaphoreSlim _renderGate = new(1, 1); + private int _renderedRevision; + + public PreviewSession(IDocumentRenderer renderer, string showcasePath, string showcaseImageRoot) + { + ArgumentNullException.ThrowIfNull(renderer); + ArgumentNullException.ThrowIfNull(showcasePath); + ArgumentNullException.ThrowIfNull(showcaseImageRoot); + _renderer = renderer; + _showcasePath = showcasePath; + _showcaseImageRoot = showcaseImageRoot; + } + + public DocumentSession Current => _controller.Current; + + public string? ImageRoot => Current.Match( + empty: _ => null, + file: file => file.ImageRoot, + showcase: showcase => showcase.ImageRoot); + + public async Task OpenFileAsync(string path) + { + ArgumentNullException.ThrowIfNull(path); + var markdown = MarkdownReader.ReadFile(path); + _controller.ReplaceFile(path, markdown); + await RenderCurrentAsync(); + } + + public async Task OpenShowcaseAsync() + { + var markdown = MarkdownReader.ReadFile(_showcasePath); + _controller.ReplaceShowcase(markdown, _showcaseImageRoot); + await RenderCurrentAsync(); + } + + private async Task RenderCurrentAsync() + { + await _renderGate.WaitAsync(); + try + { + while (true) + { + var snapshot = _controller.Current; + var revision = Revision(snapshot); + if (revision == 0 || revision == _renderedRevision) + return; + + var payload = RenderPayloadFactory.Create(snapshot, _renderer.SystemDark); + await _renderer.RenderAsync(payload, CancellationToken.None); + + if (Revision(_controller.Current) == revision) + { + _renderedRevision = revision; + return; + } + } + } + finally + { + _renderGate.Release(); + } + } + + private static int Revision(DocumentSession session) => session.Match( + empty: _ => 0, + file: file => file.Revision, + showcase: showcase => showcase.Revision); +} diff --git a/ports/windows/PreviewMD.Windows.Core/RenderPayload.cs b/ports/windows/PreviewMD.Windows.Core/RenderPayload.cs new file mode 100644 index 0000000..3274275 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/RenderPayload.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace PreviewMD.Windows.Core; + +public sealed record RenderPayload( + string documentID, + string markdown, + int revision, + bool editable, + string theme, + string readingStyle, + object? customReadingPreset, + bool systemDark, + int readingWidth, + bool readingWidthIsFluid, + bool paperCanvas, + double zoom, + string searchText, + string? outlineTarget, + object[] externalChanges, + int? externalChangeSelection, + double topInset); + +public static class RenderPayloadFactory +{ + public const int ReadingWidth = 820; + + public static RenderPayload Create(DocumentSession session, bool systemDark) + { + return session.Match( + empty: _ => throw new InvalidOperationException("There is no document to render."), + file: file => Create(file.AbsolutePath, file.Markdown, file.Revision, systemDark), + showcase: showcase => Create("showcase", showcase.Markdown, showcase.Revision, systemDark)); + } + + private static RenderPayload Create(string documentID, string markdown, int revision, bool systemDark) + { + return new RenderPayload( + documentID, + markdown, + revision, + editable: false, + theme: "system", + readingStyle: "modern", + customReadingPreset: null, + systemDark, + ReadingWidth, + readingWidthIsFluid: false, + paperCanvas: false, + zoom: 1, + searchText: "", + outlineTarget: null, + externalChanges: [], + externalChangeSelection: null, + topInset: 0); + } +} + +public static class RenderPayloadJson +{ + public static string Serialize(RenderPayload payload) => JsonSerializer.Serialize(payload); +} diff --git a/ports/windows/PreviewMD.Windows.Core/RendererChecksum.cs b/ports/windows/PreviewMD.Windows.Core/RendererChecksum.cs new file mode 100644 index 0000000..cec1d27 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/RendererChecksum.cs @@ -0,0 +1,84 @@ +using System.Security.Cryptography; + +namespace PreviewMD.Windows.Core; + +public sealed class RendererChecksumException : Exception +{ + public RendererChecksumException(string message) : base(message) { } +} + +public static class RendererChecksum +{ + public static string Format(string directory) + { + var lines = HashDirectory(directory) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Value + " " + pair.Key); + return string.Join('\n', lines) + "\n"; + } + + public static void Verify(string directory, string manifestPath) + { + var expected = Parse(File.ReadAllText(manifestPath)); + var actual = HashDirectory(directory); + var paths = expected.Keys + .Concat(actual.Keys) + .Distinct(StringComparer.Ordinal) + .OrderBy(path => path, StringComparer.Ordinal); + + foreach (var path in paths) + { + expected.TryGetValue(path, out var expectedHash); + actual.TryGetValue(path, out var actualHash); + if (!string.Equals(expectedHash, actualHash, StringComparison.Ordinal)) + throw new RendererChecksumException("renderer checksum mismatch for " + path); + } + } + + private static Dictionary Parse(string manifest) + { + var map = new Dictionary(StringComparer.Ordinal); + var lines = manifest.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + foreach (var raw in lines) + { + if (raw.Length == 0) + continue; + var split = raw.IndexOf(" ", StringComparison.Ordinal); + if (split != 64) + throw new RendererChecksumException("renderer checksum manifest line is invalid: " + raw); + var hash = raw[..split]; + var path = raw[(split + 2)..]; + if (path.Length == 0 || path.Contains('\\') || !IsLowerHex(hash)) + throw new RendererChecksumException("renderer checksum manifest line is invalid: " + raw); + map[path] = hash; + } + + return map; + } + + private static bool IsLowerHex(string hash) + { + foreach (var character in hash) + { + var digit = character is >= '0' and <= '9' || character is >= 'a' and <= 'f'; + if (!digit) + return false; + } + + return true; + } + + private static Dictionary HashDirectory(string directory) + { + var root = Path.GetFullPath(directory); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file))).ToLowerInvariant(); + map[relative] = hash; + } + + return map; + } +} From 2a87ee604de8890253c91244de764698f930f7e5 Mon Sep 17 00:00:00 2001 From: kamilkaczmareksolutions Date: Sun, 4 Oct 2026 12:59:26 +0200 Subject: [PATCH 02/13] feat(windows): open a Markdown file in a WinUI window Issue 22 asks for one offline window. The build copies the renderer unchanged and checks it against a checksum. External links leave the app. Signed-off-by: kamilkaczmareksolutions --- ports/windows/.gitignore | 4 + ports/windows/PreviewMD.Windows.sln | 34 +++ ports/windows/PreviewMD.Windows/App.xaml | 12 + ports/windows/PreviewMD.Windows/App.xaml.cs | 35 +++ .../Assets/Square150x150Logo.png | Bin 0 -> 70 bytes .../Assets/Square44x44Logo.png | Bin 0 -> 70 bytes .../PreviewMD.Windows/Assets/StoreLogo.png | Bin 0 -> 70 bytes .../windows/PreviewMD.Windows/MainWindow.xaml | 19 ++ .../PreviewMD.Windows/MainWindow.xaml.cs | 269 ++++++++++++++++++ .../PreviewMD.Windows/Package.appxmanifest | 35 +++ .../PreviewMD.Windows.csproj | 66 +++++ .../PreviewMD.Windows/WebAssets/index.html | 35 +++ ports/windows/PreviewMD.Windows/app.manifest | 14 + ports/windows/README.md | 35 +++ ports/windows/renderer.sha256 | 77 +++++ ports/windows/verify-renderer.ps1 | 45 +++ 16 files changed, 680 insertions(+) create mode 100644 ports/windows/.gitignore create mode 100644 ports/windows/PreviewMD.Windows.sln create mode 100644 ports/windows/PreviewMD.Windows/App.xaml create mode 100644 ports/windows/PreviewMD.Windows/App.xaml.cs create mode 100644 ports/windows/PreviewMD.Windows/Assets/Square150x150Logo.png create mode 100644 ports/windows/PreviewMD.Windows/Assets/Square44x44Logo.png create mode 100644 ports/windows/PreviewMD.Windows/Assets/StoreLogo.png create mode 100644 ports/windows/PreviewMD.Windows/MainWindow.xaml create mode 100644 ports/windows/PreviewMD.Windows/MainWindow.xaml.cs create mode 100644 ports/windows/PreviewMD.Windows/Package.appxmanifest create mode 100644 ports/windows/PreviewMD.Windows/PreviewMD.Windows.csproj create mode 100644 ports/windows/PreviewMD.Windows/WebAssets/index.html create mode 100644 ports/windows/PreviewMD.Windows/app.manifest create mode 100644 ports/windows/README.md create mode 100644 ports/windows/renderer.sha256 create mode 100644 ports/windows/verify-renderer.ps1 diff --git a/ports/windows/.gitignore b/ports/windows/.gitignore new file mode 100644 index 0000000..547a964 --- /dev/null +++ b/ports/windows/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +.vs/ +*.user diff --git a/ports/windows/PreviewMD.Windows.sln b/ports/windows/PreviewMD.Windows.sln new file mode 100644 index 0000000..9fb9bd1 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PreviewMD.Windows.Core", "PreviewMD.Windows.Core\PreviewMD.Windows.Core.csproj", "{DF83C438-F5EF-4CCE-89AC-945020389E42}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PreviewMD.Windows.Core.Tests", "PreviewMD.Windows.Core.Tests\PreviewMD.Windows.Core.Tests.csproj", "{07A5564B-9BA2-4CF6-9FE7-995AD6EA3E49}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PreviewMD.Windows", "PreviewMD.Windows\PreviewMD.Windows.csproj", "{0C99E012-077B-4258-B566-487881713454}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DF83C438-F5EF-4CCE-89AC-945020389E42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF83C438-F5EF-4CCE-89AC-945020389E42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF83C438-F5EF-4CCE-89AC-945020389E42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF83C438-F5EF-4CCE-89AC-945020389E42}.Release|Any CPU.Build.0 = Release|Any CPU + {07A5564B-9BA2-4CF6-9FE7-995AD6EA3E49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07A5564B-9BA2-4CF6-9FE7-995AD6EA3E49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07A5564B-9BA2-4CF6-9FE7-995AD6EA3E49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07A5564B-9BA2-4CF6-9FE7-995AD6EA3E49}.Release|Any CPU.Build.0 = Release|Any CPU + {0C99E012-077B-4258-B566-487881713454}.Debug|Any CPU.ActiveCfg = Debug|x64 + {0C99E012-077B-4258-B566-487881713454}.Debug|Any CPU.Build.0 = Debug|x64 + {0C99E012-077B-4258-B566-487881713454}.Release|Any CPU.ActiveCfg = Release|x64 + {0C99E012-077B-4258-B566-487881713454}.Release|Any CPU.Build.0 = Release|x64 + EndGlobalSection +EndGlobal diff --git a/ports/windows/PreviewMD.Windows/App.xaml b/ports/windows/PreviewMD.Windows/App.xaml new file mode 100644 index 0000000..227b763 --- /dev/null +++ b/ports/windows/PreviewMD.Windows/App.xaml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/ports/windows/PreviewMD.Windows/App.xaml.cs b/ports/windows/PreviewMD.Windows/App.xaml.cs new file mode 100644 index 0000000..265f143 --- /dev/null +++ b/ports/windows/PreviewMD.Windows/App.xaml.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using Microsoft.UI.Xaml; + +namespace PreviewMD.Windows; + +public partial class App : Application +{ + private Window? _window; + + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + var window = new MainWindow(); + _window = window; + window.Activate(); + + var command = Environment.GetCommandLineArgs().Skip(1).ToArray(); + if (command.Any(argument => argument.Equals("--showcase", StringComparison.OrdinalIgnoreCase))) + { + window.OpenRequestedShowcase(); + return; + } + + var path = command.FirstOrDefault(argument => + argument.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + || argument.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase)); + if (path is not null) + window.OpenRequestedPath(path); + } +} diff --git a/ports/windows/PreviewMD.Windows/Assets/Square150x150Logo.png b/ports/windows/PreviewMD.Windows/Assets/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/ports/windows/PreviewMD.Windows/Assets/Square44x44Logo.png b/ports/windows/PreviewMD.Windows/Assets/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/ports/windows/PreviewMD.Windows/Assets/StoreLogo.png b/ports/windows/PreviewMD.Windows/Assets/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/ports/windows/PreviewMD.Windows/MainWindow.xaml b/ports/windows/PreviewMD.Windows/MainWindow.xaml new file mode 100644 index 0000000..7e2ba4a --- /dev/null +++ b/ports/windows/PreviewMD.Windows/MainWindow.xaml @@ -0,0 +1,19 @@ + + + + + + + +