Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
026f17b
Add in-app updater for Linux
ivan-ottinger May 13, 2026
69c7479
Generalize distro mention and show actual .deb filename in updater di…
ivan-ottinger May 13, 2026
8d733ac
Split updater dialog copy into translator-friendly sentences
ivan-ottinger May 13, 2026
10c7bd0
Use ~/Downloads/ in updater command so it works from any terminal cwd
ivan-ottinger May 13, 2026
9c2f618
Add unit tests for the Linux updater paths
ivan-ottinger May 13, 2026
edd942b
Fix typecheck error reading dialog.showMessageBox mock args
ivan-ottinger May 13, 2026
bdab436
Trim redundant comments from Linux updater response handling
ivan-ottinger May 13, 2026
46adc3d
Use process.platform for Linux updates URL to mirror Mac/Win pattern
ivan-ottinger May 13, 2026
b5346c6
Address Copilot review on Linux updater: guard concurrent polls, vali…
ivan-ottinger May 13, 2026
8d4b0c1
Drop defensive timeout-clear in rescheduleLinuxOrFinish
ivan-ottinger May 13, 2026
83aecf7
Merge remote-tracking branch 'origin/trunk' into add-linux-in-app-upd…
ivan-ottinger May 13, 2026
6d8d7d6
Validate .deb filename before interpolating into displayed shell command
ivan-ottinger May 13, 2026
f6901e0
Rename Linux skip log: "auto-updates" → "update checks"
ivan-ottinger May 14, 2026
d42ca03
Add translator comment for version placeholder in Linux updater dialog
ivan-ottinger May 14, 2026
25cb673
Drop verbose vitest overload comment from updates test helper
ivan-ottinger May 14, 2026
be0d292
Merge branch 'trunk' into add-linux-in-app-updater
ivan-ottinger May 14, 2026
370fe2a
Merge branch 'trunk' into add-linux-in-app-updater
ivan-ottinger May 14, 2026
5f18545
Merge branch 'trunk' into add-linux-in-app-updater
gavande1 May 14, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions apps/studio/src/tests/updates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @vitest-environment node
*/
import { app, dialog, shell, type MessageBoxOptions } from 'electron';
import * as Sentry from '@sentry/electron/main';
import { vi } from 'vitest';
import { manualCheckForUpdates } from 'src/updates';

function getLastDialogOptions(): MessageBoxOptions {
const lastCall = vi.mocked( dialog.showMessageBox ).mock.lastCall as unknown as [
unknown,
MessageBoxOptions,
];
return lastCall[ 1 ];
}

vi.mock( 'src/main-window', () => ( {
getMainWindow: vi.fn().mockResolvedValue( {} ),
} ) );

const originalFetch = global.fetch;
const originalPlatform = process.platform;
const originalArch = process.arch;

beforeEach( () => {
Object.defineProperty( process, 'platform', { value: 'linux', configurable: true } );
Object.defineProperty( process, 'arch', { value: 'arm64', configurable: true } );
vi.mocked( app.getVersion ).mockReturnValue( '1.8.2' );
// shell.openExternal isn't part of the global electron mock in vitest.setup.ts.
( shell as unknown as { openExternal: ReturnType< typeof vi.fn > } ).openExternal = vi
.fn()
.mockResolvedValue( undefined );
} );

afterEach( () => {
global.fetch = originalFetch;
Object.defineProperty( process, 'platform', { value: originalPlatform, configurable: true } );
Object.defineProperty( process, 'arch', { value: originalArch, configurable: true } );
} );

describe( 'Linux updater', () => {
it( 'shows the download dialog with the install command and opens the browser on Download', async () => {
global.fetch = vi.fn().mockResolvedValue( {
status: 200,
ok: true,
json: async () => ( {
version: '1.9.0',
downloadUrl: 'https://appscdn.example.com/path/studio_1.9.0_arm64.deb',
} ),
} as Response );
vi.mocked( dialog.showMessageBox ).mockResolvedValue( {
response: 0,
checkboxChecked: false,
} );

await manualCheckForUpdates();

await vi.waitFor( () => {
expect( dialog.showMessageBox ).toHaveBeenCalled();
} );

const args = getLastDialogOptions();
expect( args.message ).toContain( '1.9.0' );
expect( args.detail ).toContain( 'sudo apt install ~/Downloads/studio_1.9.0_arm64.deb' );

expect( shell.openExternal ).toHaveBeenCalledWith(
'https://appscdn.example.com/path/studio_1.9.0_arm64.deb'
);
} );

it( 'shows "No updates available" on a manual check when the server returns 204', async () => {
global.fetch = vi.fn().mockResolvedValue( {
status: 204,
ok: true,
} as Response );
vi.mocked( dialog.showMessageBox ).mockResolvedValue( {
response: 0,
checkboxChecked: false,
} );

await manualCheckForUpdates();

await vi.waitFor( () => {
expect( dialog.showMessageBox ).toHaveBeenCalled();
} );

const args = getLastDialogOptions();
expect( args.message ).toBe( 'No updates available' );
expect( shell.openExternal ).not.toHaveBeenCalled();
} );

it( 'reports to Sentry and shows no dialog when the server returns an error status', async () => {
global.fetch = vi.fn().mockResolvedValue( {
status: 500,
ok: false,
} as Response );

await manualCheckForUpdates();

await vi.waitFor( () => {
expect( Sentry.captureException ).toHaveBeenCalled();
} );

expect( dialog.showMessageBox ).not.toHaveBeenCalled();
expect( shell.openExternal ).not.toHaveBeenCalled();
} );
} );
146 changes: 146 additions & 0 deletions apps/studio/src/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { app, autoUpdater, dialog } from 'electron';
import * as Sentry from '@sentry/electron/main';
import { sprintf, __ } from '@wordpress/i18n';
import { AUTO_UPDATE_INTERVAL_MS } from 'src/constants';
import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper';
import { isDevRelease } from 'src/lib/version-utils';
import { getMainWindow } from 'src/main-window';

Expand Down Expand Up @@ -33,6 +34,14 @@ export function setupUpdates() {
return;
}

if ( process.platform === 'linux' ) {
// Electron's built-in autoUpdater is macOS/Windows only. On Linux we
// poll the same WPCOM endpoint ourselves and show a dialog pointing
// the user at the .deb to install manually.
setupLinuxUpdates();
return;
}

const url = new URL( 'https://public-api.wordpress.com/wpcom/v2/studio-app/updates' );
url.searchParams.append( 'platform', process.platform );
url.searchParams.append( 'studioArch', process.arch );
Expand Down Expand Up @@ -135,6 +144,16 @@ export async function manualCheckForUpdates() {
// to the event handler that it should show a dialog.
showManualCheckDialogs = true;

if ( process.platform === 'linux' ) {
if ( updaterState === 'checking-for-update' ) {
console.log( 'Manually polling for Linux update, but a check is already in progress' );
return;
}
console.log( 'Manually polling for Linux update' );
void pollLinuxUpdates();
return;
}
Comment thread
ivan-ottinger marked this conversation as resolved.

if ( updaterState === 'checking-for-update' ) {
console.log( 'Manually checking for update, but discovered an check is already in progress' );
} else {
Expand Down Expand Up @@ -202,6 +221,133 @@ async function showUpdateReadyToInstallNotice() {
}
}

function setupLinuxUpdates() {
if ( ! shouldPoll ) {
console.log( 'Skipping Linux update checks', {
env: process.env.NODE_ENV,
isPackaged: app.isPackaged,
version: app.getVersion(),
} );
return;
}

console.log( 'Polling for Linux update on app launch' );
void pollLinuxUpdates();
}

async function pollLinuxUpdates() {
updaterState = 'checking-for-update';

const url = new URL( 'https://public-api.wordpress.com/wpcom/v2/studio-app/updates' );
url.searchParams.append( 'platform', process.platform );
url.searchParams.append( 'studioArch', process.arch );
url.searchParams.append( 'version', app.getVersion() );

try {
const response = await fetch( url.toString() );

if ( response.status === 204 ) {
if ( showManualCheckDialogs ) {
await showUpdateUnavailableNotice();
}
return;
}

if ( response.status === 404 ) {
Sentry.captureException(
new Error( `Linux updates endpoint returned 404 (arch=${ process.arch })` )
);
return;
}

if ( ! response.ok ) {
Sentry.captureException(
new Error( `Linux updates endpoint returned HTTP ${ response.status }` )
);
return;
Comment thread
ivan-ottinger marked this conversation as resolved.
}

const data = ( await response.json() ) as { version?: string; downloadUrl?: string };

if ( ! data?.version || ! data?.downloadUrl ) {
Sentry.captureException( new Error( 'Linux updates endpoint returned malformed response' ) );
return;
}

await showLinuxUpdateAvailableNotice( data.version, data.downloadUrl );
} catch ( err ) {
console.error( err );
Sentry.captureException( err );
} finally {
showManualCheckDialogs = false;
rescheduleLinuxOrFinish();
}
}

function rescheduleLinuxOrFinish() {
if ( ! shouldPoll ) {
updaterState = 'done';
return;
}
updaterState = 'polling';
timeout = setTimeout( () => {
console.log( 'Automatically polling for Linux update' );
void pollLinuxUpdates();
}, AUTO_UPDATE_INTERVAL_MS );
}

async function showLinuxUpdateAvailableNotice( version: string, downloadUrl: string ) {
const mainWindow = await getMainWindow();

const command = `sudo apt install ~/Downloads/${ debFilenameFromUrl( downloadUrl ) }`;
Comment thread
ivan-ottinger marked this conversation as resolved.
const introLine = __(
'After downloading, quit Studio and run this command from a terminal to install:'
);
const doubleClickHint = __(
'On some distributions, double-clicking the downloaded file may also work.'
);

const { response } = await dialog.showMessageBox( mainWindow, {
type: 'info',
buttons: [ __( 'Download' ), __( 'Later' ) ],
title: __( 'New Version Available' ),
// translators: %s is the version number, e.g. "1.9.0".
message: sprintf( __( 'Studio %s is available' ), version ),
detail: `${ introLine }\n\n${ command }\n\n${ doubleClickHint }`,
defaultId: 0,
cancelId: 1,
} );

if ( response !== 0 ) {
return;
}

try {
const parsedUrl = new URL( downloadUrl );
if ( ! [ 'http:', 'https:' ].includes( parsedUrl.protocol ) ) {
Sentry.captureException(
new Error( `Unexpected protocol in downloadUrl: ${ parsedUrl.protocol }` )
);
return;
}
void shellOpenExternalWrapper( parsedUrl.toString() );
} catch {
Sentry.captureException( new Error( `Malformed downloadUrl: ${ downloadUrl }` ) );
}
}

function debFilenameFromUrl( downloadUrl: string ): string {
try {
const filename = new URL( downloadUrl ).pathname.split( '/' ).pop() ?? '';
if ( /^[A-Za-z0-9._~+-]+\.deb$/.test( filename ) ) {
return filename;
}
} catch {
// ignore
}
return 'studio.deb';
}

function isAppRunningFromDMG(): boolean {
if ( process.platform !== 'darwin' ) {
return false;
Expand Down
Loading