feat(app): Help > Share gclo by email, a ready-to-send invitation with download links #164
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 > 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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user