Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 123 additions & 55 deletions src/Microsoft.PowerShell.ConsoleHost/host/msh/UpdatesNotification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,61 @@ namespace Microsoft.PowerShell
/// <summary>
Comment thread
daxian-dbw marked this conversation as resolved.
Comment thread
daxian-dbw marked this conversation as resolved.
Comment thread
daxian-dbw marked this conversation as resolved.
Comment thread
daxian-dbw marked this conversation as resolved.
/// A Helper class for printing notification on PowerShell startup when there is a new update.
/// </summary>
Comment thread
daxian-dbw marked this conversation as resolved.
/// <remarks>
/// For the detailed design, please take a look at the corresponding RFC.
/// </remarks>
internal static class UpdatesNotification
{
private const string UpdateCheckOptOutEnvVar = "POWERSHELL_UPDATECHECK_OPTOUT";
private const string Last3ReleasesUri = "https://api.github.com/repos/PowerShell/PowerShell/releases?per_page=3";
private const string LatestReleaseUri = "https://api.github.com/repos/PowerShell/PowerShell/releases/latest";
private const string UpdateCheckEnvVar = "POWERSHELL_UPDATECHECK";
private const string LTSBuildInfoURL = "https://aka.ms/pwsh-buildinfo-lts";
private const string StableBuildInfoURL = "https://aka.ms/pwsh-buildinfo-stable";
private const string PreviewBuildInfoURL = "https://aka.ms/pwsh-buildinfo-preview";

private const string SentinelFileName = "_sentinel_";
private const string DoneFileNameTemplate = "sentinel-{0}-{1}-{2}.done";
private const string DoneFileNamePattern = "sentinel-*.done";
private const string UpdateFileNameTemplate = "update_{0}_{1}";
private const string UpdateFileNamePattern = "update_v*.*.*_????-??-??";
/// <summary>
/// The version of new update is persisted using a file, not as the file content, but instead baked in the file name in the following template:
/// `update{notification-type}_{version}_{publish-date}` -- held by 's_updateFileNameTemplate',
/// while 's_updateFileNamePattern' holds the pattern of this file name.
/// </summary>
private static readonly string s_updateFileNameTemplate, s_updateFileNamePattern;

/// <summary>
/// For each notification type, we need two files to achieve the synchronization for the update check:
/// `_sentinel{notification-type}_` -- held by 's_sentinelFileName';
/// `sentinel{notification-type}-{year}-{month}-{day}.done`
/// -- held by 's_doneFileNameTemplate', while 's_doneFileNamePattern' holds the pattern of this file name.
/// The {notification-type} part will be the integer value of the corresponding `NotificationType` member.
/// The {year}-{month}-{day} part will be filled with the date of current day when the update check runs.
/// </summary>
private static readonly string s_sentinelFileName, s_doneFileNameTemplate, s_doneFileNamePattern;

private static readonly EnumerationOptions s_enumOptions = new EnumerationOptions();
private static readonly string s_cacheDirectory = Path.Combine(Platform.CacheDirectory, PSVersionInfo.GitCommitId);
private static readonly string s_cacheDirectory;
private static readonly EnumerationOptions s_enumOptions;
private static readonly NotificationType s_notificationType;
Comment thread
iSazonov marked this conversation as resolved.

/// <summary>
/// Gets a value indicating whether update notification should be done.
/// </summary>
internal static readonly bool CanNotifyUpdates = !Utils.GetOptOutEnvVariableAsBool(UpdateCheckOptOutEnvVar, defaultValue: false)
&& ExperimentalFeature.IsEnabled("PSUpdatesNotification");
internal static readonly bool CanNotifyUpdates;
Comment thread
daxian-dbw marked this conversation as resolved.

static UpdatesNotification()
{
s_notificationType = GetNotificationType();
CanNotifyUpdates = s_notificationType != NotificationType.Off && ExperimentalFeature.IsEnabled("PSUpdatesNotification");

if (CanNotifyUpdates)
{
s_enumOptions = new EnumerationOptions();
s_cacheDirectory = Path.Combine(Platform.CacheDirectory, PSVersionInfo.GitCommitId);

// Build the template/pattern strings for the configured notification type.
string typeNum = ((int)s_notificationType).ToString();
s_sentinelFileName = $"_sentinel{typeNum}_";
Comment thread
daxian-dbw marked this conversation as resolved.
s_doneFileNameTemplate = $"sentinel{typeNum}-{{0}}-{{1}}-{{2}}.done";
s_doneFileNamePattern = $"sentinel{typeNum}-*.done";
s_updateFileNameTemplate = $"update{typeNum}_{{0}}_{{1}}";
s_updateFileNamePattern = $"update{typeNum}_v*.*.*_????-??-??";
}
}

// Maybe we shouldn't do update check and show notification when it's from a mini-shell, meaning when
// 'ConsoleShell.Start' is not called by 'ManagedEntrance.Start'.
Expand All @@ -58,9 +93,11 @@ internal static void ShowUpdateNotification(PSHostUserInterface hostUI)
&& lastUpdateVersion != null)
{
string releaseTag = lastUpdateVersion.ToString();
string notificationMsgTemplate = string.IsNullOrEmpty(lastUpdateVersion.PreReleaseLabel)
? ManagedEntranceStrings.StableUpdateNotificationMessage
: ManagedEntranceStrings.PreviewUpdateNotificationMessage;
string notificationMsgTemplate = s_notificationType == NotificationType.LTS
? ManagedEntranceStrings.LTSUpdateNotificationMessage
: string.IsNullOrEmpty(lastUpdateVersion.PreReleaseLabel)
? ManagedEntranceStrings.StableUpdateNotificationMessage
: ManagedEntranceStrings.PreviewUpdateNotificationMessage;

string notificationColor = string.Empty;
string resetColor = string.Empty;
Expand Down Expand Up @@ -141,7 +178,7 @@ internal static async Task CheckForUpdates()
// Construct the sentinel file paths for today's check.
string todayDoneFileName = string.Format(
CultureInfo.InvariantCulture,
DoneFileNameTemplate,
s_doneFileNameTemplate,
today.Year.ToString(),
today.Month.ToString(),
today.Day.ToString());
Expand All @@ -156,9 +193,9 @@ internal static async Task CheckForUpdates()

try
{
// Use 'sentinelFilePath' as the file lock.
// Use 's_sentinelFileName' as the file lock.
// The update-check tasks started by every 'pwsh' process of the same version will compete on holding this file.
string sentinelFilePath = Path.Combine(s_cacheDirectory, SentinelFileName);
string sentinelFilePath = Path.Combine(s_cacheDirectory, s_sentinelFileName);
using (new FileStream(sentinelFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose))
{
if (File.Exists(todayDoneFilePath))
Expand All @@ -170,7 +207,7 @@ internal static async Task CheckForUpdates()

// Now it's guaranteed that this is the only process that reaches here.
// Clean up the old '.done' file, there should be only one of it.
foreach (string oldFile in Directory.EnumerateFiles(s_cacheDirectory, DoneFileNamePattern, s_enumOptions))
foreach (string oldFile in Directory.EnumerateFiles(s_cacheDirectory, s_doneFileNamePattern, s_enumOptions))
{
File.Delete(oldFile);
}
Expand All @@ -180,8 +217,8 @@ internal static async Task CheckForUpdates()
// The update file is corrupted, either because more than one update files were found unexpectedly,
// or because the update file name failed to be parsed into a release version and a publish date.
// This is **very unlikely** to happen unless the file is accidentally altered manually.
// We try to recover here by cleaning up all update files.
foreach (string file in Directory.EnumerateFiles(s_cacheDirectory, UpdateFileNamePattern, s_enumOptions))
// We try to recover here by cleaning up all update files for the configured notification type.
foreach (string file in Directory.EnumerateFiles(s_cacheDirectory, s_updateFileNamePattern, s_enumOptions))
{
File.Delete(file);
}
Expand All @@ -190,8 +227,8 @@ internal static async Task CheckForUpdates()
// Do the real update check:
// - Send HTTP request to query for the new release/pre-release;
// - If there is a valid new release that should be reported to the user,
// create the file `update_<tag>_<publish-date>` when no `update` file exists,
// or rename the existing file to `update_<new-version>_<new-publish-date>`.
// create the file `update<NotificationType>_<tag>_<publish-date>` when no `update` file exists,
// or rename the existing file to `update<NotificationType>_<new-version>_<new-publish-date>`.
SemanticVersion baselineVersion = lastUpdateVersion ?? PSVersionInfo.PSCurrentVersion;
Release release = await QueryNewReleaseAsync(baselineVersion);

Expand All @@ -201,7 +238,7 @@ internal static async Task CheckForUpdates()
const int dateLength = 10;
string newUpdateFileName = string.Format(
CultureInfo.InvariantCulture,
UpdateFileNameTemplate,
s_updateFileNameTemplate,
release.TagName,
release.PublishAt.Substring(0, dateLength));

Expand Down Expand Up @@ -254,7 +291,7 @@ private static bool TryParseUpdateFile(
lastUpdateVersion = null;
lastUpdateDate = default;

var files = Directory.EnumerateFiles(s_cacheDirectory, UpdateFileNamePattern, s_enumOptions);
var files = Directory.EnumerateFiles(s_cacheDirectory, s_updateFileNamePattern, s_enumOptions);
var enumerator = files.GetEnumerator();

if (!enumerator.MoveNext())
Expand All @@ -272,7 +309,7 @@ private static bool TryParseUpdateFile(
return false;
}

// OK, only found one update file, which is expected.
// OK, only found one update file for the configured notification type, which is expected.
// Now let's parse the file name.
string updateFileName = Path.GetFileName(updateFilePath);
int dateStartIndex = updateFileName.LastIndexOf('_') + 1;
Expand Down Expand Up @@ -304,60 +341,91 @@ private static bool TryParseUpdateFile(
private static async Task<Release> QueryNewReleaseAsync(SemanticVersion baselineVersion)
{
bool isStableRelease = string.IsNullOrEmpty(PSVersionInfo.PSCurrentVersion.PreReleaseLabel);
string queryUri = isStableRelease ? LatestReleaseUri : Last3ReleasesUri;
string[] queryUris = s_notificationType switch
{
NotificationType.LTS => new[] { LTSBuildInfoURL },
Comment thread
daxian-dbw marked this conversation as resolved.
NotificationType.Default => isStableRelease
? new[] { StableBuildInfoURL }
: new[] { StableBuildInfoURL, PreviewBuildInfoURL },
_ => Array.Empty<string>()
};

using var client = new HttpClient();

string userAgent = string.Format(CultureInfo.InvariantCulture, "PowerShell {0}", PSVersionInfo.GitCommitId);
client.DefaultRequestHeaders.Add("User-Agent", userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

// Query the GitHub Rest API and throw if the query fails.
HttpResponseMessage response = await client.GetAsync(queryUri);
response.EnsureSuccessStatusCode();

using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
using var jsonReader = new JsonTextReader(reader);

Release releaseToReturn = null;
SemanticVersion highestVersion = baselineVersion;
var settings = new JsonSerializerSettings() { DateParseHandling = DateParseHandling.None };
var serializer = JsonSerializer.Create(settings);

if (isStableRelease)
foreach (string queryUri in queryUris)
{
// Query the GitHub Rest API and throw if the query fails.
HttpResponseMessage response = await client.GetAsync(queryUri);
response.EnsureSuccessStatusCode();

using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
using var jsonReader = new JsonTextReader(reader);

JObject release = serializer.Deserialize<JObject>(jsonReader);
var tagName = release["tag_name"].ToString();
var tagName = release["ReleaseTag"].ToString();
var version = SemanticVersion.Parse(tagName.Substring(1));

if (version > baselineVersion)
if (version > highestVersion)
{
var publishAt = release["published_at"].ToString();
highestVersion = version;
var publishAt = release["ReleaseDate"].ToString();
releaseToReturn = new Release(publishAt, tagName);
}

return releaseToReturn;
}

// The current 'pwsh' is a preview release.
JArray last3Releases = serializer.Deserialize<JArray>(jsonReader);
SemanticVersion highestVersion = baselineVersion;
return releaseToReturn;
}

for (int i = 0; i < last3Releases.Count; i++)
/// <summary>
/// Get the notification type setting.
/// </summary>
private static NotificationType GetNotificationType()
{
string str = Environment.GetEnvironmentVariable(UpdateCheckEnvVar);
if (string.IsNullOrEmpty(str))
{
JToken release = last3Releases[i];
var tagName = release["tag_name"].ToString();
var version = SemanticVersion.Parse(tagName.Substring(1));
return NotificationType.Default;
}

if (version > highestVersion)
{
highestVersion = version;
var publishAt = release["published_at"].ToString();
releaseToReturn = new Release(publishAt, tagName);
}
if (Enum.TryParse(str, ignoreCase: true, out NotificationType type))
{
return type;
}

return releaseToReturn;
return NotificationType.Default;
}

/// <summary>
/// Notification type that can be configured.
/// </summary>
private enum NotificationType
{
/// <summary>
/// Turn off the udpate notification.
/// </summary>
Off = 0,

/// <summary>
/// Give you the default behaviors:
/// - the preview version 'pwsh' checks for the new preview version and the new GA version.
/// - the GA version 'pwsh' checks for the new GA version only.
/// </summary>
Default = 1,

/// <summary>
/// Both preview and GA version 'pwsh' checks for the new LTS version only.
/// </summary>
LTS = 2
}

private class Release
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ Type 'help' to get help.</value>
<value> {1} A new PowerShell stable release is available: v{0} {2}
{1} Upgrade now, or check out the release page at:{3}{2}
{1} https://aka.ms/PowerShell-Release?tag=v{0} {4}{2}
</value>
</data>
<data name="LTSUpdateNotificationMessage" xml:space="preserve">
<value> {1} A new PowerShell LTS release is available: v{0} {2}
{1} Upgrade now, or check out the release page at:{3}{2}
{1} https://aka.ms/PowerShell-Release?tag=v{0} {4}{2}
</value>
</data>
<data name="UsageHelp" xml:space="preserve">
Expand Down
30 changes: 0 additions & 30 deletions src/System.Management.Automation/engine/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,36 +303,6 @@ internal static int CombineHashCodes(int h1, int h2, int h3, int h4, int h5, int
/// </summary>
internal static string[] AllowedEditionValues = { "Desktop", "Core" };

/// <summary>
/// Utility method to interpret the value of an opt-out environment variable.
/// e.g. POWERSHELL_TELEMETRY_OPTOUT and POWERSHELL_UPDATECHECK_OPTOUT.
/// </summary>
/// <param name="name">The name of the environment variable.</param>
/// <param name="defaultValue">If the environment variable is not set, use this as the default value.</param>
/// <returns>A boolean representing the value of the environment variable.</returns>
internal static bool GetOptOutEnvVariableAsBool(string name, bool defaultValue)
{
string str = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrEmpty(str))
{
return defaultValue;
}

switch (str.ToLowerInvariant())
{
case "true":
case "1":
case "yes":
return true;
case "false":
case "0":
case "no":
return false;
default:
return defaultValue;
}
}

/// <summary>
/// Helper fn to check byte[] arg for null.
/// </summary>
Expand Down
31 changes: 30 additions & 1 deletion src/System.Management.Automation/utils/Telemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public static class ApplicationInsightsTelemetry
static ApplicationInsightsTelemetry()
{
// If we can't send telemetry, there's no reason to do any of this
CanSendTelemetry = !Utils.GetOptOutEnvVariableAsBool(name: _telemetryOptoutEnvVar, defaultValue: false);
CanSendTelemetry = !GetEnvironmentVariableAsBool(name: _telemetryOptoutEnvVar, defaultValue: false);
if (CanSendTelemetry)
{
TelemetryConfiguration configuration = TelemetryConfiguration.CreateDefault();
Expand Down Expand Up @@ -137,6 +137,35 @@ static ApplicationInsightsTelemetry()
}
}

/// <summary>
/// Determine whether the environment variable is set and how.
/// </summary>
/// <param name="name">The name of the environment variable.</param>
/// <param name="defaultValue">If the environment variable is not set, use this as the default value.</param>
/// <returns>A boolean representing the value of the environment variable.</returns>
private static bool GetEnvironmentVariableAsBool(string name, bool defaultValue)
{
var str = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrEmpty(str))
{
return defaultValue;
}

switch (str.ToLowerInvariant())
{
case "true":
case "1":
case "yes":
return true;
case "false":
case "0":
case "no":
return false;
default:
return defaultValue;
}
}

/// <summary>
/// Send telemetry as a metric.
/// </summary>
Expand Down