From 1265d77923a928b5fb34f44069c579a1d1693823 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 17:14:23 -0600 Subject: [PATCH 1/4] feat(app): Help > Share gclo by email, a ready-to-send invitation with download links Help > Share gclo by email opens a new message in the user's email program with an invitation already written: what gclo is, where to download the latest release, how to install it (the installer, and Scoop for the command line), where the documentation is, and where to send feedback. No recipient is filled in, so the user types an address and sends. gclo itself sends nothing, and nothing about the user, their accounts, or their repositories is in the text. - ShareInvitation (gclo.ViewModels) writes the subject, the body, and the mailto link, and keeps the link short enough for mail programs that cut long ones. - The shell hands the link to the system. When no email program takes it, the invitation is copied to the clipboard and shown, to be pasted into a message by hand. Under the UI test fixture it is only shown, so the suite opens nothing on the machine it runs on. Closes #163 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- README.md | 2 + gclo.Engine.Tests/ShareInvitationTests.cs | 124 ++++++++++++++++++++++ gclo.UiTests/SmokeTests.cs | 27 +++++ gclo.ViewModels/ShareInvitation.cs | 89 ++++++++++++++++ gclo/MainWindow.xaml | 1 + gclo/MainWindow.xaml.cs | 72 +++++++++++++ 6 files changed, 315 insertions(+) create mode 100644 gclo.Engine.Tests/ShareInvitationTests.cs create mode 100644 gclo.ViewModels/ShareInvitation.cs diff --git a/README.md b/README.md index 1a4d0cd..f96873d 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, how to install it, and where to send feedback. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is copied to the clipboard and shown, 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..1b6272f --- /dev/null +++ b/gclo.Engine.Tests/ShareInvitationTests.cs @@ -0,0 +1,124 @@ +/* + * 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_HowToInstallIt_AndWhereToSendFeedback() + { + 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); + Assert.Contains(Repository + "/issues/new/choose", 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.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); + } + + + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a web address")] + [InlineData("ftp://example.test/gclo")] + [InlineData("/relative/path")] + public void AnAddressThatIsNotAWebAddress_IsRefused(string repositoryUrl) + => Assert.ThrowsAny(() => ShareInvitation.Create(repositoryUrl, "1.2.3")); + + + + [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..bdd2f29 --- /dev/null +++ b/gclo.ViewModels/ShareInvitation.cs @@ -0,0 +1,89 @@ +/* + * 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, how to install it, and where to send +/// feedback, 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 that is handed to a mail program. Some of them + /// cut a longer one short; the text is written to stay well below this. + /// + 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, for the clipboard when no mail program takes the link. + public string AsPlainText => "Subject: " + Subject + "\r\n\r\n" + Body; + + + + /// Writes the invitation. + /// The project's public page (https), which the release, documentation, and feedback links hang off. + /// The running app's version, named at the end so the recipient knows what the sender uses. + /// is not an absolute http(s) address. + public static ShareInvitation Create(string repositoryUrl, string version) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryUrl); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + if(!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out Uri? page) || page.Scheme is not ("https" or "http")) + { + throw new ArgumentException($"'{repositoryUrl}' is not a web address.", nameof(repositoryUrl)); + } + + string repository = repositoryUrl.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", + "Problems and ideas: " + repository + "/issues/new/choose", + "", + "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..7d049d6 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1164,6 +1164,78 @@ 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, where to send feedback) 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 goes to the clipboard + /// and is shown, 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: + // under its fixture the invitation is only shown. + bool opened = !UiTestFixture.IsEnabled && await Windows.System.Launcher.LaunchUriAsync(invitation.MailtoUri); + if(opened) + { + _log.Info("Share: the invitation was handed to the email program."); + return; + } + + _log.Info("Share: no email program took the invitation; it was copied to the clipboard and shown."); + var clipboard = new Windows.ApplicationModel.DataTransfer.DataPackage(); + clipboard.SetText(invitation.AsPlainText); + Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(clipboard); + await DialogGuard.ShowAsync(NewShareDialog(invitation)); + } + 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); + } + } + + + + /// The invitation as text to paste, for when no email program opened it. + private ContentDialog NewShareDialog(ShareInvitation invitation) + { + var text = new TextBox + { + Text = invitation.AsPlainText, + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + MaxHeight = 320, + }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(text, "ShareInvitationText"); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(text, "Invitation text"); + ScrollViewer.SetVerticalScrollBarVisibility(text, ScrollBarVisibility.Auto); + + var panel = new StackPanel { Spacing = 12 }; + panel.Children.Add(new TextBlock + { + Text = "No email program opened the invitation. It has been copied to the clipboard: paste it into a new message, add an address, and send.", + 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) { -- 2.54.0 From 7ca752eab5b8aa0a837358c6b22d6b0bda7f5e12 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 19:08:32 -0600 Subject: [PATCH 2/4] fix(app): the share dialog shows the whole invitation, not only its subject The text box was given its text before it was told to take several lines, and a one-line box keeps only the first line of what it is given. Found by the UI test for this dialog on its first run. Refs #163 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- gclo/MainWindow.xaml.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 7d049d6..9a8256c 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1204,13 +1204,15 @@ public sealed partial class MainWindow : Window /// The invitation as text to paste, for when no email program opened it. private ContentDialog NewShareDialog(ShareInvitation invitation) { + // 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 { - Text = invitation.AsPlainText, - IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, + IsReadOnly = true, MaxHeight = 320, + Text = invitation.AsPlainText, }; Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(text, "ShareInvitationText"); Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(text, "Invitation text"); -- 2.54.0 From 124d081964de72df712f92342b524bef61ad40ff Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 19:16:15 -0600 Subject: [PATCH 3/4] fix(app): the share dialog does not depend on the clipboard, and the UI suite leaves the clipboard alone The fallback dialog copied the invitation to the clipboard before it showed itself. The clipboard belongs to the whole desktop: when another program held it the dialog never appeared, and under the UI suite the copy overwrote whatever the person at the machine had copied. The copy is now best effort, the dialog says whether it happened, and the UI fixture skips it. Refs #163 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- README.md | 2 +- gclo/MainWindow.xaml.cs | 46 +++++++++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f96873d..7e72fdd 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ 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, how to install it, and where to send feedback. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is copied to the clipboard and shown, to paste into a message by hand. +**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, how to install it, and where to send feedback. 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). diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 9a8256c..49148e0 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1168,8 +1168,9 @@ 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, where to send feedback) 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 goes to the clipboard - /// and is shown, to be pasted into a message by hand. + /// 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) { @@ -1186,11 +1187,14 @@ public sealed partial class MainWindow : Window return; } - _log.Info("Share: no email program took the invitation; it was copied to the clipboard and shown."); - var clipboard = new Windows.ApplicationModel.DataTransfer.DataPackage(); - clipboard.SetText(invitation.AsPlainText); - Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(clipboard); - await DialogGuard.ShowAsync(NewShareDialog(invitation)); + // The clipboard belongs to the whole desktop: the UI suite leaves it alone + // (it would overwrite what the person at the machine copied), and when + // another program holds it the text is still shown, to be copied by hand. + bool copied = !UiTestFixture.IsEnabled && TryCopyToClipboard(invitation.AsPlainText); + _log.Info(copied + ? "Share: no email program took the invitation; it was copied to the clipboard and shown." + : "Share: no email program took the invitation; it was shown."); + await DialogGuard.ShowAsync(NewShareDialog(invitation, copied)); } catch(Exception ex) { @@ -1201,8 +1205,30 @@ public sealed partial class MainWindow : Window + /// 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); + 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. - private ContentDialog NewShareDialog(ShareInvitation invitation) + /// 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. @@ -1221,7 +1247,9 @@ public sealed partial class MainWindow : Window var panel = new StackPanel { Spacing = 12 }; panel.Children.Add(new TextBlock { - Text = "No email program opened the invitation. It has been copied to the clipboard: paste it into a new message, add an address, and send.", + Text = 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.", TextWrapping = TextWrapping.Wrap, }); panel.Children.Add(text); -- 2.54.0 From 6f30a3369c09548aff71e1140849438ecdf0a5f6 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 19:32:01 -0600 Subject: [PATCH 4/4] fix(app): the share invitation holds only what it should, and its fallback is reached when it is needed From the independent review of this pull request: - The links are built from the parsed address (scheme, host, path); an address with a user name, a query or a fragment, and a version with a control character, are refused. - An invitation too long for a link (FitsInALink) is shown for copying instead of being handed to a mail program that might cut it short. - A launch that throws is treated like one that found no mail program. - The log says what happened after it happened, including a dialog that could not be shown because another one is open. - The clipboard copy is flushed, so the text survives gclo closing. - The box that holds the text tells a screen reader why the dialog is there. - The message no longer sends its reader to the issue tracker: filing an issue there needs an account, and registration is closed. The reader is asked to tell the sender. Refs #163 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- README.md | 2 +- gclo.Engine.Tests/ShareInvitationTests.cs | 43 ++++++++++++++-- gclo.ViewModels/ShareInvitation.cs | 50 +++++++++++++----- gclo/MainWindow.xaml.cs | 62 +++++++++++++++-------- 4 files changed, 121 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7e72fdd..71ea5f9 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ 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, how to install it, and where to send feedback. 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. +**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). diff --git a/gclo.Engine.Tests/ShareInvitationTests.cs b/gclo.Engine.Tests/ShareInvitationTests.cs index 1b6272f..c69d800 100644 --- a/gclo.Engine.Tests/ShareInvitationTests.cs +++ b/gclo.Engine.Tests/ShareInvitationTests.cs @@ -23,7 +23,7 @@ public sealed class ShareInvitationTests [Fact] - public void TheBody_SaysWhatItIs_WhereToGetIt_HowToInstallIt_AndWhereToSendFeedback() + public void TheBody_SaysWhatItIs_WhereToGetIt_AndHowToInstallIt() { ShareInvitation invitation = Invitation(); @@ -34,7 +34,10 @@ public sealed class ShareInvitationTests Assert.Contains("scoop bucket add gclo " + Repository, invitation.Body); Assert.Contains("scoop install gclo", invitation.Body); Assert.Contains(Repository + "#readme", invitation.Body); - Assert.Contains(Repository + "/issues/new/choose", 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); } @@ -62,6 +65,7 @@ public sealed class ShareInvitationTests 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); @@ -103,17 +107,50 @@ public sealed class ShareInvitationTests + [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")] - public void AnAddressThatIsNotAWebAddress_IsRefused(string repositoryUrl) + [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() { diff --git a/gclo.ViewModels/ShareInvitation.cs b/gclo.ViewModels/ShareInvitation.cs index bdd2f29..f3fcd23 100644 --- a/gclo.ViewModels/ShareInvitation.cs +++ b/gclo.ViewModels/ShareInvitation.cs @@ -9,16 +9,17 @@ 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, how to install it, and where to send -/// feedback, 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. +/// 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 that is handed to a mail program. Some of them - /// cut a longer one short; the text is written to stay well below this. + /// 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; @@ -42,25 +43,51 @@ public sealed class ShareInvitation /// 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, for the clipboard when no mail program takes the link. + /// 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 (https), which the release, documentation, and feedback links hang off. + /// + /// 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. + /// + /// 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, UriKind.Absolute, out Uri? page) || page.Scheme is not ("https" or "http")) + 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)); + } - string repository = repositoryUrl.TrimEnd('/'); + // 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,", @@ -77,7 +104,6 @@ public sealed class ShareInvitation "scoop install gclo", "", "How to use it: " + repository + "#readme", - "Problems and ideas: " + repository + "/issues/new/choose", "", "If you try it, tell me what you think.", "", diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 49148e0..e5f6e77 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1166,7 +1166,7 @@ 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, where to send feedback) to the user's email program as a + /// 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 @@ -1178,23 +1178,25 @@ public sealed partial class MainWindow : Window { 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: - // under its fixture the invitation is only shown. - bool opened = !UiTestFixture.IsEnabled && await Windows.System.Launcher.LaunchUriAsync(invitation.MailtoUri); - if(opened) + // 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; } - // The clipboard belongs to the whole desktop: the UI suite leaves it alone - // (it would overwrite what the person at the machine copied), and when - // another program holds it the text is still shown, to be copied by hand. - bool copied = !UiTestFixture.IsEnabled && TryCopyToClipboard(invitation.AsPlainText); - _log.Info(copied - ? "Share: no email program took the invitation; it was copied to the clipboard and shown." - : "Share: no email program took the invitation; it was shown."); - await DialogGuard.ShowAsync(NewShareDialog(invitation, copied)); + // 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) { @@ -1205,6 +1207,25 @@ public sealed partial class MainWindow : Window + /// + /// 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) { @@ -1213,6 +1234,8 @@ public sealed partial class MainWindow : Window 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) @@ -1240,18 +1263,17 @@ public sealed partial class MainWindow : Window 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 = 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.", - TextWrapping = TextWrapping.Wrap, - }); + panel.Children.Add(new TextBlock { Text = explanation, TextWrapping = TextWrapping.Wrap }); panel.Children.Add(text); return new ContentDialog -- 2.54.0