diff --git a/build/win32/code.iss b/build/win32/code.iss index 05c8465a17b..0e2b143f3b8 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -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 diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 40d896439e2..8e3db7c247b 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -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 }), }; diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 7778a01ffa3..da4c875845a 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -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 { - 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 { @@ -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 { @@ -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; diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 8dd877b6337..970d72c5a4f 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -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. */ diff --git a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts index c97431d5dd0..aa8c2a4693f 100644 --- a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts +++ b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts @@ -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();