feat(app): Help > Share gclo by email, a ready-to-send invitation with download links #164

Merged
james.maes merged 4 commits from feat/share-invitation into main 2026-11-11 01:32:28 +00:00
6 changed files with 430 additions and 0 deletions

No files matched your search

+2
View File
@@ -118,6 +118,8 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters and per-repo failures and never contain your token (see [SECURITY.md](SECURITY.md)).
**Share:** Help > Share gclo by email… opens a new message in your email program with an invitation already written: what gclo is, where to download the latest release, and how to install it. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is shown and copied to the clipboard, to paste into a message by hand.
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel. Self-update is only available in installed builds (not the portable zip or a local debug build).
### Saved accounts
+161
View File
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="ShareInvitation"/> writes the email behind Help &gt; Share: what the app
/// is, where to get it, and a link a mail program opens with everything but the
/// recipient filled in (#163).
/// </summary>
public sealed class ShareInvitationTests
{
private const string Repository = "https://github.com/KofTwentyTwo/gclo";
private static ShareInvitation Invitation() => ShareInvitation.Create(Repository, "1.2.3 (abc1234)");
[Fact]
public void TheBody_SaysWhatItIs_WhereToGetIt_AndHowToInstallIt()
{
ShareInvitation invitation = Invitation();
Assert.Equal("Try gclo: clone and update every repository of a GitHub organization", invitation.Subject);
Assert.Contains("clones every repository of a GitHub organization or account", invitation.Body);
Assert.Contains(Repository + "/releases/latest", invitation.Body);
Assert.Contains("gclo-stable-Setup.exe", invitation.Body);
Assert.Contains("scoop bucket add gclo " + Repository, invitation.Body);
Assert.Contains("scoop install gclo", invitation.Body);
Assert.Contains(Repository + "#readme", invitation.Body);
// Filing an issue needs an account on the project's server; the message does
// not send a stranger to a sign-in page. They answer the person who wrote.
Assert.DoesNotContain("/issues", invitation.Body);
Assert.Contains("tell me what you think", invitation.Body);
Assert.EndsWith("(sent from gclo 1.2.3 (abc1234))", invitation.Body);
}
[Fact]
public void TheLink_OpensANewMessage_WithNoRecipient_AndCarriesSubjectAndBodyExactly()
{
ShareInvitation invitation = Invitation();
string link = invitation.MailtoUri.OriginalString;
Assert.Equal("mailto", invitation.MailtoUri.Scheme);
Assert.StartsWith("mailto:?subject=", link); // nothing between "mailto:" and "?": the user types the address
string[] parts = link["mailto:?".Length..].Split('&');
Assert.Equal(2, parts.Length);
Assert.Equal(invitation.Subject, Uri.UnescapeDataString(parts[0]["subject=".Length..]));
Assert.Equal(invitation.Body, Uri.UnescapeDataString(parts[1]["body=".Length..]));
}
[Fact]
public void TheLink_IsShortEnoughForMailProgramsThatCutLongOnes_AndHasNoRawSpaceOrLineBreak()
{
string link = Invitation().MailtoUri.OriginalString;
Assert.True(link.Length < ShareInvitation.MaxLinkLength, $"the link is {link.Length} characters long");
Assert.True(Invitation().FitsInALink);
Assert.DoesNotContain(' ', link);
Assert.DoesNotContain('\n', link);
Assert.DoesNotContain('\r', link);
Assert.Contains("%0D%0A", link, StringComparison.Ordinal); // line ends travel as CR LF
}
[Fact]
public void TheBody_IsPlainText_WithCrLfLineEnds_AndNoLineThatAMailProgramWouldMangle()
{
string body = Invitation().Body;
Assert.DoesNotContain("\n", body.Replace("\r\n", "", StringComparison.Ordinal), StringComparison.Ordinal);
Assert.All(body.Split("\r\n"), line => Assert.Equal(line.TrimEnd(), line));
}
[Fact]
public void AsPlainText_IsWhatAPersonCanPasteIntoAMessage()
{
ShareInvitation invitation = Invitation();
Assert.Equal("Subject: " + invitation.Subject + "\r\n\r\n" + invitation.Body, invitation.AsPlainText);
}
[Fact]
public void ATrailingSlashOnTheRepositoryAddress_DoesNotDoubleUpInTheLinks()
{
var invitation = ShareInvitation.Create(Repository + "/", " 1.2.3 ");
Assert.Contains(Repository + "/releases/latest", invitation.Body);
Assert.DoesNotContain("gclo//", invitation.Body);
Assert.EndsWith("(sent from gclo 1.2.3)", invitation.Body);
}
[Fact]
public void SpaceAroundTheAddress_IsNotPartOfTheLinks()
{
var invitation = ShareInvitation.Create(" " + Repository + " ", "1.2.3");
Assert.Contains("\r\n" + Repository + "/releases/latest\r\n", invitation.Body);
}
[Fact]
public void AnInvitationTooLongForALink_SaysSo()
{
var invitation = ShareInvitation.Create("https://example.test/" + new string('a', 1200), "1.2.3");
Assert.True(invitation.MailtoUri.OriginalString.Length > ShareInvitation.MaxLinkLength);
Assert.False(invitation.FitsInALink);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a web address")]
[InlineData("ftp://example.test/gclo")]
[InlineData("/relative/path")]
[InlineData("https://someone:secret@example.test/gclo")] // a name and a password would travel in every link
[InlineData("https://example.test/gclo?tab=readme")]
[InlineData("https://example.test/gclo#readme")]
public void AnAddressThatIsNotAPlainWebAddress_IsRefused(string repositoryUrl)
=> Assert.ThrowsAny<ArgumentException>(() => ShareInvitation.Create(repositoryUrl, "1.2.3"));
[Theory]
[InlineData("1.2.3\r\nBcc: someone@example.test")]
[InlineData("1.2.3\nmore")]
[InlineData("1.2.3\t(x)")]
public void AVersionWithAControlCharacter_IsRefused(string version)
=> Assert.ThrowsAny<ArgumentException>(() => ShareInvitation.Create(Repository, version));
[Fact]
public void AMissingVersion_IsRefused()
{
Assert.ThrowsAny<ArgumentException>(() => ShareInvitation.Create(Repository, " "));
Assert.ThrowsAny<ArgumentException>(() => ShareInvitation.Create(Repository, null!));
Assert.ThrowsAny<ArgumentException>(() => ShareInvitation.Create(null!, "1.2.3"));
}
}
+27
View File
@@ -95,6 +95,33 @@ public sealed partial class SmokeTests : UiTestBase
// Help -> Share gclo by email. Under the fixture the invitation is shown in the
// app instead of being handed to an email program, so the test can read it and
// nothing opens on the machine that runs the suite.
[Fact]
public void HelpMenu_Share_ShowsTheInvitation_WithTheDownloadLink()
{
Session.InvokeMenuItem("Help", "MenuShare");
try
{
AutomationElement text = Session.WaitForElement("ShareInvitationText");
string invitation = text.Patterns.Value.Pattern.Value.Value;
Assert.StartsWith("Subject: Try gclo", invitation, StringComparison.Ordinal);
Assert.Contains("https://github.com/KofTwentyTwo/gclo/releases/latest", invitation, StringComparison.Ordinal);
Assert.False(Session.App.HasExited);
Session.ClickButton("Close");
Session.WaitForElementGone("ShareInvitationText");
}
finally
{
Session.TryClickButton("Close"); // no-op when the happy path closed it
}
}
// (5) Help -> About shows the semantic version plus the parenthesized git hash.
[Fact]
public void HelpMenu_About_ShowsSemanticVersionWithCommitHash()
+115
View File
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// An invitation to try the app, written as an email that the user's own mail
/// program opens ready to send: a subject, a plain-text body that says what the app
/// is, where to download the latest release, and how to install it, and the
/// <c>mailto:</c> link that carries both. The link names no recipient, so the user
/// types an address and sends. Nothing is sent by the app, and nothing about the
/// user, their accounts, or their repositories is in the text.
/// </summary>
public sealed class ShareInvitation
{
/// <summary>
/// The longest <c>mailto:</c> link worth handing to a mail program: some of them
/// cut a longer one short. The text is written to stay well below this; see
/// <see cref="FitsInALink"/> for an invitation that does not.
/// </summary>
public const int MaxLinkLength = 2000;
private ShareInvitation(string subject, string body)
{
Subject = subject;
Body = body;
MailtoUri = new Uri("mailto:?subject=" + Uri.EscapeDataString(subject) + "&body=" + Uri.EscapeDataString(body));
}
/// <summary>The subject line.</summary>
public string Subject { get; }
/// <summary>The message, as plain text with CR LF line ends.</summary>
public string Body { get; }
/// <summary>The link that opens a new message with the subject and the body filled in, and no recipient.</summary>
public Uri MailtoUri { get; }
/// <summary>Subject and body as one text, to show and copy when no mail program takes the link.</summary>
public string AsPlainText => "Subject: " + Subject + "\r\n\r\n" + Body;
/// <summary>
/// False when <see cref="MailtoUri"/> is longer than <see cref="MaxLinkLength"/>:
/// a mail program might then open with half a message, so the text is better shown
/// for copying.
/// </summary>
public bool FitsInALink => MailtoUri.OriginalString.Length <= MaxLinkLength;
/// <summary>Writes the invitation.</summary>
/// <param name="repositoryUrl">
/// The project's public page, which the release and documentation links hang off:
/// an http(s) address made of a host and a path, nothing else.
/// </param>
/// <param name="version">The running app's version, named at the end so the recipient knows what the sender uses.</param>
/// <exception cref="ArgumentException">
/// <paramref name="repositoryUrl"/> is not an absolute http(s) address, or carries
/// a user name, a query, or a fragment; or <paramref name="version"/> holds a
/// control character. Either would put something into the message that the links
/// and lines of it are not meant to hold.
/// </exception>
public static ShareInvitation Create(string repositoryUrl, string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
if(!Uri.TryCreate(repositoryUrl.Trim(), UriKind.Absolute, out Uri? page) || page.Scheme is not ("https" or "http"))
{
throw new ArgumentException($"'{repositoryUrl}' is not a web address.", nameof(repositoryUrl));
}
if(page.UserInfo.Length > 0 || page.Query.Length > 0 || page.Fragment.Length > 0)
{
throw new ArgumentException(
"The project's page is a host and a path: no user name, no query, no fragment.", nameof(repositoryUrl));
}
if(version.Any(char.IsControl))
{
throw new ArgumentException("The version is one line of text.", nameof(version));
}
// From the parsed address, not from the text as given: what goes into the
// message is the scheme, the host, and the path, and nothing more.
string repository = page.GetLeftPart(UriPartial.Path).TrimEnd('/');
string[] lines =
[
"Hi,",
"",
"I use gclo and thought you might like it. It is a small Windows app (with a command-line version) that "
+ "clones every repository of a GitHub organization or account into a folder and keeps them up to date.",
"",
"Download the latest release:",
repository + "/releases/latest",
"Get gclo-stable-Setup.exe there. It installs for your user only (no admin rights) and updates itself.",
"",
"Command line only, with Scoop:",
"scoop bucket add gclo " + repository,
"scoop install gclo",
"",
"How to use it: " + repository + "#readme",
"",
"If you try it, tell me what you think.",
"",
"(sent from gclo " + version.Trim() + ")",
];
return new ShareInvitation(
"Try gclo: clone and update every repository of a GitHub organization", string.Join("\r\n", lines));
}
}
+1
View File
@@ -33,6 +33,7 @@
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" AutomationProperties.AutomationId="MenuGitHub" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem Text="Submit an issue…" AccessKey="I" AutomationProperties.AutomationId="MenuSubmitIssue" ToolTipService.ToolTip="Report a bug or request a feature on GitHub" Click="SubmitIssueMenuItem_Click" />
<MenuFlyoutItem Text="Community support…" AccessKey="S" AutomationProperties.AutomationId="MenuCommunitySupport" ToolTipService.ToolTip="Ask a question or share tips in GitHub Discussions" Click="CommunitySupportMenuItem_Click" />
<MenuFlyoutItem Text="Share gclo by email…" AccessKey="E" AutomationProperties.AutomationId="MenuShare" ToolTipService.ToolTip="Draft an invitation with download links in your email program" Click="ShareMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="CheckUpdatesMenuItem" Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutSeparator />
+124
View File
@@ -1164,6 +1164,130 @@ public sealed partial class MainWindow : Window
/// <summary>
/// Help > Share gclo by email: hands an invitation (what gclo is, where to download
/// it, how to install it) to the user's email program as a
/// new message with no recipient, so they type an address and send. gclo sends
/// nothing itself. When no email program takes it, the text is shown, and copied
/// to the clipboard when the clipboard can be had, to be pasted into a message by
/// hand.
/// </summary>
private async void ShareMenuItem_Click(object sender, RoutedEventArgs e)
{
try
{
var invitation = ShareInvitation.Create(RepoUrl, BuildVersion.Describe(typeof(MainWindow).Assembly));
// The UI suite must not open an email program on the machine it runs on,
// nor touch the clipboard, which belongs to the whole desktop (it would
// overwrite what the person at the machine copied): under its fixture the
// invitation is only shown.
bool isUnderTest = UiTestFixture.IsEnabled;
if(!isUnderTest && invitation.FitsInALink && await TryOpenInEmailProgramAsync(invitation))
{
_log.Info("Share: the invitation was handed to the email program.");
return;
}
// When another program holds the clipboard the text is still shown, to be
// copied by hand; the dialog says which of the two it is.
bool copied = !isUnderTest && TryCopyToClipboard(invitation.AsPlainText);
ContentDialogResult? shown = await DialogGuard.ShowAsync(NewShareDialog(invitation, copied));
_log.Info(
(isUnderTest ? "Share: under the UI test fixture no email program is asked; " : "Share: no email program took the invitation; ")
+ (copied ? "it was copied to the clipboard and " : "it was ")
+ (shown is null ? "could not be shown, because another dialog is open." : "shown."));
}
catch(Exception ex)
{
// Nothing depends on this action; a failure is said in the log, not as a crash.
_log.Error($"Share: the invitation could not be opened ({ex.Message}).", ex);
}
}
/// <summary>
/// Asks Windows to open the invitation in the email program; false when none took
/// it, or when asking failed, which is said in the log and treated the same way.
/// </summary>
private async Task<bool> TryOpenInEmailProgramAsync(ShareInvitation invitation)
{
try
{
return await Windows.System.Launcher.LaunchUriAsync(invitation.MailtoUri);
}
catch(Exception ex)
{
_log.Error($"Share: the email program could not be asked ({ex.Message}).", ex);
return false;
}
}
/// <summary>Puts text on the clipboard; false, with a line in the log, when the clipboard cannot be had.</summary>
private bool TryCopyToClipboard(string text)
{
try
{
var package = new Windows.ApplicationModel.DataTransfer.DataPackage();
package.SetText(text);
Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package);
// Hand the text over for good: without this it is gone when gclo closes.
Windows.ApplicationModel.DataTransfer.Clipboard.Flush();
return true;
}
catch(Exception ex)
{
// Another program holds the clipboard: not worth failing the action for.
_log.Error($"Share: the invitation could not be copied to the clipboard ({ex.Message}).", ex);
return false;
}
}
/// <summary>The invitation as text to paste, for when no email program opened it.</summary>
/// <param name="invitation">The invitation to show.</param>
/// <param name="copied">True when the text is on the clipboard already; the dialog says which it is.</param>
private ContentDialog NewShareDialog(ShareInvitation invitation, bool copied)
{
// AcceptsReturn before Text: a box that takes one line keeps only the first
// line of what it is given, which here would be the subject and nothing else.
var text = new TextBox
{
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
IsReadOnly = true,
MaxHeight = 320,
Text = invitation.AsPlainText,
};
string explanation = copied
? "No email program opened the invitation. It has been copied to the clipboard: paste it into a new message, add an address, and send."
: "No email program opened the invitation. Copy the text below into a new message, add an address, and send.";
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(text, "ShareInvitationText");
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(text, "Invitation text");
// Focus lands in the box: a screen reader then also says why the dialog is there.
Microsoft.UI.Xaml.Automation.AutomationProperties.SetHelpText(text, explanation);
ScrollViewer.SetVerticalScrollBarVisibility(text, ScrollBarVisibility.Auto);
var panel = new StackPanel { Spacing = 12 };
panel.Children.Add(new TextBlock { Text = explanation, TextWrapping = TextWrapping.Wrap });
panel.Children.Add(text);
return new ContentDialog
{
Title = "Share gclo by email",
Content = panel,
CloseButtonText = "Close",
DefaultButton = ContentDialogButton.Close,
XamlRoot = Content.XamlRoot,
};
}
/// <summary>Opens a page of the repository in the default browser; the log names the page, never a token.</summary>
private async Task OpenRepositoryPageAsync(string path)
{