diff --git a/README.md b/README.md index 1a4d0cd..71ea5f9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gclo.Engine.Tests/ShareInvitationTests.cs b/gclo.Engine.Tests/ShareInvitationTests.cs new file mode 100644 index 0000000..c69d800 --- /dev/null +++ b/gclo.Engine.Tests/ShareInvitationTests.cs @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +/// +/// 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). +/// +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(() => 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(() => ShareInvitation.Create(Repository, version)); + + + + [Fact] + public void AMissingVersion_IsRefused() + { + Assert.ThrowsAny(() => ShareInvitation.Create(Repository, " ")); + Assert.ThrowsAny(() => ShareInvitation.Create(Repository, null!)); + Assert.ThrowsAny(() => ShareInvitation.Create(null!, "1.2.3")); + } +} diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs index f29cc2b..6eff845 100644 --- a/gclo.UiTests/SmokeTests.cs +++ b/gclo.UiTests/SmokeTests.cs @@ -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() diff --git a/gclo.ViewModels/ShareInvitation.cs b/gclo.ViewModels/ShareInvitation.cs new file mode 100644 index 0000000..f3fcd23 --- /dev/null +++ b/gclo.ViewModels/ShareInvitation.cs @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// 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 +/// mailto: 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. +/// +public sealed class ShareInvitation +{ + /// + /// The longest mailto: 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 + /// for an invitation that does not. + /// + 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)); + } + + + + /// The subject line. + public string Subject { get; } + + /// The message, as plain text with CR LF line ends. + public string Body { get; } + + /// The link that opens a new message with the subject and the body filled in, and no recipient. + public Uri MailtoUri { get; } + + /// Subject and body as one text, to show and copy when no mail program takes the link. + public string AsPlainText => "Subject: " + Subject + "\r\n\r\n" + Body; + + /// + /// False when is longer than : + /// a mail program might then open with half a message, so the text is better shown + /// for copying. + /// + public bool FitsInALink => MailtoUri.OriginalString.Length <= MaxLinkLength; + + + + /// Writes the invitation. + /// + /// 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. + /// + /// The running app's version, named at the end so the recipient knows what the sender uses. + /// + /// is not an absolute http(s) address, or carries + /// a user name, a query, or a fragment; or holds a + /// control character. Either would put something into the message that the links + /// and lines of it are not meant to hold. + /// + 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)); + } +} diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index c692f7e..df6af48 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -33,6 +33,7 @@ + diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 54451ba..e5f6e77 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1164,6 +1164,130 @@ public sealed partial class MainWindow : Window + /// + /// 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. + /// + 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); + } + } + + + + /// + /// 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. + /// + private async Task 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; + } + } + + + + /// Puts text on the clipboard; false, with a line in the log, when the clipboard cannot be had. + 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; + } + } + + + + /// The invitation as text to paste, for when no email program opened it. + /// The invitation to show. + /// True when the text is on the clipboard already; the dialog says which it is. + 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, + }; + } + + + /// Opens a page of the repository in the default browser; the log names the page, never a token. private async Task OpenRepositoryPageAsync(string path) {