Browse Source

feat: redesign preload APIs

Samuel Maddock 5 months ago
parent
commit
ed33b9ddca

+ 28 - 2
docs/api/session.md

@@ -1330,18 +1330,44 @@ 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. This will overwrite any preload scripts
+registered for `service-worker` context types.
+
+#### `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` or `service-worker`.
+* `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` or `service-worker`.
+* `id` string - Unique ID of preload script.
+* `filePath` string - Path of the script file. Must be an absolute path.

+ 21 - 0
docs/breaking-changes.md

@@ -12,6 +12,27 @@ This document uses the following convention to categorize breaking changes:
 * **Deprecated:** An API was marked as deprecated. The API will continue to function, but will emit a deprecation warning, and will be removed in a future release.
 * **Removed:** An API or feature was removed, and is no longer supported by Electron.
 
+## 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 such as `service-worker`.
+
+```ts
+// Deprecated
+session.setPreloads([path.join(__dirname, 'preload.js')])
+
+// Replace with:
+session.registerPreloadScript({
+  type: 'frame',
+  id: 'app-preload',
+  filePath: path.join(__dirname, 'preload.js')
+})
+```
+
 ## Planned Breaking API Changes (34.0)
 
 ### Deprecated: `level`, `message`, `line`, and `sourceId` arguments in `console-message` event on `WebContents`

+ 1 - 0
filenames.auto.gni

@@ -114,6 +114,7 @@ auto_filenames = {
     "docs/api/structures/permission-request.md",
     "docs/api/structures/point.md",
     "docs/api/structures/post-body.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",

+ 1 - 0
filenames.gni

@@ -482,6 +482,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,

+ 37 - 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,46 @@ 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.type === 'service-worker' ? event.session : event.sender.session;
+  let preloadScripts = session.getPreloadScripts();
+
+  if (event.type === 'frame') {
+    preloadScripts = preloadScripts.filter(script => script.type === 'frame');
+    const preload = event.sender._getPreloadScript();
+    if (preload) preloadScripts.push(preload);
+  } else if (event.type === 'service-worker') {
+    preloadScripts = preloadScripts.filter(script => script.type === 'service-worker');
+  } else {
+    throw new Error(`getPreloadScriptsFromEvent: event.type is invalid (${(event as any).type})`);
+  }
+
+  // TODO(samuelmaddock): Remove filter after Session.setPreloads is fully
+  // deprecated. The new API will prevent relative paths from being registered.
+  return preloadScripts.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 +96,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) {

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

@@ -1064,16 +1064,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();
 }
 
 /**
@@ -1799,8 +1855,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 {
 
@@ -138,8 +139,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

@@ -3706,16 +3706,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(
@@ -4469,7 +4468,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"
@@ -337,8 +338,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 {