Repository: electron/electron
Pull request: fix: extension service workers not starting beyond first app launch

Backport of #50611

See that PR for details.


Notes: Fixed certain DevTools extension panels not showing without a page reload.


Changed files:
filename: shell/browser/extensions/electron_extension_loader.cc
patch: @@ -20,13 +20,15 @@
 #include "base/time/time.h"
 #include "content/public/browser/browser_context.h"
 #include "extensions/browser/extension_file_task_runner.h"
+#include "extensions/browser/extension_pref_names.h"
 #include "extensions/browser/extension_prefs.h"
 #include "extensions/browser/extension_registry.h"
 #include "extensions/browser/pref_names.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/error_utils.h"
 #include "extensions/common/file_util.h"
 #include "extensions/common/manifest_constants.h"
+#include "extensions/common/manifest_handlers/background_info.h"
 
 namespace extensions {
 
@@ -143,6 +145,19 @@ void ElectronExtensionLoader::FinishExtensionLoad(
     std::pair<scoped_refptr<const Extension>, std::string> result) {
   scoped_refptr<const Extension> extension = result.first;
   if (extension) {
+    ExtensionPrefs* extension_prefs = ExtensionPrefs::Get(browser_context_);
+
+    if (BackgroundInfo::IsServiceWorkerBased(extension.get())) {
+      // Tell Chromium that it needs to start the extension's service worker.
+      // Chromium usually does this only when an extension is first installed
+      // because Chrome will restart the service worker when the browser
+      // relaunches. In Electron, we make a fresh install on every app start,
+      // so we need to run the fresh install logic again.
+      extension_prefs->UpdateExtensionPref(
+          extension.get()->id(), extensions::kPrefHasStartedServiceWorker,
+          base::Value(false));
+    }
+
     extension_registrar_->AddExtension(extension);
 
     // Write extension install time to ExtensionPrefs.
@@ -152,7 +167,6 @@ void ElectronExtensionLoader::FinishExtensionLoad(
     // Implementation for writing the pref was based on
     // PreferenceAPIBase::SetExtensionControlledPref.
     {
-      ExtensionPrefs* extension_prefs = ExtensionPrefs::Get(browser_context_);
       ExtensionPrefs::ScopedDictionaryUpdate update(
           extension_prefs, extension.get()->id(),
           extensions::pref_names::kPrefPreferences);

filename: spec/extensions-spec.ts
patch: @@ -1,4 +1,4 @@
-import { app, session, webFrameMain, BrowserWindow, ipcMain, WebContents, Extension, Session } from 'electron/main';
+import { app, session, webFrameMain, BrowserWindow, ipcMain, WebContents, Extension, Session, ServiceWorkerInfo, ServiceWorkersRunningStatusChangedEventParams } from 'electron/main';
 
 import { expect } from 'chai';
 import * as WebSocket from 'ws';
@@ -9,7 +9,7 @@ import * as http from 'node:http';
 import * as path from 'node:path';
 
 import { emittedNTimes, emittedUntil } from './lib/events-helpers';
-import { ifit, listen, waitUntil } from './lib/spec-helpers';
+import { ifit, listen, startRemoteControlApp, waitUntil } from './lib/spec-helpers';
 import { expectWarningMessages } from './lib/warning-helpers';
 import { closeAllWindows, closeWindow, cleanupWebContents } from './lib/window-helpers';
 
@@ -815,6 +815,99 @@ describe('chrome extensions', () => {
       expect(scope).equals(extension.url);
     });
 
+    it('launches background service worker', async () => {
+      const customSession = session.fromPartition(`persist:${uuid.v4()}`);
+      const launchPromise = new Promise<void>(resolve => {
+        customSession.serviceWorkers.on('running-status-changed', ({ runningStatus }) => {
+          if (runningStatus === 'running') resolve();
+        });
+      });
+      const extension = await customSession.extensions.loadExtension(path.join(fixtures, 'extensions', 'mv3-service-worker'));
+      await launchPromise;
+      const serviceWorkers = customSession.serviceWorkers.getAllRunning();
+      expect(Object.values(serviceWorkers).some(worker => worker.scope === extension.url)).equals(true);
+    });
+
+    it('launches background service worker when the extension is loaded again without restarting the app', async () => {
+      const customSession = session.fromPartition(`persist:${uuid.v4()}`);
+      const extensionPath = path.join(fixtures, 'extensions', 'mv3-service-worker');
+
+      const isWorkerRunning = (extension: Extension) => {
+        const serviceWorkers = customSession.serviceWorkers.getAllRunning();
+        return Object.values(serviceWorkers).some(worker => worker.scope === extension.url);
+      };
+
+      const loadAndUnloadExtension = async () => {
+        const launchPromise = new Promise<void>(resolve => {
+          customSession.serviceWorkers.on('running-status-changed', ({ runningStatus }) => {
+            if (runningStatus === 'running') resolve();
+          });
+        });
+        const extension = await customSession.extensions.loadExtension(extensionPath);
+        await launchPromise;
+        expect(isWorkerRunning(extension)).equals(true);
+
+        const stopPromise = new Promise<void>(resolve => {
+          customSession.serviceWorkers.on('running-status-changed', ({ runningStatus }) => {
+            if (runningStatus === 'stopped') resolve();
+          });
+        });
+        customSession.extensions.removeExtension(extension.id);
+        await stopPromise;
+        expect(isWorkerRunning(extension)).equals(false);
+      };
+
+      for (let i = 0; i < 3; i++) {
+        await loadAndUnloadExtension();
+      }
+    });
+
+    // Regression test for https://github.com/electron/electron/issues/41613
+    it('launches background service worker after restarting the app', async () => {
+      const partition = `persist:${uuid.v4()}`;
+      const extensionPath = path.join(fixtures, 'extensions', 'mv3-service-worker');
+
+      const runRemoteControlApp = async () => {
+        const rc = await startRemoteControlApp();
+
+        const exitPromise = once(rc.process, 'exit');
+
+        const { workerScopes, extensionUrl } = await rc.remotely(async (partition: string, extensionPath: string) => {
+          const { session } = require('electron/main');
+          const { setTimeout } = require('node:timers/promises');
+
+          const customSession = session.fromPartition(partition);
+
+          const launchPromise = new Promise<void>(resolve => {
+            customSession.serviceWorkers.on('running-status-changed', ({ runningStatus }: ServiceWorkersRunningStatusChangedEventParams) => {
+              if (runningStatus === 'running') resolve();
+            });
+          });
+          const extension = await customSession.extensions.loadExtension(extensionPath);
+          await launchPromise;
+          const serviceWorkers = customSession.serviceWorkers.getAllRunning();
+
+          const workerScopes = Object.values(serviceWorkers).map(worker => (worker as ServiceWorkerInfo).scope);
+          const extensionUrl = extension.url;
+
+          // Give Chromium some time to update extensions::kPrefHasStartedServiceWorker on disk
+          await setTimeout(500);
+
+          global.setTimeout(() => require('electron').app.quit());
+
+          return { workerScopes, extensionUrl };
+        }, partition, extensionPath);
+
+        await exitPromise;
+
+        expect(workerScopes.includes(extensionUrl)).equals(true);
+      };
+
+      for (let i = 0; i < 3; i++) {
+        await runRemoteControlApp();
+      }
+    });
+
     it('can run chrome extension APIs', async () => {
       const customSession = session.fromPartition(`persist:${uuid.v4()}`);
       const w = new BrowserWindow({ show: false, webPreferences: { session: customSession, nodeIntegration: true } });