Show installation progress in update status bar entry on Windows (#292970)

This commit is contained in:
Dmitriy Vasyura
2026-02-12 03:17:41 -08:00
committed by GitHub
parent 576ddceccc
commit e44773ccce
5 changed files with 167 additions and 29 deletions
+31
View File
@@ -1365,6 +1365,36 @@ end;
// Updates
function GetUpdateProgressFilePath(): String;
begin
Result := ExpandConstant('{param:progress}');
end;
var
LastReportedProgressPct: Integer;
procedure CurInstallProgressChanged(CurProgress, MaxProgress: Integer);
var
ProgressFilePath: String;
ProgressContent: String;
CurrentPct: Integer;
begin
if IsBackgroundUpdate() then begin
ProgressFilePath := GetUpdateProgressFilePath();
if ProgressFilePath <> '' then begin
if MaxProgress > 0 then
CurrentPct := (CurProgress * 100) div MaxProgress
else
CurrentPct := 0;
if CurrentPct <> LastReportedProgressPct then begin
LastReportedProgressPct := CurrentPct;
ProgressContent := IntToStr(CurProgress) + ',' + IntToStr(MaxProgress);
SaveStringToFile(ProgressFilePath, ProgressContent, False);
end;
end;
end;
end;
var
ShouldRestartTunnelService: Boolean;
@@ -1658,6 +1688,7 @@ begin
begin
SaveStringToFile(ExpandConstant('{app}\updating_version'), '{#Commit}', False);
CreateMutex('{#AppMutex}-ready');
DeleteFile(GetUpdateProgressFilePath());
Log('Checking whether application is still running...');
while (CheckForMutexes('{#AppMutex}')) do
+2 -2
View File
@@ -71,7 +71,7 @@ export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit:
export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate };
export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number };
export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean };
export type Updating = { type: StateType.Updating; update: IUpdate };
export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number };
export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean };
export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean };
@@ -85,7 +85,7 @@ export const State = {
AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }),
Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }),
Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }),
Updating: (update: IUpdate): Updating => ({ type: StateType.Updating, update }),
Updating: (update: IUpdate, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, currentProgress, maxProgress }),
Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }),
Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }),
};
@@ -4,13 +4,13 @@
*--------------------------------------------------------------------------------------------*/
import { ChildProcess, spawn } from 'child_process';
import { app } from 'electron';
import { existsSync, unlinkSync } from 'fs';
import { mkdir, readFile, unlink } from 'fs/promises';
import { tmpdir } from 'os';
import { app } from 'electron';
import { Delayer, timeout } from '../../../base/common/async.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
import { memoize } from '../../../base/common/decorators.js';
import { hash } from '../../../base/common/hash.js';
import * as path from '../../../base/common/path.js';
@@ -24,19 +24,13 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ
import { IFileService } from '../../files/common/files.js';
import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js';
import { ILogService } from '../../log/common/log.js';
import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js';
import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js';
import { IProductService } from '../../product/common/productService.js';
import { asJson, IRequestService } from '../../request/common/request.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js';
import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js';
import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js';
async function pollUntil(fn: () => boolean, millis = 1000): Promise<void> {
while (!fn()) {
await timeout(millis);
}
}
interface IAvailableUpdate {
packagePath: string;
@@ -61,6 +55,7 @@ function getUpdateType(): UpdateType {
export class Win32UpdateService extends AbstractUpdateService implements IRelaunchHandler {
private availableUpdate: IAvailableUpdate | undefined;
private updateCancellationTokenSource: CancellationTokenSource | undefined;
@memoize
get cachePath(): Promise<string> {
@@ -342,6 +337,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun
// ignore
}
const progressFilePath = path.join(cachePath, `update-progress`);
try {
await unlink(progressFilePath);
} catch {
// ignore
}
this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`);
this.availableUpdate.cancelFilePath = cancelFilePath;
@@ -351,6 +353,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun
'/verysilent',
'/log',
`/update="${this.availableUpdate.updateFilePath}"`,
`/progress="${progressFilePath}"`,
`/sessionend="${sessionEndFlagPath}"`,
`/cancel="${cancelFilePath}"`,
'/nocloseapplications',
@@ -374,9 +377,53 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun
const readyMutexName = `${this.productService.win32MutexName}-ready`;
const mutex = await import('@vscode/windows-mutex');
// poll for mutex-ready
pollUntil(() => mutex.isActive(readyMutexName))
.then(() => this.setState(State.Ready(update, explicit, this._overwrite)));
// Poll for progress and ready mutex (timeout after 30 minutes)
const pollTimeoutMs = 30 * 60 * 1000;
const pollStartTime = Date.now();
this.updateCancellationTokenSource?.dispose(true);
const cts = this.updateCancellationTokenSource = new CancellationTokenSource();
const token = cts.token;
const poll = async () => {
while (this.state.type === StateType.Updating && !token.isCancellationRequested) {
if (mutex.isActive(readyMutexName)) {
this.setState(State.Ready(update, explicit, this._overwrite));
return;
}
if (Date.now() - pollStartTime > pollTimeoutMs) {
this.logService.warn('update#doApplyUpdate: polling timed out waiting for update to be ready');
this.setState(State.Idle(getUpdateType(), 'Update did not complete within expected time'));
return;
}
try {
const progressContent = await readFile(progressFilePath, 'utf8');
if (!token.isCancellationRequested) {
const [currentStr, maxStr] = progressContent.split(',');
const currentProgress = parseInt(currentStr, 10);
const maxProgress = parseInt(maxStr, 10);
if (!isNaN(currentProgress) && !isNaN(maxProgress) && this.state.type === StateType.Updating) {
if (this.state.currentProgress !== currentProgress || this.state.maxProgress !== maxProgress) {
this.setState(State.Updating(update, currentProgress, maxProgress));
}
}
}
} catch {
// Progress file may not exist yet or be locked, ignore
}
await timeout(500);
}
};
poll().finally(() => {
if (this.updateCancellationTokenSource === cts) {
this.updateCancellationTokenSource = undefined;
}
cts.dispose();
});
}
protected override async cancelPendingUpdate(): Promise<void> {
@@ -384,6 +431,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun
return;
}
// Cancel the polling loop
this.updateCancellationTokenSource?.dispose(true);
this.updateCancellationTokenSource = undefined;
this.logService.trace('update#cancelPendingUpdate: cancelling pending update');
const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate;
@@ -16,7 +16,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState } from '../../../../platform/update/common/update.js';
import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState, Updating } from '../../../../platform/update/common/update.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, TooltipContent } from '../../../services/statusbar/browser/statusbar.js';
import './media/updateStatusBarEntry.css';
@@ -127,9 +127,9 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
case StateType.Updating:
this.updateStatusBarEntry({
name: UpdateStatusBarEntryContribution.NAME,
text: nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update..."),
ariaLabel: nls.localize('updateStatus.installingUpdateAria', "Installing update"),
tooltip: this.getUpdatingTooltip(state.update),
text: this.getUpdatingText(state),
ariaLabel: this.getUpdatingText(state),
tooltip: this.getUpdatingTooltip(state),
command: ShowTooltipCommand
});
break;
@@ -178,8 +178,8 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
this.appendHeader(container, nls.localize('updateStatus.checkingForUpdatesTitle', "Checking for Updates"), store);
this.appendProductInfo(container);
const waitMessage = dom.append(container, dom.$('.progress-details'));
waitMessage.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait...");
const message = dom.append(container, dom.$('.progress-details'));
message.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait...");
return container;
}
@@ -206,7 +206,7 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
return nls.localize('updateStatus.downloadUpdateProgressStatus', "$(sync~spin) Downloading update: {0} / {1} • {2}%",
formatBytes(downloadedBytes),
formatBytes(totalBytes),
Math.round((downloadedBytes / totalBytes) * 100));
getProgressPercent(downloadedBytes, totalBytes) ?? 0);
} else {
return nls.localize('updateStatus.downloadUpdateStatus', "$(sync~spin) Downloading update...");
}
@@ -223,7 +223,7 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
const { downloadedBytes, totalBytes } = state;
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
const percentage = Math.round((downloadedBytes / totalBytes) * 100);
const percentage = getProgressPercent(downloadedBytes, totalBytes) ?? 0;
const progressContainer = dom.append(container, dom.$('.progress-container'));
const progressBar = dom.append(progressContainer, dom.$('.progress-bar'));
@@ -249,8 +249,8 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
timeRemainingNode.textContent = `~${formatTimeRemaining(timeRemaining)} ${nls.localize('updateStatus.timeRemaining', "remaining")}`;
}
} else {
const waitMessage = dom.append(container, dom.$('.progress-details'));
waitMessage.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait...");
const message = dom.append(container, dom.$('.progress-details'));
message.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait...");
}
return container;
@@ -288,17 +288,39 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
};
}
private getUpdatingTooltip(update: IUpdate): TooltipContent {
private getUpdatingText({ currentProgress, maxProgress }: Updating): string {
const percentage = getProgressPercent(currentProgress, maxProgress);
if (percentage !== undefined) {
return nls.localize('updateStatus.installingUpdateProgressStatus', "$(sync~spin) Installing update: {0}%", percentage);
} else {
return nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update...");
}
}
private getUpdatingTooltip(state: Updating): TooltipContent {
return {
element: (token: CancellationToken) => {
const store = this.createTooltipDisposableStore(token);
const container = dom.$('.update-status-tooltip');
this.appendHeader(container, nls.localize('updateStatus.installingUpdateTitle', "Installing Update"), store);
this.appendProductInfo(container, update);
this.appendProductInfo(container, state.update);
const message = dom.append(container, dom.$('.progress-details'));
message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait...");
const { currentProgress, maxProgress } = state;
const percentage = getProgressPercent(currentProgress, maxProgress);
if (percentage !== undefined) {
const progressContainer = dom.append(container, dom.$('.progress-container'));
const progressBar = dom.append(progressContainer, dom.$('.progress-bar'));
const progressFill = dom.append(progressBar, dom.$('.progress-fill'));
progressFill.style.width = `${percentage}%`;
const progressText = dom.append(progressContainer, dom.$('.progress-text'));
const percentageSpan = dom.append(progressText, dom.$('span'));
percentageSpan.textContent = `${percentage}%`;
} else {
const message = dom.append(container, dom.$('.progress-details'));
message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait...");
}
return container;
}
@@ -418,6 +440,17 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor
}
}
/**
* Returns the progress percentage based on the current and maximum progress values.
*/
export function getProgressPercent(current: number | undefined, max: number | undefined): number | undefined {
if (current === undefined || max === undefined || max <= 0) {
return undefined;
} else {
return Math.max(Math.min(Math.round((current / max) * 100), 100), 0);
}
}
/**
* Tries to parse a date string and returns the timestamp or undefined if parsing fails.
*/
@@ -7,7 +7,7 @@ import assert from 'assert';
import * as sinon from 'sinon';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { Downloading, StateType } from '../../../../../platform/update/common/update.js';
import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatDate, formatTimeRemaining, tryParseDate } from '../../browser/updateStatusBarEntry.js';
import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatDate, formatTimeRemaining, getProgressPercent, tryParseDate } from '../../browser/updateStatusBarEntry.js';
suite('UpdateStatusBarEntry', () => {
ensureNoDisposablesAreLeakedInTestSuite();
@@ -26,6 +26,29 @@ suite('UpdateStatusBarEntry', () => {
return { type: StateType.Downloading, explicit: true, overwrite: false, downloadedBytes, totalBytes, startTime };
}
suite('getProgressPercent', () => {
test('handles invalid values', () => {
assert.strictEqual(getProgressPercent(undefined, 100), undefined);
assert.strictEqual(getProgressPercent(50, undefined), undefined);
assert.strictEqual(getProgressPercent(undefined, undefined), undefined);
assert.strictEqual(getProgressPercent(50, 0), undefined);
assert.strictEqual(getProgressPercent(50, -10), undefined);
});
test('computes correct percentage', () => {
assert.strictEqual(getProgressPercent(0, 100), 0);
assert.strictEqual(getProgressPercent(50, 100), 50);
assert.strictEqual(getProgressPercent(100, 100), 100);
assert.strictEqual(getProgressPercent(1, 3), 33);
assert.strictEqual(getProgressPercent(2, 3), 67);
});
test('clamps to 0-100 range', () => {
assert.strictEqual(getProgressPercent(-10, 100), 0);
assert.strictEqual(getProgressPercent(200, 100), 100);
});
});
suite('computeDownloadTimeRemaining', () => {
test('returns undefined for invalid or incomplete input', () => {
const now = Date.now();