From 0fb881b799fc1f29e4f9c6233d82d4815075bb21 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Tue, 26 May 2026 18:45:16 +0100 Subject: [PATCH 1/2] Add nightly build support for Automatticians - Enable automatic update polling for dev/nightly builds (previously disabled) - Add 24-hour TTL for nightly update checks stored in app.json to avoid checking on every launch - Extract buildUpdateFeedUrl() helper and add channel param support so the nightly channel can be requested from the update endpoint - Add maybePromptNightlySwitch() which detects signed-in Automatticians on stable builds and offers a one-time prompt to switch to nightly, with a "Don't ask again" option - On accepting, switchToNightlyAndUpdate() points autoUpdater at the nightly channel feed and triggers an immediate update using the existing Electron update flow - Add hourly poller to catch users who sign in after app launch --- apps/studio/src/constants.ts | 1 + apps/studio/src/index.ts | 4 + apps/studio/src/lib/nightly-prompt.ts | 110 +++++++++++++++++++++++ apps/studio/src/storage/storage-types.ts | 7 ++ apps/studio/src/storage/user-data.ts | 4 +- apps/studio/src/updates.ts | 81 +++++++++++++---- 6 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 apps/studio/src/lib/nightly-prompt.ts diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index 31ebded9ef..7649eb8229 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -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/'; diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 04199f7e38..016a15d5d3 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -38,6 +38,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'; @@ -376,6 +377,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 ) { diff --git a/apps/studio/src/lib/nightly-prompt.ts b/apps/studio/src/lib/nightly-prompt.ts new file mode 100644 index 0000000000..f262f49119 --- /dev/null +++ b/apps/studio/src/lib/nightly-prompt.ts @@ -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 ) { + 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 ); +} diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 51bfb0d8aa..6edab9a557 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -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 >; @@ -44,6 +49,8 @@ export interface UserData { wapuuScore?: number; desks?: DesksConfig; aiSessionPlacements?: Record< string, AiSessionSitePlacement >; + lastNightlyUpdateCheck?: number; + nightlyPromptResult?: NightlyPromptResult; } export interface PromptWindowsSpeedUpResult { diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 36a1318fed..348b884071 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -74,7 +74,9 @@ type UserDataSafeKeys = | 'colorScheme' | 'defaultSiteDirectory' | 'cliAutoInstalled' - | 'wapuuScore'; + | 'wapuuScore' + | 'lastNightlyUpdateCheck' + | 'nightlyPromptResult'; type PartialUserDataWithSafeKeysToUpdate = Partial< Pick< UserData, UserDataSafeKeys > >; diff --git a/apps/studio/src/updates.ts b/apps/studio/src/updates.ts index 9455ad2ef2..6c3130153e 100644 --- a/apps/studio/src/updates.ts +++ b/apps/studio/src/updates.ts @@ -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' @@ -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' ); @@ -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'; @@ -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(); } @@ -72,6 +97,10 @@ export function setupUpdates() { return; } + if ( isDevRelease( app.getVersion() ) ) { + await updateAppdata( { lastNightlyUpdateCheck: Date.now() } ); + } + queueUpdateCheck(); } ); @@ -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(); } @@ -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 ) { From 32d37b829e8812b5e844b2ea34db5b8a66f0963c Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Tue, 26 May 2026 18:53:48 +0100 Subject: [PATCH 2/2] Skip nightly prompt when running unpackaged (npm start) --- apps/studio/src/lib/nightly-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/src/lib/nightly-prompt.ts b/apps/studio/src/lib/nightly-prompt.ts index f262f49119..2246b7dc13 100644 --- a/apps/studio/src/lib/nightly-prompt.ts +++ b/apps/studio/src/lib/nightly-prompt.ts @@ -37,7 +37,7 @@ async function isAutomattician(): Promise< boolean > { * - Skipped if the user is not authenticated or not an Automattician. */ export async function maybePromptNightlySwitch(): Promise< void > { - if ( process.env.E2E ) { + if ( process.env.E2E || ! app.isPackaged ) { return; }