diff --git a/README.md b/README.md index bac8561..1574c2b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Read Markdown comfortably, keep up with changes, and turn your notes into someth MarkdownViewr is a dedicated Windows reader for project documentation, notes, and README files. Open a document, choose a look you like, and focus on the words. -**[Download Windows installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Setup.exe) · [User guide](docs/user-guide.md) · [Ask a question](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions)** +**[Download Windows installer](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/latest/markdownviewr-windows-stable-Setup.exe) · [User guide](docs/user-guide.md) · [Questions and issues](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues)** [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15417/badge)](https://www.bestpractices.dev/en/projects/15417) @@ -31,11 +31,11 @@ Windows packages support **Windows 10 version 1809 or later and Windows 11, x64* | Edition | Choose it for | Package | | --- | --- | --- | -| Stable | Everyday reading | [Download EXE installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Setup.exe) · [MSI installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable.msi) | -| Development preview | Trying upcoming changes | [Download dev EXE installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev-Setup.exe) · [MSI installer](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev.msi) | -| Portable | Running without installation | [Download portable ZIP](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest/download/markdownviewr-windows-stable-Portable.zip) | +| Stable | Everyday reading | [Download EXE installer](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/latest/markdownviewr-windows-stable-Setup.exe) · [MSI installer](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/latest/markdownviewr-windows-stable.msi) | +| Development preview | Trying upcoming changes | [Download dev EXE installer](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev-Setup.exe) · [MSI installer](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/v1.0.3-beta.1/markdownviewr-windows-dev-dev.msi) | +| Portable | Running without installation | [Download portable ZIP](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/latest/markdownviewr-windows-stable-Portable.zip) | -Download a package from [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases). Run Setup for a per-user installation without administrator rights, or extract the portable ZIP before launching the app. +Download a package from [Releases](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases). Run Setup for a per-user installation without administrator rights, or extract the portable ZIP before launching the app. Installed and portable editions check for updates through **Help → Check for updates**. Stable and development editions keep separate settings and update channels, so they can coexist. @@ -52,7 +52,7 @@ See the [user guide](docs/user-guide.md) for settings, shortcuts, printing, and ## Help and feedback -Use **Help → Community support** for questions and **Help → Submit an issue** for bugs or feature requests. You can also visit [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions) or [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues) directly. +Use **Help → Community support** to see questions and known problems, and **Help → Submit an issue** for bugs or feature requests. You can also visit [Issues](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues) directly. To tell someone else about the app, **Help → Share MarkdownViewr** starts an invitation email with the download links. For a bug report, include the version from **Help → About**, your Windows version, and steps to reproduce. Remove private information from logs and screenshots. Report security vulnerabilities privately using [SECURITY.md](SECURITY.md). diff --git a/docs/adr/0007-releases-and-updates-from-gitea.md b/docs/adr/0007-releases-and-updates-from-gitea.md new file mode 100644 index 0000000..5270d10 --- /dev/null +++ b/docs/adr/0007-releases-and-updates-from-gitea.md @@ -0,0 +1,62 @@ +# ADR-0007: Releases and updates from Gitea + +- **Status:** Accepted +- **Date:** 2026-11-10 + +## Context + +The project moved from GitHub to the Gitea server at `git.kof22.com`, and GitHub is no +longer used. The app's links, its update check and its download instructions all +pointed at the GitHub repository. + +The update check came from AppKit's `VelopackUpdateService`, which can only read +GitHub Releases. Where an installed copy looks for updates is hard to change later: +a copy can only learn about a new location from an update it finds at the old one. + +## Decision + +**One repository address.** `AppIdentity.Info.RepositoryUrl` is the Gitea repository. +Every page the app opens is derived from it: the project page, new issue, issue list, +latest release, installer, user guide and license. + +**Updates read the Gitea releases.** `ReleaseUpdateService` in the core library +implements AppKit's `IUpdateService`, so AppKit's coordinator and dialogs are used +unchanged. It holds the decisions (is this copy installed, was an update found, turn +every failure into a message) and is fully tested against a scripted updater. The +app project supplies `GiteaReleaseUpdater`, a pass-through to Velopack's update +manager over its Gitea release source. Releases are read without credentials; the +repository is public. + +**Channels are unchanged.** A stable build ignores prereleases; a development build +(a prerelease version) reads them. Velopack then matches packages to the installed +channel by its own channel name. Package ids, channel names and asset names stay as +they were, so the existing releases, which are also present on Gitea, remain a valid +update history. + +**Asset names are part of the contract.** The download links in the README and in the +invitation email use Gitea's `releases/download/latest/` form with the names +Velopack gives the stable edition, for example +`markdownviewr-windows-stable-Setup.exe`. A release pipeline must keep those names. + +## Consequences + +- Copies installed from release 1.0.3 or earlier look at GitHub for updates. They will + not find releases published only on Gitea and must be reinstalled once from the new + location, unless a release that contains this change is also published on GitHub. +- The Gitea host is now the trust anchor for updates. Signing and package integrity + checks are unchanged. +- Gitea has no discussions area. **Help → Community support** opens the issue list. +- The update decisions exist twice, here and in AppKit. They should move back once + AppKit accepts an update source other than GitHub. +- The installed-update acceptance harness (`build/Test-InstalledUpdates.ps1` with + `tests/MarkdownViewr.Windows.UpdateProbe`) still exercises AppKit's GitHub service + against a local fixture. It no longer covers the path the app uses and needs porting + to the Gitea source. + +## Options considered + +- **Keep the update check on GitHub and move only the links.** Rejected: no further + release will be published there, so installed copies would never update. +- **Change AppKit first.** The better home for the code, but it needs an AppKit + release before the app can move, and the app's links cannot move without the + update source moving too: both come from the one repository address. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 37506ff..1473000 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -35,7 +35,8 @@ physical directory. It does not authorize native commands or remote resource acc | External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page | | Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled | | Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths | -| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight | +| Updates | Release feed of the app's Gitea repository over HTTPS, read without credentials; Velopack integrity checks and production signing preflight. The repository host is the trust anchor: whoever controls it or its releases can offer an update (ADR-0007) | +| Help links and invitation | Fixed HTTPS pages of the app's repository and one fixed `mailto:` draft, each opened by a deliberate menu action. The draft has no recipient and no text taken from documents, settings or the environment; every character outside the unreserved set is percent-encoded | | Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token | ## Threats and verification diff --git a/docs/user-guide.md b/docs/user-guide.md index 1b4c4bf..3a35c69 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -74,7 +74,11 @@ Documents are rendered locally. Authored scripts and remote images are blocked; - **The viewer is not a default option:** use Setup rather than a portable copy, then reopen Windows Default Apps. - **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available. -For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md). +For help, look through the [questions and known problems](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues), also reachable from **Help → Community support**. Report reproducible problems through [a new issue](https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md). + +## Sharing the app + +**Help → Share MarkdownViewr** opens a new message in your email program with an invitation already written: what the app is, a link to the installer, the release page, this guide, and where to report problems. Add the recipient, change the wording if you like, and send. Nothing about your documents or settings is included. If no email program is set up, the invitation is copied to the clipboard instead so you can paste it into any message. ## MSI installation diff --git a/src/MarkdownViewr.Windows.Core/AboutComponents.cs b/src/MarkdownViewr.Windows.Core/AboutComponents.cs index 0fcc1aa..18752cc 100644 --- a/src/MarkdownViewr.Windows.Core/AboutComponents.cs +++ b/src/MarkdownViewr.Windows.Core/AboutComponents.cs @@ -14,9 +14,9 @@ public static class AboutComponents new("AngleSharp.Css", "1.0.2", "MIT", "https://github.com/AngleSharp/AngleSharp.Css", "https://licenses.nuget.org/MIT"), new("AngleSharp", "1.7.2", "MIT", "https://github.com/AngleSharp/AngleSharp", "https://licenses.nuget.org/MIT"), new("HtmlSanitizer", "9.2.1039", "MIT", "https://github.com/mganss/HtmlSanitizer", "https://licenses.nuget.org/MIT"), - new("KofTwentyTwo.AppKit.Updates", "0.1.0", "MIT", "https://github.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"), - new("KofTwentyTwo.AppKit.WinUI", "0.1.0", "MIT", "https://github.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"), - new("KofTwentyTwo.AppKit", "0.1.0", "MIT", "https://github.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"), + new("KofTwentyTwo.AppKit.Updates", "0.1.0", "MIT", "https://git.kof22.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"), + new("KofTwentyTwo.AppKit.WinUI", "0.1.0", "MIT", "https://git.kof22.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"), + new("KofTwentyTwo.AppKit", "0.1.0", "MIT", "https://git.kof22.com/KofTwentyTwo/AppKit", "https://licenses.nuget.org/MIT"), new("Markdig", "1.4.0", "BSD-2-Clause", "https://github.com/xoofx/markdig", "https://licenses.nuget.org/BSD-2-Clause"), new("Microsoft.Web.WebView2", "1.0.3719.77", "Microsoft package license", "https://aka.ms/webview", "https://www.nuget.org/packages/Microsoft.Web.WebView2/1.0.3719.77/License"), new("Microsoft.Windows.AI.MachineLearning", "2.1.74", "Microsoft package license", "https://github.com/microsoft/windowsappsdk", "https://www.nuget.org/packages/Microsoft.Windows.AI.MachineLearning/2.1.74/License"), diff --git a/src/MarkdownViewr.Windows.Core/AppIdentity.cs b/src/MarkdownViewr.Windows.Core/AppIdentity.cs index c284366..8e19b55 100644 --- a/src/MarkdownViewr.Windows.Core/AppIdentity.cs +++ b/src/MarkdownViewr.Windows.Core/AppIdentity.cs @@ -24,7 +24,7 @@ public static class AppIdentity DisplayName = "MarkdownViewr", 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"), + RepositoryUrl = new Uri("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows"), Author = "James Maes", Copyright = "Copyright (c) 2026 James Maes", // Keep the gradient in step with the mark (regenerate both with AppKit's BrandTool). @@ -63,12 +63,37 @@ public static class AppIdentity /// The app's bug/feature templates, independent of any upstream project credited in About. - public static Uri IssuesUrl { get; } = new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/issues/new/choose"); + public static Uri IssuesUrl { get; } = RepositoryPage("/issues/new/choose"); - /// Community questions and tips in the app's own repository. - public static Uri SupportUrl { get; } = new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/discussions"); + /// Questions already asked and problems already reported in the app's own repository. + public static Uri SupportUrl { get; } = RepositoryPage("/issues"); + + + + /// The newest stable release, with its installers and portable archive. + public static Uri LatestReleaseUrl { get; } = RepositoryPage("/releases/latest"); + + + + /// The installer of the newest stable release. The file name is the one the release packaging gives the stable edition's Setup. + public static Uri InstallerUrl { get; } = RepositoryPage("/releases/download/latest/" + Info.Id + "-stable-Setup.exe"); + + + + /// The user guide as published on the main branch. + public static Uri UserGuideUrl { get; } = RepositoryPage("/src/branch/main/docs/user-guide.md"); + + + + /// The app's own license as published on the main branch. + public static Uri LicenseUrl { get; } = RepositoryPage("/src/branch/main/LICENSE"); + + + + /// A page of the app's repository, given its path below the repository root. + private static Uri RepositoryPage(string path) => new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + path); diff --git a/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj b/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj index 317c5d4..e0ea90d 100644 --- a/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj +++ b/src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/src/MarkdownViewr.Windows.Core/Sharing/ShareInvitation.cs b/src/MarkdownViewr.Windows.Core/Sharing/ShareInvitation.cs new file mode 100644 index 0000000..fbdea79 --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Sharing/ShareInvitation.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + + +namespace MarkdownViewr.Windows.Core.Sharing; + +/// +/// The invitation a person sends to someone who might like the app: an email draft that says +/// what the app is and where to download it, learn it and report on it. The text is fixed, so +/// nothing about the person's documents or settings leaves with it. +/// +public static class ShareInvitation +{ + /// + /// Some mail programs are started with the whole link on a command line and cut it near 2,000 + /// characters. The draft stays under that, so it arrives complete. + /// + public const int MaximumLinkLength = 2000; + + + + /// The subject line of the draft. + public static string Subject { get; } = "Try " + AppIdentity.Info.DisplayName + ", a Markdown reader for Windows"; + + + + /// The message, with lines separated the way email requires. + public static string Body { get; } = string.Join( + "\r\n", + "Hello,", + "", + "I use " + AppIdentity.Info.DisplayName + " to read Markdown files on Windows and thought you might like to try it. It shows README files, notes and project documentation in a clean reading view, follows the file as it changes, and prints or saves to PDF. It is free and open source.", + "", + "Download the installer:", + AppIdentity.InstallerUrl.AbsoluteUri, + "", + "It installs for your account only, with no administrator rights needed, on 64-bit Windows 10 (version 1809 or later) or Windows 11. A portable ZIP and an MSI are on the release page:", + AppIdentity.LatestReleaseUrl.AbsoluteUri, + "", + "User guide:", + AppIdentity.UserGuideUrl.AbsoluteUri, + "", + "If something does not work, or you have an idea, please report it here:", + AppIdentity.SupportUrl.AbsoluteUri, + "", + "Project page and source code:", + AppIdentity.Info.RepositoryUrl!.AbsoluteUri); + + + + /// The whole invitation as plain text, for when no email program is available. + public static string Text { get; } = Subject + "\r\n\r\n" + Body; + + + + /// + /// A link that opens the draft in the person's email program with no recipient, so the only + /// thing left to do is address it and send. + /// + public static Uri MailLink { get; } = new("mailto:?subject=" + Uri.EscapeDataString(Subject) + "&body=" + Uri.EscapeDataString(Body)); +} diff --git a/src/MarkdownViewr.Windows.Core/Updates/IReleaseUpdater.cs b/src/MarkdownViewr.Windows.Core/Updates/IReleaseUpdater.cs new file mode 100644 index 0000000..1a23f5d --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Updates/IReleaseUpdater.cs @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + + +namespace MarkdownViewr.Windows.Core.Updates; + +/// +/// The part of the update library the app uses: find a newer release of this edition, download +/// it, and restart into it. Implementations may throw; turns +/// every failure into a message. +/// +public interface IReleaseUpdater +{ + /// True when this copy was installed by the update library and can replace itself. + bool IsInstalled { get; } + + + + /// The installed version, or null for a copy that was not installed. + string? CurrentVersion { get; } + + + + /// Looks for a newer release and remembers it for the download. Returns its version, or null when there is none. + Task CheckAsync(); + + + + /// Downloads the release found by the last check, reporting progress from 0 to 100. + Task DownloadAsync(Action? progress, CancellationToken cancellation); + + + + /// Installs the downloaded release and restarts the app. The process exits on success. + void ApplyAndRestart(); +} diff --git a/src/MarkdownViewr.Windows.Core/Updates/ReleaseUpdateService.cs b/src/MarkdownViewr.Windows.Core/Updates/ReleaseUpdateService.cs new file mode 100644 index 0000000..6b330b1 --- /dev/null +++ b/src/MarkdownViewr.Windows.Core/Updates/ReleaseUpdateService.cs @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using KofTwentyTwo.AppKit.Updates; + + +namespace MarkdownViewr.Windows.Core.Updates; + +/// +/// Checks for and installs updates published as releases of the app's repository. Every failure +/// comes back as a value, never an exception: nothing as optional as an update may take the +/// app down. +/// +public sealed class ReleaseUpdateService : IUpdateService +{ + private readonly Func _create; + private IReleaseUpdater? _updater; + private bool _updateReady; + + + + /// The updater is created on first use, so constructing the service cannot fail. + public ReleaseUpdateService(Func create) + { + ArgumentNullException.ThrowIfNull(create); + _create = create; + } + + + + /// + public bool IsSupported + { + get + { + try + { + return Updater.IsInstalled; + } + catch(Exception) + { + return false; + } + } + } + + + + /// + public string? CurrentVersion + { + get + { + try + { + return Updater.CurrentVersion; + } + catch(Exception) + { + return null; + } + } + } + + + + /// The one updater this service uses; it remembers the release a check found. + private IReleaseUpdater Updater => _updater ??= _create(); + + + + /// + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + _updateReady = false; + try + { + if(!Updater.IsInstalled) + { + return UpdateCheckResult.Failed(UpdateText.NotSupported); + } + string? version = await Updater.CheckAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + _updateReady = version is not null; + return version is null ? UpdateCheckResult.UpToDate : UpdateCheckResult.Available(version); + } + catch(Exception exception) + { + return UpdateCheckResult.Failed(exception.Message); + } + } + + + + /// + public async Task DownloadAndApplyAsync(IProgress? progress = null, CancellationToken cancellationToken = default) + { + if(!_updateReady) + { + return "No update is ready to install; check for updates first."; + } + try + { + await Updater.DownloadAsync(progress is null ? null : progress.Report, cancellationToken).ConfigureAwait(false); + Updater.ApplyAndRestart(); + return null; + } + catch(Exception exception) + { + return exception.Message; + } + } +} diff --git a/src/MarkdownViewr.Windows.Core/packages.lock.json b/src/MarkdownViewr.Windows.Core/packages.lock.json index 25cb5ba..fd6d99e 100644 --- a/src/MarkdownViewr.Windows.Core/packages.lock.json +++ b/src/MarkdownViewr.Windows.Core/packages.lock.json @@ -18,6 +18,16 @@ "resolved": "0.1.0", "contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ==" }, + "KofTwentyTwo.AppKit.Updates": { + "type": "Direct", + "requested": "[0.1.0, )", + "resolved": "0.1.0", + "contentHash": "81vbwwOKoMtF7O423F8LYUTg8KjcyKQXqfqC6oWnRsQfDNQMgh+vaSKjmX6qmgvOAIY0dVsmu55TEpeRaJMGDw==", + "dependencies": { + "KofTwentyTwo.AppKit": "0.1.0", + "Velopack": "1.2.161" + } + }, "Markdig": { "type": "Direct", "requested": "[1.4.0, )", @@ -48,6 +58,11 @@ "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } + }, + "Velopack": { + "type": "Transitive", + "resolved": "1.2.161", + "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg==" } }, "net10.0/win-arm64": {}, diff --git a/src/MarkdownViewr.Windows/AppAbout.cs b/src/MarkdownViewr.Windows/AppAbout.cs index c642ec9..6ad4ffc 100644 --- a/src/MarkdownViewr.Windows/AppAbout.cs +++ b/src/MarkdownViewr.Windows/AppAbout.cs @@ -23,7 +23,7 @@ internal static class AppAbout body.MaxWidth = 580; scroll.MaxHeight = Math.Max(160, Math.Min(460, root.Size.Height - 220)); dialog.Resources["ContentDialogMaxWidth"] = 680d; - body.Children.Add(Link("AboutApplicationLicense", "MarkdownViewr MIT license", AppIdentity.Info.RepositoryUrl!.AbsoluteUri + "/blob/main/LICENSE")); + body.Children.Add(Link("AboutApplicationLicense", "MarkdownViewr MIT license", Core.AppIdentity.LicenseUrl.AbsoluteUri)); body.Children.Add(Contributions()); body.Children.Add(Components("Components", "AboutComponents", AboutComponents.OpenSource)); return dialog; diff --git a/src/MarkdownViewr.Windows/GiteaReleaseUpdater.cs b/src/MarkdownViewr.Windows/GiteaReleaseUpdater.cs new file mode 100644 index 0000000..a87448a --- /dev/null +++ b/src/MarkdownViewr.Windows/GiteaReleaseUpdater.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using MarkdownViewr.Windows.Core.Updates; +using Velopack; +using Velopack.Sources; + + +namespace MarkdownViewr.Windows; + +/// +/// Velopack's update manager over the releases of the app's Gitea repository. A pass-through: +/// every member needs an installed copy or the network, and the decisions around it live in +/// . +/// +internal sealed class GiteaReleaseUpdater : IReleaseUpdater +{ + private readonly UpdateManager _manager; + private UpdateInfo? _pending; + + + + /// + /// Reads the repository's releases without credentials. Development builds are published as + /// prereleases, so only a development build looks at those. + /// + public GiteaReleaseUpdater(Uri repository, bool includePrereleases) + { + _manager = new UpdateManager(new GiteaSource(repository.AbsoluteUri, accessToken: null, prerelease: includePrereleases)); + } + + + + /// + public bool IsInstalled => _manager.IsInstalled; + + + + /// + public string? CurrentVersion => _manager.CurrentVersion?.ToString(); + + + + /// + public async Task CheckAsync() + { + _pending = await _manager.CheckForUpdatesAsync().ConfigureAwait(false); + return _pending?.TargetFullRelease.Version.ToString(); + } + + + + /// + public Task DownloadAsync(Action? progress, CancellationToken cancellation) + => _pending is null + ? Task.FromException(new InvalidOperationException("No update was found to download.")) + : _manager.DownloadUpdatesAsync(_pending, progress, cancellation); + + + + /// + public void ApplyAndRestart() + { + if(_pending is null) + { + throw new InvalidOperationException("No update was found to apply."); + } + _manager.ApplyUpdatesAndRestart(_pending.TargetFullRelease); + } +} diff --git a/src/MarkdownViewr.Windows/MainWindow.Help.cs b/src/MarkdownViewr.Windows/MainWindow.Help.cs index 83ff829..7a468d3 100644 --- a/src/MarkdownViewr.Windows/MainWindow.Help.cs +++ b/src/MarkdownViewr.Windows/MainWindow.Help.cs @@ -3,7 +3,11 @@ * SPDX-License-Identifier: MIT */ +using System.Runtime.InteropServices; +using MarkdownViewr.Windows.Core.Sharing; using Microsoft.UI.Xaml; +using Windows.ApplicationModel.DataTransfer; +using Windows.System; namespace MarkdownViewr.Windows; @@ -12,7 +16,7 @@ namespace MarkdownViewr.Windows; public sealed partial class MainWindow { /// Shows this Windows app's repository in the user's default browser. - private async void GitHub_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.Info.RepositoryUrl!); + private async void Repository_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.Info.RepositoryUrl!); @@ -21,25 +25,59 @@ public sealed partial class MainWindow - /// Opens community questions and tips in this app's repository. + /// Opens the questions and reports in this app's repository. private async void CommunitySupport_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.SupportUrl); + /// + /// Starts an invitation in the person's email program, ready to address and send. Without an + /// email program the invitation is put on the clipboard, so it can be pasted anywhere. + /// + private async void Share_Click(object sender, RoutedEventArgs args) + { + if(await LaunchAsync(ShareInvitation.MailLink)) + { + return; + } + try + { + var invitation = new DataPackage(); + invitation.SetText(ShareInvitation.Text); + Clipboard.SetContent(invitation); + StatusText.Text = "No email app opened. The invitation was copied to the clipboard."; + } + catch(Exception exception) when(exception is COMException or UnauthorizedAccessException) + { + StatusText.Text = "The invitation could not be opened or copied. Check your default email app."; + App.Log.Warning(FormattableString.Invariant($"The invitation could not be copied ({exception.GetType().Name}, 0x{exception.HResult:X8}).")); + } + } + + + /// A missing browser or OS launch failure is visible; no document path/content is included. private async Task OpenHelpPageAsync(Uri target) { - try - { - if(!await global::Windows.System.Launcher.LaunchUriAsync(target)) - { - StatusText.Text = "The link could not be opened. Check your default browser."; - } - } - catch(Exception exception) when(exception is System.Runtime.InteropServices.COMException or InvalidOperationException or ArgumentException) + if(!await LaunchAsync(target)) { StatusText.Text = "The link could not be opened. Check your default browser."; + } + } + + + + /// Hands one of the app's fixed links to Windows. False when nothing opened it; the failure is logged without the link. + private static async Task LaunchAsync(Uri target) + { + try + { + return await Launcher.LaunchUriAsync(target); + } + catch(Exception exception) when(exception is COMException or InvalidOperationException or ArgumentException) + { App.Log.Warning(FormattableString.Invariant($"A Help link could not be opened ({exception.GetType().Name}, 0x{exception.HResult:X8}).")); + return false; } } } diff --git a/src/MarkdownViewr.Windows/MainWindow.xaml b/src/MarkdownViewr.Windows/MainWindow.xaml index e53d936..f2f634c 100644 --- a/src/MarkdownViewr.Windows/MainWindow.xaml +++ b/src/MarkdownViewr.Windows/MainWindow.xaml @@ -87,9 +87,10 @@ - - - + + + + diff --git a/src/MarkdownViewr.Windows/MainWindow.xaml.cs b/src/MarkdownViewr.Windows/MainWindow.xaml.cs index e26f58f..dceebca 100644 --- a/src/MarkdownViewr.Windows/MainWindow.xaml.cs +++ b/src/MarkdownViewr.Windows/MainWindow.xaml.cs @@ -3,9 +3,11 @@ * SPDX-License-Identifier: MIT */ +using KofTwentyTwo.AppKit; using KofTwentyTwo.AppKit.Settings; using KofTwentyTwo.AppKit.Updates; using KofTwentyTwo.AppKit.WinUI; +using MarkdownViewr.Windows.Core.Updates; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.Windows.Storage.Pickers; @@ -64,7 +66,7 @@ public sealed partial class MainWindow : Window, IDisposable this.SetAppIcon(AppIdentity.Info); _updates = new UpdateCoordinator( - new VelopackUpdateService(AppIdentity.Info.RepositoryUrl!), + new ReleaseUpdateService(() => new GiteaReleaseUpdater(AppIdentity.Info.RepositoryUrl!, BuildVersion.IsPrerelease(typeof(App).Assembly))), new ContentDialogPrompter(() => Content.XamlRoot), AppIdentity.Info.DisplayName, App.Log); diff --git a/src/MarkdownViewr.Windows/packages.lock.json b/src/MarkdownViewr.Windows/packages.lock.json index b04eca2..8aea4d9 100644 --- a/src/MarkdownViewr.Windows/packages.lock.json +++ b/src/MarkdownViewr.Windows/packages.lock.json @@ -190,6 +190,7 @@ "dependencies": { "HtmlSanitizer": "[9.2.1039, )", "KofTwentyTwo.AppKit": "[0.1.0, )", + "KofTwentyTwo.AppKit.Updates": "[0.1.0, )", "Markdig": "[1.4.0, )" } }, diff --git a/tests/MarkdownViewr.Windows.Core.Tests/AppIdentityTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/AppIdentityTests.cs index fe5ba41..2d7c681 100644 --- a/tests/MarkdownViewr.Windows.Core.Tests/AppIdentityTests.cs +++ b/tests/MarkdownViewr.Windows.Core.Tests/AppIdentityTests.cs @@ -24,8 +24,21 @@ public class AppIdentityTests Assert.Equal("https://github.com/darinkelkhoff/markdownViewr", AppIdentity.OriginalRepositoryUrl.AbsoluteUri); Assert.Equal("https://kelkhoff.com/", AppIdentity.OriginalAuthorWebsite.AbsoluteUri); Assert.DoesNotContain(AppIdentity.Info.Attributions, credit => credit.Name.Contains("Darin Kelkhoff", StringComparison.Ordinal)); - Assert.Equal(AppIdentity.Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/issues/new/choose", AppIdentity.IssuesUrl.AbsoluteUri); - Assert.Equal(AppIdentity.Info.RepositoryUrl.AbsoluteUri.TrimEnd('/') + "/discussions", AppIdentity.SupportUrl.AbsoluteUri); + } + + + + /// The app's own pages are on its Gitea repository; each is spelled out because a wrong path only shows up as a broken link. + [Fact] + public void Links_PointAtTheAppsRepository() + { + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows", AppIdentity.Info.RepositoryUrl!.AbsoluteUri); + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose", AppIdentity.IssuesUrl.AbsoluteUri); + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues", AppIdentity.SupportUrl.AbsoluteUri); + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest", AppIdentity.LatestReleaseUrl.AbsoluteUri); + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/latest/markdownviewr-windows-stable-Setup.exe", AppIdentity.InstallerUrl.AbsoluteUri); + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/src/branch/main/docs/user-guide.md", AppIdentity.UserGuideUrl.AbsoluteUri); + Assert.Equal("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/src/branch/main/LICENSE", AppIdentity.LicenseUrl.AbsoluteUri); } diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ReleaseUpdateServiceTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ReleaseUpdateServiceTests.cs new file mode 100644 index 0000000..f1a80ac --- /dev/null +++ b/tests/MarkdownViewr.Windows.Core.Tests/ReleaseUpdateServiceTests.cs @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using KofTwentyTwo.AppKit.Updates; +using MarkdownViewr.Windows.Core.Updates; + + +namespace MarkdownViewr.Windows.Core.Tests; + +/// Tests of the update service's decisions around a scripted updater: no installed copy and no network are needed. +public sealed class ReleaseUpdateServiceTests +{ + /// A service needs a way to make its updater. + [Fact] + public void Constructor_WithoutAFactory_IsRefused() + { + Assert.Throws(() => new ReleaseUpdateService(null!)); + } + + + + /// The updater is made on first use and reused, so building the window cannot fail and a check's result is still there for the download. + [Fact] + public async Task Updater_IsCreatedOnFirstUseAndOnlyOnce() + { + int created = 0; + var updater = new ScriptedUpdater { Installed = true, Version = "1.0.3", Found = "1.0.4" }; + var service = new ReleaseUpdateService(() => + { + created++; + return updater; + }); + Assert.Equal(0, created); + Assert.True(service.IsSupported); + Assert.Equal("1.0.3", service.CurrentVersion); + Assert.True((await service.CheckAsync()).IsUpdateAvailable); + Assert.Null(await service.DownloadAndApplyAsync()); + Assert.Equal(1, created); + } + + + + /// A copy that was not installed reports that, with no version, and a check explains why instead of asking the network. + [Fact] + public async Task CheckAsync_CopyNotInstalled_ExplainsWithoutChecking() + { + var updater = new ScriptedUpdater { Installed = false }; + var service = new ReleaseUpdateService(() => updater); + Assert.False(service.IsSupported); + Assert.Null(service.CurrentVersion); + UpdateCheckResult result = await service.CheckAsync(); + Assert.Equal(UpdateText.NotSupported, result.Error); + Assert.False(result.IsUpdateAvailable); + Assert.Equal(0, updater.Checks); + } + + + + /// An updater that cannot even be made leaves the app running: unsupported, no version, and a failed check carrying the reason. + [Fact] + public async Task Members_UpdaterCannotBeCreated_ReportFailureAsValues() + { + var service = new ReleaseUpdateService(() => throw new InvalidOperationException("no update manager")); + Assert.False(service.IsSupported); + Assert.Null(service.CurrentVersion); + UpdateCheckResult result = await service.CheckAsync(); + Assert.Equal("no update manager", result.Error); + Assert.Equal("No update is ready to install; check for updates first.", await service.DownloadAndApplyAsync()); + } + + + + /// No newer release is not an error and leaves nothing to install. + [Fact] + public async Task CheckAsync_NoNewerRelease_IsUpToDate() + { + var updater = new ScriptedUpdater { Installed = true, Version = "1.0.4", Found = null }; + var service = new ReleaseUpdateService(() => updater); + UpdateCheckResult result = await service.CheckAsync(); + Assert.Same(UpdateCheckResult.UpToDate, result); + Assert.Equal("No update is ready to install; check for updates first.", await service.DownloadAndApplyAsync()); + Assert.Equal(0, updater.Downloads); + } + + + + /// A newer release is offered by version, then downloaded with progress and applied. + [Fact] + public async Task DownloadAndApplyAsync_AfterAFoundRelease_DownloadsReportsProgressAndApplies() + { + var updater = new ScriptedUpdater { Installed = true, Version = "1.0.3", Found = "1.0.4" }; + var service = new ReleaseUpdateService(() => updater); + UpdateCheckResult result = await service.CheckAsync(); + Assert.Equal("1.0.4", result.AvailableVersion); + var progress = new RecordingProgress(); + Assert.Null(await service.DownloadAndApplyAsync(progress)); + Assert.Equal([0, 100], progress.Values); + Assert.Equal(1, updater.Downloads); + Assert.Equal(1, updater.Applied); + } + + + + /// A download without a progress listener passes none on. + [Fact] + public async Task DownloadAndApplyAsync_WithoutProgress_PassesNoCallback() + { + var updater = new ScriptedUpdater { Installed = true, Found = "1.0.4" }; + var service = new ReleaseUpdateService(() => updater); + await service.CheckAsync(); + Assert.Null(await service.DownloadAndApplyAsync()); + Assert.False(updater.ProgressRequested); + } + + + + /// A check that fails says why and leaves nothing to install, even after an earlier check found a release. + [Fact] + public async Task CheckAsync_Fails_ReportsTheReasonAndForgetsTheEarlierRelease() + { + var updater = new ScriptedUpdater { Installed = true, Found = "1.0.4" }; + var service = new ReleaseUpdateService(() => updater); + Assert.True((await service.CheckAsync()).IsUpdateAvailable); + updater.CheckFailure = new HttpRequestException("the server did not answer"); + UpdateCheckResult failed = await service.CheckAsync(); + Assert.Equal("the server did not answer", failed.Error); + Assert.Equal("No update is ready to install; check for updates first.", await service.DownloadAndApplyAsync()); + Assert.Equal(0, updater.Downloads); + } + + + + /// A check abandoned by the caller ends as a failed check, not an exception. + [Fact] + public async Task CheckAsync_Cancelled_ReturnsAFailureValue() + { + var updater = new ScriptedUpdater { Installed = true, Pending = new TaskCompletionSource() }; + var service = new ReleaseUpdateService(() => updater); + using var cancellation = new CancellationTokenSource(); + Task check = service.CheckAsync(cancellation.Token); + await cancellation.CancelAsync(); + UpdateCheckResult result = await check; + Assert.NotNull(result.Error); + Assert.False(result.IsUpdateAvailable); + } + + + + /// A failed download or a failed install comes back as the reason; the app keeps running on the installed version. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DownloadAndApplyAsync_DownloadOrInstallFails_ReturnsTheReason(bool failDownload) + { + var updater = new ScriptedUpdater { Installed = true, Found = "1.0.4" }; + if(failDownload) + { + updater.DownloadFailure = new IOException("the disk is full"); + } + else + { + updater.ApplyFailure = new InvalidOperationException("the installer is busy"); + } + var service = new ReleaseUpdateService(() => updater); + await service.CheckAsync(); + string? error = await service.DownloadAndApplyAsync(); + Assert.Equal(failDownload ? "the disk is full" : "the installer is busy", error); + Assert.Equal(failDownload ? 0 : 1, updater.Applied); + } + + + + /// Collects progress reports synchronously, in order. + private sealed class RecordingProgress : IProgress + { + /// Every value reported so far. + public List Values { get; } = []; + + + + /// Records one report. + public void Report(int value) => Values.Add(value); + } + + + + /// An updater whose answers and failures are set by the test. + private sealed class ScriptedUpdater : IReleaseUpdater + { + /// Whether this copy counts as installed. + public bool Installed { get; init; } + + /// The installed version reported. + public string? Version { get; init; } + + /// The newer version a check finds, or null for none. + public string? Found { get; init; } + + /// A check that completes only when the test says so. + public TaskCompletionSource? Pending { get; init; } + + /// Makes the next checks fail. + public Exception? CheckFailure { get; set; } + + /// Makes the download fail. + public Exception? DownloadFailure { get; set; } + + /// Makes the install fail. + public Exception? ApplyFailure { get; set; } + + /// How many checks ran. + public int Checks { get; private set; } + + /// How many downloads completed. + public int Downloads { get; private set; } + + /// How many installs were started. + public int Applied { get; private set; } + + /// Whether the last download was given a progress callback. + public bool ProgressRequested { get; private set; } + + /// + public bool IsInstalled => Installed; + + /// + public string? CurrentVersion => Installed ? Version : null; + + + + /// + public Task CheckAsync() + { + Checks++; + if(CheckFailure is not null) + { + return Task.FromException(CheckFailure); + } + return Pending?.Task ?? Task.FromResult(Found); + } + + + + /// + public Task DownloadAsync(Action? progress, CancellationToken cancellation) + { + ProgressRequested = progress is not null; + if(DownloadFailure is not null) + { + return Task.FromException(DownloadFailure); + } + progress?.Invoke(0); + progress?.Invoke(100); + Downloads++; + return Task.CompletedTask; + } + + + + /// + public void ApplyAndRestart() + { + Applied++; + if(ApplyFailure is not null) + { + throw ApplyFailure; + } + } + } +} diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ShareInvitationTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ShareInvitationTests.cs new file mode 100644 index 0000000..6293140 --- /dev/null +++ b/tests/MarkdownViewr.Windows.Core.Tests/ShareInvitationTests.cs @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 James Maes + * SPDX-License-Identifier: MIT + */ + +using MarkdownViewr.Windows.Core.Sharing; + + +namespace MarkdownViewr.Windows.Core.Tests; + +/// Tests of the invitation email a person can start from the Help menu. +public sealed class ShareInvitationTests +{ + /// The draft has no recipient: the person only has to address it and send. + [Fact] + public void MailLink_IsAMailDraftWithoutARecipient() + { + Assert.Equal("mailto", ShareInvitation.MailLink.Scheme); + Assert.StartsWith("mailto:?subject=", ShareInvitation.MailLink.AbsoluteUri, StringComparison.Ordinal); + Assert.Equal(ShareInvitation.MailLink.OriginalString, ShareInvitation.MailLink.AbsoluteUri); + } + + + + /// An email program that decodes the link gets exactly the subject and message, and nothing else. + [Fact] + public void MailLink_DecodesToTheSubjectAndBody() + { + string query = ShareInvitation.MailLink.AbsoluteUri["mailto:?".Length..]; + string[] fields = query.Split('&'); + Assert.Equal(2, fields.Length); + Assert.StartsWith("subject=", fields[0], StringComparison.Ordinal); + Assert.StartsWith("body=", fields[1], StringComparison.Ordinal); + Assert.Equal(ShareInvitation.Subject, Uri.UnescapeDataString(fields[0]["subject=".Length..])); + Assert.Equal(ShareInvitation.Body, Uri.UnescapeDataString(fields[1]["body=".Length..])); + } + + + + /// Only characters every mail link parser reads the same way appear unescaped, so nothing in the text can end a field early or add one. + [Fact] + public void MailLink_EscapesEverythingButUnreservedCharacters() + { + string query = ShareInvitation.MailLink.AbsoluteUri["mailto:?".Length..]; + foreach(string field in query.Split('&')) + { + string value = field[(field.IndexOf('=', StringComparison.Ordinal) + 1)..]; + Assert.Matches("^(?:[A-Za-z0-9._~-]|%[0-9A-F]{2})+$", value); + } + } + + + + /// Mail programs started from a command line cut a long link; the whole draft fits under that length. + [Fact] + public void MailLink_FitsTheLengthMailProgramsAccept() + { + Assert.Equal(2000, ShareInvitation.MaximumLinkLength); + Assert.InRange(ShareInvitation.MailLink.AbsoluteUri.Length, 1, ShareInvitation.MaximumLinkLength); + } + + + + /// The message gives the installer, the release page, the user guide, where to report on the app and where it lives. + [Fact] + public void Body_GivesTheDownloadGuideReportAndProjectLinks() + { + string[] lines = ShareInvitation.Body.Split("\r\n"); + Assert.Contains("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/download/latest/markdownviewr-windows-stable-Setup.exe", lines, StringComparer.Ordinal); + Assert.Contains("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest", lines, StringComparer.Ordinal); + Assert.Contains("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/src/branch/main/docs/user-guide.md", lines, StringComparer.Ordinal); + Assert.Contains("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows/issues", lines, StringComparer.Ordinal); + Assert.Contains("https://git.kof22.com/KofTwentyTwo/MarkdownViewr.Windows", lines, StringComparer.Ordinal); + Assert.Equal(AppIdentity.Info.RepositoryUrl!.AbsoluteUri, lines[^1]); + Assert.Contains("Windows 10 (version 1809 or later) or Windows 11", ShareInvitation.Body, StringComparison.Ordinal); + Assert.Contains("MarkdownViewr", ShareInvitation.Subject, StringComparison.Ordinal); + } + + + + /// Email requires CRLF line ends; a bare line feed or carriage return shows as one long line in some programs. + [Fact] + public void Body_UsesOnlyEmailLineEnds() + { + string withoutLineEnds = ShareInvitation.Body.Replace("\r\n", "", StringComparison.Ordinal); + Assert.DoesNotContain('\r', withoutLineEnds); + Assert.DoesNotContain('\n', withoutLineEnds); + Assert.False(ShareInvitation.Body.EndsWith("\r\n", StringComparison.Ordinal)); + } + + + + /// The clipboard fallback carries the same invitation: the subject, a blank line, then the message. + [Fact] + public void Text_IsTheSubjectThenTheBody() + { + Assert.Equal(ShareInvitation.Subject + "\r\n\r\n" + ShareInvitation.Body, ShareInvitation.Text); + } +} diff --git a/tests/MarkdownViewr.Windows.Core.Tests/packages.lock.json b/tests/MarkdownViewr.Windows.Core.Tests/packages.lock.json index 946aa88..6e2686b 100644 --- a/tests/MarkdownViewr.Windows.Core.Tests/packages.lock.json +++ b/tests/MarkdownViewr.Windows.Core.Tests/packages.lock.json @@ -72,6 +72,11 @@ "Microsoft.TestPlatform.ObjectModel": "18.10.1" } }, + "Velopack": { + "type": "Transitive", + "resolved": "1.2.161", + "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg==" + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -117,6 +122,7 @@ "dependencies": { "HtmlSanitizer": "[9.2.1039, )", "KofTwentyTwo.AppKit": "[0.1.0, )", + "KofTwentyTwo.AppKit.Updates": "[0.1.0, )", "Markdig": "[1.4.0, )" } }, @@ -136,6 +142,16 @@ "resolved": "0.1.0", "contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ==" }, + "KofTwentyTwo.AppKit.Updates": { + "type": "CentralTransitive", + "requested": "[0.1.0, )", + "resolved": "0.1.0", + "contentHash": "81vbwwOKoMtF7O423F8LYUTg8KjcyKQXqfqC6oWnRsQfDNQMgh+vaSKjmX6qmgvOAIY0dVsmu55TEpeRaJMGDw==", + "dependencies": { + "KofTwentyTwo.AppKit": "0.1.0", + "Velopack": "1.2.161" + } + }, "Markdig": { "type": "CentralTransitive", "requested": "[1.4.0, )", diff --git a/tests/MarkdownViewr.Windows.UiTests/packages.lock.json b/tests/MarkdownViewr.Windows.UiTests/packages.lock.json index 2e549fd..493cc8e 100644 --- a/tests/MarkdownViewr.Windows.UiTests/packages.lock.json +++ b/tests/MarkdownViewr.Windows.UiTests/packages.lock.json @@ -158,6 +158,11 @@ "resolved": "8.0.0", "contentHash": "Obg3a90MkOw9mYKxrardLpY2u0axDMrSmy4JCdq2cYbelM2cUwmUir5Bomvd1yxmPL9h5LVHU1tuKBZpUjfASg==" }, + "Velopack": { + "type": "Transitive", + "resolved": "1.2.161", + "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg==" + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -203,6 +208,7 @@ "dependencies": { "HtmlSanitizer": "[9.2.1039, )", "KofTwentyTwo.AppKit": "[0.1.0, )", + "KofTwentyTwo.AppKit.Updates": "[0.1.0, )", "Markdig": "[1.4.0, )" } }, @@ -222,6 +228,16 @@ "resolved": "0.1.0", "contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ==" }, + "KofTwentyTwo.AppKit.Updates": { + "type": "CentralTransitive", + "requested": "[0.1.0, )", + "resolved": "0.1.0", + "contentHash": "81vbwwOKoMtF7O423F8LYUTg8KjcyKQXqfqC6oWnRsQfDNQMgh+vaSKjmX6qmgvOAIY0dVsmu55TEpeRaJMGDw==", + "dependencies": { + "KofTwentyTwo.AppKit": "0.1.0", + "Velopack": "1.2.161" + } + }, "Markdig": { "type": "CentralTransitive", "requested": "[1.4.0, )",