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

#### Description of Change

Fixes https://github.com/electron/electron/issues/41613

##### Before this patch
When an extension was loaded into an app for the very first time, its service worker would start.
When the same extension was loaded on subsequent app launches, the service worker would no longer start.

##### With this patch
This PR makes it so that extension service workers are also started on subsequent app launches.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included
- [x] I have built and tested this PR
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

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';
@@ -10,7 +10,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';
 
@@ -816,6 +816,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 } });