From 8139cd0eaf3a986a0918f8b349a6d1c18f956ba1 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 24 Dec 2025 15:30:30 +0000 Subject: [PATCH 1/3] stop sites on exit dialog --- src/index.ts | 127 +++++++++++++++++++++++++--------- src/site-server.ts | 10 +++ src/storage/storage-types.ts | 1 + src/storage/user-data.ts | 1 + src/tests/site-server.test.ts | 58 +++++++++++++++- 5 files changed, 163 insertions(+), 34 deletions(-) diff --git a/src/index.ts b/src/index.ts index 09892facd7..d74632735b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { import path from 'path'; import { pathToFileURL } from 'url'; import * as Sentry from '@sentry/electron/main'; -import { __ } from '@wordpress/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; import { installExtension, REACT_DEVELOPER_TOOLS, @@ -46,8 +46,14 @@ import { renameLaunchUniquesStat } from 'src/migrations/rename-launch-uniques-st import { startSiteWatcher, stopSiteWatcher } from 'src/modules/cli/lib/execute-site-watch-command'; import { updateWindowsCliVersionedPathIfNeeded } from 'src/modules/cli/lib/windows-installation-manager'; import { setupWPServerFiles, updateWPServerFiles } from 'src/setup-wp-server-files'; -import { stopAllServersOnQuit } from 'src/site-server'; -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; +import { getRunningSiteCount, stopAllServersOnQuit } from 'src/site-server'; +import { + loadUserData, + lockAppdata, + saveUserData, + unlockAppdata, + updateAppdata, +} from 'src/storage/user-data'; import { setupUpdates } from 'src/updates'; // eslint-disable-next-line import/order import packageJson from '../package.json'; @@ -84,6 +90,8 @@ const isInInstaller = require( 'electron-squirrel-startup' ); const gotTheLock = app.requestSingleInstanceLock(); let finishedInitialization = false; +let isQuittingConfirmed = false; +let shouldStopSitesOnQuit = true; if ( gotTheLock && ! isInInstaller ) { void appBoot(); @@ -356,46 +364,99 @@ async function appBoot() { } ); app.on( 'before-quit', ( event ) => { - if ( ! hasActiveSyncOperations() ) { + if ( isQuittingConfirmed ) { return; } - const QUIT_APP_BUTTON_INDEX = 0; - const CANCEL_BUTTON_INDEX = 1; + if ( hasActiveSyncOperations() ) { + const QUIT_APP_BUTTON_INDEX = 0; + const CANCEL_BUTTON_INDEX = 1; + + const messageInformation: Pick< MessageBoxSyncOptions, 'message' | 'detail' | 'type' > = + hasUploadingPushOperations() + ? { + message: __( 'Sync is in progress' ), + detail: __( + "There's a sync operation in progress. Quitting the app will abort that operation. Are you sure you want to quit?" + ), + type: 'warning', + } + : { + message: __( 'Sync will continue' ), + detail: __( + 'The sync process will continue running remotely after you quit Studio. We will send you an email once it is complete.' + ), + type: 'info', + }; + + const clickedButtonIndex = dialog.showMessageBoxSync( { + message: messageInformation.message, + detail: messageInformation.detail, + type: messageInformation.type, + buttons: [ __( 'Yes, quit the app' ), __( 'No, take me back' ) ], + cancelId: CANCEL_BUTTON_INDEX, + defaultId: QUIT_APP_BUTTON_INDEX, + } ); - const messageInformation: Pick< MessageBoxSyncOptions, 'message' | 'detail' | 'type' > = - hasUploadingPushOperations() - ? { - message: __( 'Sync is in progress' ), - detail: __( - "There's a sync operation in progress. Quitting the app will abort that operation. Are you sure you want to quit?" - ), - type: 'warning', - } - : { - message: __( 'Sync will continue' ), - detail: __( - 'The sync process will continue running remotely after you quit Studio. We will send you an email once it is complete.' - ), - type: 'info', - }; - - const clickedButtonIndex = dialog.showMessageBoxSync( { - message: messageInformation.message, - detail: messageInformation.detail, - type: messageInformation.type, - buttons: [ __( 'Yes, quit the app' ), __( 'No, take me back' ) ], - cancelId: CANCEL_BUTTON_INDEX, - defaultId: QUIT_APP_BUTTON_INDEX, - } ); + if ( clickedButtonIndex === CANCEL_BUTTON_INDEX ) { + event.preventDefault(); + return; + } + } - if ( clickedButtonIndex === CANCEL_BUTTON_INDEX ) { + const runningSiteCount = getRunningSiteCount(); + if ( runningSiteCount > 0 ) { event.preventDefault(); + + void ( async () => { + const userData = await loadUserData(); + + if ( userData.stopSitesOnQuit !== undefined ) { + shouldStopSitesOnQuit = userData.stopSitesOnQuit; + isQuittingConfirmed = true; + app.quit(); + return; + } + + const STOP_SITES_BUTTON_INDEX = 0; + const LEAVE_RUNNING_BUTTON_INDEX = 1; + + const { response, checkboxChecked } = await dialog.showMessageBox( { + type: 'question', + message: _n( 'You have a running site', 'You have running sites', runningSiteCount ), + detail: sprintf( + _n( + '%d site is currently running. Do you want to stop it before quitting?', + '%d sites are currently running. Do you want to stop them before quitting?', + runningSiteCount + ), + runningSiteCount + ), + buttons: [ __( 'Stop sites' ), __( 'Leave running' ) ], + checkboxLabel: __( 'Remember my choice' ), + cancelId: LEAVE_RUNNING_BUTTON_INDEX, + defaultId: STOP_SITES_BUTTON_INDEX, + } ); + + const stopSites = response === STOP_SITES_BUTTON_INDEX; + + if ( checkboxChecked ) { + await updateAppdata( { stopSitesOnQuit: stopSites } ); + } + + shouldStopSitesOnQuit = stopSites; + isQuittingConfirmed = true; + app.quit(); + } )(); + + return; } } ); app.on( 'quit', () => { - void stopAllServersOnQuit(); + if ( shouldStopSitesOnQuit ) { + void stopAllServersOnQuit(); + } stopUserDataWatcher(); stopSiteWatcher(); } ); diff --git a/src/site-server.ts b/src/site-server.ts index a78f6c8b43..4422684a78 100644 --- a/src/site-server.ts +++ b/src/site-server.ts @@ -48,6 +48,16 @@ export async function stopAllServersOnQuit() { } ); } +export function getRunningSiteCount(): number { + return Array.from( servers.values() ).filter( ( server ) => server.details.running ).length; +} + +// Only for testing purposes +export function __resetServersForTesting(): void { + servers.clear(); + deletedServers.length = 0; +} + function getAbsoluteUrl( details: SiteDetails ): string { if ( details.customDomain ) { const protocol = details.enableHttps ? 'https' : 'http'; diff --git a/src/storage/storage-types.ts b/src/storage/storage-types.ts index 70d2e52f6c..ee37cde824 100644 --- a/src/storage/storage-types.ts +++ b/src/storage/storage-types.ts @@ -32,6 +32,7 @@ export interface UserData { preferredTerminal?: SupportedTerminal; preferredEditor?: SupportedEditor; betaFeatures?: BetaFeatures; + stopSitesOnQuit?: boolean; } export interface PersistedUserData extends Omit< UserData, 'sites' > { diff --git a/src/storage/user-data.ts b/src/storage/user-data.ts index a173dc43a0..73e0bea6fa 100644 --- a/src/storage/user-data.ts +++ b/src/storage/user-data.ts @@ -125,6 +125,7 @@ type UserDataSafeKeys = | 'onboardingCompleted' | 'locale' | 'promptWindowsSpeedUpResult' + | 'stopSitesOnQuit' | 'sentryUserId' | 'lastSeenVersion' | 'preferredTerminal' diff --git a/src/tests/site-server.test.ts b/src/tests/site-server.test.ts index 43d33e9595..819700163b 100644 --- a/src/tests/site-server.test.ts +++ b/src/tests/site-server.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import { CliServerProcess } from 'src/modules/cli/lib/cli-server-process'; -import { SiteServer } from 'src/site-server'; +import { getRunningSiteCount, SiteServer, __resetServersForTesting } from 'src/site-server'; // Electron's Node.js environment provides `bota`/`atob`, but Jests' does not jest.mock( 'common/lib/passwords' ); @@ -40,6 +40,7 @@ jest.mock( 'src/storage/user-data', () => ( { describe( 'SiteServer', () => { beforeEach( () => { jest.clearAllMocks(); + __resetServersForTesting(); } ); describe( 'start', () => { @@ -90,4 +91,59 @@ describe( 'SiteServer', () => { expect( server.details.running ).toBe( true ); } ); } ); + + describe( 'getRunningSiteCount', () => { + it( 'should return 0 when no servers are registered', () => { + expect( getRunningSiteCount() ).toBe( 0 ); + } ); + + it( 'should count only running servers', async () => { + const mockStart = jest.fn().mockResolvedValue( undefined ); + const mockStop = jest.fn().mockResolvedValue( undefined ); + ( CliServerProcess as jest.Mock ).mockReturnValue( { + url: 'http://localhost:1234', + start: mockStart, + stop: mockStop, + } ); + + const server1 = SiteServer.register( { + id: 'running-site-1', + name: 'Running Site 1', + path: 'test-path-1', + port: 1234, + adminPassword: 'test-password', + phpVersion: '8.3', + running: false, + themeDetails: undefined, + } ); + await server1.start(); + + SiteServer.register( { + id: 'stopped-site', + name: 'Stopped Site', + path: 'test-path-2', + port: 1235, + adminPassword: 'test-password', + phpVersion: '8.3', + running: false, + themeDetails: undefined, + } ); + + expect( getRunningSiteCount() ).toBe( 1 ); + + const server3 = SiteServer.register( { + id: 'running-site-2', + name: 'Running Site 2', + path: 'test-path-3', + port: 1236, + adminPassword: 'test-password', + phpVersion: '8.3', + running: false, + themeDetails: undefined, + } ); + await server3.start(); + + expect( getRunningSiteCount() ).toBe( 2 ); + } ); + } ); } ); From f6cada36a2e8a719cfb5a9aac219886ce56fbddf Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 5 Jan 2026 11:40:00 +0000 Subject: [PATCH 2/3] cancel option, consistent message and e2e check --- src/index.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2f21b93cf2..52753a1335 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,8 +101,6 @@ const isInInstaller = require( 'electron-squirrel-startup' ); const gotTheLock = app.requestSingleInstanceLock(); let finishedInitialization = false; -let isQuittingConfirmed = false; -let shouldStopSitesOnQuit = true; if ( gotTheLock && ! isInInstaller ) { void appBoot(); @@ -403,6 +401,9 @@ async function appBoot() { globalShortcut.unregisterAll(); } ); + let isQuittingConfirmed = false; + let shouldStopSitesOnQuit = true; + app.on( 'before-quit', ( event ) => { if ( isQuittingConfirmed ) { return; @@ -458,8 +459,15 @@ async function appBoot() { return; } + // Skip dialog in E2E tests - just quit and stop sites + if ( process.env.E2E ) { + isQuittingConfirmed = true; + app.quit(); + return; + } + const STOP_SITES_BUTTON_INDEX = 0; - const LEAVE_RUNNING_BUTTON_INDEX = 1; + const CANCEL_BUTTON_INDEX = 2; const { response, checkboxChecked } = await dialog.showMessageBox( { type: 'question', @@ -472,12 +480,16 @@ async function appBoot() { ), runningSiteCount ), - buttons: [ __( 'Stop sites' ), __( 'Leave running' ) ], - checkboxLabel: __( 'Remember my choice' ), - cancelId: LEAVE_RUNNING_BUTTON_INDEX, + buttons: [ __( 'Stop sites' ), __( 'Leave running' ), __( 'Cancel' ) ], + checkboxLabel: __( "Don't ask again" ), + cancelId: CANCEL_BUTTON_INDEX, defaultId: STOP_SITES_BUTTON_INDEX, } ); + if ( response === CANCEL_BUTTON_INDEX ) { + return; + } + const stopSites = response === STOP_SITES_BUTTON_INDEX; if ( checkboxChecked ) { From 35e4b2da557025d22ca20ee769cf227f0638f0f3 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 6 Jan 2026 10:41:03 +0000 Subject: [PATCH 3/3] conditional dialog when studio cli is installed --- src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 52753a1335..8e1ad39745 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import { import { removeSitesWithEmptyDirectories } from 'src/migrations/remove-sites-with-empty-dirs'; import { renameLaunchUniquesStat } from 'src/migrations/rename-launch-uniques-stat'; import { startSiteWatcher, stopSiteWatcher } from 'src/modules/cli/lib/execute-site-watch-command'; +import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers'; import { updateWindowsCliVersionedPathIfNeeded } from 'src/modules/cli/lib/windows-installation-manager'; import { setupWPServerFiles, updateWPServerFiles } from 'src/setup-wp-server-files'; import { getRunningSiteCount, stopAllServersOnQuit } from 'src/site-server'; @@ -451,6 +452,7 @@ async function appBoot() { void ( async () => { const userData = await loadUserData(); + const isCliInstalled = await isStudioCliInstalled(); if ( userData.stopSitesOnQuit !== undefined ) { shouldStopSitesOnQuit = userData.stopSitesOnQuit; @@ -459,8 +461,7 @@ async function appBoot() { return; } - // Skip dialog in E2E tests - just quit and stop sites - if ( process.env.E2E ) { + if ( ! isCliInstalled || process.env.E2E ) { isQuittingConfirmed = true; app.quit(); return;