Repository: electron/electron
Pull request: feat: add exposeInIsolatedWorld(worldId, key, api) to contextBridge

#### Description of Change

This PR adds a new function, exposeInIsolatedWorld to contextBridge. With this change, custom APIs can be exposed to Isolated worlds without any hacks when `contextIsolation` is enabled.

Fixes https://github.com/electron/electron/issues/32588

cc @miniak 

#### Checklist

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant documentation is changed or added
- [x] [PR release notes](https://github.com/electron/clerk/blob/master/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/master/README.md#examples).

#### Release Notes
Notes: Added `contextBridge.exposeInIsolatedWorld(worldId, key, api)` to expose an API to an `isolatedWorld` within a renderer from a preload script.


Changed files:
filename: docs/api/context-bridge.md
patch: @@ -46,6 +46,12 @@ The `contextBridge` module has the following methods:
 * `apiKey` string - The key to inject the API onto `window` with.  The API will be accessible on `window[apiKey]`.
 * `api` any - Your API, more information on what this API can be and how it works is available below.
 
+### `contextBridge.exposeInIsolatedWorld(worldId, apiKey, api)`
+
+* `worldId` Integer - The ID of the world to inject the API into. `0` is the default world, `999` is the world used by Electron's `contextIsolation` feature. Using 999 would expose the object for preload context. We recommend using 1000+ while creating isolated world.
+* `apiKey` string - The key to inject the API onto `window` with.  The API will be accessible on `window[apiKey]`.
+* `api` any - Your API, more information on what this API can be and how it works is available below.
+
 ## Usage
 
 ### API
@@ -84,6 +90,26 @@ contextBridge.exposeInMainWorld(
 )
 ```
 
+An example of `exposeInIsolatedWorld` is shown below:
+
+```javascript
+const { contextBridge, ipcRenderer } = require('electron')
+
+contextBridge.exposeInIsolatedWorld(
+  1004,
+  'electron',
+  {
+    doThing: () => ipcRenderer.send('do-a-thing')
+  }
+)
+```
+
+```javascript
+// Renderer (In isolated world id1004)
+
+window.electron.doThing()
+```
+
 ### API Functions
 
 `Function` values that you bind through the `contextBridge` are proxied through Electron to ensure that contexts remain isolated.  This

filename: filenames.auto.gni
patch: @@ -140,6 +140,7 @@ auto_filenames = {
     "lib/common/define-properties.ts",
     "lib/common/ipc-messages.ts",
     "lib/common/web-view-methods.ts",
+    "lib/common/webpack-globals-provider.ts",
     "lib/renderer/api/context-bridge.ts",
     "lib/renderer/api/crash-reporter.ts",
     "lib/renderer/api/ipc-renderer.ts",

filename: lib/renderer/api/context-bridge.ts
patch: @@ -7,7 +7,11 @@ const checkContextIsolationEnabled = () => {
 const contextBridge: Electron.ContextBridge = {
   exposeInMainWorld: (key: string, api: any) => {
     checkContextIsolationEnabled();
-    return binding.exposeAPIInMainWorld(key, api);
+    return binding.exposeAPIInWorld(0, key, api);
+  },
+  exposeInIsolatedWorld: (worldId: number, key: string, api: any) => {
+    checkContextIsolationEnabled();
+    return binding.exposeAPIInWorld(worldId, key, api);
   }
 };
 

filename: shell/renderer/api/electron_api_context_bridge.cc
patch: @@ -561,19 +561,26 @@ v8::MaybeLocal<v8::Object> CreateProxyForAPI(
   }
 }
 
-void ExposeAPIInMainWorld(v8::Isolate* isolate,
-                          const std::string& key,
-                          v8::Local<v8::Value> api,
-                          gin_helper::Arguments* args) {
-  TRACE_EVENT1("electron", "ContextBridge::ExposeAPIInMainWorld", "key", key);
+void ExposeAPIInWorld(v8::Isolate* isolate,
+                      const int world_id,
+                      const std::string& key,
+                      v8::Local<v8::Value> api,
+                      gin_helper::Arguments* args) {
+  TRACE_EVENT2("electron", "ContextBridge::ExposeAPIInWorld", "key", key,
+               "worldId", world_id);
 
   auto* render_frame = GetRenderFrame(isolate->GetCurrentContext()->Global());
   CHECK(render_frame);
   auto* frame = render_frame->GetWebFrame();
   CHECK(frame);
-  v8::Local<v8::Context> main_context = frame->MainWorldScriptContext();
-  gin_helper::Dictionary global(main_context->GetIsolate(),
-                                main_context->Global());
+
+  v8::Local<v8::Context> target_context =
+      world_id == WorldIDs::MAIN_WORLD_ID
+          ? frame->MainWorldScriptContext()
+          : frame->GetScriptContextFromWorldId(isolate, world_id);
+
+  gin_helper::Dictionary global(target_context->GetIsolate(),
+                                target_context->Global());
 
   if (global.Has(key)) {
     args->ThrowError(
@@ -582,15 +589,17 @@ void ExposeAPIInMainWorld(v8::Isolate* isolate,
     return;
   }
 
-  v8::Local<v8::Context> isolated_context = frame->GetScriptContextFromWorldId(
-      args->isolate(), WorldIDs::ISOLATED_WORLD_ID);
+  v8::Local<v8::Context> electron_isolated_context =
+      frame->GetScriptContextFromWorldId(args->isolate(),
+                                         WorldIDs::ISOLATED_WORLD_ID);
 
   {
     context_bridge::ObjectCache object_cache;
-    v8::Context::Scope main_context_scope(main_context);
+    v8::Context::Scope target_context_scope(target_context);
 
-    v8::MaybeLocal<v8::Value> maybe_proxy = PassValueToOtherContext(
-        isolated_context, main_context, api, &object_cache, false, 0);
+    v8::MaybeLocal<v8::Value> maybe_proxy =
+        PassValueToOtherContext(electron_isolated_context, target_context, api,
+                                &object_cache, false, 0);
     if (maybe_proxy.IsEmpty())
       return;
     auto proxy = maybe_proxy.ToLocalChecked();
@@ -601,7 +610,7 @@ void ExposeAPIInMainWorld(v8::Isolate* isolate,
     }
 
     if (proxy->IsObject() && !proxy->IsTypedArray() &&
-        !DeepFreeze(proxy.As<v8::Object>(), main_context))
+        !DeepFreeze(proxy.As<v8::Object>(), target_context))
       return;
 
     global.SetReadOnlyNonConfigurable(key, proxy);
@@ -717,7 +726,7 @@ void Initialize(v8::Local<v8::Object> exports,
                 void* priv) {
   v8::Isolate* isolate = context->GetIsolate();
   gin_helper::Dictionary dict(isolate, exports);
-  dict.SetMethod("exposeAPIInMainWorld", &electron::api::ExposeAPIInMainWorld);
+  dict.SetMethod("exposeAPIInWorld", &electron::api::ExposeAPIInWorld);
   dict.SetMethod("_overrideGlobalValueFromIsolatedWorld",
                  &electron::api::OverrideGlobalValueFromIsolatedWorld);
   dict.SetMethod("_overrideGlobalPropertyFromIsolatedWorld",

filename: spec/api-context-bridge-spec.ts
patch: @@ -62,17 +62,29 @@ describe('contextBridge', () => {
 
   const generateTests = (useSandbox: boolean) => {
     describe(`with sandbox=${useSandbox}`, () => {
-      const makeBindingWindow = async (bindingCreator: Function) => {
-        const preloadContent = `const renderer_1 = require('electron');
+      const makeBindingWindow = async (bindingCreator: Function, worldId: number = 0) => {
+        const preloadContentForMainWorld = `const renderer_1 = require('electron');
         ${useSandbox ? '' : `require('v8').setFlagsFromString('--expose_gc');
         const gc=require('vm').runInNewContext('gc');
         renderer_1.contextBridge.exposeInMainWorld('GCRunner', {
           run: () => gc()
         });`}
         (${bindingCreator.toString()})();`;
+
+        const preloadContentForIsolatedWorld = `const renderer_1 = require('electron');
+        ${useSandbox ? '' : `require('v8').setFlagsFromString('--expose_gc');
+        const gc=require('vm').runInNewContext('gc');
+        renderer_1.webFrame.setIsolatedWorldInfo(${worldId}, {
+          name: "Isolated World"
+        });  
+        renderer_1.contextBridge.exposeInIsolatedWorld(${worldId}, 'GCRunner', {
+          run: () => gc()
+        });`}
+        (${bindingCreator.toString()})();`;
+
         const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-spec-preload-'));
         dir = tmpDir;
-        await fs.writeFile(path.resolve(tmpDir, 'preload.js'), preloadContent);
+        await fs.writeFile(path.resolve(tmpDir, 'preload.js'), worldId === 0 ? preloadContentForMainWorld : preloadContentForIsolatedWorld);
         w = new BrowserWindow({
           show: false,
           webPreferences: {
@@ -86,8 +98,8 @@ describe('contextBridge', () => {
         await w.loadURL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
       };
 
-      const callWithBindings = (fn: Function) =>
-        w.webContents.executeJavaScript(`(${fn.toString()})(window)`);
+      const callWithBindings = (fn: Function, worldId: number = 0) =>
+        worldId === 0 ? w.webContents.executeJavaScript(`(${fn.toString()})(window)`) : w.webContents.executeJavaScriptInIsolatedWorld(worldId, [{ code: `(${fn.toString()})(window)` }]); ;
 
       const getGCInfo = async (): Promise<{
         trackedValues: number;
@@ -114,6 +126,16 @@ describe('contextBridge', () => {
         expect(result).to.equal(123);
       });
 
+      it('should proxy numbers when exposed in isolated world', async () => {
+        await makeBindingWindow(() => {
+          contextBridge.exposeInIsolatedWorld(1004, 'example', 123);
+        }, 1004);
+        const result = await callWithBindings((root: any) => {
+          return root.example;
+        }, 1004);
+        expect(result).to.equal(123);
+      });
+
       it('should make global properties read-only', async () => {
         await makeBindingWindow(() => {
           contextBridge.exposeInMainWorld('example', 123);
@@ -172,6 +194,18 @@ describe('contextBridge', () => {
         expect(result).to.equal('my-words');
       });
 
+      it('should proxy nested strings when exposed in isolated world', async () => {
+        await makeBindingWindow(() => {
+          contextBridge.exposeInIsolatedWorld(1004, 'example', {
+            myString: 'my-words'
+          });
+        }, 1004);
+        const result = await callWithBindings((root: any) => {
+          return root.example.myString;
+        }, 1004);
+        expect(result).to.equal('my-words');
+      });
+
       it('should proxy arrays', async () => {
         await makeBindingWindow(() => {
           contextBridge.exposeInMainWorld('example', [123, 'my-words']);