feat(help): move links and updates to Gitea and add Share #57

Open
james.maes wants to merge 1 commits from feat/gitea-links-and-share into main
pull from: feat/gitea-links-and-share
22 changed files with 885 additions and 32 deletions

No files matched your search

+6 -6
View File
@@ -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).
@@ -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/<asset>` 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.
+2 -1
View File
@@ -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
+5 -1
View File
@@ -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
@@ -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"),
+29 -4
View File
@@ -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
/// <summary>The app's bug/feature templates, independent of any upstream project credited in About.</summary>
public static Uri IssuesUrl { get; } = new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/issues/new/choose");
public static Uri IssuesUrl { get; } = RepositoryPage("/issues/new/choose");
/// <summary>Community questions and tips in the app's own repository.</summary>
public static Uri SupportUrl { get; } = new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + "/discussions");
/// <summary>Questions already asked and problems already reported in the app's own repository.</summary>
public static Uri SupportUrl { get; } = RepositoryPage("/issues");
/// <summary>The newest stable release, with its installers and portable archive.</summary>
public static Uri LatestReleaseUrl { get; } = RepositoryPage("/releases/latest");
/// <summary>The installer of the newest stable release. The file name is the one the release packaging gives the stable edition's Setup.</summary>
public static Uri InstallerUrl { get; } = RepositoryPage("/releases/download/latest/" + Info.Id + "-stable-Setup.exe");
/// <summary>The user guide as published on the main branch.</summary>
public static Uri UserGuideUrl { get; } = RepositoryPage("/src/branch/main/docs/user-guide.md");
/// <summary>The app's own license as published on the main branch.</summary>
public static Uri LicenseUrl { get; } = RepositoryPage("/src/branch/main/LICENSE");
/// <summary>A page of the app's repository, given its path below the repository root.</summary>
private static Uri RepositoryPage(string path) => new(Info.RepositoryUrl!.AbsoluteUri.TrimEnd('/') + path);
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="KofTwentyTwo.AppKit" />
<PackageReference Include="KofTwentyTwo.AppKit.Updates" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Sharing;
/// <summary>
/// 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.
/// </summary>
public static class ShareInvitation
{
/// <summary>
/// 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.
/// </summary>
public const int MaximumLinkLength = 2000;
/// <summary>The subject line of the draft.</summary>
public static string Subject { get; } = "Try " + AppIdentity.Info.DisplayName + ", a Markdown reader for Windows";
/// <summary>The message, with lines separated the way email requires.</summary>
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);
/// <summary>The whole invitation as plain text, for when no email program is available.</summary>
public static string Text { get; } = Subject + "\r\n\r\n" + Body;
/// <summary>
/// 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.
/// </summary>
public static Uri MailLink { get; } = new("mailto:?subject=" + Uri.EscapeDataString(Subject) + "&body=" + Uri.EscapeDataString(Body));
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Updates;
/// <summary>
/// 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; <see cref="ReleaseUpdateService"/> turns
/// every failure into a message.
/// </summary>
public interface IReleaseUpdater
{
/// <summary>True when this copy was installed by the update library and can replace itself.</summary>
bool IsInstalled { get; }
/// <summary>The installed version, or null for a copy that was not installed.</summary>
string? CurrentVersion { get; }
/// <summary>Looks for a newer release and remembers it for the download. Returns its version, or null when there is none.</summary>
Task<string?> CheckAsync();
/// <summary>Downloads the release found by the last check, reporting progress from 0 to 100.</summary>
Task DownloadAsync(Action<int>? progress, CancellationToken cancellation);
/// <summary>Installs the downloaded release and restarts the app. The process exits on success.</summary>
void ApplyAndRestart();
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Updates;
namespace MarkdownViewr.Windows.Core.Updates;
/// <summary>
/// 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.
/// </summary>
public sealed class ReleaseUpdateService : IUpdateService
{
private readonly Func<IReleaseUpdater> _create;
private IReleaseUpdater? _updater;
private bool _updateReady;
/// <summary>The updater is created on first use, so constructing the service cannot fail.</summary>
public ReleaseUpdateService(Func<IReleaseUpdater> create)
{
ArgumentNullException.ThrowIfNull(create);
_create = create;
}
/// <inheritdoc/>
public bool IsSupported
{
get
{
try
{
return Updater.IsInstalled;
}
catch(Exception)
{
return false;
}
}
}
/// <inheritdoc/>
public string? CurrentVersion
{
get
{
try
{
return Updater.CurrentVersion;
}
catch(Exception)
{
return null;
}
}
}
/// <summary>The one updater this service uses; it remembers the release a check found.</summary>
private IReleaseUpdater Updater => _updater ??= _create();
/// <inheritdoc/>
public async Task<UpdateCheckResult> 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);
}
}
/// <inheritdoc/>
public async Task<string?> DownloadAndApplyAsync(IProgress<int>? 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;
}
}
}
@@ -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": {},
+1 -1
View File
@@ -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;
@@ -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;
/// <summary>
/// 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
/// <see cref="ReleaseUpdateService"/>.
/// </summary>
internal sealed class GiteaReleaseUpdater : IReleaseUpdater
{
private readonly UpdateManager _manager;
private UpdateInfo? _pending;
/// <summary>
/// Reads the repository's releases without credentials. Development builds are published as
/// prereleases, so only a development build looks at those.
/// </summary>
public GiteaReleaseUpdater(Uri repository, bool includePrereleases)
{
_manager = new UpdateManager(new GiteaSource(repository.AbsoluteUri, accessToken: null, prerelease: includePrereleases));
}
/// <inheritdoc/>
public bool IsInstalled => _manager.IsInstalled;
/// <inheritdoc/>
public string? CurrentVersion => _manager.CurrentVersion?.ToString();
/// <inheritdoc/>
public async Task<string?> CheckAsync()
{
_pending = await _manager.CheckForUpdatesAsync().ConfigureAwait(false);
return _pending?.TargetFullRelease.Version.ToString();
}
/// <inheritdoc/>
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellation)
=> _pending is null
? Task.FromException(new InvalidOperationException("No update was found to download."))
: _manager.DownloadUpdatesAsync(_pending, progress, cancellation);
/// <inheritdoc/>
public void ApplyAndRestart()
{
if(_pending is null)
{
throw new InvalidOperationException("No update was found to apply.");
}
_manager.ApplyUpdatesAndRestart(_pending.TargetFullRelease);
}
}
+48 -10
View File
@@ -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
{
/// <summary>Shows this Windows app's repository in the user's default browser.</summary>
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
/// <summary>Opens community questions and tips in this app's repository.</summary>
/// <summary>Opens the questions and reports in this app's repository.</summary>
private async void CommunitySupport_Click(object sender, RoutedEventArgs args) => await OpenHelpPageAsync(AppIdentity.SupportUrl);
/// <summary>
/// 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.
/// </summary>
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})."));
}
}
/// <summary>A missing browser or OS launch failure is visible; no document path/content is included.</summary>
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.";
}
}
/// <summary>Hands one of the app's fixed links to Windows. False when nothing opened it; the failure is logged without the link.</summary>
private static async Task<bool> 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;
}
}
}
+4 -3
View File
@@ -87,9 +87,10 @@
<RadioMenuFlyoutItem x:Name="ThemeDark" Text="Dark theme" GroupName="Theme" Tag="Dark" Click="Theme_Click" />
</MenuBarItem>
<MenuBarItem Title="Help" AccessKey="H">
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" AutomationProperties.AutomationId="MenuGitHub" Click="GitHub_Click" />
<MenuFlyoutItem Text="Submit an issue…" AccessKey="I" AutomationProperties.AutomationId="MenuSubmitIssue" ToolTipService.ToolTip="Report a bug or request a feature on GitHub" Click="SubmitIssue_Click" />
<MenuFlyoutItem Text="Community support…" AccessKey="S" AutomationProperties.AutomationId="MenuCommunitySupport" ToolTipService.ToolTip="Ask a question or share tips in GitHub Discussions" Click="CommunitySupport_Click" />
<MenuFlyoutItem Text="Project page" AccessKey="P" AutomationProperties.AutomationId="MenuRepository" ToolTipService.ToolTip="Open the project's page and source code" Click="Repository_Click" />
<MenuFlyoutItem Text="Submit an issue…" AccessKey="I" AutomationProperties.AutomationId="MenuSubmitIssue" ToolTipService.ToolTip="Report a bug or request a feature" Click="SubmitIssue_Click" />
<MenuFlyoutItem Text="Community support…" AccessKey="S" AutomationProperties.AutomationId="MenuCommunitySupport" ToolTipService.ToolTip="See questions already asked and problems already reported" Click="CommunitySupport_Click" />
<MenuFlyoutItem Text="Share MarkdownViewr…" AccessKey="H" AutomationProperties.AutomationId="MenuShare" ToolTipService.ToolTip="Start an email that invites someone to try the app" Click="Share_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="CheckForUpdatesMenuItem" Text="Check for updates" Click="CheckForUpdates_Click" />
<MenuFlyoutItem x:Name="ActivityLogMenuItem" Text="Activity log" Click="ActivityLog_Click" />
+3 -1
View File
@@ -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);
@@ -190,6 +190,7 @@
"dependencies": {
"HtmlSanitizer": "[9.2.1039, )",
"KofTwentyTwo.AppKit": "[0.1.0, )",
"KofTwentyTwo.AppKit.Updates": "[0.1.0, )",
"Markdig": "[1.4.0, )"
}
},
@@ -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);
}
/// <summary>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.</summary>
[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);
}
@@ -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;
/// <summary>Tests of the update service's decisions around a scripted updater: no installed copy and no network are needed.</summary>
public sealed class ReleaseUpdateServiceTests
{
/// <summary>A service needs a way to make its updater.</summary>
[Fact]
public void Constructor_WithoutAFactory_IsRefused()
{
Assert.Throws<ArgumentNullException>(() => new ReleaseUpdateService(null!));
}
/// <summary>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.</summary>
[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);
}
/// <summary>A copy that was not installed reports that, with no version, and a check explains why instead of asking the network.</summary>
[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);
}
/// <summary>An updater that cannot even be made leaves the app running: unsupported, no version, and a failed check carrying the reason.</summary>
[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());
}
/// <summary>No newer release is not an error and leaves nothing to install.</summary>
[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);
}
/// <summary>A newer release is offered by version, then downloaded with progress and applied.</summary>
[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);
}
/// <summary>A download without a progress listener passes none on.</summary>
[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);
}
/// <summary>A check that fails says why and leaves nothing to install, even after an earlier check found a release.</summary>
[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);
}
/// <summary>A check abandoned by the caller ends as a failed check, not an exception.</summary>
[Fact]
public async Task CheckAsync_Cancelled_ReturnsAFailureValue()
{
var updater = new ScriptedUpdater { Installed = true, Pending = new TaskCompletionSource<string?>() };
var service = new ReleaseUpdateService(() => updater);
using var cancellation = new CancellationTokenSource();
Task<UpdateCheckResult> check = service.CheckAsync(cancellation.Token);
await cancellation.CancelAsync();
UpdateCheckResult result = await check;
Assert.NotNull(result.Error);
Assert.False(result.IsUpdateAvailable);
}
/// <summary>A failed download or a failed install comes back as the reason; the app keeps running on the installed version.</summary>
[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);
}
/// <summary>Collects progress reports synchronously, in order.</summary>
private sealed class RecordingProgress : IProgress<int>
{
/// <summary>Every value reported so far.</summary>
public List<int> Values { get; } = [];
/// <summary>Records one report.</summary>
public void Report(int value) => Values.Add(value);
}
/// <summary>An updater whose answers and failures are set by the test.</summary>
private sealed class ScriptedUpdater : IReleaseUpdater
{
/// <summary>Whether this copy counts as installed.</summary>
public bool Installed { get; init; }
/// <summary>The installed version reported.</summary>
public string? Version { get; init; }
/// <summary>The newer version a check finds, or null for none.</summary>
public string? Found { get; init; }
/// <summary>A check that completes only when the test says so.</summary>
public TaskCompletionSource<string?>? Pending { get; init; }
/// <summary>Makes the next checks fail.</summary>
public Exception? CheckFailure { get; set; }
/// <summary>Makes the download fail.</summary>
public Exception? DownloadFailure { get; set; }
/// <summary>Makes the install fail.</summary>
public Exception? ApplyFailure { get; set; }
/// <summary>How many checks ran.</summary>
public int Checks { get; private set; }
/// <summary>How many downloads completed.</summary>
public int Downloads { get; private set; }
/// <summary>How many installs were started.</summary>
public int Applied { get; private set; }
/// <summary>Whether the last download was given a progress callback.</summary>
public bool ProgressRequested { get; private set; }
/// <inheritdoc/>
public bool IsInstalled => Installed;
/// <inheritdoc/>
public string? CurrentVersion => Installed ? Version : null;
/// <inheritdoc/>
public Task<string?> CheckAsync()
{
Checks++;
if(CheckFailure is not null)
{
return Task.FromException<string?>(CheckFailure);
}
return Pending?.Task ?? Task.FromResult(Found);
}
/// <inheritdoc/>
public Task DownloadAsync(Action<int>? 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;
}
/// <inheritdoc/>
public void ApplyAndRestart()
{
Applied++;
if(ApplyFailure is not null)
{
throw ApplyFailure;
}
}
}
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Sharing;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Tests of the invitation email a person can start from the Help menu.</summary>
public sealed class ShareInvitationTests
{
/// <summary>The draft has no recipient: the person only has to address it and send.</summary>
[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);
}
/// <summary>An email program that decodes the link gets exactly the subject and message, and nothing else.</summary>
[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..]));
}
/// <summary>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.</summary>
[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);
}
}
/// <summary>Mail programs started from a command line cut a long link; the whole draft fits under that length.</summary>
[Fact]
public void MailLink_FitsTheLengthMailProgramsAccept()
{
Assert.Equal(2000, ShareInvitation.MaximumLinkLength);
Assert.InRange(ShareInvitation.MailLink.AbsoluteUri.Length, 1, ShareInvitation.MaximumLinkLength);
}
/// <summary>The message gives the installer, the release page, the user guide, where to report on the app and where it lives.</summary>
[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);
}
/// <summary>Email requires CRLF line ends; a bare line feed or carriage return shows as one long line in some programs.</summary>
[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));
}
/// <summary>The clipboard fallback carries the same invitation: the subject, a blank line, then the message.</summary>
[Fact]
public void Text_IsTheSubjectThenTheBody()
{
Assert.Equal(ShareInvitation.Subject + "\r\n\r\n" + ShareInvitation.Body, ShareInvitation.Text);
}
}
@@ -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, )",
@@ -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, )",