Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/studio/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const LIMIT_OF_ZIP_SITES_PER_USER = 10;
export const LIMIT_OF_PROMPTS_PER_USER = 200;
export const UPDATED_MESSAGE_DURATION_MS = 60000; // 1 minute
export const AUTO_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
export const NIGHTLY_UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 20, y: 20 };
export const WINDOWS_TITLEBAR_HEIGHT = 44;
export const EMPTY_SITE_PLAYGROUND_URL = 'https://playground.wordpress.net/';
Expand Down
4 changes: 4 additions & 0 deletions apps/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { handleDeeplink } from 'src/lib/deeplink';
import { getUserLocaleWithFallback } from 'src/lib/locale-node';
import { setSentryWpcomUserIdMain } from 'src/lib/main-sentry-utils';
import { maybePromptNightlySwitch, startNightlyPromptPoller } from 'src/lib/nightly-prompt';
import { getSentryReleaseInfo } from 'src/lib/sentry-release';
import { setupLogging } from 'src/logging';
import { createMainWindow, getCurrentRendererUrl, getMainWindow } from 'src/main-window';
Expand Down Expand Up @@ -377,6 +378,9 @@ async function appBoot() {

await createMainWindow();

void maybePromptNightlySwitch().catch( Sentry.captureException );
startNightlyPromptPoller();

const userData = await loadUserData();
// Bump stats for the first time the app runs - this is when no lastBumpStats are available
if ( ! userData.lastBumpStats ) {
Expand Down
110 changes: 110 additions & 0 deletions apps/studio/src/lib/nightly-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { app, dialog } from 'electron';
import * as Sentry from '@sentry/electron/main';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __ } from '@wordpress/i18n';
import { isDevRelease } from 'src/lib/version-utils';
import { getMainWindow } from 'src/main-window';
import { loadUserData, updateAppdata } from 'src/storage/user-data';
import { switchToNightlyAndUpdate } from 'src/updates';

async function isAutomattician(): Promise< boolean > {
try {
const token = await readAuthToken();
if ( ! token ) {
return false;
}

const res = await fetch( 'https://public-api.wordpress.com/rest/v1.2/read/teams', {
headers: { Authorization: `Bearer ${ token.accessToken }` },
signal: AbortSignal.timeout( 5000 ),
} );

if ( ! res.ok ) {
return false;
}

const data = ( await res.json() ) as { teams?: { slug: string }[] };
return data.teams?.some( ( team ) => team.slug === 'a8c' ) ?? false;
} catch {
return false;
}
}

/**
* Shows a one-time dialog prompting Automatticians on stable builds to switch to nightly builds.
* - Skipped if already on a dev/nightly build.
* - Skipped if the user previously chose "Don't ask again" or already switched.
* - Skipped if the user is not authenticated or not an Automattician.
*/
export async function maybePromptNightlySwitch(): Promise< void > {
if ( process.env.E2E || ! app.isPackaged ) {
return;
}

// Already on a nightly build — nothing to prompt.
if ( isDevRelease( app.getVersion() ) ) {
return;
}

const userData = await loadUserData();
const result = userData.nightlyPromptResult;

if ( result?.dontAskAgain || result?.response === 'yes' ) {
return;
}

const automattician = await isAutomattician();
if ( ! automattician ) {
return;
}

const SWITCH = __( 'Switch to nightly builds' );
const NOT_NOW = __( 'Not now' );
const buttons = [ SWITCH, NOT_NOW ];

const mainWindow = await getMainWindow();
const { response, checkboxChecked } = await dialog.showMessageBox( mainWindow, {
type: 'question',
buttons,
title: __( 'Try nightly builds?' ),
message: __(
'As an Automattician, you can run the latest trunk build of Studio and get updates daily.'
),
detail: __(
'Nightly builds let you catch issues earlier and try new features before they ship. You can always reinstall the stable version if needed.'
),
checkboxLabel: __( "Don't ask me again" ),
defaultId: buttons.indexOf( SWITCH ),
cancelId: buttons.indexOf( NOT_NOW ),
} );

switch ( response ) {
case buttons.indexOf( SWITCH ):
await updateAppdata( {
nightlyPromptResult: { response: 'yes', dontAskAgain: checkboxChecked },
} );
switchToNightlyAndUpdate();
break;

case buttons.indexOf( NOT_NOW ):
await updateAppdata( {
nightlyPromptResult: { response: 'no', dontAskAgain: checkboxChecked },
} );
break;
}
}

/**
* Starts a recurring check that runs maybePromptNightlySwitch once per hour.
* This catches users who sign in after the initial app launch.
*
* @returns A cleanup function that clears the interval.
*/
export function startNightlyPromptPoller(): () => void {
const ONE_HOUR_MS = 60 * 60 * 1000;
const interval = setInterval( () => {
void maybePromptNightlySwitch().catch( Sentry.captureException );
}, ONE_HOUR_MS );

return () => clearInterval( interval );
}
7 changes: 7 additions & 0 deletions apps/studio/src/storage/storage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface AiSessionSitePlacement {
siteName: string;
}

export interface NightlyPromptResult {
response: 'yes' | 'no';
dontAskAgain: boolean;
}

export interface UserData {
version: 1;
siteMetadata: Record< string, AppdataSiteData >;
Expand All @@ -44,6 +49,8 @@ export interface UserData {
wapuuScore?: number;
desks?: DesksConfig;
aiSessionPlacements?: Record< string, AiSessionSitePlacement >;
lastNightlyUpdateCheck?: number;
nightlyPromptResult?: NightlyPromptResult;
}

export interface PromptWindowsSpeedUpResult {
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/src/storage/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ type UserDataSafeKeys =
| 'colorScheme'
| 'defaultSiteDirectory'
| 'cliAutoInstalled'
| 'wapuuScore';
| 'wapuuScore'
| 'lastNightlyUpdateCheck'
| 'nightlyPromptResult';

type PartialUserDataWithSafeKeysToUpdate = Partial< Pick< UserData, UserDataSafeKeys > >;

Expand Down
81 changes: 65 additions & 16 deletions apps/studio/src/updates.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { app, autoUpdater, clipboard, dialog } from 'electron';
import * as Sentry from '@sentry/electron/main';
import { sprintf, __ } from '@wordpress/i18n';
import { AUTO_UPDATE_INTERVAL_MS } from 'src/constants';
import { AUTO_UPDATE_INTERVAL_MS, NIGHTLY_UPDATE_TTL_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';
import { loadUserData, updateAppdata } from 'src/storage/user-data';

type UpdpaterState =
| 'init'
Expand All @@ -20,13 +21,38 @@ let timeout: NodeJS.Timeout | null = null;

let showManualCheckDialogs = false;

const shouldPoll =
process.env.NODE_ENV === 'production' && app.isPackaged && ! isDevRelease( app.getVersion() );
const shouldPoll = process.env.NODE_ENV === 'production' && app.isPackaged;

const STUDIO_UPDATES_ENDPOINT = 'https://public-api.wordpress.com/wpcom/v2/studio-app/updates';

function buildUpdateFeedUrl( { channel }: { channel?: 'nightly' } = {} ): string {
const url = new URL( STUDIO_UPDATES_ENDPOINT );
url.searchParams.append( 'platform', process.platform );
url.searchParams.append( 'studioArch', process.arch );
url.searchParams.append( 'version', app.getVersion() );
if ( channel ) {
url.searchParams.append( 'channel', channel );
}
return url.toString();
}

export function getAutoUpdaterState() {
return updaterState;
}

/**
* Switches the autoUpdater feed to the nightly channel and immediately checks for an update.
* On Linux, triggers a nightly poll directly since Electron's autoUpdater is not available.
*/
export function switchToNightlyAndUpdate(): void {
if ( process.platform === 'linux' ) {
void pollLinuxUpdates( { channel: 'nightly' } );
return;
}
autoUpdater.setFeedURL( { url: buildUpdateFeedUrl( { channel: 'nightly' } ) } );
autoUpdater.checkForUpdates();
}

export function setupUpdates() {
if ( process.env.E2E ) {
console.log( 'Skipping update server setup in E2E tests' );
Expand All @@ -42,12 +68,7 @@ export function setupUpdates() {
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 );
url.searchParams.append( 'version', app.getVersion() );

autoUpdater.setFeedURL( { url: url.toString() } );
autoUpdater.setFeedURL( { url: buildUpdateFeedUrl() } );

autoUpdater.on( 'checking-for-update', () => {
updaterState = 'checking-for-update';
Expand All @@ -57,6 +78,10 @@ export function setupUpdates() {
console.log( 'Update available' );
updaterState = 'downloading';

if ( isDevRelease( app.getVersion() ) ) {
await updateAppdata( { lastNightlyUpdateCheck: Date.now() } );
}

if ( showManualCheckDialogs ) {
await showUpdateAvailableNotice();
}
Expand All @@ -72,6 +97,10 @@ export function setupUpdates() {
return;
}

if ( isDevRelease( app.getVersion() ) ) {
await updateAppdata( { lastNightlyUpdateCheck: Date.now() } );
}

queueUpdateCheck();
} );

Expand Down Expand Up @@ -115,6 +144,31 @@ export function setupUpdates() {
return;
}

if ( isDevRelease( app.getVersion() ) ) {
// For nightly builds, respect a 24-hour TTL to avoid checking on every launch.
// If the TTL hasn't elapsed, schedule the next check for the remaining time.
void ( async () => {
const userData = await loadUserData();
const lastCheck = userData.lastNightlyUpdateCheck ?? 0;
const elapsed = Date.now() - lastCheck;
if ( elapsed < NIGHTLY_UPDATE_TTL_MS ) {
const remaining = NIGHTLY_UPDATE_TTL_MS - elapsed;
console.log(
`Nightly update check skipped, next check in ${ Math.round( remaining / 60000 ) } minutes`
);
updaterState = 'polling';
timeout = setTimeout( () => {
console.log( `Automatically checking for nightly update: ${ autoUpdater.getFeedURL() }` );
autoUpdater.checkForUpdates();
}, remaining );
return;
}
console.log( `Checking for nightly update on app launch: ${ autoUpdater.getFeedURL() }` );
autoUpdater.checkForUpdates();
} )();
return;
}

console.log( `Checking for update on app launch: ${ autoUpdater.getFeedURL() }` );
autoUpdater.checkForUpdates();
}
Expand Down Expand Up @@ -235,16 +289,11 @@ function setupLinuxUpdates() {
void pollLinuxUpdates();
}

async function pollLinuxUpdates() {
async function pollLinuxUpdates( { channel }: { channel?: 'nightly' } = {} ) {
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() );
const response = await fetch( buildUpdateFeedUrl( { channel } ) );

if ( response.status === 204 ) {
if ( showManualCheckDialogs ) {
Expand Down