Files
PowerToys/src/runner/UpdateUtils.cpp
Boliang Zhang 558e633c59 Add preview release versioning and update channel support (#49414)
## Summary
- Publish scheduled `main` builds as GitHub prereleases while keeping
manual `main` runs as preview validation builds.
- Add an opt-in Settings switch for prerelease update checks; stable
updates remain the default.
- Use one MSI-safe version across bundles, MSI packages, binaries,
symbols, and package manifests.
- Prevent preview releases from triggering Microsoft Store, WinGet, or
public-symbol publication.
- Label preview builds explicitly in Settings, update notifications, and
What's New.

## Build intent

| Source | Trigger | Intent |
| --- | --- | --- |
| `main` | Scheduled | Publish a preview release |
| `main` | Manual | Validate a preview build without publishing |
| `stable` | Manual | Produce a stable release |
| Other branches | Any supported trigger | Produce a private validation
build |

## MSI-safe release versioning

Windows Installer compares only `major.minor.build` and ignores the
fourth version component. Preview and stable release builds therefore
use:

```text
major.minor.YDDDB.0
```

- `Y`: zero-based number of calendar years since `ReleaseTrainEpoch`.
- `DDD`: three-position calendar day of year.
- `B`: daily release sequence `1-9`.
- The fourth component is always `0`.

With `ReleaseTrainVersion=0.100` and `ReleaseTrainEpoch=2026-01-01`:

```text
0.100.2111.0   = July 30, 2026, release build 1
0.100.3659.0   = December 31, 2026, release build 9
0.100.10011.0  = January 1, 2027, release build 1
```

The allocator formats `DDD` as exactly three digits before converting
the MSI component to its numeric representation. Leading zeros may not
be displayed because Windows version components are numeric; decoding
remains positional:

```text
B   = component % 10
DDD = (component / 10) % 1000
Y   = component / 10000
```

`ReleaseTrainVersion` and `ReleaseTrainEpoch` are checked in under
`src/Version.props`. The epoch remains January 1 of the active epoch
year and advances on the first release-train minor change in a new year.

## Daily release counter

Azure DevOps persists the daily sequence server-side using a counter
keyed as `release-YYYYMMDD`.

- `main` and `stable` share the same daily counter.
- Other branches do not evaluate or consume the release counter.
- Failed or canceled `main`/`stable` runs may leave gaps.
- The build fails when the daily sequence exceeds `9`.
- The counter date and encoded `YDDD` date both use
`pipeline.startTime`.

Private branches retain independent `0.0.<extended-day><NN>.0`
validation versions.

## Update behavior
- Stable users continue to query GitHub's stable latest-release path.
- Users who explicitly enable preview updates can select newer GitHub
prereleases.
- Preview releases and notifications are labeled as PowerToys Preview.
- What's New separates preview entries from stable release history and
hides previews by default.

## Validation
- 17 Pester tests cover `main`, `stable`, private branches, year
rollover, epoch reset, monotonicity, override validation, sequence
limits, and date alignment.
- Version propagation verified `0.100.2111.0` in `Version.props` and all
affected AppX/MSIX manifests.
- Azure DevOps pipeline dry-runs succeeded for both `refs/heads/main`
and `refs/heads/stable`.
- The affected native version project builds successfully.
- PR CI is green for x64, ARM64, Command Palette SDK, dependency review,
telemetry detection, and CLA.

## Remaining end-to-end checks
- Install two locally or officially produced installers with consecutive
MSI-visible `YDDDB` versions and verify the upgrade preserves binaries,
package registrations, hardlinks, and shell integrations.
- On the first natural post-merge `main` or `stable` run, verify the
production counter value and resolved version in the release logs.
## Local GPO verification

Validated locally with the signed `v0.100.2171` build from Azure DevOps
build
[153961073](https://microsoft.visualstudio.com/Dart/_build/results?buildId=153961073).
These checks cover the administrative-template integration and Settings
behavior.

### Policy enabled: preview updates are disabled

With `PreviewUpdatesDisabled=1`, **Include prerelease updates** is
forced off and locked, and Settings displays the
managed-by-your-organization notice.

![PowerToys Settings with preview updates disabled by
policy](https://raw.githubusercontent.com/LegendaryBlair/PowerToys/df808630b04e65ba437081aff9401c4efd58e67f/.github/pr-assets/49414/gpo-policy-enforced.png)

### Policy removed: the user preference is preserved

After removing `PreviewUpdatesDisabled` and restarting PowerToys, the
previously selected preview-update preference is restored and editable.
The policy suppresses the preference without overwriting it.

![PowerToys Settings with the preview-update preference
restored](https://raw.githubusercontent.com/LegendaryBlair/PowerToys/df808630b04e65ba437081aff9401c4efd58e67f/.github/pr-assets/49414/gpo-preference-restored.png)

### Group Policy Editor

After importing the updated ADMX/ADML templates, **Disable preview build
updates** appears under **Microsoft PowerToys > Installer and Updates**.
The policy dialog documents that **Enabled** blocks preview updates,
while **Disabled** or **Not Configured** leaves the choice available to
the user.

![Disable preview build updates in Local Group Policy
Editor](https://raw.githubusercontent.com/LegendaryBlair/PowerToys/e9e5c12f4480ef895d263460a59982246b7654dc/.github/pr-assets/49414/group-policy-editor-policy-dialog.png)

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ad8b7909-0472-4464-bdee-deaeca726f94
Copilot-Session: 8e04a72e-3b0f-4ac4-8156-d04ea9b8bb85
2026-08-06 23:45:11 +08:00

349 lines
14 KiB
C++

#include "pch.h"
#include "Generated Files/resource.h"
#include "ActionRunnerUtils.h"
#include "general_settings.h"
#include "trace.h"
#include "tray_icon.h"
#include "UpdateUtils.h"
#include <common/utils/gpo.h>
#include <common/logger/logger.h>
#include <common/notifications/notifications.h>
#include <common/updating/installer.h>
#include <common/updating/updating.h>
#include <common/updating/updateState.h>
#include <common/utils/HttpClient.h>
#include <common/utils/process_path.h>
#include <common/utils/resources.h>
#include <common/utils/timeutil.h>
#include <common/version/version.h>
namespace
{
constexpr int64_t UPDATE_CHECK_INTERVAL_MINUTES = 60 * 24;
constexpr int64_t UPDATE_CHECK_AFTER_FAILED_INTERVAL_MINUTES = 60 * 2;
// How many minor versions to suspend the toast notification (example: installed=0.60.0, suspend=2, next notification=0.63.*)
// Attention: When changing this value please update the ADML file to.
const int UPDATE_NOTIFICATION_TOAST_SUSPEND_MINOR_VERSION_COUNT = 2;
// The per-user "include prerelease updates" opt-in, additionally gated by the DisablePreviewUpdates
// group policy: when that policy is Enabled, preview (prerelease) updates are forced off regardless
// of the user's setting. Stable updates are unaffected.
bool effective_include_prerelease_updates()
{
if (powertoys_gpo::getDisablePreviewUpdatesValue() == powertoys_gpo::gpo_rule_configured_enabled)
{
return false;
}
return get_general_settings().includePrereleaseUpdates;
}
}
using namespace notifications;
using namespace updating;
std::wstring AvailableVersionToWstring(const new_version_download_info& info)
{
auto result = info.version.toWstring();
if (info.is_prerelease)
{
result += L"-preview";
}
return result;
}
std::wstring CurrentVersionToNextVersion(const new_version_download_info& info)
{
auto result = VersionHelper{ VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION }.toWstring();
result += L" \u2192 "; // Right arrow
result += AvailableVersionToWstring(info);
return result;
}
std::wstring UpdateAvailableMessage(const new_version_download_info& info)
{
return info.is_prerelease ?
GET_RESOURCE_STRING(IDS_GITHUB_NEW_PREVIEW_VERSION_AVAILABLE) :
GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_AVAILABLE);
}
void ShowNewVersionAvailable(const new_version_download_info& info)
{
remove_toasts_by_tag(UPDATING_PROCESS_TOAST_TAG);
toast_params toast_params{ UPDATING_PROCESS_TOAST_TAG, false };
std::wstring contents = UpdateAvailableMessage(info);
contents += L'\n';
contents += CurrentVersionToNextVersion(info);
show_toast_with_activations(std::move(contents),
GET_RESOURCE_STRING(IDS_TOAST_TITLE),
{},
{ link_button{ GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_UPDATE_NOW),
L"powertoys://update_now/" },
link_button{ GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_MORE_INFO),
L"powertoys://open_overview/" } },
std::move(toast_params),
L"powertoys://open_overview/");
}
void ShowOpenSettingsForUpdate(const new_version_download_info& info)
{
remove_toasts_by_tag(UPDATING_PROCESS_TOAST_TAG);
toast_params toast_params{ UPDATING_PROCESS_TOAST_TAG, false };
std::vector<action_t> actions = {
link_button{ GET_RESOURCE_STRING(IDS_GITHUB_NEW_VERSION_MORE_INFO),
L"powertoys://open_overview/" },
};
auto contents = UpdateAvailableMessage(info);
contents += L'\n';
contents += AvailableVersionToWstring(info);
show_toast_with_activations(std::move(contents),
GET_RESOURCE_STRING(IDS_TOAST_TITLE),
{},
std::move(actions),
std::move(toast_params),
L"powertoys://open_overview/");
}
SHELLEXECUTEINFOW LaunchPowerToysUpdate(const wchar_t* cmdline)
{
std::wstring powertoysUpdaterPath;
powertoysUpdaterPath = get_module_folderpath();
powertoysUpdaterPath += L"\\PowerToys.Update.exe";
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = { SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC | SEE_MASK_NOCLOSEPROCESS };
sei.lpFile = powertoysUpdaterPath.c_str();
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = cmdline;
ShellExecuteExW(&sei);
return sei;
}
bool IsMeteredConnection()
{
using namespace winrt::Windows::Networking::Connectivity;
ConnectionProfile internetConnectionProfile = NetworkInformation::GetInternetConnectionProfile();
if (!internetConnectionProfile)
{
return false;
}
if (internetConnectionProfile.IsWwanConnectionProfile())
{
return true;
}
ConnectionCost connectionCost = internetConnectionProfile.GetConnectionCost();
if (connectionCost.Roaming()
|| connectionCost.OverDataLimit()
|| connectionCost.NetworkCostType() == NetworkCostType::Fixed
|| connectionCost.NetworkCostType() == NetworkCostType::Variable)
{
return true;
}
return false;
}
void ProcessNewVersionInfo(const github_version_info& version_info,
UpdateState& state,
const bool download_update,
bool show_notifications)
{
state.githubUpdateLastCheckedDate.emplace(timeutil::now());
if (std::holds_alternative<version_up_to_date>(version_info))
{
state.state = UpdateState::upToDate;
state.releasePageUrl = {};
state.downloadedInstallerFilename = {};
state.isPrerelease = false;
Logger::trace(L"Version is up to date");
dispatch_run_on_main_ui_thread([](PVOID) { set_tray_icon_update_available(false); }, nullptr);
return;
}
const auto new_version_info = std::get<new_version_download_info>(version_info);
state.releasePageUrl = new_version_info.release_page_uri.ToString().c_str();
state.isPrerelease = new_version_info.is_prerelease;
Logger::trace(L"Discovered new version {}", new_version_info.version.toWstring());
const bool already_downloaded = state.state == UpdateState::readyToInstall && state.downloadedInstallerFilename == new_version_info.installer_filename;
if (already_downloaded)
{
Logger::trace(L"New version is already downloaded");
return;
}
// Check toast notification GPOs and settings. (We check only if notifications are allowed. This is the case if we are triggered by the periodic check.)
// Disable notification GPO or setting
bool disable_notification_setting = get_general_settings().showNewUpdatesToastNotification == false;
if (show_notifications && (disable_notification_setting || powertoys_gpo::getDisableNewUpdateToastValue() == powertoys_gpo::gpo_rule_configured_enabled))
{
Logger::info(L"There is a new update available or ready to install. But the toast notification is disabled by setting or GPO.");
show_notifications = false;
}
// Suspend notification GPO
else if (show_notifications && powertoys_gpo::getSuspendNewUpdateToastValue() == powertoys_gpo::gpo_rule_configured_enabled)
{
Logger::info(L"GPO to suspend new update toast notification is enabled.");
if (new_version_info.version.major <= VERSION_MAJOR && new_version_info.version.minor - VERSION_MINOR <= UPDATE_NOTIFICATION_TOAST_SUSPEND_MINOR_VERSION_COUNT)
{
Logger::info(L"The difference between the installed version and the newer version is within the allowed period. The toast notification is not shown.");
show_notifications = false;
}
else
{
Logger::info(L"The installed version is older than allowed for suspending the toast notification. The toast notification is shown.");
}
}
if (download_update)
{
Logger::trace(L"Downloading installer for a new version");
// Cleanup old updates before downloading the latest
updating::cleanup_updates();
auto downloaded_installer = std::move(download_new_version_async(new_version_info)).get();
if (downloaded_installer)
{
state.state = UpdateState::readyToInstall;
state.downloadedInstallerFilename = new_version_info.installer_filename;
Trace::UpdateDownloadCompleted(true, new_version_info.version.toWstring());
dispatch_run_on_main_ui_thread([](PVOID) { set_tray_icon_update_available(true); }, nullptr);
if (show_notifications)
{
ShowNewVersionAvailable(new_version_info);
}
}
else
{
state.state = UpdateState::errorDownloading;
state.downloadedInstallerFilename = {};
Trace::UpdateDownloadCompleted(false, new_version_info.version.toWstring());
Logger::error("Couldn't download new installer");
}
}
else
{
Logger::trace(L"New version is ready to download, showing notification");
state.state = UpdateState::readyToDownload;
state.downloadedInstallerFilename = {};
dispatch_run_on_main_ui_thread([](PVOID) { set_tray_icon_update_available(true); }, nullptr);
if (show_notifications)
{
ShowOpenSettingsForUpdate(new_version_info);
}
}
}
void PeriodicUpdateWorker()
{
for (;;)
{
auto state = UpdateState::read();
int64_t sleep_minutes_till_next_update = UPDATE_CHECK_AFTER_FAILED_INTERVAL_MINUTES;
if (state.githubUpdateLastCheckedDate.has_value())
{
int64_t last_checked_minutes_ago = timeutil::diff::in_minutes(timeutil::now(), *state.githubUpdateLastCheckedDate);
if (last_checked_minutes_ago < 0)
{
last_checked_minutes_ago = UPDATE_CHECK_INTERVAL_MINUTES;
}
sleep_minutes_till_next_update = max(0, UPDATE_CHECK_INTERVAL_MINUTES - last_checked_minutes_ago);
}
std::this_thread::sleep_for(std::chrono::minutes{ sleep_minutes_till_next_update });
// Auto download setting.
bool download_update = !IsMeteredConnection() && get_general_settings().downloadUpdatesAutomatically;
if (powertoys_gpo::getDisableAutomaticUpdateDownloadValue() == powertoys_gpo::gpo_rule_configured_enabled)
{
Logger::info(L"Automatic download of updates is disabled by GPO.");
download_update = false;
}
bool version_info_obtained = false;
try
{
const auto new_version_info = std::move(get_github_version_info_async(effective_include_prerelease_updates())).get();
if (new_version_info.has_value())
{
version_info_obtained = true;
bool updateAvailable = std::holds_alternative<new_version_download_info>(*new_version_info);
std::wstring fromVersion = get_product_version();
std::wstring toVersion = updateAvailable ? std::get<new_version_download_info>(*new_version_info).version.toWstring() : L"";
Trace::UpdateCheckCompleted(true, updateAvailable, fromVersion, toVersion);
ProcessNewVersionInfo(*new_version_info, state, download_update, true);
}
else
{
Trace::UpdateCheckCompleted(false, false, get_product_version(), L"");
Logger::error(L"Couldn't obtain version info from github: {}", new_version_info.error());
}
}
catch (...)
{
Logger::error("periodic_update_worker: error while processing version info");
}
if (version_info_obtained)
{
UpdateState::store([&](UpdateState& v) {
v = std::move(state);
});
}
else
{
std::this_thread::sleep_for(std::chrono::minutes{ UPDATE_CHECK_AFTER_FAILED_INTERVAL_MINUTES });
}
}
}
void CheckForUpdatesCallback()
{
Logger::trace(L"Check for updates callback invoked");
auto state = UpdateState::read();
try
{
auto new_version_info = std::move(get_github_version_info_async(effective_include_prerelease_updates())).get();
if (!new_version_info)
{
// We couldn't get a new version from github for some reason, log error
state.state = UpdateState::networkError;
Trace::UpdateCheckCompleted(false, false, get_product_version(), L"");
Logger::error(L"Couldn't obtain version info from github: {}", new_version_info.error());
}
else
{
// Auto download setting
bool download_update = !IsMeteredConnection() && get_general_settings().downloadUpdatesAutomatically;
if (powertoys_gpo::getDisableAutomaticUpdateDownloadValue() == powertoys_gpo::gpo_rule_configured_enabled)
{
Logger::info(L"Automatic download of updates is disabled by GPO.");
download_update = false;
}
bool updateAvailable = std::holds_alternative<new_version_download_info>(*new_version_info);
std::wstring fromVersion = get_product_version();
std::wstring toVersion = updateAvailable ? std::get<new_version_download_info>(*new_version_info).version.toWstring() : L"";
Trace::UpdateCheckCompleted(true, updateAvailable, fromVersion, toVersion);
ProcessNewVersionInfo(*new_version_info, state, download_update, false);
}
UpdateState::store([&](UpdateState& v) {
v = std::move(state);
});
}
catch (...)
{
Logger::error("CheckForUpdatesCallback: error while processing version info");
}
}