Browse Source

feat: redesign preload APIs (#45329)

* feat: redesign preload APIs

Co-authored-by: Samuel Maddock <[email protected]>

* docs: remove service-worker mentions for now

Co-authored-by: Samuel Maddock <[email protected]>

* fix lint

Co-authored-by: Samuel Maddock <[email protected]>

* remove service-worker ipc code

Co-authored-by: Samuel Maddock <[email protected]>

* add filename

Co-authored-by: Samuel Maddock <[email protected]>

* fix: web preferences preload not included

Co-authored-by: Samuel Maddock <[email protected]>

* fix: missing common init

Co-authored-by: Samuel Maddock <[email protected]>

* fix: preload bundle script error

Co-authored-by: Samuel Maddock <[email protected]>

---------

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Samuel Maddock <[email protected]>
trop[bot] 2 months ago
parent
commit
9d696ceffe

+ 27 - 2
docs/api/session.md

@@ -1330,18 +1330,43 @@ the initial state will be `interrupted`. The download will start only when the
 
 Returns `Promise<void>` - resolves when the session’s HTTP authentication cache has been cleared.
 
-#### `ses.setPreloads(preloads)`
+#### `ses.setPreloads(preloads)` _Deprecated_
 
 * `preloads` string[] - An array of absolute path to preload scripts
 
 Adds scripts that will be executed on ALL web contents that are associated with
 this session just before normal `preload` scripts run.
 
-#### `ses.getPreloads()`
+**Deprecated:** Use the new `ses.registerPreloadScript` API.
+
+#### `ses.getPreloads()` _Deprecated_
 
 Returns `string[]` an array of paths to preload scripts that have been
 registered.
 
+**Deprecated:** Use the new `ses.getPreloadScripts` API. This will only return preload script paths
+for `frame` context types.
+
+#### `ses.registerPreloadScript(script)`
+
+* `script` [PreloadScriptRegistration](structures/preload-script-registration.md) - Preload script
+
+Registers preload script that will be executed in its associated context type in this session. For
+`frame` contexts, this will run prior to any preload defined in the web preferences of a
+WebContents.
+
+Returns `string` - The ID of the registered preload script.
+
+#### `ses.unregisterPreloadScript(id)`
+
+* `id` string - Preload script ID
+
+Unregisters script.
+
+#### `ses.getPreloadScripts()`
+
+Returns [`PreloadScript[]`](structures/preload-script.md): An array of paths to preload scripts that have been registered.
+
 #### `ses.setCodeCachePath(path)`
 
 * `path` String - Absolute path to store the v8 generated JS code cache from the renderer.

+ 6 - 0
docs/api/structures/preload-script-registration.md

@@ -0,0 +1,6 @@
+# PreloadScriptRegistration Object
+
+* `type` string - Context type where the preload script will be executed.
+  Possible values include `frame`.
+* `id` string (optional) - Unique ID of preload script. Defaults to a random UUID.
+* `filePath` string - Path of the script file. Must be an absolute path.

+ 6 - 0
docs/api/structures/preload-script.md

@@ -0,0 +1,6 @@
+# PreloadScript Object
+
+* `type` string - Context type where the preload script will be executed.
+  Possible values include `frame`.
+* `id` string - Unique ID of preload script.
+* `filePath` string - Path of the script file. Must be an absolute path.

+ 19 - 0
docs/breaking-changes.md

@@ -14,6 +14,25 @@ This document uses the following convention to categorize breaking changes:
 
 ## Planned Breaking API Changes (35.0)
 
+### Deprecated: `setPreloads`, `getPreloads` on `Session`
+
+`registerPreloadScript`, `unregisterPreloadScript`, and `getPreloadScripts` are introduced as a
+replacement for the deprecated methods. These new APIs allow third-party libraries to register
+preload scripts without replacing existing scripts. Also, the new `type` option allows for
+additional preload targets beyond `frame`.
+
+```ts
+// Deprecated
+session.setPreloads([path.join(__dirname, 'preload.js')])
+
+// Replace with:
+session.registerPreloadScript({
+  type: 'frame',
+  id: 'app-preload',
+  filePath: path.join(__dirname, 'preload.js')
+})
+```
+
 ### Deprecated: `level`, `message`, `line`, and `sourceId` arguments in `console-message` event on `WebContents`
 
 The `console-message` event on `WebContents` has been updated to provide details on the `Event`

+ 4 - 0
filenames.auto.gni

@@ -114,6 +114,8 @@ auto_filenames = {
     "docs/api/structures/permission-request.md",
     "docs/api/structures/point.md",
     "docs/api/structures/post-body.md",
+    "docs/api/structures/preload-script-registration.md",
+    "docs/api/structures/preload-script.md",
     "docs/api/structures/printer-info.md",
     "docs/api/structures/process-memory-info.md",
     "docs/api/structures/process-metric.md",
@@ -183,6 +185,8 @@ auto_filenames = {
     "lib/sandboxed_renderer/api/exports/electron.ts",
     "lib/sandboxed_renderer/api/module-list.ts",
     "lib/sandboxed_renderer/init.ts",
+    "lib/sandboxed_renderer/pre-init.ts",
+    "lib/sandboxed_renderer/preload.ts",
     "package.json",
     "tsconfig.electron.json",
     "tsconfig.json",

+ 1 - 0
filenames.gni

@@ -474,6 +474,7 @@ filenames = {
     "shell/browser/osr/osr_web_contents_view.h",
     "shell/browser/plugins/plugin_utils.cc",
     "shell/browser/plugins/plugin_utils.h",
+    "shell/browser/preload_script.h",
     "shell/browser/protocol_registry.cc",
     "shell/browser/protocol_registry.h",
     "shell/browser/relauncher.cc",

+ 26 - 0
lib/browser/api/session.ts

@@ -1,4 +1,5 @@
 import { fetchWithSession } from '@electron/internal/browser/api/net-fetch';
+import * as deprecate from '@electron/internal/common/deprecate';
 
 import { net } from 'electron/main';
 
@@ -36,6 +37,31 @@ Session.prototype.setDisplayMediaRequestHandler = function (handler, opts) {
   }, opts);
 };
 
+const getPreloadsDeprecated = deprecate.warnOnce('session.getPreloads', 'session.getPreloadScripts');
+Session.prototype.getPreloads = function () {
+  getPreloadsDeprecated();
+  return this.getPreloadScripts()
+    .filter((script) => script.type === 'frame')
+    .map((script) => script.filePath);
+};
+
+const setPreloadsDeprecated = deprecate.warnOnce('session.setPreloads', 'session.registerPreloadScript');
+Session.prototype.setPreloads = function (preloads) {
+  setPreloadsDeprecated();
+  this.getPreloadScripts()
+    .filter((script) => script.type === 'frame')
+    .forEach((script) => {
+      this.unregisterPreloadScript(script.id);
+    });
+  preloads.map(filePath => ({
+    type: 'frame',
+    filePath,
+    _deprecated: true
+  }) as Electron.PreloadScriptRegistration).forEach(script => {
+    this.registerPreloadScript(script);
+  });
+};
+
 export default {
   fromPartition,
   fromPath,

+ 31 - 11
lib/browser/rpc-server.ts

@@ -5,6 +5,7 @@ import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
 import { clipboard } from 'electron/common';
 
 import * as fs from 'fs';
+import * as path from 'path';
 
 // Implements window.close()
 ipcMainInternal.on(IPC_MESSAGES.BROWSER_WINDOW_CLOSE, function (event) {
@@ -43,22 +44,40 @@ ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_CLIPBOARD_SYNC, function (event, me
   return (clipboard as any)[method](...args);
 });
 
-const getPreloadScript = async function (preloadPath: string) {
-  let preloadSrc = null;
-  let preloadError = null;
+const getPreloadScriptsFromEvent = (event: ElectronInternal.IpcMainInternalEvent) => {
+  const session: Electron.Session = event.sender.session;
+  const preloadScripts = session.getPreloadScripts();
+  const framePreloads = preloadScripts.filter(script => script.type === 'frame');
+
+  const webPrefPreload = event.sender._getPreloadScript();
+  if (webPrefPreload) framePreloads.push(webPrefPreload);
+
+  // TODO(samuelmaddock): Remove filter after Session.setPreloads is fully
+  // deprecated. The new API will prevent relative paths from being registered.
+  return framePreloads.filter(script => path.isAbsolute(script.filePath));
+};
+
+const readPreloadScript = async function (script: Electron.PreloadScript): Promise<ElectronInternal.PreloadScript> {
+  let contents;
+  let error;
   try {
-    preloadSrc = await fs.promises.readFile(preloadPath, 'utf8');
-  } catch (error) {
-    preloadError = error;
+    contents = await fs.promises.readFile(script.filePath, 'utf8');
+  } catch (err) {
+    if (err instanceof Error) {
+      error = err;
+    }
   }
-  return { preloadPath, preloadSrc, preloadError };
+  return {
+    ...script,
+    contents,
+    error
+  };
 };
 
 ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_SANDBOX_LOAD, async function (event) {
-  const preloadPaths = event.sender._getPreloadPaths();
-
+  const preloadScripts = getPreloadScriptsFromEvent(event);
   return {
-    preloadScripts: await Promise.all(preloadPaths.map(path => getPreloadScript(path))),
+    preloadScripts: await Promise.all(preloadScripts.map(readPreloadScript)),
     process: {
       arch: process.arch,
       platform: process.platform,
@@ -71,7 +90,8 @@ ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_SANDBOX_LOAD, async function (event
 });
 
 ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_NONSANDBOX_LOAD, function (event) {
-  return { preloadPaths: event.sender._getPreloadPaths() };
+  const preloadScripts = getPreloadScriptsFromEvent(event);
+  return { preloadPaths: preloadScripts.map(script => script.filePath) };
 });
 
 ipcMainInternal.on(IPC_MESSAGES.BROWSER_PRELOAD_ERROR, function (event, preloadPath: string, error: Error) {

+ 17 - 93
lib/sandboxed_renderer/init.ts

@@ -1,6 +1,7 @@
+import '@electron/internal/sandboxed_renderer/pre-init';
 import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
-import type * as ipcRendererInternalModule from '@electron/internal/renderer/ipc-renderer-internal';
 import type * as ipcRendererUtilsModule from '@electron/internal/renderer/ipc-renderer-internal-utils';
+import { createPreloadProcessObject, executeSandboxedPreloadScripts } from '@electron/internal/sandboxed_renderer/preload';
 
 import * as events from 'events';
 import { setImmediate, clearImmediate } from 'timers';
@@ -11,35 +12,14 @@ declare const binding: {
   createPreloadScript: (src: string) => Function
 };
 
-const { EventEmitter } = events;
-
-process._linkedBinding = binding.get;
-
 const v8Util = process._linkedBinding('electron_common_v8_util');
-// Expose Buffer shim as a hidden value. This is used by C++ code to
-// deserialize Buffer instances sent from browser process.
-v8Util.setHiddenValue(global, 'Buffer', Buffer);
-// The process object created by webpack is not an event emitter, fix it so
-// the API is more compatible with non-sandboxed renderers.
-for (const prop of Object.keys(EventEmitter.prototype) as (keyof typeof process)[]) {
-  if (Object.hasOwn(process, prop)) {
-    delete process[prop];
-  }
-}
-Object.setPrototypeOf(process, EventEmitter.prototype);
-
-const { ipcRendererInternal } = require('@electron/internal/renderer/ipc-renderer-internal') as typeof ipcRendererInternalModule;
 const ipcRendererUtils = require('@electron/internal/renderer/ipc-renderer-internal-utils') as typeof ipcRendererUtilsModule;
 
 const {
   preloadScripts,
   process: processProps
 } = ipcRendererUtils.invokeSync<{
-  preloadScripts: {
-    preloadPath: string;
-    preloadSrc: string | null;
-    preloadError: null | Error;
-  }[];
+  preloadScripts: ElectronInternal.PreloadScript[];
   process: NodeJS.Process;
 }>(IPC_MESSAGES.BROWSER_SANDBOX_LOAD);
 
@@ -60,8 +40,7 @@ const loadableModules = new Map<string, Function>([
   ['node:url', () => require('url')]
 ]);
 
-// Pass different process object to the preload script.
-const preloadProcess: NodeJS.Process = new EventEmitter() as any;
+const preloadProcess = createPreloadProcessObject();
 
 // InvokeEmitProcessEvent in ElectronSandboxedRendererClient will look for this
 v8Util.setHiddenValue(global, 'emit-process-event', (event: string) => {
@@ -72,77 +51,22 @@ v8Util.setHiddenValue(global, 'emit-process-event', (event: string) => {
 Object.assign(preloadProcess, binding.process);
 Object.assign(preloadProcess, processProps);
 
-Object.assign(process, binding.process);
 Object.assign(process, processProps);
 
-process.getProcessMemoryInfo = preloadProcess.getProcessMemoryInfo = () => {
-  return ipcRendererInternal.invoke<Electron.ProcessMemoryInfo>(IPC_MESSAGES.BROWSER_GET_PROCESS_MEMORY_INFO);
-};
-
-Object.defineProperty(preloadProcess, 'noDeprecation', {
-  get () {
-    return process.noDeprecation;
-  },
-  set (value) {
-    process.noDeprecation = value;
-  }
-});
-
-// This is the `require` function that will be visible to the preload script
-function preloadRequire (module: string) {
-  if (loadedModules.has(module)) {
-    return loadedModules.get(module);
-  }
-  if (loadableModules.has(module)) {
-    const loadedModule = loadableModules.get(module)!();
-    loadedModules.set(module, loadedModule);
-    return loadedModule;
-  }
-  throw new Error(`module not found: ${module}`);
-}
-
-// Process command line arguments.
-const { hasSwitch } = process._linkedBinding('electron_common_command_line');
-
-// Similar to nodes --expose-internals flag, this exposes _linkedBinding so
-// that tests can call it to get access to some test only bindings
-if (hasSwitch('unsafely-expose-electron-internals-for-testing')) {
-  preloadProcess._linkedBinding = process._linkedBinding;
-}
-
 // Common renderer initialization
 require('@electron/internal/renderer/common-init');
 
-// Wrap the script into a function executed in global scope. It won't have
-// access to the current scope, so we'll expose a few objects as arguments:
-//
-// - `require`: The `preloadRequire` function
-// - `process`: The `preloadProcess` object
-// - `Buffer`: Shim of `Buffer` implementation
-// - `global`: The window object, which is aliased to `global` by webpack.
-function runPreloadScript (preloadSrc: string) {
-  const preloadWrapperSrc = `(function(require, process, Buffer, global, setImmediate, clearImmediate, exports, module) {
-  ${preloadSrc}
-  })`;
-
-  // eval in window scope
-  const preloadFn = binding.createPreloadScript(preloadWrapperSrc);
-  const exports = {};
-
-  preloadFn(preloadRequire, preloadProcess, Buffer, global, setImmediate, clearImmediate, exports, { exports });
-}
-
-for (const { preloadPath, preloadSrc, preloadError } of preloadScripts) {
-  try {
-    if (preloadSrc) {
-      runPreloadScript(preloadSrc);
-    } else if (preloadError) {
-      throw preloadError;
-    }
-  } catch (error) {
-    console.error(`Unable to load preload script: ${preloadPath}`);
-    console.error(error);
-
-    ipcRendererInternal.send(IPC_MESSAGES.BROWSER_PRELOAD_ERROR, preloadPath, error);
+executeSandboxedPreloadScripts({
+  loadedModules,
+  loadableModules,
+  process: preloadProcess,
+  createPreloadScript: binding.createPreloadScript,
+  exposeGlobals: {
+    Buffer,
+    // FIXME(samuelmaddock): workaround webpack bug replacing this with just
+    // `__webpack_require__.g,` which causes script error
+    global: globalThis,
+    setImmediate,
+    clearImmediate
   }
-}
+}, preloadScripts);

+ 30 - 0
lib/sandboxed_renderer/pre-init.ts

@@ -0,0 +1,30 @@
+// Pre-initialization code for sandboxed renderers.
+
+import * as events from 'events';
+
+declare const binding: {
+  get: (name: string) => any;
+  process: NodeJS.Process;
+};
+
+// Expose internal binding getter.
+process._linkedBinding = binding.get;
+
+const { EventEmitter } = events;
+const v8Util = process._linkedBinding('electron_common_v8_util');
+
+// Include properties from script 'binding' parameter.
+Object.assign(process, binding.process);
+
+// Expose Buffer shim as a hidden value. This is used by C++ code to
+// deserialize Buffer instances sent from browser process.
+v8Util.setHiddenValue(global, 'Buffer', Buffer);
+
+// The process object created by webpack is not an event emitter, fix it so
+// the API is more compatible with non-sandboxed renderers.
+for (const prop of Object.keys(EventEmitter.prototype) as (keyof typeof process)[]) {
+  if (Object.hasOwn(process, prop)) {
+    delete process[prop];
+  }
+}
+Object.setPrototypeOf(process, EventEmitter.prototype);

+ 101 - 0
lib/sandboxed_renderer/preload.ts

@@ -0,0 +1,101 @@
+import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
+import { ipcRendererInternal } from '@electron/internal/renderer/ipc-renderer-internal';
+
+import { EventEmitter } from 'events';
+
+interface PreloadContext {
+  loadedModules: Map<string, any>;
+  loadableModules: Map<string, any>;
+
+  /** Process object to pass into preloads. */
+  process: NodeJS.Process;
+
+  createPreloadScript: (src: string) => Function
+
+  /** Globals to be exposed to preload context. */
+  exposeGlobals: any;
+}
+
+export function createPreloadProcessObject (): NodeJS.Process {
+  const preloadProcess: NodeJS.Process = new EventEmitter() as any;
+
+  preloadProcess.getProcessMemoryInfo = () => {
+    return ipcRendererInternal.invoke<Electron.ProcessMemoryInfo>(IPC_MESSAGES.BROWSER_GET_PROCESS_MEMORY_INFO);
+  };
+
+  Object.defineProperty(preloadProcess, 'noDeprecation', {
+    get () {
+      return process.noDeprecation;
+    },
+    set (value) {
+      process.noDeprecation = value;
+    }
+  });
+
+  const { hasSwitch } = process._linkedBinding('electron_common_command_line');
+
+  // Similar to nodes --expose-internals flag, this exposes _linkedBinding so
+  // that tests can call it to get access to some test only bindings
+  if (hasSwitch('unsafely-expose-electron-internals-for-testing')) {
+    preloadProcess._linkedBinding = process._linkedBinding;
+  }
+
+  return preloadProcess;
+}
+
+// This is the `require` function that will be visible to the preload script
+function preloadRequire (context: PreloadContext, module: string) {
+  if (context.loadedModules.has(module)) {
+    return context.loadedModules.get(module);
+  }
+  if (context.loadableModules.has(module)) {
+    const loadedModule = context.loadableModules.get(module)!();
+    context.loadedModules.set(module, loadedModule);
+    return loadedModule;
+  }
+  throw new Error(`module not found: ${module}`);
+}
+
+// Wrap the script into a function executed in global scope. It won't have
+// access to the current scope, so we'll expose a few objects as arguments:
+//
+// - `require`: The `preloadRequire` function
+// - `process`: The `preloadProcess` object
+// - `Buffer`: Shim of `Buffer` implementation
+// - `global`: The window object, which is aliased to `global` by webpack.
+function runPreloadScript (context: PreloadContext, preloadSrc: string) {
+  const globalVariables = [];
+  const fnParameters = [];
+  for (const [key, value] of Object.entries(context.exposeGlobals)) {
+    globalVariables.push(key);
+    fnParameters.push(value);
+  }
+  const preloadWrapperSrc = `(function(require, process, exports, module, ${globalVariables.join(', ')}) {
+  ${preloadSrc}
+  })`;
+
+  // eval in window scope
+  const preloadFn = context.createPreloadScript(preloadWrapperSrc);
+  const exports = {};
+
+  preloadFn(preloadRequire.bind(null, context), context.process, exports, { exports }, ...fnParameters);
+}
+
+/**
+ * Execute preload scripts within a sandboxed process.
+ */
+export function executeSandboxedPreloadScripts (context: PreloadContext, preloadScripts: ElectronInternal.PreloadScript[]) {
+  for (const { filePath, contents, error } of preloadScripts) {
+    try {
+      if (contents) {
+        runPreloadScript(context, contents);
+      } else if (error) {
+        throw error;
+      }
+    } catch (error) {
+      console.error(`Unable to load preload script: ${filePath}`);
+      console.error(error);
+      ipcRendererInternal.send(IPC_MESSAGES.BROWSER_PRELOAD_ERROR, filePath, error);
+    }
+  }
+}

+ 63 - 6
shell/browser/api/electron_api_session.cc

@@ -1065,16 +1065,72 @@ void Session::CreateInterruptedDownload(const gin_helper::Dictionary& options) {
       base::Time::FromSecondsSinceUnixEpoch(start_time)));
 }
 
-void Session::SetPreloads(const std::vector<base::FilePath>& preloads) {
+std::string Session::RegisterPreloadScript(
+    gin_helper::ErrorThrower thrower,
+    const PreloadScript& new_preload_script) {
+  auto* prefs = SessionPreferences::FromBrowserContext(browser_context());
+  DCHECK(prefs);
+
+  auto& preload_scripts = prefs->preload_scripts();
+
+  auto it = std::find_if(preload_scripts.begin(), preload_scripts.end(),
+                         [&new_preload_script](const PreloadScript& script) {
+                           return script.id == new_preload_script.id;
+                         });
+
+  if (it != preload_scripts.end()) {
+    thrower.ThrowError(base::StringPrintf(
+        "Cannot register preload script with existing ID '%s'",
+        new_preload_script.id.c_str()));
+    return "";
+  }
+
+  if (!new_preload_script.file_path.IsAbsolute()) {
+    // Deprecated preload scripts logged error without throwing.
+    if (new_preload_script.deprecated) {
+      LOG(ERROR) << "preload script must have absolute path: "
+                 << new_preload_script.file_path;
+    } else {
+      thrower.ThrowError(
+          base::StringPrintf("Preload script must have absolute path: %s",
+                             new_preload_script.file_path.value().c_str()));
+      return "";
+    }
+  }
+
+  preload_scripts.push_back(new_preload_script);
+  return new_preload_script.id;
+}
+
+void Session::UnregisterPreloadScript(gin_helper::ErrorThrower thrower,
+                                      const std::string& script_id) {
   auto* prefs = SessionPreferences::FromBrowserContext(browser_context());
   DCHECK(prefs);
-  prefs->set_preloads(preloads);
+
+  auto& preload_scripts = prefs->preload_scripts();
+
+  // Find the preload script by its ID
+  auto it = std::find_if(preload_scripts.begin(), preload_scripts.end(),
+                         [&script_id](const PreloadScript& script) {
+                           return script.id == script_id;
+                         });
+
+  // If the script is found, erase it from the vector
+  if (it != preload_scripts.end()) {
+    preload_scripts.erase(it);
+    return;
+  }
+
+  // If the script is not found, throw an error
+  thrower.ThrowError(base::StringPrintf(
+      "Cannot unregister preload script with non-existing ID '%s'",
+      script_id.c_str()));
 }
 
-std::vector<base::FilePath> Session::GetPreloads() const {
+std::vector<PreloadScript> Session::GetPreloadScripts() const {
   auto* prefs = SessionPreferences::FromBrowserContext(browser_context());
   DCHECK(prefs);
-  return prefs->preloads();
+  return prefs->preload_scripts();
 }
 
 /**
@@ -1800,8 +1856,9 @@ void Session::FillObjectTemplate(v8::Isolate* isolate,
       .SetMethod("downloadURL", &Session::DownloadURL)
       .SetMethod("createInterruptedDownload",
                  &Session::CreateInterruptedDownload)
-      .SetMethod("setPreloads", &Session::SetPreloads)
-      .SetMethod("getPreloads", &Session::GetPreloads)
+      .SetMethod("registerPreloadScript", &Session::RegisterPreloadScript)
+      .SetMethod("unregisterPreloadScript", &Session::UnregisterPreloadScript)
+      .SetMethod("getPreloadScripts", &Session::GetPreloadScripts)
       .SetMethod("getSharedDictionaryUsageInfo",
                  &Session::GetSharedDictionaryUsageInfo)
       .SetMethod("getSharedDictionaryInfo", &Session::GetSharedDictionaryInfo)

+ 6 - 2
shell/browser/api/electron_api_session.h

@@ -57,6 +57,7 @@ class ProxyConfig;
 namespace electron {
 
 class ElectronBrowserContext;
+struct PreloadScript;
 
 namespace api {
 
@@ -141,8 +142,11 @@ class Session final : public gin::Wrappable<Session>,
                                      const std::string& uuid);
   void DownloadURL(const GURL& url, gin::Arguments* args);
   void CreateInterruptedDownload(const gin_helper::Dictionary& options);
-  void SetPreloads(const std::vector<base::FilePath>& preloads);
-  std::vector<base::FilePath> GetPreloads() const;
+  std::string RegisterPreloadScript(gin_helper::ErrorThrower thrower,
+                                    const PreloadScript& new_preload_script);
+  void UnregisterPreloadScript(gin_helper::ErrorThrower thrower,
+                               const std::string& script_id);
+  std::vector<PreloadScript> GetPreloadScripts() const;
   v8::Local<v8::Promise> GetSharedDictionaryInfo(
       const gin_helper::Dictionary& options);
   v8::Local<v8::Promise> GetSharedDictionaryUsageInfo();

+ 6 - 7
shell/browser/api/electron_api_web_contents.cc

@@ -3757,16 +3757,15 @@ void WebContents::DoGetZoomLevel(
   std::move(callback).Run(GetZoomLevel());
 }
 
-std::vector<base::FilePath> WebContents::GetPreloadPaths() const {
-  auto result = SessionPreferences::GetValidPreloads(GetBrowserContext());
-
+std::optional<PreloadScript> WebContents::GetPreloadScript() const {
   if (auto* web_preferences = WebContentsPreferences::From(web_contents())) {
     if (auto preload = web_preferences->GetPreloadPath()) {
-      result.emplace_back(*preload);
+      auto preload_script = PreloadScript{
+          "", PreloadScript::ScriptType::kWebFrame, preload.value()};
+      return preload_script;
     }
   }
-
-  return result;
+  return std::nullopt;
 }
 
 v8::Local<v8::Value> WebContents::GetLastWebPreferences(
@@ -4520,7 +4519,7 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate,
       .SetMethod("setZoomFactor", &WebContents::SetZoomFactor)
       .SetMethod("getZoomFactor", &WebContents::GetZoomFactor)
       .SetMethod("getType", &WebContents::type)
-      .SetMethod("_getPreloadPaths", &WebContents::GetPreloadPaths)
+      .SetMethod("_getPreloadScript", &WebContents::GetPreloadScript)
       .SetMethod("getLastWebPreferences", &WebContents::GetLastWebPreferences)
       .SetMethod("getOwnerBrowserWindow", &WebContents::GetOwnerBrowserWindow)
       .SetMethod("inspectServiceWorker", &WebContents::InspectServiceWorker)

+ 3 - 2
shell/browser/api/electron_api_web_contents.h

@@ -40,6 +40,7 @@
 #include "shell/browser/event_emitter_mixin.h"
 #include "shell/browser/extended_web_contents_observer.h"
 #include "shell/browser/osr/osr_paint_event.h"
+#include "shell/browser/preload_script.h"
 #include "shell/browser/ui/inspectable_web_contents_delegate.h"
 #include "shell/browser/ui/inspectable_web_contents_view_delegate.h"
 #include "shell/common/gin_helper/cleaned_up_at_exit.h"
@@ -344,8 +345,8 @@ class WebContents final : public ExclusiveAccessContext,
                       const std::string& features,
                       const scoped_refptr<network::ResourceRequestBody>& body);
 
-  // Returns the preload script path of current WebContents.
-  std::vector<base::FilePath> GetPreloadPaths() const;
+  // Returns the preload script of current WebContents.
+  std::optional<PreloadScript> GetPreloadScript() const;
 
   // Returns the web preferences of current WebContents.
   v8::Local<v8::Value> GetLastWebPreferences(v8::Isolate* isolate) const;

+ 104 - 0
shell/browser/preload_script.h

@@ -0,0 +1,104 @@
+// Copyright (c) 2025 Salesforce, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_PRELOAD_SCRIPT_H_
+#define ELECTRON_SHELL_BROWSER_PRELOAD_SCRIPT_H_
+
+#include <string_view>
+
+#include "base/containers/fixed_flat_map.h"
+#include "base/files/file_path.h"
+#include "base/uuid.h"
+#include "gin/converter.h"
+#include "shell/common/gin_converters/file_path_converter.h"
+#include "shell/common/gin_helper/dictionary.h"
+
+namespace electron {
+
+struct PreloadScript {
+  enum class ScriptType { kWebFrame, kServiceWorker };
+
+  std::string id;
+  ScriptType script_type;
+  base::FilePath file_path;
+
+  // If set, use the deprecated validation behavior of Session.setPreloads
+  bool deprecated = false;
+};
+
+}  // namespace electron
+
+namespace gin {
+
+using electron::PreloadScript;
+
+template <>
+struct Converter<PreloadScript::ScriptType> {
+  static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
+                                   const PreloadScript::ScriptType& in) {
+    using Val = PreloadScript::ScriptType;
+    static constexpr auto Lookup =
+        base::MakeFixedFlatMap<Val, std::string_view>({
+            {Val::kWebFrame, "frame"},
+            {Val::kServiceWorker, "service-worker"},
+        });
+    return StringToV8(isolate, Lookup.at(in));
+  }
+
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> val,
+                     PreloadScript::ScriptType* out) {
+    using Val = PreloadScript::ScriptType;
+    static constexpr auto Lookup =
+        base::MakeFixedFlatMap<std::string_view, Val>({
+            {"frame", Val::kWebFrame},
+            {"service-worker", Val::kServiceWorker},
+        });
+    return FromV8WithLookup(isolate, val, Lookup, out);
+  }
+};
+
+template <>
+struct Converter<PreloadScript> {
+  static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
+                                   const PreloadScript& script) {
+    gin::Dictionary dict(isolate, v8::Object::New(isolate));
+    dict.Set("filePath", script.file_path.AsUTF8Unsafe());
+    dict.Set("id", script.id);
+    dict.Set("type", script.script_type);
+    return ConvertToV8(isolate, dict).As<v8::Object>();
+  }
+
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> val,
+                     PreloadScript* out) {
+    gin_helper::Dictionary options;
+    if (!ConvertFromV8(isolate, val, &options))
+      return false;
+    if (PreloadScript::ScriptType script_type;
+        options.Get("type", &script_type)) {
+      out->script_type = script_type;
+    } else {
+      return false;
+    }
+    if (base::FilePath file_path; options.Get("filePath", &file_path)) {
+      out->file_path = file_path;
+    } else {
+      return false;
+    }
+    if (std::string id; options.Get("id", &id)) {
+      out->id = id;
+    } else {
+      out->id = base::Uuid::GenerateRandomV4().AsLowercaseString();
+    }
+    if (bool deprecated; options.Get("_deprecated", &deprecated)) {
+      out->deprecated = deprecated;
+    }
+    return true;
+  }
+};
+
+}  // namespace gin
+
+#endif  // ELECTRON_SHELL_BROWSER_PRELOAD_SCRIPT_H_

+ 0 - 18
shell/browser/session_preferences.cc

@@ -30,22 +30,4 @@ SessionPreferences* SessionPreferences::FromBrowserContext(
   return static_cast<SessionPreferences*>(context->GetUserData(&kLocatorKey));
 }
 
-// static
-std::vector<base::FilePath> SessionPreferences::GetValidPreloads(
-    content::BrowserContext* context) {
-  std::vector<base::FilePath> result;
-
-  if (auto* self = FromBrowserContext(context)) {
-    for (const auto& preload : self->preloads()) {
-      if (preload.IsAbsolute()) {
-        result.emplace_back(preload);
-      } else {
-        LOG(ERROR) << "preload script must have absolute path: " << preload;
-      }
-    }
-  }
-
-  return result;
-}
-
 }  // namespace electron

+ 3 - 7
shell/browser/session_preferences.h

@@ -9,6 +9,7 @@
 
 #include "base/files/file_path.h"
 #include "base/supports_user_data.h"
+#include "shell/browser/preload_script.h"
 
 namespace content {
 class BrowserContext;
@@ -20,17 +21,12 @@ class SessionPreferences : public base::SupportsUserData::Data {
  public:
   static SessionPreferences* FromBrowserContext(
       content::BrowserContext* context);
-  static std::vector<base::FilePath> GetValidPreloads(
-      content::BrowserContext* context);
 
   static void CreateForBrowserContext(content::BrowserContext* context);
 
   ~SessionPreferences() override;
 
-  void set_preloads(const std::vector<base::FilePath>& preloads) {
-    preloads_ = preloads;
-  }
-  const std::vector<base::FilePath>& preloads() const { return preloads_; }
+  std::vector<PreloadScript>& preload_scripts() { return preload_scripts_; }
 
  private:
   SessionPreferences();
@@ -38,7 +34,7 @@ class SessionPreferences : public base::SupportsUserData::Data {
   // The user data key.
   static int kLocatorKey;
 
-  std::vector<base::FilePath> preloads_;
+  std::vector<PreloadScript> preload_scripts_;
 };
 
 }  // namespace electron

+ 6 - 1
typings/internal-electron.d.ts

@@ -76,7 +76,7 @@ declare namespace Electron {
     getOwnerBrowserWindow(): Electron.BrowserWindow | null;
     getLastWebPreferences(): Electron.WebPreferences | null;
     _getProcessMemoryInfo(): Electron.ProcessMemoryInfo;
-    _getPreloadPaths(): string[];
+    _getPreloadScript(): Electron.PreloadScript | null;
     equal(other: WebContents): boolean;
     browserWindowOptions: BrowserWindowConstructorOptions;
     _windowOpenHandler: ((details: Electron.HandlerDetails) => any) | null;
@@ -330,6 +330,11 @@ declare namespace ElectronInternal {
   class WebContents extends Electron.WebContents {
     static create(opts?: Electron.WebPreferences): Electron.WebContents;
   }
+
+  interface PreloadScript extends Electron.PreloadScript {
+    contents?: string;
+    error?: Error;
+  }
 }
 
 declare namespace Chrome {