diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1504ecb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +Sources/PreviewMD/Resources/Renderer/**/*.js text eol=lf +Sources/PreviewMD/Resources/Renderer/**/*.css text eol=lf +Sources/PreviewMD/Resources/Renderer/**/*.md text eol=lf +Sources/PreviewMD/Resources/Renderer/**/*.svg text eol=lf +Sources/PreviewMD/Resources/Renderer/**/*.html text eol=lf +Sources/PreviewMD/Resources/Renderer/**/*.ttf binary +Sources/PreviewMD/Resources/Renderer/**/*.woff binary +Sources/PreviewMD/Resources/Renderer/**/*.woff2 binary +ports/windows/renderer.sha256 text eol=lf +ports/windows/verify-renderer.ps1 text eol=lf +ports/windows/pin-renderer.py text eol=lf 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.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..0ffcefa --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/ImagePolicyTests.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +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 DirectoryNotFoundFromLinkQueryStillAllowsRelativePng() + { + var notes = Directory.CreateTempSubdirectory("previewmd-link-miss-"); + var picture = Path.Combine(notes.FullName, "pic.png"); + var png = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + File.WriteAllBytes(picture, png); + var previousDirectory = ImagePolicy.DirectoryLinkTarget; + + try + { + ImagePolicy.DirectoryLinkTarget = path => + { + if (SamePath(path, notes.FullName)) + throw new DirectoryNotFoundException(path); + return Directory.ResolveLinkTarget(path, returnFinalTarget: true); + }; + + var allowed = ImagePolicy.Decide("pic.png", notes.FullName, ImageInspection.Read); + var allowedPath = Assert.IsType(allowed); + Assert.Equal(png, File.ReadAllBytes(allowedPath.Path)); + } + finally + { + ImagePolicy.DirectoryLinkTarget = previousDirectory; + notes.Delete(recursive: true); + } + } + + [Fact] + public void VolumeRootLinkFailureStillAllowsRelativePngAndRefusesJunctionEscape() + { + var parent = Directory.CreateTempSubdirectory("previewmd-volume-"); + var notes = Directory.CreateDirectory(Path.Combine(parent.FullName, "notes")); + var picture = Path.Combine(notes.FullName, "pic.png"); + var png = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + File.WriteAllBytes(picture, png); + var outside = Path.Combine(parent.FullName, "secret.png"); + File.WriteAllBytes(outside, new byte[] { 1, 2, 3, 4 }); + var directoryLink = Path.Combine(notes.FullName, "out"); + var volumeRoot = Path.GetPathRoot(notes.FullName) ?? ""; + var previousDirectory = ImagePolicy.DirectoryLinkTarget; + + try + { + ImagePolicy.DirectoryLinkTarget = path => + { + if (SamePath(path, volumeRoot)) + throw new DirectoryNotFoundException(path); + return Directory.ResolveLinkTarget(path, returnFinalTarget: true); + }; + + var allowed = ImagePolicy.Decide("pic.png", notes.FullName, ImageInspection.Read); + var allowedPath = Assert.IsType(allowed); + Assert.Equal(png, File.ReadAllBytes(allowedPath.Path)); + + CreateDirectoryLink(directoryLink, parent.FullName); + var escaped = ImagePolicy.Decide("out/secret.png", notes.FullName, ImageInspection.Read); + Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), escaped); + } + finally + { + ImagePolicy.DirectoryLinkTarget = previousDirectory; + RemoveLink(directoryLink); + parent.Delete(recursive: true); + } + } + + private static bool SamePath(string left, string right) + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + comparison); + } + + [Fact] + public void DocumentFolderAllowsARelativeImageAndRefusesEscape() + { + var parent = Directory.CreateTempSubdirectory("previewmd-jail-"); + var notes = Directory.CreateDirectory(Path.Combine(parent.FullName, "notes")); + var img = Directory.CreateDirectory(Path.Combine(notes.FullName, "img")); + var picture = Path.Combine(img.FullName, "pic.png"); + var png = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + File.WriteAllBytes(picture, png); + var outside = Path.Combine(parent.FullName, "secret.png"); + File.WriteAllBytes(outside, new byte[] { 1, 2, 3, 4 }); + var fileLink = Path.Combine(img.FullName, "escape.png"); + var directoryLink = Path.Combine(notes.FullName, "out"); + + try + { + var allowed = ImagePolicy.Decide("img/pic.png", notes.FullName, ImageInspection.Read); + var allowedPath = Assert.IsType(allowed); + Assert.Equal(png, File.ReadAllBytes(allowedPath.Path)); + + var parentEscape = ImagePolicy.Decide("../secret.png", notes.FullName, ImageInspection.Read); + Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), parentEscape); + + var absoluteEscape = ImagePolicy.Decide(outside, notes.FullName, ImageInspection.Read); + Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), absoluteEscape); + + CreateDirectoryLink(directoryLink, parent.FullName); + var directoryEscape = ImagePolicy.Decide("out/secret.png", notes.FullName, ImageInspection.Read); + Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), directoryEscape); + + if (TryCreateFileLink(fileLink, outside)) + { + var fileEscape = ImagePolicy.Decide("img/escape.png", notes.FullName, ImageInspection.Read); + Assert.Equal(new ImageDecision.Refused(ImageRefusal.EscapesJail), fileEscape); + + var alias = Path.Combine(notes.FullName, "alias.png"); + Assert.True(TryCreateFileLink(alias, picture)); + var aliased = ImagePolicy.Decide("alias.png", notes.FullName, ImageInspection.Read); + var aliasPath = Assert.IsType(aliased); + Assert.Equal(png, File.ReadAllBytes(aliasPath.Path)); + RemoveLink(alias); + } + } + finally + { + RemoveLink(directoryLink); + RemoveLink(fileLink); + parent.Delete(recursive: true); + } + } + + private static bool TryCreateFileLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (IOException) when (OperatingSystem.IsWindows()) + { + return false; + } + } + + private static void CreateDirectoryLink(string linkPath, string targetPath) + { + if (!OperatingSystem.IsWindows()) + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return; + } + + using var process = new Process(); + process.StartInfo.FileName = "cmd.exe"; + process.StartInfo.Arguments = "/c mklink /J \"" + linkPath + "\" \"" + targetPath + "\""; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.Start(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException("The directory junction was not created."); + } + + private static void RemoveLink(string linkPath) + { + if (Directory.Exists(linkPath)) + { + Directory.Delete(linkPath, recursive: false); + return; + } + + if (File.Exists(linkPath)) + File.Delete(linkPath); + } + + [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..c605817 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/PreviewSessionTests.cs @@ -0,0 +1,95 @@ +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); + } + } + + [Fact] + public async Task CloseDocumentReturnsToEmpty() + { + var directory = Path.Combine(Path.GetTempPath(), "previewmd-close-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var note = Path.Combine(directory, "note.md"); + await File.WriteAllTextAsync(note, "body"); + var session = new PreviewSession(new RecordingRenderer(), note, directory); + + try + { + await session.OpenFileAsync(note); + session.CloseDocument(); + + Assert.IsType(session.Current); + } + 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/RecentDocumentsTests.cs b/ports/windows/PreviewMD.Windows.Core.Tests/RecentDocumentsTests.cs new file mode 100644 index 0000000..3d81e35 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/RecentDocumentsTests.cs @@ -0,0 +1,40 @@ +using PreviewMD.Windows.Core; +using Xunit; + +namespace PreviewMD.Windows.Core.Tests; + +public class RecentDocumentsTests +{ + [Fact] + public void NewestPathIsFirstAndARepeatDoesNotDuplicate() + { + var first = RecentDocuments.Remember(Array.Empty(), @"C:\docs\a.md"); + var second = RecentDocuments.Remember(first, @"C:\docs\b.md"); + var repeated = RecentDocuments.Remember(second, @"C:\docs\a.md"); + + Assert.Equal(new[] { @"C:\docs\a.md" }, first); + Assert.Equal(new[] { @"C:\docs\b.md", @"C:\docs\a.md" }, second); + Assert.Equal(new[] { @"C:\docs\a.md", @"C:\docs\b.md" }, repeated); + } + + [Fact] + public void RememberKeepsTenPaths() + { + IReadOnlyList current = Array.Empty(); + for (var index = 0; index < 12; index++) + current = RecentDocuments.Remember(current, $@"C:\docs\n{index}.md"); + + Assert.Equal(10, current.Count); + Assert.Equal(@"C:\docs\n11.md", current[0]); + Assert.Equal(@"C:\docs\n2.md", current[9]); + } + + [Fact] + public void FirstMarkdownSkipsOtherFiles() + { + var chosen = RecentDocuments.FirstMarkdown(new[] { @"C:\docs\pic.png", @"C:\docs\note.md" }); + + Assert.Equal(@"C:\docs\note.md", chosen); + Assert.Null(RecentDocuments.FirstMarkdown(new[] { @"C:\docs\pic.png" })); + } +} 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..829b2f3 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core.Tests/RendererChecksumTests.cs @@ -0,0 +1,86 @@ +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)); + } + + [Fact] + public void RendererTextFilesUseLfBytes() + { + var directory = Path.Combine(RepoRoot(), "Sources", "PreviewMD", "Resources", "Renderer"); + var textExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".js", + ".css", + ".md", + ".svg", + ".html", + }; + + foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + if (!textExtensions.Contains(Path.GetExtension(file))) + continue; + + var bytes = File.ReadAllBytes(file); + Assert.DoesNotContain((byte)'\r', bytes); + } + } + + 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..c0e10a9 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/DocumentSessionController.cs @@ -0,0 +1,31 @@ +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; + } + + public DocumentSession Clear() + { + Current = new DocumentSession.Empty(); + 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..4d90be3 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/ImagePolicy.cs @@ -0,0 +1,235 @@ +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; + + internal static Func DirectoryLinkTarget { get; set; } = + static path => Directory.ResolveLinkTarget(path, returnFinalTarget: true); + + internal static Func FileLinkTarget { get; set; } = + static path => File.ResolveLinkTarget(path, returnFinalTarget: true); + + 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); + if (!IsInside(rootFull, fullPath)) + return new ImageDecision.Refused(ImageRefusal.EscapesJail); + + var canonicalRoot = Canonicalize(rootFull); + var canonicalPath = Canonicalize(fullPath); + if (!IsInside(canonicalRoot, canonicalPath)) + return new ImageDecision.Refused(ImageRefusal.EscapesJail); + fullPath = canonicalPath; + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException or IOException or UnauthorizedAccessException) + { + 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 bool IsInside(string rootFull, string candidate) + { + var rootPrefix = rootFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase); + } + + private static string Canonicalize(string fullPath) + { + fullPath = Path.GetFullPath(fullPath); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + throw new IOException("The path has no root."); + + var current = FollowLink(root); + var relative = Path.GetRelativePath(root, fullPath); + if (relative == ".") + return current; + + foreach (var segment in relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + { + if (segment.Length == 0 || segment == ".") + continue; + current = FollowLink(Path.Combine(current, segment)); + } + + return current; + } + + private static string FollowLink(string path) + { + var fullPath = Path.GetFullPath(path); + if (IsVolumeRoot(fullPath)) + return fullPath; + + try + { + FileSystemInfo? link = null; + if (Directory.Exists(fullPath)) + link = DirectoryLinkTarget(fullPath); + else if (File.Exists(fullPath)) + link = FileLinkTarget(fullPath); + + if (link is null || link.FullName.Length == 0) + return fullPath; + + return Path.GetFullPath(link.FullName); + } + catch (DirectoryNotFoundException) + { + return fullPath; + } + } + + private static bool IsVolumeRoot(string fullPath) + { + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + return false; + + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals( + Path.TrimEndingDirectorySeparator(fullPath), + Path.TrimEndingDirectorySeparator(root), + comparison); + } + + 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..1a85aee --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/PreviewMD.Windows.Core.csproj @@ -0,0 +1,13 @@ + + + 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..9c0b957 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/PreviewSession.cs @@ -0,0 +1,88 @@ +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(); + } + + public void CloseDocument() + { + _controller.Clear(); + } + + 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/RecentDocuments.cs b/ports/windows/PreviewMD.Windows.Core/RecentDocuments.cs new file mode 100644 index 0000000..754a836 --- /dev/null +++ b/ports/windows/PreviewMD.Windows.Core/RecentDocuments.cs @@ -0,0 +1,44 @@ +namespace PreviewMD.Windows.Core; + +public static class RecentDocuments +{ + public const int Limit = 10; + + public static bool IsMarkdownPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + var extension = Path.GetExtension(path); + return extension.Equals(".md", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".markdown", StringComparison.OrdinalIgnoreCase); + } + + public static string? FirstMarkdown(IEnumerable paths) + { + ArgumentNullException.ThrowIfNull(paths); + foreach (var path in paths) + { + if (IsMarkdownPath(path)) + return Path.GetFullPath(path); + } + + return null; + } + + public static IReadOnlyList Remember(IReadOnlyList current, string path) + { + ArgumentNullException.ThrowIfNull(current); + var fullPath = Path.GetFullPath(path); + var remembered = new List { fullPath }; + foreach (var existing in current) + { + if (remembered.Count == Limit) + break; + if (!existing.Equals(fullPath, StringComparison.OrdinalIgnoreCase)) + remembered.Add(existing); + } + + return remembered; + } +} 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; + } +} 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..f1aa7da --- /dev/null +++ b/ports/windows/PreviewMD.Windows/App.xaml.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; + +namespace PreviewMD.Windows; + +public partial class App : Application +{ + private MainWindow? _window; + + public App() + { + ShellIntegration.SetProcessId(); + ShellIntegration.EnsureStartMenuShortcut(); + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + var instance = AppInstance.FindOrRegisterForKey("PreviewMD.Windows.Main"); + if (!instance.IsCurrent) + { + var activation = AppInstance.GetCurrent().GetActivatedEventArgs(); + instance.RedirectActivationToAsync(activation).AsTask().GetAwaiter().GetResult(); + Environment.Exit(0); + return; + } + + var window = new MainWindow(); + _window = window; + instance.Activated += OnInstanceActivated; + ShellIntegration.AssignWindowIdentity(WinRT.Interop.WindowNative.GetWindowHandle(window)); + window.Activate(); + window.HandleCommandLine(Environment.GetCommandLineArgs().Skip(1).ToArray()); + } + + void OnInstanceActivated(object? sender, AppActivationArguments arguments) + { + var window = _window; + if (window is null) + return; + + var command = arguments.Data is global::Windows.ApplicationModel.Activation.LaunchActivatedEventArgs launch + ? SplitArguments(launch.Arguments) + : Array.Empty(); + window.DispatcherQueue.TryEnqueue(() => + { + window.Activate(); + window.HandleCommandLine(command); + }); + } + + static string[] SplitArguments(string arguments) + { + if (string.IsNullOrWhiteSpace(arguments)) + return Array.Empty(); + + var parsed = CommandLineToArgvW(arguments, out var count); + if (parsed == IntPtr.Zero || count <= 0) + return Array.Empty(); + + try + { + var result = new string[count]; + for (var index = 0; index < count; index++) + { + var pointer = Marshal.ReadIntPtr(parsed, index * IntPtr.Size); + result[index] = Marshal.PtrToStringUni(pointer) ?? ""; + } + + return result; + } + finally + { + LocalFree(parsed); + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + static extern IntPtr CommandLineToArgvW(string commandLine, out int argumentCount); + + [DllImport("kernel32.dll")] + static extern IntPtr LocalFree(IntPtr handle); +} diff --git a/ports/windows/PreviewMD.Windows/Assets/Square150x150Logo.png b/ports/windows/PreviewMD.Windows/Assets/Square150x150Logo.png new file mode 100644 index 0000000..f37764b Binary files /dev/null and b/ports/windows/PreviewMD.Windows/Assets/Square150x150Logo.png differ diff --git a/ports/windows/PreviewMD.Windows/Assets/Square44x44Logo.png b/ports/windows/PreviewMD.Windows/Assets/Square44x44Logo.png new file mode 100644 index 0000000..f37764b Binary files /dev/null and b/ports/windows/PreviewMD.Windows/Assets/Square44x44Logo.png differ diff --git a/ports/windows/PreviewMD.Windows/Assets/StoreLogo.png b/ports/windows/PreviewMD.Windows/Assets/StoreLogo.png new file mode 100644 index 0000000..f37764b Binary files /dev/null and b/ports/windows/PreviewMD.Windows/Assets/StoreLogo.png differ diff --git a/ports/windows/PreviewMD.Windows/MainWindow.xaml b/ports/windows/PreviewMD.Windows/MainWindow.xaml new file mode 100644 index 0000000..21551a3 --- /dev/null +++ b/ports/windows/PreviewMD.Windows/MainWindow.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + +