From 0edcdc47c08a9fbe3f356425731182fcfce53e3a Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Fri, 16 Oct 2026 14:03:19 -0700 Subject: [PATCH] feat: add app shims on macOS (#54932) * feat: add app shims on macOS An app shim is a small app, in a bundle of its own, that shows windows on behalf of a host Electron app. The host still creates each window, loads its page and handles its events, but the NSWindow belongs to the shim's process, so the Dock, the app switcher, Mission Control and Stage Manager attribute the window to the shim and not to the host. - `app.startAppShimServer()` / `app.stopAppShimServer()` and the `app-shim-connected` event, which hands over an `AppShimHost`. - A `BaseWindow` / `BrowserWindow` created with the `appShim` option has its native window made in that shim's process, through Chromium's remote_cocoa. Such a window is a new NativeWindow implementation that only goes through views::Widget; what remote_cocoa has no call for is a no-op (see docs/api/app-shim-host.md). - The shim's executable is `Helpers/app_shim_loader` from the framework bundle. It links nothing from Electron: it loads the host's framework and calls the new `ElectronAppShimMain` export, which runs an `app-shim` process that connects back to the host over a Mach bootstrap server. - Electron Framework gains an rpath to the folder it is in, so that it finds its sibling frameworks when an executable in another bundle loads it. Not available in Mac App Store builds. Co-Authored-By: Claude * feat: app shim menus, dialogs, window chrome and review fixes Follow-up to the first app shim commit. Fixes from review: - A window torn down from one of its own listeners (shim.quit() or win.destroy() in `show`) no longer frees the native host that is notifying. - The native close button no longer leaves the shim waiting on an answer that was dropped with the window. - Keys the page does not handle go back to the shim, whose menu they are shortcuts of, and not to the host's menu. - stopAppShimServer() drops shims that have connected and not said hello. - Quitting a shim asks the host without blocking and without cancelling a logout; quit-requested can now be answered with "no". - Size limits and useContentSize are in content sizes; restore() and unmaximize() each do one thing; webContents.isFocused() works. New: - shim.setMenu(): a menu bar of the shim's own, built in the shim from a description of the Menu, with clicks sent back. - Message boxes and open and save dialogs are sheets of the window in the shim's process. - titleBarStyle, traffic light position and visibility, vibrancy, tabs and Quick Look, applied in the shim to the NSWindow that remote_cocoa made. - A Chromium patch for what remote_cocoa has no call for: movable, closable, minimizable and fullscreenable, clearing an aspect ratio, and yielding activation to the hosting process by pid. - A shim that loaded another build of Electron than the running host tells the host and quits: app-shim-version-mismatch. The API is marked experimental in the docs. Co-Authored-By: Claude * fix: address review of app shims - A window in an app shim can't be given a parent, at construction or with setParentWindow(), as the docs say. - shim.setMenu() is now shim.setApplicationMenu(). - A message box that is showing under an id makes way for a new one with that id, whichever process shows it. - Mac App Store builds no longer export the shim's entry point; they already had no loader. - setProgressBar() and setHasShadow() are listed among what does nothing on a window in a shim. - The spec no longer asserts inside an event listener. Co-Authored-By: Claude * fix: clang-tidy findings in app shim code * docs: reword the app shim docs for clarity * chore: update patches Re-exported after rebasing onto Chromium 158.0.8105.0; only line offsets changed. Co-Authored-By: Claude * test: use vitest matchers in the app shim spec --------- Co-authored-by: Claude --- BUILD.gn | 28 +- docs/api/app-shim-host.md | 263 ++++ docs/api/app.md | 70 ++ docs/api/structures/base-window-options.md | 4 + filenames.auto.gni | 1 + filenames.gni | 20 + patches/chromium/.patches | 1 + ...te_nswindow_set_more_of_its_behavior.patch | 248 ++++ .../zip_manifests/dist_zip.mac.arm64.manifest | 1 + .../zip_manifests/dist_zip.mac.x64.manifest | 1 + shell/app/electron_library_main.h | 9 + shell/app/electron_library_main.mm | 54 + shell/app_shim/app_shim_application.h | 19 + shell/app_shim/app_shim_application.mm | 81 ++ shell/app_shim/app_shim_controller.h | 163 +++ shell/app_shim/app_shim_controller.mm | 744 ++++++++++++ shell/app_shim/app_shim_loader_mac.mm | 83 ++ shell/app_shim/app_shim_main_delegate.h | 52 + shell/app_shim/app_shim_main_delegate.mm | 148 +++ shell/app_shim/app_shim_menu.h | 73 ++ shell/app_shim/app_shim_menu.mm | 160 +++ shell/app_shim/app_shim_window.h | 46 + shell/app_shim/app_shim_window.mm | 332 ++++++ shell/browser/api/electron_api_app.cc | 2 + shell/browser/api/electron_api_app.h | 2 + shell/browser/api/electron_api_app_mac.mm | 38 + .../browser/api/electron_api_app_shim_host.h | 117 ++ .../browser/api/electron_api_app_shim_host.mm | 547 +++++++++ shell/browser/api/electron_api_base_window.cc | 64 +- shell/browser/api/electron_api_base_window.h | 8 + .../api/electron_api_browser_window.cc | 5 + .../api/electron_api_web_contents_mac.mm | 17 + shell/browser/app_shim/app_shim_listener.h | 128 ++ shell/browser/app_shim/app_shim_listener.mm | 315 +++++ shell/browser/native_window.cc | 4 + shell/browser/native_window.h | 4 + shell/browser/native_window_mac.mm | 55 +- shell/browser/native_window_mac_remote.h | 267 +++++ shell/browser/native_window_mac_remote.mm | 1055 +++++++++++++++++ shell/browser/ui/certificate_trust_mac.mm | 3 +- shell/browser/ui/file_dialog.h | 22 + shell/browser/ui/file_dialog_mac.mm | 219 +++- shell/browser/ui/message_box.h | 14 + shell/browser/ui/message_box_mac.mm | 52 +- .../views/content_view_targeter_delegate.cc | 33 + .../ui/views/content_view_targeter_delegate.h | 46 + shell/common/BUILD.gn | 14 + shell/common/app_shim.mojom | 205 ++++ shell/common/app_shim_constants.cc | 25 + shell/common/app_shim_constants.h | 54 + .../gin_helper/wrappable_pointer_tags.h | 1 + shell/common/options_switches.h | 1 + spec/api-app-shim.spec.ts | 380 ++++++ 53 files changed, 6199 insertions(+), 99 deletions(-) create mode 100644 docs/api/app-shim-host.md create mode 100644 patches/chromium/feat_let_the_host_of_a_remote_nswindow_set_more_of_its_behavior.patch create mode 100644 shell/app_shim/app_shim_application.h create mode 100644 shell/app_shim/app_shim_application.mm create mode 100644 shell/app_shim/app_shim_controller.h create mode 100644 shell/app_shim/app_shim_controller.mm create mode 100644 shell/app_shim/app_shim_loader_mac.mm create mode 100644 shell/app_shim/app_shim_main_delegate.h create mode 100644 shell/app_shim/app_shim_main_delegate.mm create mode 100644 shell/app_shim/app_shim_menu.h create mode 100644 shell/app_shim/app_shim_menu.mm create mode 100644 shell/app_shim/app_shim_window.h create mode 100644 shell/app_shim/app_shim_window.mm create mode 100644 shell/browser/api/electron_api_app_shim_host.h create mode 100644 shell/browser/api/electron_api_app_shim_host.mm create mode 100644 shell/browser/app_shim/app_shim_listener.h create mode 100644 shell/browser/app_shim/app_shim_listener.mm create mode 100644 shell/browser/native_window_mac_remote.h create mode 100644 shell/browser/native_window_mac_remote.mm create mode 100644 shell/browser/ui/views/content_view_targeter_delegate.cc create mode 100644 shell/browser/ui/views/content_view_targeter_delegate.h create mode 100644 shell/common/app_shim.mojom create mode 100644 shell/common/app_shim_constants.cc create mode 100644 shell/common/app_shim_constants.h create mode 100644 spec/api-app-shim.spec.ts diff --git a/BUILD.gn b/BUILD.gn index 998255587e..9d7a235403 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -684,11 +684,14 @@ source_set("electron_lib") { if (is_mac) { deps += [ + "shell/common:app_shim", "//components/os_crypt/async/browser:keychain_key_provider", "//components/os_crypt/common:keychain_password_mac", "//components/remote_cocoa/app_shim", "//components/remote_cocoa/browser", "//content/browser:mac_helpers", + "//mojo/core/embedder", + "//mojo/public/cpp/platform", "//ui/accelerated_widget_mac", ] @@ -1256,6 +1259,20 @@ if (is_mac) { deps = [ ":electron_swiftshader_binaries" ] } + # The executable of an app shim. Apps copy it into the shim bundles they make; + # it links nothing from Electron and loads the host app's framework. + executable("electron_app_shim_loader") { + output_name = "app_shim_loader" + sources = [ "shell/app_shim/app_shim_loader_mac.mm" ] + frameworks = [ "Cocoa.framework" ] + } + + bundle_data("electron_app_shim_loader_bundle_data") { + sources = [ "$root_out_dir/app_shim_loader" ] + outputs = [ "{{bundle_contents_dir}}/Helpers/{{source_file_part}}" ] + public_deps = [ ":electron_app_shim_loader" ] + } + bundle_data("electron_crashpad_helper") { sources = [ "$root_out_dir/chrome_crashpad_handler" ] @@ -1354,7 +1371,10 @@ if (is_mac) { ":electron_xibs", ] if (!is_mas_build) { - bundle_deps += [ ":electron_crashpad_helper" ] + bundle_deps += [ + ":electron_app_shim_loader_bundle_data", + ":electron_crashpad_helper", + ] } info_plist = "shell/common/resources/mac/Info.plist" @@ -1372,6 +1392,12 @@ if (is_mac) { "-rpath", "@loader_path/Libraries", + # The folder this framework is in, where its sibling frameworks are. The + # app's executable has it as an rpath too, but an app shim loads this + # framework from an executable in another bundle. + "-rpath", + "@loader_path/../../..", + # Required for exporting all symbols of libuv. "-Wl,-force_load,obj/third_party/electron_node/deps/uv/libuv.a", ] diff --git a/docs/api/app-shim-host.md b/docs/api/app-shim-host.md new file mode 100644 index 0000000000..fba58e3e7a --- /dev/null +++ b/docs/api/app-shim-host.md @@ -0,0 +1,263 @@ +## Class: AppShimHost + +> Show windows under a separate app with its own Dock icon. + +> [!NOTE] +> App shims are experimental. This API may change in a future Electron +> release. + +Process: [Main](../glossary.md#main-process)
+_This class is not exported from the `'electron'` module. It is only available as a return value of other methods in the Electron API._ + +An app shim is a small macOS app, in its own bundle, that displays windows on +behalf of your app. Your app still creates each window, loads its content and +handles its events, but the native window belongs to the shim's process. As a +result, the Dock, the app switcher, Mission Control and Stage Manager show the +window under the shim's name and icon instead of your app's. + +An `AppShimHost` represents your app's connection to one running shim. Your +app receives one in the +[`app-shim-connected`](app.md#event-app-shim-connected-macos-experimental) +event after calling +[`app.startAppShimServer()`](app.md#appstartappshimserveroptions-macos-experimental). + +```js +const { app, BrowserWindow } = require('electron') + +app.whenReady().then(() => { + app.startAppShimServer() +}) + +app.on('app-shim-connected', (event, shim) => { + // Any process running as the same user can connect, so verify the shim. + if (!shim.satisfiesCodeRequirement('anchor apple generic and certificate leaf[subject.OU] = "ABCDE12345"')) { + shim.quit() + return + } + + const win = new BrowserWindow({ appShim: shim }) + win.loadURL('https://example.com') + + shim.on('activate', (event, hasVisibleWindows) => { + if (!hasVisibleWindows) win.show() + }) +}) +``` + +**Creating an app shim** + +A shim is an app bundle whose executable is a copy of +`Electron Framework.framework/Helpers/app_shim_loader` from your packaged app. +The loader does not link against Electron. It loads your app's Electron +Framework when it starts, so a shim created by one version of your app keeps +working after your app is updated. + +The shim's `Info.plist` holds the usual keys (`CFBundleExecutable`, +`CFBundleIdentifier`, `CFBundleName`, `CFBundleIconFile` and so on) as well as these keys: + +* `ElectronAppShimHostPath` string - The absolute path of your app's `.app` + bundle. +* `ElectronAppShimHostBundleIdentifier` string - Your app's bundle identifier. + It is used to locate your app if `ElectronAppShimHostPath` is missing or no + longer points to an app. +* `ElectronAppShimServerName` string (optional) - The `name` your app passes + to `app.startAppShimServer()`, if it passes one. +* `ElectronAppShimHostArguments` string[] (optional) - Command-line arguments + used to launch your app if it is not running when the shim starts. + +When a shim starts, it connects to your app. If your app is not running, the +shim launches it in the background and waits up to one minute for it to call +`app.startAppShimServer()`. + +In a signed app, macOS only allows the shim to load your framework if both are +signed by the same team. + +**App updates** + +A shim can only connect to your app while your app is running the same version +that is installed on disk. If your app's bundle is replaced while your app is +running, any shim launched afterwards quits immediately without showing any +UI, and your app receives an +[`app-shim-version-mismatch`](app.md#event-app-shim-version-mismatch-macos-experimental) +event. Use that event to ask the user to restart your app. + +Updates installed by [`autoUpdater`](auto-updater.md) are applied after your +app quits, so they do not cause a mismatch. + +**Limitations** + +App shims are not available in Mac App Store builds. + +A window in an app shim has no native window in your app's process. Window +APIs that operate directly on the native window are not supported yet and do +nothing: + +* The Touch Bar +* The window controls overlay (`titleBarOverlay`) +* Simple fullscreen +* `win.setEnabled()` +* `win.flashFrame()` (use [`shim.bounce()`](#shimbouncetype-experimental) instead) +* `win.setProgressBar()` +* `win.setHasShadow()` +* `win.setRepresentedFilename()` +* `win.setDocumentEdited()` +* `win.setHiddenInMissionControl()` + +`win.getNativeWindowHandle()` and `win.getMediaSourceId()` refer to a hidden +placeholder window in your app's process, not to the window on screen. + +Other differences from a regular window: + +* A window's shim is set when the window is created and cannot be changed. +* A window in a shim cannot be `modal`, cannot have a `parent` and cannot be + the `parent` of another window. Attempting any of these throws an error. +* `win.addTabbedWindow()` only works between two windows in the same shim. +* Window operations are sent to the shim's process and applied asynchronously, + so window state may not update immediately. For example, after calling + `win.show()`, `win.isVisible()` returns `false` until the window emits its + `show` event. +* Message boxes and open and save dialogs from the [`dialog`](dialog.md) + module can use a window in a shim as their parent. The shim displays them + as sheets attached to that window. The synchronous variants, such as + `dialog.showMessageBoxSync()`, do not block your app while the dialog is + open, so your app may handle other events before they return. Other dialogs + are shown without a parent window. +* While a shim is the active app, macOS shows the shim's menu bar instead of + your app's. Set it with + [`shim.setApplicationMenu()`](#shimsetapplicationmenumenu-experimental). If + no menu is set, the shim shows a minimal default menu. Your app's + application menu and its accelerators do not apply to windows in a shim. + +### Instance Events + +#### Event: 'activate' _Experimental_ + +Returns: + +* `event` Event +* `hasVisibleWindows` boolean + +Emitted when the user clicks the shim's Dock icon, or launches the shim again +while it is already running. + +#### Event: 'open-file' _Experimental_ + +Returns: + +* `event` Event +* `path` string + +Emitted when the user opens a file with the shim. + +#### Event: 'open-url' _Experimental_ + +Returns: + +* `event` Event +* `url` string + +Emitted when the user opens a URL with the shim. + +#### Event: 'quit-requested' _Experimental_ + +Returns: + +* `event` Event + +Emitted when the shim is asked to quit: by the user, for example with +Cmd+Q or from its Dock menu, or by macOS at logout. + +By default, the shim quits and its windows are closed without emitting their +`close` events. To keep the shim running, call `event.preventDefault()`. You +can then close its windows yourself and call `shim.quit()` when you are done. + +`event.preventDefault()` must be called synchronously in the listener. If your +app's main process is unresponsive for more than five seconds, the shim quits +without waiting for it. + +#### Event: 'disconnected' _Experimental_ + +Returns: + +* `event` Event + +Emitted when the shim's process has exited or its connection was lost. All +windows that were created in the shim have already been closed. + +### Instance Methods + +#### `shim.isConnected()` _Experimental_ + +Returns `boolean` - Whether the shim's process is still connected. + +#### `shim.satisfiesCodeRequirement(requirement)` _Experimental_ + +* `requirement` string - A [code requirement](https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/RequirementLang/RequirementLang.html). + +Returns `boolean` - Whether the code signature of the shim's process satisfies +`requirement`. The check is performed on the running process that connected, +not on the bundle at `shim.bundlePath`. + +Throws if `requirement` cannot be parsed. + +#### `shim.setBadge(text)` _Experimental_ + +* `text` string + +Sets the text displayed on the shim's Dock icon. An empty string clears it. + +#### `shim.bounce([type])` _Experimental_ + +* `type` string (optional) - Can be `critical` or `informational`. The default is + `informational` + +Bounces the shim's Dock icon, in the same way that +[`dock.bounce()`](dock.md#dockbouncetype-macos) bounces your app's. + +#### `shim.cancelBounce()` _Experimental_ + +Cancels a bounce started by `shim.bounce()`. + +#### `shim.setApplicationMenu(menu)` _Experimental_ + +* `menu` [Menu](menu.md) | null + +Sets the menu bar that is shown while the shim is the active app. This is the +shim's equivalent of +[`Menu.setApplicationMenu()`](menu.md#menusetapplicationmenumenu). Passing +`null` restores the shim's minimal default menu. + +The `click` handlers of the menu's items and the menu's `menu-will-show` event +run in your app's main process. Items with a `role` act on the shim instead of +your app: for example, the `quit` role quits the shim. The default labels of +the `about`, `hide` and `quit` roles use the shim's name. + +The shim displays the menu's items as they were when this method was called. +Later changes to an item's `label`, `enabled`, `checked` or `visible` +properties are applied when the shim becomes the active app, when one of its +menus is opened, and after an item is clicked. + +#### `shim.quit()` _Experimental_ + +Quits the shim's process and closes its windows. The windows do not emit +`close` events. They emit `closed` shortly after this method returns. +`shim.isConnected()` returns `false` as soon as this method returns. + +### Instance Properties + +#### `shim.pid` _Readonly_ _Experimental_ + +A `number` representing the process ID of the shim. + +#### `shim.bundlePath` _Readonly_ _Experimental_ + +A `string` representing the path of the app bundle that the shim's process was +launched from. + +#### `shim.bundleId` _Readonly_ _Experimental_ + +A `string` representing the bundle identifier from that bundle's `Info.plist`. +This value is not verified, so do not use it to decide whether to trust the +shim. Use +[`shim.satisfiesCodeRequirement()`](#shimsatisfiescoderequirementrequirement-experimental) +instead. diff --git a/docs/api/app.md b/docs/api/app.md index 0f5ddfb91b..ed50216261 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -153,6 +153,52 @@ this event, such as launching the application for the first time, attempting to re-launch the application when it's already running, or clicking on the application's dock or taskbar icon. +### Event: 'app-shim-connected' _macOS_ _Experimental_ + +Returns: + +* `event` Event +* `shim` [AppShimHost](app-shim-host.md) + +Emitted when an app shim connects to your app. Shims can only connect after +[`app.startAppShimServer()`](#appstartappshimserveroptions-macos-experimental) +has been called. + +Any process running as the same user can connect. Verify the shim before +creating windows in it, for example with +[`shim.satisfiesCodeRequirement()`](app-shim-host.md#shimsatisfiescoderequirementrequirement-experimental), +and call `shim.quit()` to reject a shim that you do not trust. + +### Event: 'app-shim-version-mismatch' _macOS_ _Experimental_ + +Returns: + +* `event` Event +* `bundlePath` string - The path of the app shim's bundle. + +Emitted when an app shim was launched but could not connect because your app's +bundle has been replaced by a different version since your app started. A shim +loads the version of Electron that is on disk, which cannot communicate with +the version that is still running. + +The shim quits without showing any UI, so use this event to tell the user what +happened. Shims can connect again after your app restarts. + +```js +const { app, dialog } = require('electron') + +app.on('app-shim-version-mismatch', async () => { + const { response } = await dialog.showMessageBox({ + message: `Restart ${app.name} to finish updating`, + buttons: ['Restart', 'Later'] + }) + if (response === 0) { + app.relaunch() + app.quit() + } +}) +``` + ### Event: 'did-become-active' _macOS_ Returns: @@ -1250,6 +1296,30 @@ Activation policy types: * 'accessory' - The application doesn’t appear in the Dock and doesn’t have a menu bar, but it may be activated programmatically or by clicking on one of its windows. * 'prohibited' - The application doesn’t appear in the Dock and may not create windows or be activated. +### `app.startAppShimServer([options])` _macOS_ _Experimental_ + +* `options` Object (optional) + * `name` string (optional) - A name for this server, which lets several + running copies of your app each have their own. It can only contain + letters, digits, `.`, `-` and `_`, and must match the + `ElectronAppShimServerName` of the shims that connect to it. Default is + `appshim`. + +Starts accepting connections from [app shims](app-shim-host.md). An +[`app-shim-connected`](#event-app-shim-connected-macos-experimental) event is +emitted for each shim that connects. + +Throws if the server cannot be started, for example because another copy of +your app has already started a server with the same `name`. + +This method can only be called after the app is ready. It is not available in +Mac App Store builds. + +### `app.stopAppShimServer()` _macOS_ _Experimental_ + +Stops accepting connections from app shims. Shims that have already connected +stay connected. + ### `app.importCertificate(options, callback)` _Linux_ * `options` Object diff --git a/docs/api/structures/base-window-options.md b/docs/api/structures/base-window-options.md index 8e848cb614..c586c69fa4 100644 --- a/docs/api/structures/base-window-options.md +++ b/docs/api/structures/base-window-options.md @@ -55,6 +55,10 @@ * `parent` BaseWindow (optional) - Specify parent window. Default is `null`. * `modal` boolean (optional) - Whether this is a modal window. This only works when the window is a child window. Default is `false`. +* `appShim` [AppShimHost](../app-shim-host.md) (optional) _macOS_ _Experimental_ - Creates the native window in + the process of this app shim, so that it belongs to the shim's Dock icon and + not to your app's. See [AppShimHost](../app-shim-host.md) for what affordances + are granted to the window. * `acceptFirstMouse` boolean (optional) _macOS_ - Whether clicking an inactive window will also click through to the web contents. Default is `false` on macOS. This option is not configurable on other platforms. diff --git a/filenames.auto.gni b/filenames.auto.gni index 7ba3df2b76..8c0428ce3a 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -1,6 +1,7 @@ # THIS FILE IS AUTO-GENERATED, PLEASE DO NOT EDIT BY HAND auto_filenames = { api_docs = [ + "docs/api/app-shim-host.md", "docs/api/app.md", "docs/api/auto-updater.md", "docs/api/base-window.md", diff --git a/filenames.gni b/filenames.gni index fbd6756791..f62d68b1cb 100644 --- a/filenames.gni +++ b/filenames.gni @@ -125,9 +125,21 @@ filenames = { "shell/app/electron_main_delegate_mac.mm", "shell/app/electron_prefetch_mac.h", "shell/app/electron_prefetch_mac.mm", + "shell/app_shim/app_shim_application.h", + "shell/app_shim/app_shim_application.mm", + "shell/app_shim/app_shim_controller.h", + "shell/app_shim/app_shim_controller.mm", + "shell/app_shim/app_shim_main_delegate.h", + "shell/app_shim/app_shim_main_delegate.mm", + "shell/app_shim/app_shim_menu.h", + "shell/app_shim/app_shim_menu.mm", + "shell/app_shim/app_shim_window.h", + "shell/app_shim/app_shim_window.mm", "shell/browser/animation_util.h", "shell/browser/animation_util_mac.mm", "shell/browser/api/electron_api_app_mac.mm", + "shell/browser/api/electron_api_app_shim_host.h", + "shell/browser/api/electron_api_app_shim_host.mm", "shell/browser/api/electron_api_base_window_mac.mm", "shell/browser/api/electron_api_clipboard_mac.mm", "shell/browser/api/electron_api_menu_mac.h", @@ -137,6 +149,8 @@ filenames = { "shell/browser/api/electron_api_push_notifications_mac.mm", "shell/browser/api/electron_api_system_preferences_mac.mm", "shell/browser/api/electron_api_web_contents_mac.mm", + "shell/browser/app_shim/app_shim_listener.h", + "shell/browser/app_shim/app_shim_listener.mm", "shell/browser/auto_updater_mac.mm", "shell/browser/browser_mac.mm", "shell/browser/electron_browser_main_parts_mac.mm", @@ -154,6 +168,8 @@ filenames = { "shell/browser/mac/in_app_purchase.mm", "shell/browser/native_window_mac.h", "shell/browser/native_window_mac.mm", + "shell/browser/native_window_mac_remote.h", + "shell/browser/native_window_mac_remote.mm", "shell/browser/notifications/mac/cocoa_notification.h", "shell/browser/notifications/mac/cocoa_notification.mm", "shell/browser/notifications/mac/notification_center_delegate.h", @@ -163,6 +179,8 @@ filenames = { "shell/browser/osr/osr_host_display_client_mac.mm", "shell/browser/osr/osr_web_contents_view_mac.mm", "shell/browser/relauncher_mac.cc", + "shell/browser/ui/views/content_view_targeter_delegate.cc", + "shell/browser/ui/views/content_view_targeter_delegate.h", "shell/browser/ui/certificate_trust_mac.mm", "shell/browser/ui/certificate_viewer_mac.mm", "shell/browser/ui/cocoa/electron_bundle_mover.h", @@ -200,6 +218,8 @@ filenames = { "shell/browser/webauthn/electron_platform_passkeys_authenticator_mac.mm", "shell/browser/webauthn/electron_platform_passkeys_discovery.h", "shell/browser/webauthn/electron_platform_passkeys_discovery_mac.mm", + "shell/common/app_shim_constants.cc", + "shell/common/app_shim_constants.h", "shell/common/api/electron_api_native_image_mac.mm", "shell/common/asar/archive_mac.mm", "shell/common/asar/integrity_digest.h", diff --git a/patches/chromium/.patches b/patches/chromium/.patches index f33f4d01a8..478eb8fb82 100644 --- a/patches/chromium/.patches +++ b/patches/chromium/.patches @@ -138,3 +138,4 @@ fix_gate_webstoreprivate_on_a_delegated_availability_check.patch build_drop_the_unused_chrome_browser_win_dep_from_color_mixers.patch fix_plumb_the_requesting_frame_through_pointer_lock_requests.patch fix_avoid_misaligned_char16_t_reads_in_crdtp_string_deserialization.patch +feat_let_the_host_of_a_remote_nswindow_set_more_of_its_behavior.patch diff --git a/patches/chromium/feat_let_the_host_of_a_remote_nswindow_set_more_of_its_behavior.patch b/patches/chromium/feat_let_the_host_of_a_remote_nswindow_set_more_of_its_behavior.patch new file mode 100644 index 0000000000..09461ba172 --- /dev/null +++ b/patches/chromium/feat_let_the_host_of_a_remote_nswindow_set_more_of_its_behavior.patch @@ -0,0 +1,248 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Samuel Attard +Date: Wed, 14 Oct 2026 10:58:22 -0700 +Subject: feat: let the host of a remote NSWindow set more of its behavior + +Electron's app shims create their windows through remote_cocoa, where the +host process has no NSWindow to call. This adds what BaseWindow's API +needs and remote_cocoa has no call for: + +- NativeWidgetNSWindow.SetMovable, SetClosable and SetMiniaturizable. +- NativeWidgetNSWindow.SetFullscreenable, which decides whether the + window can go full screen whatever the size constraints say. Without it + that follows from "resizable and maximizable", which is a different + question to an app that sets the three separately. +- An empty aspect ratio passed to SetAspectRatio removes the restriction, + where it used to hit a DCHECK. +- ApplicationHost::set_pid. When the embedder knows the process that hosts + the application, showing one of its windows yields activation to that + process and not to whichever running app declares the bundle + identifier, which the hosted process chooses for itself. + +Chromium's own remote windows (PWA shims) call none of these, so there is +nothing here that it needs; the calls could be upstreamed as they are if +it ever does. + +Co-Authored-By: Claude + +diff --git a/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h b/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h +index de4c147140265a8a576aa5ef4d0276e1a3cf1de5..c5bb4f6871c161e457d25a229005285e5f70e660 100644 +--- a/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h ++++ b/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h +@@ -320,6 +320,10 @@ class REMOTE_COCOA_APP_SHIM_EXPORT NativeWidgetNSWindowBridge + void SetCALayerParams(gfx::CALayerParams ca_layer_params) override; + void SetWindowTitle(const std::u16string& title) override; + void SetIgnoresMouseEvents(bool ignores_mouse_events) override; ++ void SetMovable(bool movable) override; ++ void SetClosable(bool closable) override; ++ void SetMiniaturizable(bool miniaturizable) override; ++ void SetFullscreenable(bool fullscreenable) override; + void MakeFirstResponder() override; + void SortSubviews( + const std::vector& associated_subview_ids) override; +@@ -471,6 +475,10 @@ class REMOTE_COCOA_APP_SHIM_EXPORT NativeWidgetNSWindowBridge + std::unique_ptr window_move_loop_; + ui::mojom::ModalType modal_type_ = ui::mojom::ModalType::kNone; + bool is_translucent_window_ = false; ++ // Set by SetFullscreenable(); takes the place of what the size constraints ++ // would decide. ++ std::optional fullscreenable_override_; ++ void ApplyFullscreenableOverride(); + id __strong local_event_monitor_; + + raw_ptr parent_ = +diff --git a/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm b/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm +index df23bb8f408e9a712ceeb24558947a83ce62e634..3c364d2f4c2bd2bc72efa22183992510ab75785a 100644 +--- a/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm ++++ b/components/remote_cocoa/app_shim/native_widget_ns_window_bridge.mm +@@ -1628,6 +1628,7 @@ NSUInteger CountBridgedWindows(NSArray* child_windows) { + gfx::ApplyNSWindowSizeConstraints(window_, min_size, max_size, + shows_resize_controls, + shows_fullscreen_controls); ++ ApplyFullscreenableOverride(); + } + + void NativeWidgetNSWindowBridge::OnShowAnimationComplete() { +@@ -2043,7 +2044,10 @@ NSUInteger CountBridgedWindows(NSArray* child_windows) { + void NativeWidgetNSWindowBridge::SetAspectRatio( + const gfx::SizeF& aspect_ratio, + const gfx::Size& excluded_margin) { +- DCHECK(!aspect_ratio.IsEmpty()); ++ if (aspect_ratio.IsEmpty()) { ++ [window_delegate_ clearAspectRatio]; ++ return; ++ } + [window_delegate_ setAspectRatio:aspect_ratio.width() / aspect_ratio.height() + excludedMargin:excluded_margin]; + } +@@ -2123,6 +2127,56 @@ NSUInteger CountBridgedWindows(NSArray* child_windows) { + [window_ setIgnoresMouseEvents:ignores_mouse_events]; + } + ++void NativeWidgetNSWindowBridge::SetMovable(bool movable) { ++ [window_ setMovable:movable]; ++} ++ ++void NativeWidgetNSWindowBridge::SetClosable(bool closable) { ++ NSUInteger style_mask = [window_ styleMask]; ++ if (closable) { ++ style_mask |= NSWindowStyleMaskClosable; ++ } else { ++ style_mask &= ~NSWindowStyleMaskClosable; ++ } ++ [window_ setStyleMask:style_mask]; ++} ++ ++void NativeWidgetNSWindowBridge::SetMiniaturizable(bool miniaturizable) { ++ NSUInteger style_mask = [window_ styleMask]; ++ if (miniaturizable) { ++ style_mask |= NSWindowStyleMaskMiniaturizable; ++ } else { ++ style_mask &= ~NSWindowStyleMaskMiniaturizable; ++ } ++ [window_ setStyleMask:style_mask]; ++} ++ ++void NativeWidgetNSWindowBridge::SetFullscreenable(bool fullscreenable) { ++ fullscreenable_override_ = fullscreenable; ++ // Not while in full screen or on the way: the window would lose the ++ // behavior it needs to come back. The next size constraints apply it. ++ if (fullscreen_controller_.CanResize()) { ++ ApplyFullscreenableOverride(); ++ } ++} ++ ++void NativeWidgetNSWindowBridge::ApplyFullscreenableOverride() { ++ if (!fullscreenable_override_) { ++ return; ++ } ++ // A resizable window can go full screen unless it is marked as auxiliary, ++ // so clearing the primary behavior alone would not stop it. ++ NSWindowCollectionBehavior behavior = [window_ collectionBehavior]; ++ if (*fullscreenable_override_) { ++ behavior |= NSWindowCollectionBehaviorFullScreenPrimary; ++ behavior &= ~NSWindowCollectionBehaviorFullScreenAuxiliary; ++ } else { ++ behavior &= ~NSWindowCollectionBehaviorFullScreenPrimary; ++ behavior |= NSWindowCollectionBehaviorFullScreenAuxiliary; ++ } ++ [window_ setCollectionBehavior:behavior]; ++} ++ + void NativeWidgetNSWindowBridge::MakeFirstResponder() { + [window_ makeFirstResponder:bridged_view_]; + } +diff --git a/components/remote_cocoa/app_shim/views_nswindow_delegate.h b/components/remote_cocoa/app_shim/views_nswindow_delegate.h +index 40b1e27ecca25715dd6677743c9f520576cd657b..751d8af8b8f03c1de9f1f1b143057fe6b3e7648d 100644 +--- a/components/remote_cocoa/app_shim/views_nswindow_delegate.h ++++ b/components/remote_cocoa/app_shim/views_nswindow_delegate.h +@@ -52,6 +52,9 @@ REMOTE_COCOA_APP_SHIM_EXPORT + - (void)setAspectRatio:(float)aspectRatio + excludedMargin:(const gfx::Size&)excludedMargin; + ++// Removes the restriction set by setAspectRatio:excludedMargin:. ++- (void)clearAspectRatio; ++ + @end + + #endif // COMPONENTS_REMOTE_COCOA_APP_SHIM_VIEWS_NSWINDOW_DELEGATE_H_ +diff --git a/components/remote_cocoa/app_shim/views_nswindow_delegate.mm b/components/remote_cocoa/app_shim/views_nswindow_delegate.mm +index 1aa3983953a5e292fd57a9e4a860a49c6942f7e5..f2c394a8afe4a09ab473e000938c42dfe0e2ea42 100644 +--- a/components/remote_cocoa/app_shim/views_nswindow_delegate.mm ++++ b/components/remote_cocoa/app_shim/views_nswindow_delegate.mm +@@ -153,6 +153,11 @@ - (void)setAspectRatio:(float)aspectRatio + _excludedMargin = excludedMargin; + } + ++- (void)clearAspectRatio { ++ _aspectRatio.reset(); ++ _excludedMargin = gfx::Size(); ++} ++ + - (NSSize)windowWillResize:(NSWindow*)window toSize:(NSSize)size { + const NSRect windowFrame = [window frame]; + if (_aspectRatio) { +diff --git a/components/remote_cocoa/browser/application_host.h b/components/remote_cocoa/browser/application_host.h +index c4f1bd2e8d646306ae2c80100aba01085777a0fc..d5d4d778fb1190eb22b4933632ad9d3a44c83141 100644 +--- a/components/remote_cocoa/browser/application_host.h ++++ b/components/remote_cocoa/browser/application_host.h +@@ -7,6 +7,7 @@ + + #include "base/observer_list.h" + #include "base/observer_list_types.h" ++#include "base/process/process_handle.h" + #include "components/remote_cocoa/browser/remote_cocoa_browser_export.h" + #include "components/remote_cocoa/common/application.mojom.h" + #include "mojo/public/cpp/bindings/associated_remote.h" +@@ -42,9 +43,16 @@ class REMOTE_COCOA_BROWSER_EXPORT ApplicationHost { + + const std::string& bundle_id() const { return bundle_id_; } + ++ // The process that hosts the application, when the embedder knows it. ++ // Activation is then yielded to that process, and not to whichever running ++ // app declares |bundle_id|. ++ void set_pid(base::ProcessId pid) { pid_ = pid; } ++ base::ProcessId pid() const { return pid_; } ++ + private: + mojo::AssociatedRemote application_remote_; + const std::string bundle_id_; ++ base::ProcessId pid_ = base::kNullProcessId; + base::ObserverList observers_; + }; + +diff --git a/components/remote_cocoa/common/native_widget_ns_window.mojom b/components/remote_cocoa/common/native_widget_ns_window.mojom +index 8ca5673a225b69b5630383ed9673653ba03a0e97..f650180f02ef8c5c92421a5bec35f1b13355e533 100644 +--- a/components/remote_cocoa/common/native_widget_ns_window.mojom ++++ b/components/remote_cocoa/common/native_widget_ns_window.mojom +@@ -291,6 +291,7 @@ interface NativeWidgetNSWindow { + // Presumably, (10, 15) of this area would be used to draw client-drawn + // decorations, while the remaining (30, 30) would be used for whatever + // content is supposed to have a 1.f aspect-ratio. ++ // An empty |aspect_ratio| removes the restriction. + SetAspectRatio( + gfx.mojom.SizeF aspect_ratio, gfx.mojom.Size excluded_margin); + +@@ -303,6 +304,18 @@ interface NativeWidgetNSWindow { + // Ignore or accept mouse events on the NSWindow. + SetIgnoresMouseEvents(bool ignores_mouse_events); + ++ // Set whether the window can be moved by dragging it. ++ SetMovable(bool movable); ++ ++ // Set whether the window has a close button and a minimize button that ++ // work. ++ SetClosable(bool closable); ++ SetMiniaturizable(bool miniaturizable); ++ ++ // Set whether the window can go full screen, whatever the size constraints ++ // say; without this call a window that can resize and maximize can. ++ SetFullscreenable(bool fullscreenable); ++ + // Make the content view be the first responder for the NSWindow. + MakeFirstResponder(); + +diff --git a/ui/views/cocoa/native_widget_mac_ns_window_host.mm b/ui/views/cocoa/native_widget_mac_ns_window_host.mm +index 0d796595adc97607f9c84990123d8a60a84d9ec8..58b73d061e17ce46c6a714e42ad9ed0190f2d328 100644 +--- a/ui/views/cocoa/native_widget_mac_ns_window_host.mm ++++ b/ui/views/cocoa/native_widget_mac_ns_window_host.mm +@@ -907,8 +907,17 @@ void HandleAccelerator(const ui::Accelerator& accelerator, + if (@available(macOS 14, *)) { + if (application_host_ && + new_state == WindowVisibilityState::kShowAndActivateWindow) { +- [NSApp yieldActivationToApplicationWithBundleIdentifier: +- base::SysUTF8ToNSString(application_host_->bundle_id())]; ++ NSRunningApplication* application = nil; ++ if (application_host_->pid() != base::kNullProcessId) { ++ application = [NSRunningApplication ++ runningApplicationWithProcessIdentifier:application_host_->pid()]; ++ } ++ if (application) { ++ [NSApp yieldActivationToApplication:application]; ++ } else { ++ [NSApp yieldActivationToApplicationWithBundleIdentifier: ++ base::SysUTF8ToNSString(application_host_->bundle_id())]; ++ } + } + } + diff --git a/script/zip_manifests/dist_zip.mac.arm64.manifest b/script/zip_manifests/dist_zip.mac.arm64.manifest index 7b52d6bad7..de2bba7bb6 100644 --- a/script/zip_manifests/dist_zip.mac.arm64.manifest +++ b/script/zip_manifests/dist_zip.mac.arm64.manifest @@ -10,6 +10,7 @@ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/ +Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/app_shim_loader Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/chrome_crashpad_handler Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib diff --git a/script/zip_manifests/dist_zip.mac.x64.manifest b/script/zip_manifests/dist_zip.mac.x64.manifest index e27a6ba3c9..36e55cbdd9 100644 --- a/script/zip_manifests/dist_zip.mac.x64.manifest +++ b/script/zip_manifests/dist_zip.mac.x64.manifest @@ -10,6 +10,7 @@ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/ +Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/app_shim_loader Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Helpers/chrome_crashpad_handler Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/ Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib diff --git a/shell/app/electron_library_main.h b/shell/app/electron_library_main.h index 25e8a73f09..a320ce04a0 100644 --- a/shell/app/electron_library_main.h +++ b/shell/app/electron_library_main.h @@ -6,6 +6,7 @@ #define ELECTRON_SHELL_APP_ELECTRON_LIBRARY_MAIN_H_ #include "build/build_config.h" +#include "electron/mas.h" #if BUILDFLAG(IS_MAC) extern "C" { @@ -13,6 +14,14 @@ __attribute__((visibility("default"))) int ElectronMain(int argc, char* argv[]); __attribute__((visibility("default"))) int ElectronInitializeICUandStartNode( int argc, char* argv[]); +#if !IS_MAS_BUILD() +// The entry point of an app shim: a small app, in a bundle of its own, that +// loads this framework from a host Electron app and shows windows on the +// host's behalf. See docs/api/app-shim-host.md. Mac App Store builds have +// neither this nor the loader that calls it. +__attribute__((visibility("default"))) int ElectronAppShimMain(int argc, + char* argv[]); +#endif } #endif diff --git a/shell/app/electron_library_main.mm b/shell/app/electron_library_main.mm index db0109db42..9b43b2500f 100644 --- a/shell/app/electron_library_main.mm +++ b/shell/app/electron_library_main.mm @@ -7,18 +7,26 @@ #include "shell/app/electron_library_main.h" +#include + #include "base/apple/bundle_locations.h" #include "base/apple/scoped_nsautorelease_pool.h" #include "base/at_exit.h" +#include "base/check.h" #include "base/command_line.h" #include "base/compiler_specific.h" +#include "base/files/file_path.h" +#include "base/files/file_util.h" #include "base/i18n/icu_util.h" #include "base/notreached.h" #include "content/public/app/content_main.h" #include "electron/fuses.h" +#include "electron/mas.h" #include "shell/app/electron_main_delegate.h" #include "shell/app/electron_prefetch_mac.h" #include "shell/app/node_main.h" +#include "shell/app_shim/app_shim_main_delegate.h" +#include "shell/common/app_shim_constants.h" #include "shell/common/electron_command_line.h" #include "shell/common/mac/main_application_bundle.h" #include "shell/common/uv_includes.h" @@ -34,6 +42,29 @@ bool HasProcessTypeSwitch(int argc, char* argv[]) { return false; } +#if !IS_MAS_BUILD() +// The .app that holds the framework this code was loaded from: the app shim's +// host. Empty when the framework is not inside an app bundle. +base::FilePath HostBundlePathFromLoadedFramework() { + Dl_info info; + if (!dladdr(reinterpret_cast(&ElectronAppShimMain), &info) || + !info.dli_fname) { + return {}; + } + base::FilePath path = + base::MakeAbsoluteFilePath(base::FilePath(info.dli_fname)); + // .app/Contents/Frameworks/.framework/Versions/A/ + while (!path.empty() && path != path.DirName() && + path.Extension() != ".framework") { + path = path.DirName(); + } + if (path.Extension() != ".framework") + return {}; + path = path.DirName().DirName().DirName(); + return path.Extension() == ".app" ? path : base::FilePath(); +} +#endif // !IS_MAS_BUILD() + } // namespace int ElectronMain(int argc, char* argv[]) { @@ -76,3 +107,26 @@ int ElectronInitializeICUandStartNode(int argc, char* argv[]) { base::i18n::InitializeICU(); return electron::NodeMain(); } + +#if !IS_MAS_BUILD() +int ElectronAppShimMain(int argc, char* argv[]) { + const base::FilePath host_bundle_path = HostBundlePathFromLoadedFramework(); + CHECK(!host_bundle_path.empty()) + << "An app shim must load the framework from inside its host app"; + + // The main bundle is the shim's; resources come from the host's. + base::apple::SetOverrideOuterBundlePath(host_bundle_path); + base::apple::SetOverrideFrameworkBundlePath( + host_bundle_path.Append("Contents") + .Append("Frameworks") + .Append(ELECTRON_PRODUCT_NAME " Framework.framework")); + + base::CommandLine::Init(argc, argv); + base::CommandLine::ForCurrentProcess()->AppendSwitchASCII( + "type", electron::app_shim::kProcessType); + + electron::AppShimMainDelegate delegate(host_bundle_path); + content::ContentMainParams params{&delegate}; + return content::ContentMain(std::move(params)); +} +#endif // !IS_MAS_BUILD() diff --git a/shell/app_shim/app_shim_application.h b/shell/app_shim/app_shim_application.h new file mode 100644 index 0000000000..a0a62a81c4 --- /dev/null +++ b/shell/app_shim/app_shim_application.h @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_APP_SHIM_APP_SHIM_APPLICATION_H_ +#define ELECTRON_SHELL_APP_SHIM_APP_SHIM_APPLICATION_H_ + +#import + +#import "components/remote_cocoa/app_shim/cr_application.h" + +// The NSApplication of an app shim process. It notices assistive technologies +// (VoiceOver, Accessibility Inspector, UI scripting) and has the host app turn +// accessibility on, because the accessibility tree of the windows shown here +// is built there. +@interface ElectronAppShimApplication : CrApplication +@end + +#endif // ELECTRON_SHELL_APP_SHIM_APP_SHIM_APPLICATION_H_ diff --git a/shell/app_shim/app_shim_application.mm b/shell/app_shim/app_shim_application.mm new file mode 100644 index 0000000000..8f444ce209 --- /dev/null +++ b/shell/app_shim/app_shim_application.mm @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#import "shell/app_shim/app_shim_application.h" + +#include "shell/app_shim/app_shim_controller.h" + +namespace { + +// A screen reader sets AXEnhancedUserInterface as it starts, and other apps +// set and clear it in passing; only one that keeps it set gets web content. +constexpr NSTimeInterval kCompleteModeDelay = 2.0; + +void EnableAccessibilitySupport( + electron::mojom::AppShimAccessibilityMode mode) { + if (auto* controller = electron::AppShimController::Get()) + controller->EnableAccessibilitySupport(mode); +} + +} // namespace + +@implementation ElectronAppShimApplication + +// Every way of quitting the app comes through here: its menu, the Dock, and +// the quit event macOS sends at logout. The host is asked first, without +// blocking: this can be running inside a task of the message loop (a key the +// host sent back to be handled as a shortcut), where waiting for an answer in +// a nested run loop would never see it. Returning does not cancel a logout, +// which waits for the app to exit. +- (void)terminate:(id)sender { + auto* controller = electron::AppShimController::Get(); + if (controller && !controller->ShouldTerminate()) + return; + [super terminate:sender]; +} + +- (void)enableCompleteAccessibility { + EnableAccessibilitySupport( + electron::mojom::AppShimAccessibilityMode::kComplete); +} + +- (void)accessibilitySetValue:(id)value forAttribute:(NSString*)attribute { + // AXEnhancedUserInterface is what screen readers set; AXManualAccessibility + // is what Electron documents for other apps to ask for accessibility. + const bool is_manual = [attribute isEqualToString:@"AXManualAccessibility"]; + if (is_manual || [attribute isEqualToString:@"AXEnhancedUserInterface"]) { + [NSObject + cancelPreviousPerformRequestsWithTarget:self + selector:@selector( + enableCompleteAccessibility) + object:nil]; + if ([value boolValue]) { + [self performSelector:@selector(enableCompleteAccessibility) + withObject:nil + afterDelay:is_manual ? 0 : kCompleteModeDelay]; + } + if (is_manual) + return; + } + [super accessibilitySetValue:value forAttribute:attribute]; +} + +- (NSArray*)accessibilityAttributeNames { + return [[super accessibilityAttributeNames] + arrayByAddingObject:@"AXManualAccessibility"]; +} + +- (BOOL)accessibilityIsAttributeSettable:(NSString*)attribute { + return [attribute isEqualToString:@"AXManualAccessibility"] || + [super accessibilityIsAttributeSettable:attribute]; +} + +- (NSAccessibilityRole)accessibilityRole { + // Something is reading this app's accessibility tree. + EnableAccessibilitySupport( + electron::mojom::AppShimAccessibilityMode::kPartial); + return [super accessibilityRole]; +} + +@end diff --git a/shell/app_shim/app_shim_controller.h b/shell/app_shim/app_shim_controller.h new file mode 100644 index 0000000000..70f4b359af --- /dev/null +++ b/shell/app_shim/app_shim_controller.h @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_APP_SHIM_APP_SHIM_CONTROLLER_H_ +#define ELECTRON_SHELL_APP_SHIM_APP_SHIM_CONTROLLER_H_ + +#import + +#include +#include +#include +#include + +#include "base/files/file_path.h" +#include "base/memory/weak_ptr.h" +#include "base/time/time.h" +#include "base/timer/timer.h" +#include "electron/shell/common/app_shim.mojom.h" +#include "mojo/public/cpp/bindings/pending_associated_receiver.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "mojo/public/cpp/platform/platform_channel_endpoint.h" +#include "mojo/public/cpp/system/isolated_connection.h" +#include "shell/app_shim/app_shim_menu.h" + +class GURL; + +namespace display { +class ScopedNativeScreen; +} // namespace display + +@class ElectronAppShimDelegate; + +@class ElectronMenuController; + +namespace electron { + +// Runs an app shim process: finds (or starts) the host Electron app, connects +// to it, and then serves the windows the host assigns to this process. +class AppShimController : public mojom::AppShim, private AppShimMenu::Client { + public: + explicit AppShimController(const base::FilePath& host_bundle_path); + ~AppShimController() override; + + // The process's one controller, or null before it is made. + static AppShimController* Get(); + + AppShimController(const AppShimController&) = delete; + AppShimController& operator=(const AppShimController&) = delete; + + // From the NSApplication delegate. + void OnReopen(bool has_visible_windows); + void OnOpenFiles(std::vector files); + void OnOpenUrls(std::vector urls); + void OnDidBecomeActive(); + void OnNewWindowForTab(uint64_t window_id); + // From -[NSApplication terminate:]. False when the host was asked instead; + // Quit(), or no answer in time, then terminates the app. + bool ShouldTerminate(); + // From the NSApplication. Each mode is asked for at most once. + void EnableAccessibilitySupport(mojom::AppShimAccessibilityMode mode); + + private: + // The name the host publishes its bootstrap server under. + std::string ServerName() const; + bool IsHostRunning() const; + void LaunchHost(); + void PollForHost(); + // Hands the host, whose bootstrap server `server_endpoint` is, one end of + // a new channel; invalid when that fails. + static mojo::PlatformChannelEndpoint ConnectToHost( + mojo::PlatformChannelEndpoint server_endpoint); + // The host is running another build of Electron than this process loaded. + // Tells the host, which tells the person, and quits. + void OnBuildMismatch(mojo::PlatformChannelEndpoint server_endpoint); + void OnConnected(mojo::PlatformChannelEndpoint endpoint); + void OnShimConnectedResponse( + mojo::PendingReceiver app_shim_receiver); + void Terminate(); + void OnQuitAnswerTimeout(); + void SetUpMenu(); + + // AppShimMenu::Client: + void OnMenuItemActivated(const std::vector& path, + int command_id, + int event_flags) override; + void OnMenuWillShow() override; + void OnMenuWillClose() override; + + // mojom::AppShim: + void CreateRemoteCocoaApplication( + mojo::PendingAssociatedReceiver + application) override; + void SetBadgeLabel(const std::string& label) override; + void SetUserAttention(mojom::AppShimAttentionType type) override; + void Quit() override; + void CancelQuit() override; + void SetApplicationMenu( + std::optional> items) override; + void InitWindowChrome(uint64_t window_id, + mojom::AppShimWindowChromePtr chrome) override; + void SetWindowButtonVisibility(uint64_t window_id, bool visible) override; + void SetWindowButtonPosition( + uint64_t window_id, + const std::optional& position) override; + void SetWindowVibrancy(uint64_t window_id, + const std::string& type, + int32_t duration_ms, + int32_t state) override; + void PerformWindowTabAction(uint64_t window_id, + mojom::AppShimTabAction action) override; + void AddTabbedWindow(uint64_t window_id, uint64_t tabbed_window_id) override; + void PreviewFile(uint64_t window_id, + const base::FilePath& path, + const std::string& display_name) override; + void CloseFilePreview(uint64_t window_id) override; + void ShowMessageBox(uint64_t window_id, + mojom::AppShimMessageBoxOptionsPtr options, + ShowMessageBoxCallback callback) override; + void CloseMessageBox(int32_t id) override; + void ShowOpenDialog(uint64_t window_id, + mojom::AppShimFileDialogOptionsPtr options, + ShowOpenDialogCallback callback) override; + void ShowSaveDialog(uint64_t window_id, + mojom::AppShimFileDialogOptionsPtr options, + ShowSaveDialogCallback callback) override; + + const base::FilePath host_bundle_path_; + ElectronAppShimDelegate* __strong delegate_; + // The windows this process hosts ask it about the displays. + std::unique_ptr screen_; + + base::TimeTicks poll_deadline_; + bool launched_host_ = false; + // The host's menu, while it has given the shim one. The controller only + // refers to the menu's model. + std::unique_ptr menu_; + ElectronMenuController* __strong menu_controller_; + + bool terminating_ = false; + // The host has been asked whether the shim may quit. + bool waiting_for_quit_answer_ = false; + base::OneShotTimer quit_answer_timer_; + bool asked_for_partial_accessibility_ = false; + bool asked_for_complete_accessibility_ = false; + NSInteger attention_request_id_ = 0; + + mojo::IsolatedConnection bootstrap_connection_; + mojo::Remote host_bootstrap_; + mojo::Receiver shim_receiver_{this}; + mojo::Remote host_; + // Calls on `host_` made before the host binds this are delivered once it + // does. + mojo::PendingReceiver host_receiver_; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_APP_SHIM_APP_SHIM_CONTROLLER_H_ diff --git a/shell/app_shim/app_shim_controller.mm b/shell/app_shim/app_shim_controller.mm new file mode 100644 index 0000000000..53f14a8d0a --- /dev/null +++ b/shell/app_shim/app_shim_controller.mm @@ -0,0 +1,744 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/app_shim/app_shim_controller.h" + +#import +#include + +#include + +#include "base/apple/bundle_locations.h" +#include "base/apple/foundation_util.h" +#include "base/apple/mach_logging.h" +#include "base/apple/scoped_mach_port.h" +#include "base/functional/bind.h" +#include "base/logging.h" +#include "base/mac/scoped_mach_msg_destroy.h" +#include "base/memory/raw_ptr.h" +#include "base/strings/strcat.h" +#include "base/strings/sys_string_conversions.h" +#include "base/task/single_thread_task_runner.h" +#include "components/remote_cocoa/app_shim/application_bridge.h" +#import "components/remote_cocoa/app_shim/native_widget_mac_nswindow.h" +#include "components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h" +#include "content/public/browser/remote_cocoa.h" +#include "mojo/public/cpp/platform/named_platform_channel.h" +#include "mojo/public/cpp/platform/platform_channel.h" +#import "shell/app_shim/app_shim_window.h" +#import "shell/browser/ui/cocoa/electron_menu_controller.h" +#include "shell/browser/ui/file_dialog.h" +#include "shell/browser/ui/message_box.h" +#include "shell/common/app_shim_constants.h" +#include "ui/accelerated_widget_mac/window_resize_helper_mac.h" +#include "ui/display/screen.h" +#include "ui/gfx/native_ui_types.h" +#include "url/gurl.h" + +namespace { + +// How long a shim waits for the host app to start listening. +constexpr base::TimeDelta kPollTimeout = base::Seconds(60); +constexpr base::TimeDelta kPollPeriod = base::Milliseconds(100); +// How long the host has to answer a quit request before the shim quits anyway, +// so that a host that is busy or hung cannot hold up a logout. +constexpr base::TimeDelta kQuitAnswerTimeout = base::Seconds(5); +// How long the host has to take note of a build mismatch before the shim +// quits without knowing that it has. +constexpr base::TimeDelta kBuildMismatchAcknowledgementTimeout = + base::Seconds(2); + +NSString* InfoPlistString(const char* key) { + id value = [NSBundle.mainBundle + objectForInfoDictionaryKey:base::SysUTF8ToNSString(key)]; + return base::apple::ObjCCast(value); +} + +NSString* AppName() { + return base::apple::ObjCCast([NSBundle.mainBundle + objectForInfoDictionaryKey:@"CFBundleDisplayName"]) + ?: base::apple::ObjCCast([NSBundle.mainBundle + objectForInfoDictionaryKey:@"CFBundleName"]) + ?: NSProcessInfo.processInfo.processName; +} + +} // namespace + +@interface ElectronAppShimDelegate : NSObject +- (instancetype)initWithController:(electron::AppShimController*)controller; +- (void)controllerDestroyed; +@end + +@implementation ElectronAppShimDelegate { + raw_ptr _controller; +} + +- (instancetype)initWithController:(electron::AppShimController*)controller { + if ((self = [super init])) { + _controller = controller; + } + return self; +} + +- (void)controllerDestroyed { + _controller = nullptr; +} + +// The "+" of a window's tab bar. AppKit shows it when something in the +// responder chain answers to this, and the window's own delegate is +// remote_cocoa's. +- (void)newWindowForTab:(id)sender { + NSWindow* window = NSApp.keyWindow ?: NSApp.mainWindow; + if (!_controller || ![window isKindOfClass:[NativeWidgetMacNSWindow class]]) { + return; + } + _controller->OnNewWindowForTab( + static_cast(window).bridgedNativeWidgetId); +} + +// The Quick Look panel asks the responder chain who fills it, which ends +// here. It is the key window's, if the host asked for a preview there. +- (ElectronAppShimWindow*)windowForPreviewPanel { + ElectronAppShimWindow* window = + [ElectronAppShimWindow existingForWindow:NSApp.mainWindow]; + return [window hasPreviewItem] ? window : nil; +} + +- (BOOL)acceptsPreviewPanelControl:(QLPreviewPanel*)panel { + return [self windowForPreviewPanel] != nil; +} + +- (void)beginPreviewPanelControl:(QLPreviewPanel*)panel { + ElectronAppShimWindow* window = [self windowForPreviewPanel]; + panel.dataSource = window; + panel.delegate = window; +} + +- (void)endPreviewPanelControl:(QLPreviewPanel*)panel { + panel.dataSource = nil; + panel.delegate = nil; +} + +- (void)applicationDidBecomeActive:(NSNotification*)notification { + if (_controller) + _controller->OnDidBecomeActive(); +} + +- (BOOL)applicationShouldHandleReopen:(NSApplication*)sender + hasVisibleWindows:(BOOL)flag { + if (_controller) + _controller->OnReopen(flag); + return YES; +} + +- (void)application:(NSApplication*)sender + openFiles:(NSArray*)filenames { + std::vector files; + for (NSString* filename in filenames) + files.push_back(base::apple::NSStringToFilePath(filename)); + if (_controller) + _controller->OnOpenFiles(std::move(files)); + [sender replyToOpenOrPrint:NSApplicationDelegateReplySuccess]; +} + +- (void)application:(NSApplication*)application + openURLs:(NSArray*)urls { + std::vector files; + std::vector other; + for (NSURL* url in urls) { + if (url.fileURL) { + files.push_back(base::apple::NSURLToFilePath(url)); + } else if (GURL gurl(base::SysNSStringToUTF8(url.absoluteString)); + gurl.is_valid()) { + other.push_back(std::move(gurl)); + } + } + if (!_controller) + return; + if (!files.empty()) + _controller->OnOpenFiles(std::move(files)); + if (!other.empty()) + _controller->OnOpenUrls(std::move(other)); +} + +- (BOOL)applicationSupportsSecureRestorableState:(NSApplication*)app { + return YES; +} + +@end + +namespace electron { + +namespace { + +AppShimController* g_controller = nullptr; + +} // namespace + +// static +AppShimController* AppShimController::Get() { + return g_controller; +} + +AppShimController::AppShimController(const base::FilePath& host_bundle_path) + : host_bundle_path_(host_bundle_path), + host_receiver_(host_.BindNewPipeAndPassReceiver()) { + g_controller = this; + screen_ = std::make_unique(); + delegate_ = [[ElectronAppShimDelegate alloc] initWithController:this]; + NSApp.delegate = delegate_; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + // The host decides which shims run; macOS must not bring this one back at + // login by itself. + [NSApp disableRelaunchOnLogin]; + SetUpMenu(); + + poll_deadline_ = base::TimeTicks::Now() + kPollTimeout; + PollForHost(); +} + +AppShimController::~AppShimController() { + g_controller = nullptr; + [delegate_ controllerDestroyed]; + NSApp.delegate = nil; +} + +std::string AppShimController::ServerName() const { + // Mirrors ElectronMainDelegate::SetUpBundleOverrides in the host. + NSBundle* host = base::apple::OuterBundle(); + std::string base_bundle_id = base::SysNSStringToUTF8(host.bundleIdentifier); + if (NSString* team_id = base::apple::ObjCCast( + [host objectForInfoDictionaryKey:@"ElectronTeamID"])) { + base_bundle_id = + base::StrCat({base::SysNSStringToUTF8(team_id), ".", base_bundle_id}); + } + NSString* name = InfoPlistString(app_shim::kInfoPlistServerNameKey); + return base::StrCat({base_bundle_id, ".", + name.length ? base::SysNSStringToUTF8(name) + : app_shim::kDefaultServerName}); +} + +bool AppShimController::IsHostRunning() const { + NSString* bundle_id = base::apple::OuterBundle().bundleIdentifier; + if (!bundle_id) + return false; + NSString* want = base::apple::FilePathToNSString(host_bundle_path_) + .stringByResolvingSymlinksInPath; + for (NSRunningApplication* app in [NSRunningApplication + runningApplicationsWithBundleIdentifier:bundle_id]) { + if (!app.terminated && [app.bundleURL.path.stringByResolvingSymlinksInPath + isEqualToString:want]) { + return true; + } + } + return false; +} + +void AppShimController::LaunchHost() { + launched_host_ = true; + NSWorkspaceOpenConfiguration* configuration = + [NSWorkspaceOpenConfiguration configuration]; + configuration.activates = NO; + configuration.addsToRecentItems = NO; + NSMutableArray* arguments = [NSMutableArray array]; + for (id argument in base::apple::ObjCCast([NSBundle.mainBundle + objectForInfoDictionaryKey: + base::SysUTF8ToNSString( + app_shim::kInfoPlistHostArgumentsKey)])) { + if (NSString* string = base::apple::ObjCCast(argument)) + [arguments addObject:string]; + } + configuration.arguments = arguments; + [NSWorkspace.sharedWorkspace + openApplicationAtURL:base::apple::FilePathToNSURL(host_bundle_path_) + configuration:configuration + completionHandler:^(NSRunningApplication* app, NSError* error) { + if (error) { + LOG(ERROR) << "Could not start the host app: " + << base::SysNSStringToUTF8(error.localizedDescription); + } + }]; +} + +void AppShimController::PollForHost() { + const std::string server_name = ServerName(); + if (mojo::PlatformChannelEndpoint server = + mojo::NamedPlatformChannel::ConnectToServer(server_name); + server.is_valid()) { + // The host is listening. It is only worth connecting to when it runs the + // build of Electron that this process loaded. + if (!mojo::NamedPlatformChannel::ConnectToServer( + app_shim::BuildMarkerServerName(server_name)) + .is_valid()) { + OnBuildMismatch(std::move(server)); + return; + } + if (mojo::PlatformChannelEndpoint endpoint = + ConnectToHost(std::move(server)); + endpoint.is_valid()) { + OnConnected(std::move(endpoint)); + return; + } + } + + if (!launched_host_ && !IsHostRunning()) + LaunchHost(); + + if (base::TimeTicks::Now() > poll_deadline_) { + LOG(ERROR) << "The host app did not start listening for app shims."; + Terminate(); + return; + } + base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask( + FROM_HERE, + base::BindOnce(&AppShimController::PollForHost, + weak_factory_.GetWeakPtr()), + kPollPeriod); +} + +// static +mojo::PlatformChannelEndpoint AppShimController::ConnectToHost( + mojo::PlatformChannelEndpoint server_endpoint) { + // One bootstrap server takes connections from every shim, so each shim makes + // a channel of its own and sends the host one end of it in a raw Mach + // message. Both sides then make an isolated Mojo connection over it. + mojo::PlatformChannel channel; + mach_msg_base_t message{}; + base::ScopedMachMsgDestroy scoped_message(&message.header); + message.header.msgh_id = app_shim::kBootstrapMsgId; + message.header.msgh_bits = + MACH_MSGH_BITS(MACH_MSG_TYPE_MOVE_SEND, MACH_MSG_TYPE_MOVE_SEND); + message.header.msgh_size = sizeof(message); + message.header.msgh_local_port = + channel.TakeLocalEndpoint().TakePlatformHandle().ReleaseMachSendRight(); + message.header.msgh_remote_port = + server_endpoint.TakePlatformHandle().ReleaseMachSendRight(); + kern_return_t kr = mach_msg_send(&message.header); + if (kr != KERN_SUCCESS) { + MACH_LOG(ERROR, kr) << "mach_msg_send"; + return mojo::PlatformChannelEndpoint(); + } + scoped_message.Disarm(); + return channel.TakeRemoteEndpoint(); +} + +void AppShimController::OnBuildMismatch( + mojo::PlatformChannelEndpoint server_endpoint) { + LOG(ERROR) << "The host app is running another build of Electron than the " + "one on disk; it has to be restarted before this app can " + "connect to it."; + + // Telling the person is the host's to do, in its own words: it gets an + // event for this message. It finds out who sent the message from the + // sender's audit token, which needs this process to still be there, so the + // host is given a moment to say that it has done so before this process + // quits. It says so by letting go of the reply right. + base::apple::ScopedMachReceiveRight reply; + if (!base::apple::CreateMachPort(&reply, nullptr)) { + Terminate(); + return; + } + mach_msg_base_t message{}; + base::ScopedMachMsgDestroy scoped_message(&message.header); + message.header.msgh_id = app_shim::kBuildMismatchMsgId; + message.header.msgh_bits = + MACH_MSGH_BITS(MACH_MSG_TYPE_MOVE_SEND, MACH_MSG_TYPE_MAKE_SEND_ONCE); + message.header.msgh_size = sizeof(message); + message.header.msgh_remote_port = + server_endpoint.TakePlatformHandle().ReleaseMachSendRight(); + message.header.msgh_local_port = reply.get(); + if (kern_return_t kr = mach_msg_send(&message.header); kr != KERN_SUCCESS) { + MACH_LOG(ERROR, kr) << "mach_msg_send"; + Terminate(); + return; + } + scoped_message.Disarm(); + + struct { + mach_msg_header_t header; + mach_msg_trailer_t trailer; + } acknowledgement{}; + mach_msg(&acknowledgement.header, MACH_RCV_MSG | MACH_RCV_TIMEOUT, 0, + sizeof(acknowledgement), reply.get(), + kBuildMismatchAcknowledgementTimeout.InMilliseconds(), + MACH_PORT_NULL); + Terminate(); +} + +void AppShimController::OnConnected(mojo::PlatformChannelEndpoint endpoint) { + mojo::ScopedMessagePipeHandle message_pipe = + bootstrap_connection_.Connect(std::move(endpoint)); + CHECK(message_pipe.is_valid()); + host_bootstrap_.Bind(mojo::PendingRemote( + std::move(message_pipe), 0)); + host_bootstrap_.set_disconnect_handler( + base::BindOnce(&AppShimController::Terminate, base::Unretained(this))); + host_bootstrap_->OnShimConnected( + std::move(host_receiver_), + base::BindOnce(&AppShimController::OnShimConnectedResponse, + base::Unretained(this))); +} + +void AppShimController::OnShimConnectedResponse( + mojo::PendingReceiver app_shim_receiver) { + if (!app_shim_receiver.is_valid()) { + LOG(ERROR) << "The host app turned this app shim away."; + Terminate(); + return; + } + // The pipe every remote_cocoa interface is associated with. It is read on + // this runner so that a window resize can wait for the host's frames. + shim_receiver_.Bind(std::move(app_shim_receiver), + ui::WindowResizeHelperMac::Get()->task_runner()); + shim_receiver_.set_disconnect_handler( + base::BindOnce(&AppShimController::Terminate, base::Unretained(this))); + host_bootstrap_.reset(); +} + +void AppShimController::Terminate() { + terminating_ = true; + waiting_for_quit_answer_ = false; + quit_answer_timer_.Stop(); + [NSApp terminate:nil]; +} + +void AppShimController::OnQuitAnswerTimeout() { + LOG(WARNING) << "The host app did not answer the quit request; quitting."; + Terminate(); +} + +void AppShimController::OnReopen(bool has_visible_windows) { + host_->ReopenApp(has_visible_windows); +} + +void AppShimController::OnOpenFiles(std::vector files) { + host_->FilesOpened(files); +} + +void AppShimController::OnOpenUrls(std::vector urls) { + host_->UrlsOpened(urls); +} + +bool AppShimController::ShouldTerminate() { + if (terminating_ || !shim_receiver_.is_bound()) + return true; + if (!waiting_for_quit_answer_) { + waiting_for_quit_answer_ = true; + host_->QuitRequested(); + quit_answer_timer_.Start(FROM_HERE, kQuitAnswerTimeout, this, + &AppShimController::OnQuitAnswerTimeout); + } + return false; +} + +void AppShimController::EnableAccessibilitySupport( + mojom::AppShimAccessibilityMode mode) { + bool& asked = mode == mojom::AppShimAccessibilityMode::kComplete + ? asked_for_complete_accessibility_ + : asked_for_partial_accessibility_; + if (asked || asked_for_complete_accessibility_) + return; + asked = true; + host_->EnableAccessibilitySupport(mode); +} + +void AppShimController::CreateRemoteCocoaApplication( + mojo::PendingAssociatedReceiver + application) { + remote_cocoa::ApplicationBridge::Get()->BindReceiver(std::move(application)); + remote_cocoa::ApplicationBridge::Get()->SetContentNSViewCreateCallbacks( + base::BindRepeating( + [](uint64_t view_id, mojo::ScopedInterfaceEndpointHandle host_handle, + mojo::ScopedInterfaceEndpointHandle view_request_handle) { + remote_cocoa::CreateRenderWidgetHostNSView( + view_id, std::move(host_handle), std::move(view_request_handle), + remote_cocoa::RenderWidgetHostViewMacDelegateCallback()); + }), + base::BindRepeating(remote_cocoa::CreateWebContentsNSView)); +} + +void AppShimController::SetBadgeLabel(const std::string& label) { + NSApp.dockTile.badgeLabel = + label.empty() ? nil : base::SysUTF8ToNSString(label); +} + +void AppShimController::SetUserAttention(mojom::AppShimAttentionType type) { + switch (type) { + case mojom::AppShimAttentionType::kCancel: + [NSApp cancelUserAttentionRequest:attention_request_id_]; + break; + case mojom::AppShimAttentionType::kInformational: + attention_request_id_ = + [NSApp requestUserAttention:NSInformationalRequest]; + break; + case mojom::AppShimAttentionType::kCritical: + attention_request_id_ = [NSApp requestUserAttention:NSCriticalRequest]; + break; + } +} + +void AppShimController::Quit() { + Terminate(); +} + +namespace { + +// The NSWindow that the host knows as `window_id`, if it is still there. +gfx::NativeWindow WindowForId(uint64_t window_id) { + auto* bridge = remote_cocoa::NativeWidgetNSWindowBridge::GetFromId(window_id); + return gfx::NativeWindow(bridge ? bridge->ns_window() : nil); +} + +file_dialog::DialogSettings FromMojo( + const mojom::AppShimFileDialogOptions& options) { + file_dialog::DialogSettings settings; + settings.title = options.title; + settings.message = options.message; + settings.button_label = options.button_label; + settings.name_field_label = options.name_field_label; + settings.default_path = options.default_path; + for (const auto& filter : options.filters) + settings.filters.emplace_back(filter->name, filter->extensions); + settings.properties = options.properties; + settings.shows_tag_field = options.shows_tag_field; + return settings; +} + +} // namespace + +void AppShimController::InitWindowChrome(uint64_t window_id, + mojom::AppShimWindowChromePtr chrome) { + [[ElectronAppShimWindow forWindowId:window_id] initChrome:*chrome]; +} + +void AppShimController::SetWindowButtonVisibility(uint64_t window_id, + bool visible) { + [[ElectronAppShimWindow forWindowId:window_id] setButtonVisibility:visible]; +} + +void AppShimController::SetWindowButtonPosition( + uint64_t window_id, + const std::optional& position) { + [[ElectronAppShimWindow forWindowId:window_id] setButtonPosition:position]; +} + +void AppShimController::SetWindowVibrancy(uint64_t window_id, + const std::string& type, + int32_t duration_ms, + int32_t state) { + [[ElectronAppShimWindow forWindowId:window_id] + setVibrancy:type + duration:duration_ms + state:static_cast(state)]; +} + +void AppShimController::PerformWindowTabAction(uint64_t window_id, + mojom::AppShimTabAction action) { + [[ElectronAppShimWindow forWindowId:window_id] performTabAction:action]; +} + +void AppShimController::AddTabbedWindow(uint64_t window_id, + uint64_t tabbed_window_id) { + [[ElectronAppShimWindow forWindowId:window_id] + addTabbedWindow:[ElectronAppShimWindow forWindowId:tabbed_window_id]]; +} + +void AppShimController::PreviewFile(uint64_t window_id, + const base::FilePath& path, + const std::string& display_name) { + [[ElectronAppShimWindow forWindowId:window_id] + previewFile:base::apple::FilePathToNSURL(path) + title:base::SysUTF8ToNSString(display_name)]; +} + +void AppShimController::CloseFilePreview(uint64_t window_id) { + [[ElectronAppShimWindow forWindowId:window_id] closeFilePreview]; +} + +void AppShimController::OnNewWindowForTab(uint64_t window_id) { + if (shim_receiver_.is_bound()) + host_->NewWindowForTab(window_id); +} + +void AppShimController::ShowMessageBox( + uint64_t window_id, + mojom::AppShimMessageBoxOptionsPtr options, + ShowMessageBoxCallback callback) { + const gfx::NativeWindow window = WindowForId(window_id); + if (!window) { + std::move(callback).Run(options->cancel_id, false); + return; + } + MessageBoxSettings settings; + settings.type = static_cast(options->type); + settings.buttons = options->buttons; + settings.default_id = options->default_id; + settings.cancel_id = options->cancel_id; + settings.message = options->message; + settings.detail = options->detail; + settings.checkbox_label = options->checkbox_label; + settings.checkbox_checked = options->checkbox_checked; + settings.icon = options->icon; + settings.text_width = options->text_width; + if (options->has_id) + settings.id = options->id; + ShowMessageBoxForNSWindow(settings, window, std::move(callback)); +} + +void AppShimController::CloseMessageBox(int32_t id) { + electron::CloseMessageBox(id); +} + +void AppShimController::ShowOpenDialog( + uint64_t window_id, + mojom::AppShimFileDialogOptionsPtr options, + ShowOpenDialogCallback callback) { + const gfx::NativeWindow window = WindowForId(window_id); + if (!window) { + std::move(callback).Run(true, {}); + return; + } + file_dialog::ShowOpenDialogForNSWindow(FromMojo(*options), window, + std::move(callback)); +} + +void AppShimController::ShowSaveDialog( + uint64_t window_id, + mojom::AppShimFileDialogOptionsPtr options, + ShowSaveDialogCallback callback) { + const gfx::NativeWindow window = WindowForId(window_id); + if (!window) { + std::move(callback).Run(true, base::FilePath()); + return; + } + file_dialog::ShowSaveDialogForNSWindow(FromMojo(*options), window, + std::move(callback)); +} + +void AppShimController::SetApplicationMenu( + std::optional> items) { + if (!items) { + if (menu_) { + menu_controller_ = nil; + menu_.reset(); + SetUpMenu(); + } + return; + } + // The usual case is the same menu with another state, which the menu on + // screen takes from the model the next time it looks. + if (menu_ && menu_->Update(&*items)) + return; + + // The controller refers to the model, so it goes first. + menu_controller_ = nil; + menu_ = + std::make_unique(std::move(*items), std::vector(), + static_cast(this)); + menu_controller_ = + [[ElectronMenuController alloc] initWithModel:menu_->model() + useDefaultAccelerator:YES]; + [NSApp setMainMenu:[menu_controller_ menu]]; +} + +void AppShimController::OnMenuItemActivated(const std::vector& path, + int command_id, + int event_flags) { + if (shim_receiver_.is_bound()) + host_->MenuItemActivated(path, command_id, event_flags); +} + +void AppShimController::OnMenuWillShow() { + if (shim_receiver_.is_bound()) + host_->MenuWillShow(); +} + +void AppShimController::OnMenuWillClose() { + if (shim_receiver_.is_bound()) + host_->MenuWillClose(); +} + +void AppShimController::OnDidBecomeActive() { + if (shim_receiver_.is_bound()) + host_->DidBecomeActive(); +} + +void AppShimController::CancelQuit() { + waiting_for_quit_answer_ = false; + quit_answer_timer_.Stop(); +} + +void AppShimController::SetUpMenu() { + NSString* name = AppName(); + NSMenu* main_menu = [[NSMenu alloc] initWithTitle:@""]; + + NSMenuItem* app_item = [main_menu addItemWithTitle:name + action:nil + keyEquivalent:@""]; + NSMenu* app_menu = [[NSMenu alloc] initWithTitle:name]; + [app_menu addItemWithTitle:[NSString stringWithFormat:@"Hide %@", name] + action:@selector(hide:) + keyEquivalent:@"h"]; + NSMenuItem* hide_others = + [app_menu addItemWithTitle:@"Hide Others" + action:@selector(hideOtherApplications:) + keyEquivalent:@"h"]; + hide_others.keyEquivalentModifierMask = + NSEventModifierFlagCommand | NSEventModifierFlagOption; + [app_menu addItemWithTitle:@"Show All" + action:@selector(unhideAllApplications:) + keyEquivalent:@""]; + [app_menu addItem:[NSMenuItem separatorItem]]; + [app_menu addItemWithTitle:[NSString stringWithFormat:@"Quit %@", name] + action:@selector(terminate:) + keyEquivalent:@"q"]; + app_item.submenu = app_menu; + + NSMenuItem* edit_item = [main_menu addItemWithTitle:@"Edit" + action:nil + keyEquivalent:@""]; + NSMenu* edit_menu = [[NSMenu alloc] initWithTitle:@"Edit"]; + [edit_menu addItemWithTitle:@"Undo" + action:@selector(undo:) + keyEquivalent:@"z"]; + [edit_menu addItemWithTitle:@"Redo" + action:@selector(redo:) + keyEquivalent:@"Z"]; + [edit_menu addItem:[NSMenuItem separatorItem]]; + [edit_menu addItemWithTitle:@"Cut" action:@selector(cut:) keyEquivalent:@"x"]; + [edit_menu addItemWithTitle:@"Copy" + action:@selector(copy:) + keyEquivalent:@"c"]; + [edit_menu addItemWithTitle:@"Paste" + action:@selector(paste:) + keyEquivalent:@"v"]; + [edit_menu addItemWithTitle:@"Select All" + action:@selector(selectAll:) + keyEquivalent:@"a"]; + edit_item.submenu = edit_menu; + + NSMenuItem* window_item = [main_menu addItemWithTitle:@"Window" + action:nil + keyEquivalent:@""]; + NSMenu* window_menu = [[NSMenu alloc] initWithTitle:@"Window"]; + [window_menu addItemWithTitle:@"Minimize" + action:@selector(performMiniaturize:) + keyEquivalent:@"m"]; + [window_menu addItemWithTitle:@"Zoom" + action:@selector(performZoom:) + keyEquivalent:@""]; + [window_menu addItemWithTitle:@"Close" + action:@selector(performClose:) + keyEquivalent:@"w"]; + [window_menu addItem:[NSMenuItem separatorItem]]; + [window_menu addItemWithTitle:@"Bring All to Front" + action:@selector(arrangeInFront:) + keyEquivalent:@""]; + window_item.submenu = window_menu; + NSApp.windowsMenu = window_menu; + + NSApp.mainMenu = main_menu; +} + +} // namespace electron diff --git a/shell/app_shim/app_shim_loader_mac.mm b/shell/app_shim/app_shim_loader_mac.mm new file mode 100644 index 0000000000..73dbe3667c --- /dev/null +++ b/shell/app_shim/app_shim_loader_mac.mm @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +// The executable of an app shim: a small app, in a bundle of its own, whose +// windows are drawn by a host Electron app. It links nothing from Electron, so +// one build of it works with any later host: it loads the host's framework and +// calls ElectronAppShimMain. +// +// The shim bundle's Info.plist names the host with one of: +// ElectronAppShimHostPath the host .app's absolute path +// ElectronAppShimHostBundleIdentifier the host's bundle identifier +// See docs/api/app-shim-host.md. + +#import +#include +#include + +namespace { + +typedef int (*AppShimMain)(int argc, char* argv[]); + +NSString* StringForKey(NSString* key) { + id value = [NSBundle.mainBundle objectForInfoDictionaryKey:key]; + return [value isKindOfClass:[NSString class]] && [value length] ? value : nil; +} + +[[noreturn]] void Fail(NSString* message) { + NSLog(@"app shim: %@", message); + NSAlert* alert = [[NSAlert alloc] init]; + alert.messageText = @"This app can’t be opened."; + alert.informativeText = message; + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [alert runModal]; + exit(1); +} + +NSString* HostBundlePath() { + // The path wins while the host is still there; a host that has been moved + // is found again by its identifier. + NSString* path = StringForKey(@"ElectronAppShimHostPath"); + if (path && [NSFileManager.defaultManager fileExistsAtPath:path]) + return path; + if (NSString* bundle_id = + StringForKey(@"ElectronAppShimHostBundleIdentifier")) { + if (NSURL* url = [NSWorkspace.sharedWorkspace + URLForApplicationWithBundleIdentifier:bundle_id]) { + return url.path; + } + } + return path; +} + +} // namespace + +int main(int argc, char* argv[]) { + AppShimMain app_shim_main = nullptr; + @autoreleasepool { + NSString* host = HostBundlePath(); + if (!host) + Fail(@"The app it belongs to could not be found."); + + NSString* framework_name = + StringForKey(@"ElectronAppShimFrameworkName") ?: @"Electron Framework"; + NSString* framework = + [NSString stringWithFormat:@"%@/Contents/Frameworks/%@.framework/%@", + host, framework_name, framework_name]; + + void* library = dlopen(framework.fileSystemRepresentation, RTLD_NOW); + if (!library) { + Fail([NSString stringWithFormat:@"%@ could not be loaded: %s", + framework_name, dlerror()]); + } + app_shim_main = + reinterpret_cast(dlsym(library, "ElectronAppShimMain")); + if (!app_shim_main) { + Fail([NSString stringWithFormat:@"%@ is too old to run this app.", + host.lastPathComponent]); + } + } + return app_shim_main(argc, argv); +} diff --git a/shell/app_shim/app_shim_main_delegate.h b/shell/app_shim/app_shim_main_delegate.h new file mode 100644 index 0000000000..8c7470828d --- /dev/null +++ b/shell/app_shim/app_shim_main_delegate.h @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_APP_SHIM_APP_SHIM_MAIN_DELEGATE_H_ +#define ELECTRON_SHELL_APP_SHIM_APP_SHIM_MAIN_DELEGATE_H_ + +#include +#include +#include + +#include "base/files/file_path.h" +#include "content/public/app/content_main_delegate.h" + +namespace electron { + +class AppShimController; + +// The content delegate of an app shim process: a process that loads the +// framework of a host Electron app, connects to it, and hosts the NSWindows of +// the browser windows that the host app assigns to it. +class AppShimMainDelegate : public content::ContentMainDelegate { + public: + // `host_bundle_path` is the .app bundle of the host Electron app. + explicit AppShimMainDelegate(const base::FilePath& host_bundle_path); + ~AppShimMainDelegate() override; + + AppShimMainDelegate(const AppShimMainDelegate&) = delete; + AppShimMainDelegate& operator=(const AppShimMainDelegate&) = delete; + + private: + // content::ContentMainDelegate: + std::optional BasicStartupComplete() override; + void PreSandboxStartup() override; + std::variant RunProcess( + const std::string& process_type, + content::MainFunctionParams main_function_params) override; + bool ShouldCreateFeatureList(InvokedIn invoked_in) override; + bool ShouldInitializeMojo(InvokedIn invoked_in) override; + bool ShouldInitializePerfetto(InvokedIn invoked_in) override; + bool ShouldReconfigurePartitionAlloc() override; + bool ShouldLoadV8Snapshot(const std::string& process_type) override; + content::ContentClient* CreateContentClient() override; + + const base::FilePath host_bundle_path_; + std::unique_ptr content_client_; + std::unique_ptr controller_; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_APP_SHIM_APP_SHIM_MAIN_DELEGATE_H_ diff --git a/shell/app_shim/app_shim_main_delegate.mm b/shell/app_shim/app_shim_main_delegate.mm new file mode 100644 index 0000000000..009c1394bd --- /dev/null +++ b/shell/app_shim/app_shim_main_delegate.mm @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/app_shim/app_shim_main_delegate.h" + +#import + +#include + +#include "base/base_switches.h" +#include "base/check.h" +#include "base/command_line.h" +#include "base/feature_list.h" +#include "base/functional/bind.h" +#include "base/logging.h" +#include "base/logging/logging_settings.h" +#include "base/run_loop.h" +#include "base/task/single_thread_task_executor.h" +#include "base/task/thread_pool/thread_pool_instance.h" +#include "base/threading/platform_thread.h" +#include "base/threading/thread.h" +#include "components/remote_cocoa/app_shim/application_bridge.h" +#include "mojo/core/embedder/configuration.h" +#include "mojo/core/embedder/embedder.h" +#include "mojo/core/embedder/scoped_ipc_support.h" +#include "mojo/public/cpp/system/functions.h" +#include "shell/app/electron_content_client.h" +#include "shell/app/electron_main_delegate.h" +#import "shell/app_shim/app_shim_application.h" +#include "shell/app_shim/app_shim_controller.h" +#include "shell/common/app_shim_constants.h" +#include "ui/accelerated_widget_mac/window_resize_helper_mac.h" +#include "ui/base/l10n/l10n_util_mac.h" + +namespace electron { + +namespace { + +void HandleBadMessage(const std::string& error) { + LOG(ERROR) << "Mojo error in app shim process: " << error; +} + +} // namespace + +AppShimMainDelegate::AppShimMainDelegate(const base::FilePath& host_bundle_path) + : host_bundle_path_(host_bundle_path) {} + +AppShimMainDelegate::~AppShimMainDelegate() = default; + +std::optional AppShimMainDelegate::BasicStartupComplete() { + logging::LoggingSettings settings; + settings.logging_dest = + logging::LOG_TO_SYSTEM_DEBUG_LOG | logging::LOG_TO_STDERR; + logging::InitLogging(settings); + + remote_cocoa::ApplicationBridge::SetIsOutOfProcessAppShim(); + return std::nullopt; +} + +void AppShimMainDelegate::PreSandboxStartup() { + // A shim is started by Launch Services, not by the browser, so it has no + // field trial state to inherit: features are the defaults, changed only by + // this process's own command line. + const base::CommandLine* command_line = + base::CommandLine::ForCurrentProcess(); + base::FeatureList::InitInstance( + command_line->GetSwitchValueASCII(::switches::kEnableFeatures), + command_line->GetSwitchValueASCII(::switches::kDisableFeatures)); + + // Localized strings and cursors for the views that draw in this process, in + // the language the host app would pick. + l10n_util::OverrideLocaleWithCocoaLocale(); + LoadResourceBundle(l10n_util::GetLocaleOverride()); + + // Mojo's features must match the browser's to speak the same IPC. + mojo::core::InitFeatures(); + // The connection to the browser is isolated, so this process is a broker. + mojo::core::Configuration config; + config.is_broker_process = true; + mojo::core::Init(config); + mojo::SetDefaultProcessErrorHandler(base::BindRepeating(&HandleBadMessage)); +} + +std::variant AppShimMainDelegate::RunProcess( + const std::string& process_type, + content::MainFunctionParams main_function_params) { + if (process_type != app_shim::kProcessType) + return std::move(main_function_params); + + base::Thread::Options io_thread_options; + io_thread_options.message_pump_type = base::MessagePumpType::IO; + // Leaked on purpose: it must outlive every Mojo object in the process. + base::Thread* io_thread = new base::Thread("ElectronAppShimIO"); + io_thread->StartWithOptions(std::move(io_thread_options)); + + mojo::core::ScopedIPCSupport ipc_support( + io_thread->task_runner(), + mojo::core::ScopedIPCSupport::ShutdownPolicy::FAST); + + // The NSApplication must be the shim's own, so must not exist yet. + [ElectronAppShimApplication sharedApplication]; + CHECK([NSApp isKindOfClass:[ElectronAppShimApplication class]]); + + base::SingleThreadTaskExecutor main_task_executor(base::MessagePumpType::UI, + /*is_main_thread=*/true); + ui::WindowResizeHelperMac::Get()->Init(main_task_executor.task_runner()); + // content made the pool for this process type but only starts it for the + // process types it runs itself. + base::ThreadPoolInstance::Get()->StartWithDefaultParams(); + base::PlatformThread::SetName("ElectronAppShimMain"); + + controller_ = std::make_unique(host_bundle_path_); + + base::RunLoop().Run(); + return 0; +} + +bool AppShimMainDelegate::ShouldCreateFeatureList(InvokedIn invoked_in) { + // Created in PreSandboxStartup. + return false; +} + +bool AppShimMainDelegate::ShouldInitializeMojo(InvokedIn invoked_in) { + // Initialized in PreSandboxStartup, as a broker. + return false; +} + +bool AppShimMainDelegate::ShouldInitializePerfetto(InvokedIn invoked_in) { + return false; +} + +bool AppShimMainDelegate::ShouldReconfigurePartitionAlloc() { + return false; +} + +bool AppShimMainDelegate::ShouldLoadV8Snapshot( + const std::string& process_type) { + // An app shim runs no JavaScript. + return false; +} + +content::ContentClient* AppShimMainDelegate::CreateContentClient() { + content_client_ = std::make_unique(); + return content_client_.get(); +} + +} // namespace electron diff --git a/shell/app_shim/app_shim_menu.h b/shell/app_shim/app_shim_menu.h new file mode 100644 index 0000000000..1047340708 --- /dev/null +++ b/shell/app_shim/app_shim_menu.h @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_APP_SHIM_APP_SHIM_MENU_H_ +#define ELECTRON_SHELL_APP_SHIM_APP_SHIM_MENU_H_ + +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/scoped_observation.h" +#include "electron/shell/common/app_shim.mojom.h" +#include "shell/browser/ui/electron_menu_model.h" + +namespace electron { + +// A menu, and the menus under it, that the host app described. It gives +// ElectronMenuController a model to build an NSMenu from, as it would have in +// the host's process, and passes on what the person does with it. +class AppShimMenu : public ElectronMenuModel::Delegate, + private ElectronMenuModel::Observer { + public: + class Client { + public: + virtual void OnMenuItemActivated(const std::vector& path, + int command_id, + int event_flags) = 0; + virtual void OnMenuWillShow() = 0; + virtual void OnMenuWillClose() = 0; + + protected: + virtual ~Client() = default; + }; + + // `client` must outlive this. `path` is where this menu is under the menu + // bar: empty for the menu bar itself. + AppShimMenu(std::vector items, + std::vector path, + Client* client); + ~AppShimMenu() override; + + AppShimMenu(const AppShimMenu&) = delete; + AppShimMenu& operator=(const AppShimMenu&) = delete; + + ElectronMenuModel* model() { return model_.get(); } + + // Takes the state of `items` (labels, checks and so on) if they are the + // same menu as this one, item for item. False, changing nothing, when they + // are not, and an NSMenu built from this would be the wrong one. + bool Update(std::vector* items); + + private: + class Item; + + bool HasShapeOf(const std::vector& items) const; + + // ElectronMenuModel::Delegate: + void ActivatedAt(size_t index, int event_flags) override; + void MenuWillShow() override; + + // ElectronMenuModel::Observer: + void OnMenuWillClose() override; + + const std::vector path_; + const raw_ptr client_; + std::vector> items_; + std::unique_ptr model_; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_APP_SHIM_APP_SHIM_MENU_H_ diff --git a/shell/app_shim/app_shim_menu.mm b/shell/app_shim/app_shim_menu.mm new file mode 100644 index 0000000000..7c4d08948a --- /dev/null +++ b/shell/app_shim/app_shim_menu.mm @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/app_shim/app_shim_menu.h" + +#include + +#include "ui/base/accelerators/accelerator.h" +#include "ui/base/models/image_model.h" + +namespace electron { + +class AppShimMenu::Item : public ElectronMenuModel::Item { + public: + Item(mojom::AppShimMenuItemPtr data, + std::vector path, + AppShimMenu::Client* client) + : data_(std::move(data)) { + if (data_->submenu) { + submenu_ = std::make_unique(std::move(*data_->submenu), + std::move(path), client); + data_->submenu.reset(); + } + } + + const mojom::AppShimMenuItem& data() const { return *data_; } + AppShimMenu* submenu() const { return submenu_.get(); } + + // What a built NSMenuItem can't be given afterwards. + bool HasShapeOf(const mojom::AppShimMenuItem& other) const { + return data_->type == other.type && data_->command_id == other.command_id && + data_->role == other.role && data_->tool_tip == other.tool_tip && + data_->custom_type == other.custom_type && + data_->has_accelerator == other.has_accelerator && + data_->accelerator_key_code == other.accelerator_key_code && + data_->accelerator_modifiers == other.accelerator_modifiers && + data_->accelerator_shifted_char == other.accelerator_shifted_char && + data_->register_accelerator == other.register_accelerator && + data_->works_when_hidden == other.works_when_hidden && + data_->group_id == other.group_id && + !!submenu_ == other.submenu.has_value() && + (!submenu_ || submenu_->HasShapeOf(*other.submenu)); + } + + void Update(mojom::AppShimMenuItemPtr data) { + if (submenu_) + submenu_->Update(&*data->submenu); + data_ = std::move(data); + data_->submenu.reset(); + } + + // ElectronMenuModel::Item: + ui::MenuModel::ItemType GetType() const override { + return static_cast(data_->type); + } + int GetCommandId() const override { return data_->command_id; } + std::u16string GetLabel() const override { return data_->label; } + std::u16string GetSecondaryLabel() const override { + return data_->secondary_label; + } + std::u16string GetToolTip() const override { return data_->tool_tip; } + std::u16string GetAccessibilityLabel() const override { + return data_->accessibility_label; + } + std::u16string GetRole() const override { return data_->role; } + std::u16string GetCustomType() const override { return data_->custom_type; } + ui::ImageModel GetIcon() const override { + return data_->icon.isNull() ? ui::ImageModel() + : ui::ImageModel::FromImageSkia(data_->icon); + } + bool GetAccelerator(ui::Accelerator* accelerator) const override { + if (!data_->has_accelerator) + return false; + *accelerator = ui::Accelerator( + static_cast(data_->accelerator_key_code), + data_->accelerator_modifiers); + if (data_->accelerator_shifted_char) + accelerator->shifted_char = data_->accelerator_shifted_char; + return true; + } + bool ShouldRegisterAccelerator() const override { + return data_->register_accelerator; + } + bool WorksWhenHidden() const override { return data_->works_when_hidden; } + bool IsChecked() const override { return data_->checked; } + bool IsEnabled() const override { return data_->enabled; } + bool IsVisible() const override { return data_->visible; } + int GetGroupId() const override { return data_->group_id; } + ElectronMenuModel* GetSubmenuModel() const override { + return submenu_ ? submenu_->model() : nullptr; + } + std::optional GetSharingItem() override { + return std::nullopt; + } + const ElectronMenuModel::Badge* GetBadge() const override { return nullptr; } + + private: + mojom::AppShimMenuItemPtr data_; + std::unique_ptr submenu_; +}; + +AppShimMenu::AppShimMenu(std::vector items, + std::vector path, + Client* client) + : path_(std::move(path)), + client_(client), + model_(std::make_unique(this)) { + for (auto& item : items) { + std::vector item_path = path_; + item_path.push_back(items_.size()); + items_.push_back( + std::make_unique(std::move(item), std::move(item_path), client)); + model_->InsertItemAt(items_.size() - 1, items_.back().get()); + } + // Only the menu bar's model is told that a menu opens and closes. + if (path_.empty()) + model_->AddObserver(this); +} + +AppShimMenu::~AppShimMenu() { + if (path_.empty()) + model_->RemoveObserver(this); +} + +bool AppShimMenu::HasShapeOf( + const std::vector& items) const { + if (items.size() != items_.size()) + return false; + for (size_t i = 0; i < items.size(); ++i) { + if (!items_[i]->HasShapeOf(*items[i])) + return false; + } + return true; +} + +bool AppShimMenu::Update(std::vector* items) { + if (!HasShapeOf(*items)) + return false; + for (size_t i = 0; i < items->size(); ++i) + items_[i]->Update(std::move((*items)[i])); + return true; +} + +void AppShimMenu::ActivatedAt(size_t index, int event_flags) { + std::vector item_path = path_; + item_path.push_back(index); + client_->OnMenuItemActivated(item_path, items_[index]->data().command_id, + event_flags); +} + +void AppShimMenu::MenuWillShow() { + client_->OnMenuWillShow(); +} + +void AppShimMenu::OnMenuWillClose() { + client_->OnMenuWillClose(); +} + +} // namespace electron diff --git a/shell/app_shim/app_shim_window.h b/shell/app_shim/app_shim_window.h new file mode 100644 index 0000000000..f14e8ed400 --- /dev/null +++ b/shell/app_shim/app_shim_window.h @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_APP_SHIM_APP_SHIM_WINDOW_H_ +#define ELECTRON_SHELL_APP_SHIM_APP_SHIM_WINDOW_H_ + +#import +#import + +#include +#include + +#include "electron/shell/common/app_shim.mojom.h" +#include "ui/gfx/geometry/point.h" + +// The parts of a window in an app shim that the host app asks for and that +// remote_cocoa has no call for: the window buttons of a hidden title bar, +// vibrancy and the Quick Look preview. What NativeWindowMac does to its own +// NSWindow for a window of the host's process, this does to the NSWindow that +// remote_cocoa made here. +@interface ElectronAppShimWindow + : NSObject + +// The object for the window that the host knows by `windowId`, made on first +// use. Nil when there is no such window (any more). ++ (instancetype)forWindowId:(uint64_t)windowId; +// The object for `window`, if one has been made. ++ (instancetype)existingForWindow:(NSWindow*)window; + +- (void)initChrome:(const electron::mojom::AppShimWindowChrome&)chrome; +- (void)setButtonVisibility:(BOOL)visible; +- (void)setButtonPosition:(const std::optional&)position; +- (void)setVibrancy:(const std::string&)type + duration:(int)durationMs + state:(NSVisualEffectState)state; +- (void)performTabAction:(electron::mojom::AppShimTabAction)action; +- (void)addTabbedWindow:(ElectronAppShimWindow*)other; +- (void)previewFile:(NSURL*)url title:(NSString*)title; +- (void)closeFilePreview; +// Whether the Quick Look panel is this window's to fill. +- (BOOL)hasPreviewItem; + +@end + +#endif // ELECTRON_SHELL_APP_SHIM_APP_SHIM_WINDOW_H_ diff --git a/shell/app_shim/app_shim_window.mm b/shell/app_shim/app_shim_window.mm new file mode 100644 index 0000000000..696f9bb6db --- /dev/null +++ b/shell/app_shim/app_shim_window.mm @@ -0,0 +1,332 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#import "shell/app_shim/app_shim_window.h" + +#include + +#include "base/strings/sys_string_conversions.h" +#import "components/remote_cocoa/app_shim/native_widget_mac_nswindow.h" +#include "components/remote_cocoa/app_shim/native_widget_ns_window_bridge.h" +#include "shell/browser/native_window.h" +#import "shell/browser/ui/cocoa/electron_preview_item.h" +#import "shell/browser/ui/cocoa/window_buttons_proxy.h" + +namespace { + +using TitleBarStyle = electron::NativeWindow::TitleBarStyle; + +const char kAssociationKey = 0; + +// 0 for a name that is not a vibrancy type. +NSVisualEffectMaterial MaterialForVibrancyType(const std::string& type) { + static const struct { + const char* type; + NSVisualEffectMaterial material; + } kMaterials[] = { + {"titlebar", NSVisualEffectMaterialTitlebar}, + {"selection", NSVisualEffectMaterialSelection}, + {"menu", NSVisualEffectMaterialMenu}, + {"popover", NSVisualEffectMaterialPopover}, + {"sidebar", NSVisualEffectMaterialSidebar}, + {"header", NSVisualEffectMaterialHeaderView}, + {"sheet", NSVisualEffectMaterialSheet}, + {"window", NSVisualEffectMaterialWindowBackground}, + {"hud", NSVisualEffectMaterialHUDWindow}, + {"fullscreen-ui", NSVisualEffectMaterialFullScreenUI}, + {"tooltip", NSVisualEffectMaterialToolTip}, + {"content", NSVisualEffectMaterialContentBackground}, + {"under-window", NSVisualEffectMaterialUnderWindowBackground}, + {"under-page", NSVisualEffectMaterialUnderPageBackground}, + }; + for (const auto& entry : kMaterials) { + if (type == entry.type) + return entry.material; + } + return static_cast(0); +} + +} // namespace + +@implementation ElectronAppShimWindow { + NSWindow* __weak _window; + BOOL _hasFrame; + TitleBarStyle _titleBarStyle; + WindowButtonsProxy* __strong _buttonsProxy; + BOOL _buttonsVisible; + NSVisualEffectView* __strong _vibrantView; + ElectronPreviewItem* __strong _previewItem; +} + ++ (instancetype)forWindowId:(uint64_t)windowId { + auto* bridge = remote_cocoa::NativeWidgetNSWindowBridge::GetFromId(windowId); + NSWindow* window = bridge ? bridge->ns_window() : nil; + if (!window) + return nil; + ElectronAppShimWindow* existing = [self existingForWindow:window]; + if (existing) + return existing; + ElectronAppShimWindow* made = [[self alloc] initWithWindow:window]; + objc_setAssociatedObject(window, &kAssociationKey, made, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return made; +} + ++ (instancetype)existingForWindow:(NSWindow*)window { + return window ? objc_getAssociatedObject(window, &kAssociationKey) : nil; +} + +- (instancetype)initWithWindow:(NSWindow*)window { + if ((self = [super init])) { + _window = window; + _hasFrame = YES; + _titleBarStyle = TitleBarStyle::kNormal; + _buttonsVisible = YES; + NSNotificationCenter* center = NSNotificationCenter.defaultCenter; + [center addObserver:self + selector:@selector(windowDidResize:) + name:NSWindowDidResizeNotification + object:window]; + for (NSNotificationName name in @[ + NSWindowDidBecomeMainNotification, NSWindowDidResignMainNotification, + NSWindowDidBecomeKeyNotification, NSWindowDidResignKeyNotification, + NSWindowDidChangeOcclusionStateNotification + ]) { + [center addObserver:self + selector:@selector(windowDidResize:) + name:name + object:window]; + } + [center addObserver:self + selector:@selector(windowDidEnterFullScreen:) + name:NSWindowDidEnterFullScreenNotification + object:window]; + [center addObserver:self + selector:@selector(windowWillExitFullScreen:) + name:NSWindowWillExitFullScreenNotification + object:window]; + [center addObserver:self + selector:@selector(windowDidExitFullScreen:) + name:NSWindowDidExitFullScreenNotification + object:window]; + } + return self; +} + +- (void)dealloc { + [NSNotificationCenter.defaultCenter removeObserver:self]; +} + +- (void)setStandardButtonsHidden:(BOOL)hidden { + [_window standardWindowButton:NSWindowCloseButton].hidden = hidden; + [_window standardWindowButton:NSWindowMiniaturizeButton].hidden = hidden; + [_window standardWindowButton:NSWindowZoomButton].hidden = hidden; +} + +- (void)initChrome:(const electron::mojom::AppShimWindowChrome&)chrome { + _hasFrame = chrome.has_frame; + _titleBarStyle = static_cast(chrome.title_bar_style); + + if (chrome.tabbing_identifier && _hasFrame) { + _window.tabbingIdentifier = + base::SysUTF8ToNSString(*chrome.tabbing_identifier); + } else { + _window.tabbingMode = NSWindowTabbingModeDisallowed; + } + + if (_hasFrame) + return; + // The window has a title bar that is see-through and has no title. Whether + // its buttons show is the title bar style's to say. + if (_titleBarStyle == TitleBarStyle::kNormal) { + [self setStandardButtonsHidden:YES]; + return; + } + _buttonsProxy = [[WindowButtonsProxy alloc] initWithWindow:_window]; + [_buttonsProxy setHeight:chrome.titlebar_overlay_height]; + if (chrome.traffic_light_position) { + [_buttonsProxy setMargin:*chrome.traffic_light_position]; + } else if (_titleBarStyle == TitleBarStyle::kHiddenInset) { + [_buttonsProxy setMargin:gfx::Point(12, 11)]; + } + if (_titleBarStyle == TitleBarStyle::kCustomButtonsOnHover) { + [_buttonsProxy setShowOnHover:YES]; + } else { + [self setStandardButtonsHidden:NO]; + } +} + +- (void)setButtonVisibility:(BOOL)visible { + _buttonsVisible = visible; + if (_buttonsProxy) { + if (visible) + [_buttonsProxy redraw]; + [_buttonsProxy setVisible:visible]; + } + if (_titleBarStyle != TitleBarStyle::kCustomButtonsOnHover) + [self setStandardButtonsHidden:!visible]; +} + +- (void)setButtonPosition:(const std::optional&)position { + [_buttonsProxy setMargin:position]; +} + +// Also when the window comes on screen, and when it becomes or stops being +// the main or key window: each lays the title bar out again, which puts the +// buttons back where the system has them. The layout may come after the +// notification, so the buttons are placed once more behind it. +- (void)windowDidResize:(NSNotification*)notification { + [self redrawButtons]; + ElectronAppShimWindow* __weak weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf redrawButtons]; + }); +} + +- (void)redrawButtons { + if (!(_window.styleMask & NSWindowStyleMaskFullScreen)) + [_buttonsProxy redraw]; +} + +- (void)windowDidEnterFullScreen:(NSNotification*)notification { + // The title comes back for full screen, where the title bar slides in. + if (_buttonsProxy) + _window.titleVisibility = NSWindowTitleVisible; + if (!_hasFrame) + _window.titlebarAppearsTransparent = NO; +} + +- (void)windowWillExitFullScreen:(NSNotification*)notification { + if (_buttonsProxy) { + _window.titleVisibility = NSWindowTitleHidden; + // Hidden meanwhile, or the buttons jump. + [_buttonsProxy setVisible:NO]; + } +} + +- (void)windowDidExitFullScreen:(NSNotification*)notification { + if (_buttonsProxy && _buttonsVisible) { + [_buttonsProxy redraw]; + [_buttonsProxy setVisible:YES]; + } + if (!_hasFrame) + _window.titlebarAppearsTransparent = YES; +} + +- (void)setVibrancy:(const std::string&)type + duration:(int)durationMs + state:(NSVisualEffectState)state { + const BOOL animate = durationMs > 0; + NSVisualEffectView* vibrantView = _vibrantView; + + if (type.empty()) { + if (!vibrantView) + return; + _vibrantView = nil; + if (animate) { + [NSAnimationContext + runAnimationGroup:^(NSAnimationContext* context) { + context.duration = durationMs / 1000.0; + vibrantView.animator.alphaValue = 0.0; + } + completionHandler:^{ + [vibrantView removeFromSuperview]; + }]; + } else { + [vibrantView removeFromSuperview]; + } + return; + } + + const NSVisualEffectMaterial material = MaterialForVibrancyType(type); + if (!material) + return; + + if (!vibrantView) { + NSView* contentView = _window.contentView; + vibrantView = [[NSVisualEffectView alloc] initWithFrame:contentView.bounds]; + vibrantView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + vibrantView.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vibrantView.state = state; + // Underneath everything else in the window. + [contentView addSubview:vibrantView + positioned:NSWindowBelow + relativeTo:nil]; + _vibrantView = vibrantView; + if (animate) { + vibrantView.alphaValue = 0.0; + [NSAnimationContext + runAnimationGroup:^(NSAnimationContext* context) { + context.duration = durationMs / 1000.0; + vibrantView.animator.alphaValue = 1.0; + } + completionHandler:nil]; + } + } + vibrantView.material = material; +} + +- (void)performTabAction:(electron::mojom::AppShimTabAction)action { + using Action = electron::mojom::AppShimTabAction; + switch (action) { + case Action::kSelectPrevious: + [_window selectPreviousTab:nil]; + break; + case Action::kSelectNext: + [_window selectNextTab:nil]; + break; + case Action::kShowAll: + [_window toggleTabOverview:nil]; + break; + case Action::kMergeAll: + [_window mergeAllWindows:nil]; + break; + case Action::kMoveToNewWindow: + [_window moveTabToNewWindow:nil]; + break; + case Action::kToggleTabBar: + [_window toggleTabBar:nil]; + break; + } +} + +- (void)addTabbedWindow:(ElectronAppShimWindow*)other { + NSWindow* otherWindow = other ? other->_window : nil; + if (otherWindow && otherWindow != _window) + [_window addTabbedWindow:otherWindow ordered:NSWindowAbove]; +} + +- (void)previewFile:(NSURL*)url title:(NSString*)title { + _previewItem = [[ElectronPreviewItem alloc] initWithURL:url title:title]; + [_window makeKeyWindow]; + QLPreviewPanel* panel = [QLPreviewPanel sharedPreviewPanel]; + // The panel asks the responder chain who fills it only when it opens. + if (panel.visible) + [panel reloadData]; + [panel makeKeyAndOrderFront:nil]; +} + +- (void)closeFilePreview { + // Asking for the shared panel makes one, which would show it. + if ([QLPreviewPanel sharedPreviewPanelExists] && _previewItem) + [[QLPreviewPanel sharedPreviewPanel] close]; + _previewItem = nil; +} + +- (BOOL)hasPreviewItem { + return _previewItem != nil; +} + +// QLPreviewPanelDataSource + +- (NSInteger)numberOfPreviewItemsInPreviewPanel:(QLPreviewPanel*)panel { + return _previewItem ? 1 : 0; +} + +- (id)previewPanel:(QLPreviewPanel*)panel + previewItemAtIndex:(NSInteger)index { + return _previewItem; +} + +@end diff --git a/shell/browser/api/electron_api_app.cc b/shell/browser/api/electron_api_app.cc index f93ecde0ab..05fb90f61c 100644 --- a/shell/browser/api/electron_api_app.cc +++ b/shell/browser/api/electron_api_app.cc @@ -2138,6 +2138,8 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) { .SetMethod("isInApplicationsFolder", &App::IsInApplicationsFolder) .SetMethod("setActivationPolicy", &App::SetActivationPolicy) .SetMethod("configureWebAuthn", &App::ConfigureWebAuthn) + .SetMethod("startAppShimServer", &App::StartAppShimServer) + .SetMethod("stopAppShimServer", &App::StopAppShimServer) #endif .SetMethod("setAboutPanelOptions", base::BindRepeating(&Browser::SetAboutPanelOptions, browser)) diff --git a/shell/browser/api/electron_api_app.h b/shell/browser/api/electron_api_app.h index c51e68ee84..ef17674a38 100644 --- a/shell/browser/api/electron_api_app.h +++ b/shell/browser/api/electron_api_app.h @@ -256,6 +256,8 @@ class App final : public gin::Wrappable, #if BUILDFLAG(IS_MAC) void SetActivationPolicy(gin_helper::ErrorThrower thrower, const std::string& policy); + void StartAppShimServer(gin::Arguments* args); + void StopAppShimServer(); void ConfigureWebAuthn(gin_helper::ErrorThrower thrower, gin::Arguments* args); bool MoveToApplicationsFolder(gin_helper::ErrorThrower, gin::Arguments* args); diff --git a/shell/browser/api/electron_api_app_mac.mm b/shell/browser/api/electron_api_app_mac.mm index 53a795e48e..3a91c7e8a4 100644 --- a/shell/browser/api/electron_api_app_mac.mm +++ b/shell/browser/api/electron_api_app_mac.mm @@ -8,7 +8,13 @@ #include "base/path_service.h" #include "base/strings/sys_string_conversions.h" +#include "electron/mas.h" +#include "gin/arguments.h" +#include "shell/browser/app_shim/app_shim_listener.h" +#include "shell/browser/browser.h" +#include "shell/common/app_shim_constants.h" #include "shell/common/electron_paths.h" +#include "shell/common/gin_helper/dictionary.h" #include "shell/common/process_util.h" #include "shell/common/thread_restrictions.h" @@ -55,4 +61,36 @@ bool App::IsRunningUnderARM64Translation() const { return proc_translated == 1; } +void App::StartAppShimServer(gin::Arguments* args) { +#if IS_MAS_BUILD() + args->ThrowTypeError("App shims are not available in Mac App Store builds"); +#else + if (!Browser::Get()->is_ready()) { + args->ThrowTypeError( + "app.startAppShimServer() can only be called after app is ready"); + return; + } + std::string name = app_shim::kDefaultServerName; + if (args->Length() > 0) { + gin_helper::Dictionary options; + v8::Local name_value; + if (!args->GetNext(&options) || + (options.Get("name", &name_value) && !name_value->IsUndefined() && + !options.Get("name", &name))) { + args->ThrowTypeError("Invalid options: name must be a string"); + return; + } + } + std::string error; + if (!AppShimListener::Get()->Start(name, &error)) + args->ThrowTypeError(error); +#endif +} + +void App::StopAppShimServer() { +#if !IS_MAS_BUILD() + AppShimListener::Get()->Stop(); +#endif +} + } // namespace electron::api diff --git a/shell/browser/api/electron_api_app_shim_host.h b/shell/browser/api/electron_api_app_shim_host.h new file mode 100644 index 0000000000..7c13c27580 --- /dev/null +++ b/shell/browser/api/electron_api_app_shim_host.h @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_API_ELECTRON_API_APP_SHIM_HOST_H_ +#define ELECTRON_SHELL_BROWSER_API_ELECTRON_API_APP_SHIM_HOST_H_ + +#include + +#include +#include +#include + +#include "base/files/file_path.h" +#include "electron/shell/common/app_shim.mojom.h" +#include "gin/wrappable.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "shell/browser/event_emitter_mixin.h" +#include "shell/browser/native_peer.h" + +class GURL; + +namespace gin { +class Arguments; +} // namespace gin + +namespace gin_helper { +class ErrorThrower; +} // namespace gin_helper + +namespace remote_cocoa { +class ApplicationHost; +} // namespace remote_cocoa + +namespace electron { + +class AppShimHostBootstrap; + +namespace api { + +class Menu; + +// A connected app shim: another app, with a Dock tile of its own, that shows +// windows on this app's behalf. Windows created with it as their `appShim` +// option are NSWindows of the shim's process. +class AppShimHost final : public gin::Wrappable, + public gin_helper::EventEmitterMixin { + public: + // Takes over a shim's connection once its hello has been answered. + static AppShimHost* Create( + v8::Isolate* isolate, + std::unique_ptr bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim); + + // Public for cppgc::MakeGarbageCollected. + AppShimHost(std::unique_ptr bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim); + ~AppShimHost() override; + + AppShimHost(const AppShimHost&) = delete; + AppShimHost& operator=(const AppShimHost&) = delete; + + // The bundle that the process an audit token names runs from; empty when + // the process is gone. + static base::FilePath BundlePathForAuditToken(const audit_token_t& token); + + // What creates this shim's NSWindows; null once the shim has disconnected. + remote_cocoa::ApplicationHost* GetRemoteCocoaApplicationHost() const; + // The shim's process; null once the shim has disconnected. + mojom::AppShim* GetAppShim() const; + // A window was made in the shim's process. + void DidCreateWindow(); + + // gin::Wrappable + static gin::WrapperInfo kWrapperInfo; + gin::ObjectTemplateBuilder GetObjectTemplateBuilder( + v8::Isolate* isolate) override; + const gin::WrapperInfo* wrapper_info() const override; + const char* GetHumanReadableName() const override; + + const char* GetClassName() const { return "AppShimHost"; } + + private: + class Connection; + + // From the connection. + void OnReopen(bool has_visible_windows); + void OnFilesOpened(const std::vector& files); + void OnUrlsOpened(const std::vector& urls); + // False when a listener prevented the default, which is to quit the shim. + bool OnQuitRequested(); + void OnDisconnected(); + + // JS API. + int GetPid() const; + base::FilePath GetBundlePath() const; + std::string GetBundleId() const; + bool IsConnected() const; + bool SatisfiesCodeRequirement(gin_helper::ErrorThrower thrower, + const std::string& requirement); + void SetBadge(const std::string& label); + void Bounce(gin::Arguments* args); + void CancelBounce(); + void Quit(); + void SetApplicationMenu(gin::Arguments* args); + + std::unique_ptr connection_; +}; + +} // namespace api + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_API_ELECTRON_API_APP_SHIM_HOST_H_ diff --git a/shell/browser/api/electron_api_app_shim_host.mm b/shell/browser/api/electron_api_app_shim_host.mm new file mode 100644 index 0000000000..d27ded5ce8 --- /dev/null +++ b/shell/browser/api/electron_api_app_shim_host.mm @@ -0,0 +1,547 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/api/electron_api_app_shim_host.h" +#include "base/memory/raw_ptr.h" +#include "base/no_destructor.h" +#include "base/strings/utf_string_conversions.h" +#include "shell/browser/api/electron_api_menu.h" +#include "shell/browser/browser.h" +#include "shell/browser/native_window_mac_remote.h" +#include "shell/browser/ui/electron_menu_model.h" +#include "shell/browser/window_list.h" +#include "ui/base/accelerators/accelerator.h" +#include "ui/base/models/image_model.h" + +#import +#import +#include + +#include +#include +#include + +#include "base/apple/foundation_util.h" +#include "base/apple/scoped_cftyperef.h" +#include "base/functional/bind.h" +#include "base/memory/weak_ptr.h" +#include "base/strings/sys_string_conversions.h" +#include "base/task/sequenced_task_runner.h" +#include "components/remote_cocoa/browser/application_host.h" +#include "content/public/browser/browser_accessibility_state.h" +#include "content/public/browser/scoped_accessibility_mode.h" +#include "gin/arguments.h" +#include "gin/object_template_builder.h" +#include "mojo/public/cpp/bindings/pending_associated_receiver.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "shell/browser/app_shim/app_shim_listener.h" +#include "shell/common/gin_converters/file_path_converter.h" +#include "shell/common/gin_converters/gurl_converter.h" +#include "shell/common/gin_helper/error_thrower.h" +#include "shell/common/gin_helper/wrappable_pointer_tags.h" +#include "ui/accessibility/ax_mode.h" +#include "url/gurl.h" +#include "v8/include/cppgc/allocation.h" +#include "v8/include/cppgc/persistent.h" +#include "v8/include/v8-cppgc.h" + +namespace electron::api { + +namespace { + +// `rename` is the app's name and the shim's. The labels that the "about", +// "hide" and "quit" roles get by default name the app the menu was built in, +// and in a shim's menu bar they are about the shim. +std::vector DescribeMenu( + ElectronMenuModel* model, + const std::pair& rename) { + std::vector items; + const size_t count = model->GetItemCount(); + items.reserve(count); + for (size_t i = 0; i < count; ++i) { + auto item = mojom::AppShimMenuItem::New(); + item->type = static_cast(model->GetTypeAt(i)); + item->command_id = model->GetCommandIdAt(i); + item->label = model->GetLabelAt(i); + item->secondary_label = model->GetSecondaryLabelAt(i); + item->tool_tip = model->GetToolTipAt(i); + item->accessibility_label = model->GetAccessibilityLabelAt(i); + item->role = model->GetRoleAt(i); + if (!rename.first.empty() && !rename.second.empty() && + (item->role == u"about" || item->role == u"hide" || + item->role == u"quit") && + item->label.ends_with(rename.first)) { + item->label.replace(item->label.size() - rename.first.size(), + rename.first.size(), rename.second); + } + item->custom_type = model->GetCustomTypeAt(i); + if (ui::ImageModel icon = model->GetIconAt(i); icon.IsImage()) + item->icon = icon.GetImage().AsImageSkia(); + ui::Accelerator accelerator; + if (model->GetAcceleratorAtWithParams(i, true, &accelerator)) { + item->has_accelerator = true; + item->accelerator_key_code = accelerator.key_code(); + item->accelerator_modifiers = accelerator.modifiers(); + item->accelerator_shifted_char = accelerator.shifted_char.value_or(0); + } + item->register_accelerator = model->ShouldRegisterAcceleratorAt(i); + item->works_when_hidden = model->WorksWhenHiddenAt(i); + item->checked = model->IsItemCheckedAt(i); + item->enabled = model->IsEnabledAt(i); + item->visible = model->IsVisibleAt(i); + item->group_id = model->GetGroupIdAt(i); + if (ElectronMenuModel* submenu = model->GetSubmenuModelAt(i)) + item->submenu = DescribeMenu(submenu, rename); + items.push_back(std::move(item)); + } + return items; +} + +// The running code of the process an audit token names. Unlike a pid, a token +// cannot come to mean another process. +base::apple::ScopedCFTypeRef CodeForAuditToken( + const audit_token_t& token) { + NSDictionary* attributes = @{ + (__bridge NSString*) + kSecGuestAttributeAudit : [NSData dataWithBytes:&token length:sizeof(token)] + }; + base::apple::ScopedCFTypeRef code; + if (SecCodeCopyGuestWithAttributes( + nullptr, (__bridge CFDictionaryRef)attributes, kSecCSDefaultFlags, + code.InitializeInto()) != errSecSuccess) { + code.reset(); + } + return code; +} + +// The bundle the code runs from, or the executable's path for bare code. +base::FilePath PathOfCode(SecCodeRef code) { + base::apple::ScopedCFTypeRef static_code; + base::apple::ScopedCFTypeRef url; + if (!code || + SecCodeCopyStaticCode(code, kSecCSDefaultFlags, + static_code.InitializeInto()) != errSecSuccess || + SecCodeCopyPath(static_code.get(), kSecCSDefaultFlags, + url.InitializeInto()) != errSecSuccess) { + return {}; + } + return base::apple::NSURLToFilePath((__bridge NSURL*)url.get()); +} + +} // namespace + +// The native half of an AppShimHost: the Mojo connection to the shim process +// and the remote_cocoa application its windows are made through. It keeps the +// wrapper alive while the shim is connected, so a shim nobody holds on to in +// JavaScript still gets its events and keeps its windows. +class AppShimHost::Connection final : public NativePeer, + public mojom::AppShimHost { + public: + Connection(api::AppShimHost* wrapper, + std::unique_ptr bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim) + : NativePeer(wrapper), + keep_alive_(wrapper), + audit_token_(bootstrap->audit_token()), + code_(CodeForAuditToken(audit_token_)), + bundle_path_(PathOfCode(code_.get())), + bootstrap_(std::move(bootstrap)), + receiver_(this, std::move(host_receiver)), + app_shim_(std::move(app_shim)) { + StartObservingShutdown(); + + if (NSBundle* bundle = [NSBundle + bundleWithPath:base::apple::FilePathToNSString(bundle_path_)]) { + bundle_id_ = base::SysNSStringToUTF8(bundle.bundleIdentifier); + NSString* name = + [bundle objectForInfoDictionaryKey:@"CFBundleDisplayName"] + ?: [bundle objectForInfoDictionaryKey:@"CFBundleName"]; + name_ = base::SysNSStringToUTF16(name); + } + + receiver_.set_disconnect_handler( + base::BindOnce(&Connection::Disconnect, base::Unretained(this))); + app_shim_.set_disconnect_handler( + base::BindOnce(&Connection::Disconnect, base::Unretained(this))); + + mojo::PendingAssociatedReceiver + application_receiver; + // Activation goes to the shim's process. Not to its bundle identifier, + // which is the shim's own claim and may be another app's. + application_host_ = std::make_unique( + &application_receiver, std::string()); + application_host_->set_pid(pid()); + app_shim_->CreateRemoteCocoaApplication(std::move(application_receiver)); + } + + // The menu bar the shim shows while it is the active app. Without one it + // shows a short menu that it builds for itself. + void SetMenu(Menu* menu) { + menu_ = menu; + SendMenu(); + } + Menu* menu() const { return menu_.Get(); } + void SendMenu() { + if (!connected()) + return; + Menu* const menu = this->menu(); + app_shim_->SetApplicationMenu( + menu ? std::make_optional(DescribeMenu( + menu->model(), + {base::UTF8ToUTF16(Browser::Get()->GetName()), name_})) + : std::nullopt); + } + + bool connected() const { return connected_ && !quit_requested_; } + pid_t pid() const { return audit_token_to_pid(audit_token_); } + const base::FilePath& bundle_path() const { return bundle_path_; } + const std::string& bundle_id() const { return bundle_id_; } + SecCodeRef code() const { return code_.get(); } + remote_cocoa::ApplicationHost* application_host() const { + return quit_requested_ ? nullptr : application_host_.get(); + } + mojom::AppShim* app_shim() const { + return connected_ ? app_shim_.get() : nullptr; + } + + void DidCreateWindow() { + hosts_window_ = true; + ApplyAccessibilityMode(); + } + + // Ends the connection from JavaScript. The caller may be a listener of one + // of this shim's windows (its `show`, say), and closing that window under + // it would free what is notifying, so the windows close in a task of their + // own. Until then the shim reads as disconnected and takes no new window. + void Quit() { + if (!connected_ || quit_requested_) + return; + quit_requested_ = true; + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce(&Connection::Disconnect, weak_factory_.GetWeakPtr())); + } + + // Ends the connection, which ends the shim process and closes its windows. + void Disconnect() { + if (!TearDownConnection()) + return; + if (auto shim_host = wrapper()) + shim_host->OnDisconnected(); + // May free the wrapper, and this with it, at the next garbage collection. + keep_alive_.Clear(); + } + + private: + ~Connection() override = default; + + void ApplyAccessibilityMode() { + if (!hosts_window_ || !wanted_accessibility_mode_ || !connected_) + return; + accessibility_mode_ = + content::BrowserAccessibilityState::GetInstance() + ->CreateScopedModeForProcess( + (*wanted_accessibility_mode_ == + mojom::AppShimAccessibilityMode::kComplete + ? ui::kAXModeComplete + : ui::kAXModeBasic) | + ui::AXMode::kFromPlatform); + } + + bool TearDownConnection() { + if (!connected_) + return false; + connected_ = false; + menu_.Clear(); + weak_factory_.InvalidateWeakPtrs(); + // Closes every window the shim hosts, synchronously: their closed events + // run JavaScript before this returns. + application_host_.reset(); + accessibility_mode_.reset(); + receiver_.reset(); + app_shim_.reset(); + bootstrap_.reset(); + return true; + } + + // NativePeerBase: + void TearDownNative() override { + TearDownConnection(); + keep_alive_.Clear(); + } + + // mojom::AppShimHost: + void ReopenApp(bool has_visible_windows) override { + if (auto shim_host = wrapper()) + shim_host->OnReopen(has_visible_windows); + } + void FilesOpened(const std::vector& files) override { + if (auto shim_host = wrapper()) + shim_host->OnFilesOpened(files); + } + void UrlsOpened(const std::vector& urls) override { + if (auto shim_host = wrapper()) + shim_host->OnUrlsOpened(urls); + } + void EnableAccessibilitySupport( + mojom::AppShimAccessibilityMode mode) override { + // Any process of the same user can connect and ask for this, and it + // costs every page in the app. It waits until the app has given the + // shim a window, which is the app's way of saying it accepts the shim. + if (!wanted_accessibility_mode_ || + mode == mojom::AppShimAccessibilityMode::kComplete) { + wanted_accessibility_mode_ = mode; + } + ApplyAccessibilityMode(); + } + void MenuItemActivated(const std::vector& path, + int32_t command_id, + int32_t event_flags) override { + // The shim's menu may be older than this one: the item is only chosen if + // what is at `path` now is still the item the shim meant. + Menu* const menu = this->menu(); + ElectronMenuModel* model = menu ? menu->model() : nullptr; + for (size_t i = 0; model && i + 1 < path.size(); ++i) { + model = path[i] < model->GetItemCount() + ? model->GetSubmenuModelAt(path[i]) + : nullptr; + } + if (model && !path.empty() && path.back() < model->GetItemCount() && + model->GetCommandIdAt(path.back()) == command_id) { + model->ActivatedAt(path.back(), event_flags); + } + // A click may have changed a check mark, or anything else. + SendMenu(); + } + void MenuWillShow() override { + if (Menu* const menu = this->menu()) + menu->model()->MenuWillShow(); + SendMenu(); + } + void MenuWillClose() override { + if (Menu* const menu = this->menu()) + menu->model()->MenuWillClose(); + } + void DidBecomeActive() override { SendMenu(); } + void NewWindowForTab(uint64_t window_id) override { + for (NativeWindow* window : WindowList::GetWindows()) { + if (!window->IsInAppShim()) + continue; + auto* remote = static_cast(window); + if (remote->GetAppShim() == app_shim() && + remote->GetRemoteWindowId() == window_id) { + remote->NotifyNewWindowForTab(); + return; + } + } + } + void QuitRequested() override { + bool quit = true; + if (auto shim_host = wrapper()) + quit = shim_host->OnQuitRequested(); + if (!connected_) + return; + if (quit) + app_shim_->Quit(); + else + app_shim_->CancelQuit(); + } + + cppgc::Persistent keep_alive_; + const audit_token_t audit_token_; + const base::apple::ScopedCFTypeRef code_; + const base::FilePath bundle_path_; + std::string bundle_id_; + // What the shim calls itself, for its menu bar; its own claim. + std::u16string name_; + bool connected_ = true; + bool quit_requested_ = false; + + // Holds the isolated connection the pipes below run over. + std::unique_ptr bootstrap_; + mojo::Receiver receiver_; + mojo::Remote app_shim_; + std::unique_ptr application_host_; + // Keeps accessibility on in this process while an assistive technology is + // looking at the shim. + // The shim's menu bar, if it has been given one. + cppgc::Persistent menu_; + + bool hosts_window_ = false; + std::optional wanted_accessibility_mode_; + std::unique_ptr accessibility_mode_; + + base::WeakPtrFactory weak_factory_{this}; +}; + +gin::WrapperInfo AppShimHost::kWrapperInfo = + electron::MakeWrapperInfo(electron::kElectronAppShimHost); + +// static +AppShimHost* AppShimHost::Create( + v8::Isolate* isolate, + std::unique_ptr bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim) { + return cppgc::MakeGarbageCollected( + isolate->GetCppHeap()->GetAllocationHandle(), std::move(bootstrap), + std::move(host_receiver), std::move(app_shim)); +} + +AppShimHost::AppShimHost( + std::unique_ptr bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim) + : connection_( + NativePeer::Create(this, + std::move(bootstrap), + std::move(host_receiver), + std::move(app_shim))) {} + +AppShimHost::~AppShimHost() = default; + +// static +base::FilePath AppShimHost::BundlePathForAuditToken( + const audit_token_t& token) { + return PathOfCode(CodeForAuditToken(token).get()); +} + +remote_cocoa::ApplicationHost* AppShimHost::GetRemoteCocoaApplicationHost() + const { + return connection_->application_host(); +} + +mojom::AppShim* AppShimHost::GetAppShim() const { + return connection_->connected() ? connection_->app_shim() : nullptr; +} + +void AppShimHost::DidCreateWindow() { + connection_->DidCreateWindow(); +} + +void AppShimHost::OnReopen(bool has_visible_windows) { + Emit("activate", has_visible_windows); +} + +void AppShimHost::OnFilesOpened(const std::vector& files) { + for (const base::FilePath& file : files) + Emit("open-file", file); +} + +void AppShimHost::OnUrlsOpened(const std::vector& urls) { + for (const GURL& url : urls) + Emit("open-url", url.spec()); +} + +bool AppShimHost::OnQuitRequested() { + return !Emit("quit-requested"); +} + +void AppShimHost::OnDisconnected() { + Emit("disconnected"); +} + +int AppShimHost::GetPid() const { + return connection_->pid(); +} + +base::FilePath AppShimHost::GetBundlePath() const { + return connection_->bundle_path(); +} + +std::string AppShimHost::GetBundleId() const { + return connection_->bundle_id(); +} + +bool AppShimHost::IsConnected() const { + return connection_->connected(); +} + +bool AppShimHost::SatisfiesCodeRequirement(gin_helper::ErrorThrower thrower, + const std::string& requirement) { + base::apple::ScopedCFTypeRef requirement_ref; + if (SecRequirementCreateWithString( + base::SysUTF8ToCFStringRef(requirement).get(), kSecCSDefaultFlags, + requirement_ref.InitializeInto()) != errSecSuccess) { + thrower.ThrowError("Invalid code requirement"); + return false; + } + return connection_->code() && + SecCodeCheckValidity(connection_->code(), kSecCSDefaultFlags, + requirement_ref.get()) == errSecSuccess; +} + +void AppShimHost::SetBadge(const std::string& label) { + if (mojom::AppShim* app_shim = connection_->app_shim()) + app_shim->SetBadgeLabel(label); +} + +void AppShimHost::Bounce(gin::Arguments* args) { + std::string type = "informational"; + if (args->Length() > 0 && !args->GetNext(&type)) { + args->ThrowTypeError("Invalid bounce type"); + return; + } + mojom::AppShimAttentionType attention_type; + if (type == "critical") { + attention_type = mojom::AppShimAttentionType::kCritical; + } else if (type == "informational") { + attention_type = mojom::AppShimAttentionType::kInformational; + } else { + args->ThrowTypeError("Invalid bounce type"); + return; + } + if (mojom::AppShim* app_shim = connection_->app_shim()) + app_shim->SetUserAttention(attention_type); +} + +void AppShimHost::CancelBounce() { + if (mojom::AppShim* app_shim = connection_->app_shim()) + app_shim->SetUserAttention(mojom::AppShimAttentionType::kCancel); +} + +void AppShimHost::Quit() { + connection_->Quit(); +} + +void AppShimHost::SetApplicationMenu(gin::Arguments* args) { + v8::Local value; + Menu* menu = nullptr; + if (!args->GetNext(&value) || + (!value->IsNull() && + (!gin::ConvertFromV8(args->isolate(), value, &menu) || !menu))) { + args->ThrowTypeError("Must pass a Menu or null"); + return; + } + connection_->SetMenu(menu); +} + +gin::ObjectTemplateBuilder AppShimHost::GetObjectTemplateBuilder( + v8::Isolate* isolate) { + return gin_helper::EventEmitterMixin::GetObjectTemplateBuilder( + isolate) + .SetProperty("pid", &AppShimHost::GetPid) + .SetProperty("bundlePath", &AppShimHost::GetBundlePath) + .SetProperty("bundleId", &AppShimHost::GetBundleId) + .SetMethod("isConnected", &AppShimHost::IsConnected) + .SetMethod("satisfiesCodeRequirement", + &AppShimHost::SatisfiesCodeRequirement) + .SetMethod("setBadge", &AppShimHost::SetBadge) + .SetMethod("bounce", &AppShimHost::Bounce) + .SetMethod("cancelBounce", &AppShimHost::CancelBounce) + .SetMethod("setApplicationMenu", &AppShimHost::SetApplicationMenu) + .SetMethod("quit", &AppShimHost::Quit); +} + +const gin::WrapperInfo* AppShimHost::wrapper_info() const { + return &kWrapperInfo; +} + +const char* AppShimHost::GetHumanReadableName() const { + return "Electron / AppShimHost"; +} + +} // namespace electron::api diff --git a/shell/browser/api/electron_api_base_window.cc b/shell/browser/api/electron_api_base_window.cc index a3bdad70e7..e95b807958 100644 --- a/shell/browser/api/electron_api_base_window.cc +++ b/shell/browser/api/electron_api_base_window.cc @@ -44,6 +44,10 @@ #include "ui/gfx/geometry/rect_conversions.h" #include "ui/gfx/geometry/rect_f.h" +#if BUILDFLAG(IS_MAC) +#include "shell/browser/api/electron_api_app_shim_host.h" +#endif + #if defined(TOOLKIT_VIEWS) #include "shell/browser/native_window_views.h" #endif @@ -890,7 +894,13 @@ void BaseWindow::SetParentWindow(v8::Local value, if (value->IsNull() || value->IsUndefined()) { parent_window_.Reset(); window_->SetParentWindow(nullptr); + } else if (window_->IsInAppShim()) { + args->ThrowTypeError("A window in an app shim can't have a parent window"); } else if (gin::ConvertFromV8(isolate(), value, &parent)) { + if (parent->window() && parent->window()->IsInAppShim()) { + args->ThrowTypeError("A window in an app shim can't be a parent window"); + return; + } parent_window_.Reset(isolate(), parent.ToV8()); window_->SetParentWindow(parent->window()); } else { @@ -1045,9 +1055,13 @@ void BaseWindow::ToggleTabBar() { void BaseWindow::AddTabbedWindow(NativeWindow* const window, gin::Arguments* const args) { - if (!window_->AddTabbedWindow(window)) + if (!window_->AddTabbedWindow(window)) { args->ThrowTypeError( - "AddTabbedWindow cannot be called by a window on itself."); + window_.get() != window && + (window_->IsInAppShim() || window->IsInAppShim()) + ? "AddTabbedWindow needs both windows to be in the same app" + : "AddTabbedWindow cannot be called by a window on itself."); + } } v8::Local BaseWindow::GetTabbingIdentifier() { @@ -1301,6 +1315,11 @@ gin_helper::WrappableBase* BaseWindow::New(gin::Arguments* const args) { args->ThrowTypeError(error_message); return nullptr; } + if (!IsAppShimValid(options, &error_message) || + !IsParentValid(options, &error_message)) { + args->ThrowTypeError(error_message); + return nullptr; + } return new BaseWindow(args, options); } @@ -1327,6 +1346,47 @@ bool BaseWindow::IsWindowNameValid(const gin_helper::Dictionary& options, return true; } +// static +bool BaseWindow::IsAppShimValid(const gin_helper::Dictionary& options, + std::string* error_message) { +#if BUILDFLAG(IS_MAC) + v8::Local value; + if (!options.Get(options::kAppShim, &value) || value->IsNullOrUndefined()) + return true; + AppShimHost* app_shim = nullptr; + if (!gin::ConvertFromV8(options.isolate(), value, &app_shim) || !app_shim) { + *error_message = "appShim must be an AppShimHost"; + return false; + } + if (!app_shim->GetRemoteCocoaApplicationHost()) { + *error_message = "The app shim has disconnected"; + return false; + } + if (bool modal = false; options.Get(options::kModal, &modal) && modal) { + *error_message = "A window in an app shim can't be modal"; + return false; + } + if (v8::Local parent; + options.Get("parent", &parent) && !parent->IsNullOrUndefined()) { + *error_message = "A window in an app shim can't have a parent window"; + return false; + } +#endif + return true; +} + +// static +bool BaseWindow::IsParentValid(const gin_helper::Dictionary& options, + std::string* error_message) { + gin_helper::Handle parent; + if (options.Get("parent", &parent) && !parent.IsEmpty() && parent->window() && + parent->window()->IsInAppShim()) { + *error_message = "A window in an app shim can't be a parent window"; + return false; + } + return true; +} + // static v8::Local BaseWindow::GetConstructorTemplate( v8::Isolate* isolate) { diff --git a/shell/browser/api/electron_api_base_window.h b/shell/browser/api/electron_api_base_window.h index bdd6832a7d..fdf8c12ca3 100644 --- a/shell/browser/api/electron_api_base_window.h +++ b/shell/browser/api/electron_api_base_window.h @@ -62,6 +62,14 @@ class BaseWindow : public gin_helper::TrackableObject, static bool IsWindowNameValid(const gin_helper::Dictionary& options, std::string* error_message); + // False when the options name an app shim (macOS) that can't host a window. + static bool IsAppShimValid(const gin_helper::Dictionary& options, + std::string* error_message); + // Returns false, with the reason, when the `parent` in `options` is a + // window that can't have children. + static bool IsParentValid(const gin_helper::Dictionary& options, + std::string* error_message); + const NativeWindow* window() const { return window_.get(); } NativeWindow* window() { return window_.get(); } diff --git a/shell/browser/api/electron_api_browser_window.cc b/shell/browser/api/electron_api_browser_window.cc index 97f118580f..dca709af72 100644 --- a/shell/browser/api/electron_api_browser_window.cc +++ b/shell/browser/api/electron_api_browser_window.cc @@ -375,6 +375,11 @@ gin_helper::WrappableBase* BrowserWindow::New(gin_helper::ErrorThrower thrower, thrower.ThrowError(error_message); return nullptr; } + if (!IsAppShimValid(options, &error_message) || + !IsParentValid(options, &error_message)) { + thrower.ThrowTypeError(error_message); + return nullptr; + } return new BrowserWindow(args, options); } diff --git a/shell/browser/api/electron_api_web_contents_mac.mm b/shell/browser/api/electron_api_web_contents_mac.mm index 6cd5d2e396..8b0320ce32 100644 --- a/shell/browser/api/electron_api_web_contents_mac.mm +++ b/shell/browser/api/electron_api_web_contents_mac.mm @@ -9,6 +9,7 @@ #include "ui/base/cocoa/nsmenu_additions.h" #include "ui/base/cocoa/nsmenuitem_additions.h" #include "ui/events/keycodes/keyboard_codes.h" +#include "ui/views/cocoa/native_widget_mac_ns_window_host.h" #import @@ -24,6 +25,13 @@ bool WebContents::IsFocused() const { return false; if (type() != Type::kBackgroundPage) { + // A window in an app shim is key in the shim's process; its stand-in in + // this process never is. + if (auto* host = views::NativeWidgetMacNSWindowHost::GetFromNativeWindow( + web_contents()->GetTopLevelNativeWindow()); + host && host->application_host()) { + return host->IsWindowKey() && view->HasFocus(); + } auto window = [web_contents()->GetNativeView().GetNativeNSView() window]; // On Mac the render widget host view does not lose focus when the window // loses focus so check if the top level window is the key window. @@ -45,6 +53,15 @@ bool WebContents::PlatformHandleKeyboardEvent( return false; NSEvent* ns_event = event.os_event.Get(); + + // A window in an app shim: the key was pressed in the shim's process, and + // the menu it is a shortcut of is the shim's, not this app's. + if (auto* host = views::NativeWidgetMacNSWindowHost::GetFromNativeWindow( + source->GetTopLevelNativeWindow()); + host && host->application_host()) { + return host->RedispatchKeyEvent(ns_event); + } + // Send the event to the menu before sending it to the window if (ns_event.type == NSEventTypeKeyDown) { // If the keyboard event is a system shortcut, it's already sent to the diff --git a/shell/browser/app_shim/app_shim_listener.h b/shell/browser/app_shim/app_shim_listener.h new file mode 100644 index 0000000000..ae453fce86 --- /dev/null +++ b/shell/browser/app_shim/app_shim_listener.h @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_APP_SHIM_APP_SHIM_LISTENER_H_ +#define ELECTRON_SHELL_BROWSER_APP_SHIM_APP_SHIM_LISTENER_H_ + +#include + +#include +#include +#include + +#include "base/apple/scoped_mach_port.h" +#include "base/containers/unique_ptr_adapters.h" +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "base/no_destructor.h" +#include "base/timer/timer.h" +#include "electron/shell/common/app_shim.mojom.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/platform/platform_channel_endpoint.h" +#include "mojo/public/cpp/platform/platform_channel_server_endpoint.h" +#include "mojo/public/cpp/system/isolated_connection.h" + +namespace electron { + +// The first interface a shim talks to, on the channel it handed the listener. +// The listener owns it until the shim says hello, and drops it if the shim +// goes away or takes too long first. After the hello it belongs to whoever +// serves the shim. +class AppShimHostBootstrap : public mojom::AppShimHostBootstrap { + public: + class Client { + public: + // The shim said hello. Returning false turns it away. + virtual bool OnShimConnected( + AppShimHostBootstrap* bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim) = 0; + // The shim went away, or timed out, before it said hello. + virtual void OnBootstrapAbandoned(AppShimHostBootstrap* bootstrap) = 0; + + protected: + virtual ~Client() = default; + }; + + // `audit_token` identifies the process that sent `endpoint`. Null when + // `endpoint` is not usable. `client` must outlive the result. + static std::unique_ptr CreateForChannel( + mojo::PlatformChannelEndpoint endpoint, + audit_token_t audit_token, + Client* client); + + ~AppShimHostBootstrap() override; + + AppShimHostBootstrap(const AppShimHostBootstrap&) = delete; + AppShimHostBootstrap& operator=(const AppShimHostBootstrap&) = delete; + + const audit_token_t& audit_token() const { return audit_token_; } + + private: + AppShimHostBootstrap(audit_token_t audit_token, Client* client); + + void Abandon(); + + // mojom::AppShimHostBootstrap: + void OnShimConnected(mojo::PendingReceiver host_receiver, + OnShimConnectedCallback callback) override; + + const audit_token_t audit_token_; + // Null once the shim has said hello. + raw_ptr client_; + base::OneShotTimer hello_timer_; + mojo::IsolatedConnection connection_; + mojo::Receiver receiver_{this}; +}; + +// Publishes a Mach bootstrap server that app shims connect to, and hands each +// shim that connects to the `app` module. Lives on the UI thread. +class AppShimListener : private AppShimHostBootstrap::Client { + public: + static AppShimListener* Get(); + + AppShimListener(const AppShimListener&) = delete; + AppShimListener& operator=(const AppShimListener&) = delete; + + // Starts listening as ".". False, with `error` set, + // when the name is malformed or another process holds it. + bool Start(const std::string& name, std::string* error); + // Stops listening and drops the shims that have not said hello yet. + void Stop(); + bool is_listening() const { return !!acceptor_; } + + private: + friend class base::NoDestructor; + class Acceptor; + + AppShimListener(); + ~AppShimListener() override; + + void OnClientConnected(mojo::PlatformChannelEndpoint endpoint, + audit_token_t audit_token); + // A shim that loaded another build of Electron than this one gave up. It + // waits for `reply` to be let go of before it quits. + void OnBuildMismatch(audit_token_t audit_token, + base::apple::ScopedMachSendRight reply); + + // AppShimHostBootstrap::Client: + bool OnShimConnected(AppShimHostBootstrap* bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim) override; + void OnBootstrapAbandoned(AppShimHostBootstrap* bootstrap) override; + + // Published for shims to look up and never read from. + mojo::PlatformChannelServerEndpoint build_marker_; + std::unique_ptr acceptor_; + // Shims that have connected and not said hello yet. + std::set, base::UniquePtrComparator> + pending_bootstraps_; + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_APP_SHIM_APP_SHIM_LISTENER_H_ diff --git a/shell/browser/app_shim/app_shim_listener.mm b/shell/browser/app_shim/app_shim_listener.mm new file mode 100644 index 0000000000..541e456454 --- /dev/null +++ b/shell/browser/app_shim/app_shim_listener.mm @@ -0,0 +1,315 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/app_shim/app_shim_listener.h" + +#include + +#include +#include + +#include "base/apple/dispatch_source.h" +#include "base/apple/foundation_util.h" +#include "base/apple/mach_logging.h" +#include "base/apple/scoped_mach_port.h" +#include "base/functional/bind.h" +#include "base/mac/scoped_mach_msg_destroy.h" +#include "base/memory/ptr_util.h" +#include "base/strings/strcat.h" +#include "base/strings/string_util.h" +#include "base/task/sequenced_task_runner.h" +#include "content/public/browser/browser_task_traits.h" +#include "content/public/browser/browser_thread.h" +#include "mojo/public/cpp/platform/named_platform_channel.h" +#include "mojo/public/cpp/platform/platform_channel_server_endpoint.h" +#include "shell/browser/api/electron_api_app.h" +#include "shell/browser/api/electron_api_app_shim_host.h" +#include "shell/browser/browser.h" +#include "shell/browser/javascript_environment.h" +#include "shell/common/app_shim_constants.h" +#include "shell/common/gin_converters/file_path_converter.h" + +namespace electron { + +namespace { + +// How long a process that connected may take to say hello. +constexpr base::TimeDelta kHelloTimeout = base::Seconds(30); + +// The longest name the bootstrap server takes (BOOTSTRAP_MAX_NAME_LEN). +constexpr size_t kMaxServerNameLength = 127; + +} // namespace + +// AppShimHostBootstrap ------------------------------------------------------ + +// static +std::unique_ptr AppShimHostBootstrap::CreateForChannel( + mojo::PlatformChannelEndpoint endpoint, + audit_token_t audit_token, + Client* client) { + DCHECK_CURRENTLY_ON(content::BrowserThread::UI); + if (!endpoint.platform_handle().is_mach_send()) + return nullptr; + auto bootstrap = + base::WrapUnique(new AppShimHostBootstrap(audit_token, client)); + mojo::ScopedMessagePipeHandle message_pipe = + bootstrap->connection_.Connect(std::move(endpoint)); + bootstrap->receiver_.Bind(mojo::PendingReceiver( + std::move(message_pipe))); + bootstrap->receiver_.set_disconnect_handler(base::BindOnce( + &AppShimHostBootstrap::Abandon, base::Unretained(bootstrap.get()))); + bootstrap->hello_timer_.Start(FROM_HERE, kHelloTimeout, bootstrap.get(), + &AppShimHostBootstrap::Abandon); + return bootstrap; +} + +AppShimHostBootstrap::AppShimHostBootstrap(audit_token_t audit_token, + Client* client) + : audit_token_(audit_token), client_(client) {} + +AppShimHostBootstrap::~AppShimHostBootstrap() = default; + +void AppShimHostBootstrap::Abandon() { + // After the hello the shim closing its bootstrap pipe is expected. + if (Client* client = std::exchange(client_, nullptr)) + client->OnBootstrapAbandoned(this); // Deletes this. +} + +void AppShimHostBootstrap::OnShimConnected( + mojo::PendingReceiver host_receiver, + OnShimConnectedCallback callback) { + hello_timer_.Stop(); + // One hello per channel. + Client* client = std::exchange(client_, nullptr); + if (!client) { + std::move(callback).Run(mojo::NullReceiver()); + return; + } + mojo::PendingRemote app_shim; + mojo::PendingReceiver app_shim_receiver = + app_shim.InitWithNewPipeAndPassReceiver(); + // A client that accepts takes ownership of this. + if (client->OnShimConnected(this, std::move(host_receiver), + std::move(app_shim))) { + std::move(callback).Run(std::move(app_shim_receiver)); + return; + } + std::move(callback).Run(mojo::NullReceiver()); + client->OnBootstrapAbandoned(this); // Deletes this. +} + +// AppShimListener::Acceptor ------------------------------------------------- + +// Receives the raw Mach messages shims send to the bootstrap server. Messages +// arrive on a dispatch queue; each connection is passed on to the UI thread. +class AppShimListener::Acceptor { + public: + using ConnectCallback = + base::RepeatingCallback; + using BuildMismatchCallback = + base::RepeatingCallback; + + Acceptor(mojo::PlatformChannelServerEndpoint endpoint, + const std::string& server_name, + ConnectCallback on_connect, + BuildMismatchCallback on_build_mismatch) + : endpoint_(std::move(endpoint)), + on_connect_(std::move(on_connect)), + on_build_mismatch_(std::move(on_build_mismatch)), + ui_task_runner_(content::GetUIThreadTaskRunner({})) { + dispatch_source_ = std::make_unique( + server_name.c_str(), port(), ^{ + HandleRequest(); + }); + dispatch_source_->Resume(); + } + + ~Acceptor() { + // Waits for a running HandleRequest. + dispatch_source_.reset(); + } + + private: + mach_port_t port() const { + return endpoint_.platform_handle().GetMachReceiveRight().get(); + } + + void HandleRequest() { + struct : mach_msg_base_t { + mach_msg_audit_trailer_t trailer; + } request{}; + request.header.msgh_size = sizeof(request); + request.header.msgh_local_port = port(); + kern_return_t kr = mach_msg( + &request.header, + MACH_RCV_MSG | MACH_RCV_TRAILER_TYPE(MACH_MSG_TRAILER_FORMAT_0) | + MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_AUDIT), + 0, sizeof(request), port(), MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); + if (kr != KERN_SUCCESS) { + MACH_LOG(ERROR, kr) << "mach_msg"; + return; + } + + base::ScopedMachMsgDestroy scoped_message(&request.header); + if (request.header.msgh_size != sizeof(mach_msg_base_t)) + return; + if (request.header.msgh_id == app_shim::kBuildMismatchMsgId) { + // The reply right is what the shim waits on; it is let go of once the + // shim has been identified, which needs its process to be there. + base::apple::ScopedMachSendRight reply(request.header.msgh_remote_port); + scoped_message.Disarm(); + ui_task_runner_->PostTask( + FROM_HERE, + base::BindOnce(on_build_mismatch_, request.trailer.msgh_audit, + std::move(reply))); + return; + } + if (request.header.msgh_id != app_shim::kBootstrapMsgId) + return; + + mojo::PlatformChannelEndpoint remote_endpoint(mojo::PlatformHandle( + base::apple::ScopedMachSendRight(request.header.msgh_remote_port))); + if (!remote_endpoint.is_valid()) + return; + scoped_message.Disarm(); + + ui_task_runner_->PostTask( + FROM_HERE, base::BindOnce(on_connect_, std::move(remote_endpoint), + request.trailer.msgh_audit)); + } + + mojo::PlatformChannelServerEndpoint endpoint_; + const ConnectCallback on_connect_; + const BuildMismatchCallback on_build_mismatch_; + const scoped_refptr ui_task_runner_; + std::unique_ptr dispatch_source_; +}; + +// AppShimListener ----------------------------------------------------------- + +// static +AppShimListener* AppShimListener::Get() { + static base::NoDestructor listener; + return listener.get(); +} + +AppShimListener::AppShimListener() = default; +AppShimListener::~AppShimListener() = default; + +bool AppShimListener::Start(const std::string& name, std::string* error) { + DCHECK_CURRENTLY_ON(content::BrowserThread::UI); + if (acceptor_) { + *error = "The app shim server is already started"; + return false; + } + const bool valid = !name.empty() && std::ranges::all_of(name, [](char c) { + return base::IsAsciiAlphaNumeric(c) || c == '.' || c == '-' || c == '_'; + }); + if (!valid) { + *error = "The app shim server name may only hold letters, digits, '.', " + "'-' and '_'"; + return false; + } + + mojo::NamedPlatformChannel::Options options; + options.server_name = base::StrCat({base::apple::BaseBundleID(), ".", name}); + if (options.server_name.size() > kMaxServerNameLength) { + *error = base::StrCat( + {"The app shim server name \"", options.server_name, "\" is too long"}); + return false; + } + // The marker goes up first, so that a shim that finds the server finds the + // marker too. See app_shim::BuildMarkerServerName(). + mojo::NamedPlatformChannel::Options marker_options; + marker_options.server_name = + app_shim::BuildMarkerServerName(options.server_name); + if (marker_options.server_name.size() > kMaxServerNameLength) { + *error = base::StrCat( + {"The app shim server name \"", options.server_name, "\" is too long"}); + return false; + } + mojo::PlatformChannelServerEndpoint marker = + mojo::NamedPlatformChannel(marker_options).TakeServerEndpoint(); + mojo::NamedPlatformChannel channel(options); + mojo::PlatformChannelServerEndpoint endpoint = channel.TakeServerEndpoint(); + if (!marker.is_valid() || !endpoint.is_valid()) { + *error = base::StrCat({"Could not publish the app shim server \"", + options.server_name, + "\"; another process may already hold that name"}); + return false; + } + + build_marker_ = std::move(marker); + acceptor_ = std::make_unique( + std::move(endpoint), options.server_name, + base::BindRepeating(&AppShimListener::OnClientConnected, + weak_factory_.GetWeakPtr()), + base::BindRepeating(&AppShimListener::OnBuildMismatch, + weak_factory_.GetWeakPtr())); + return true; +} + +void AppShimListener::OnBuildMismatch(audit_token_t audit_token, + base::apple::ScopedMachSendRight reply) { + if (!is_listening() || Browser::Get()->is_shutting_down()) + return; + const base::FilePath bundle_path = + api::AppShimHost::BundlePathForAuditToken(audit_token); + // The shim may quit now, and need not wait for what listeners do. + reply.reset(); + + v8::Isolate* const isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope handle_scope(isolate); + api::App::Get()->Emit("app-shim-version-mismatch", bundle_path); +} + +void AppShimListener::OnClientConnected(mojo::PlatformChannelEndpoint endpoint, + audit_token_t audit_token) { + if (!is_listening() || Browser::Get()->is_shutting_down()) + return; + if (auto bootstrap = AppShimHostBootstrap::CreateForChannel( + std::move(endpoint), audit_token, this)) { + pending_bootstraps_.insert(std::move(bootstrap)); + } +} + +void AppShimListener::Stop() { + DCHECK_CURRENTLY_ON(content::BrowserThread::UI); + acceptor_.reset(); + build_marker_.reset(); + pending_bootstraps_.clear(); +} + +bool AppShimListener::OnShimConnected( + AppShimHostBootstrap* bootstrap, + mojo::PendingReceiver host_receiver, + mojo::PendingRemote app_shim) { + auto it = pending_bootstraps_.find(bootstrap); + if (it == pending_bootstraps_.end() || !is_listening() || + Browser::Get()->is_shutting_down()) { + return false; + } + std::unique_ptr owned = + std::move(pending_bootstraps_.extract(it).value()); + + v8::Isolate* const isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope handle_scope(isolate); + api::AppShimHost* shim_host = api::AppShimHost::Create( + isolate, std::move(owned), std::move(host_receiver), std::move(app_shim)); + v8::Local wrapper; + if (shim_host->GetWrapper(isolate).ToLocal(&wrapper)) + api::App::Get()->Emit("app-shim-connected", wrapper); + return true; +} + +void AppShimListener::OnBootstrapAbandoned(AppShimHostBootstrap* bootstrap) { + auto it = pending_bootstraps_.find(bootstrap); + if (it != pending_bootstraps_.end()) + pending_bootstraps_.erase(it); +} + +} // namespace electron diff --git a/shell/browser/native_window.cc b/shell/browser/native_window.cc index 8d89816a79..37e48b3fbd 100644 --- a/shell/browser/native_window.cc +++ b/shell/browser/native_window.cc @@ -336,6 +336,10 @@ bool NativeWindow::IsClosed() const { return is_closed_; } +bool NativeWindow::IsInAppShim() const { + return false; +} + void NativeWindow::SetSize(const gfx::Size& size, bool animate) { SetBounds(gfx::Rect(GetPosition(), size), animate); } diff --git a/shell/browser/native_window.h b/shell/browser/native_window.h index e5a5df26d3..b63e9c0d3f 100644 --- a/shell/browser/native_window.h +++ b/shell/browser/native_window.h @@ -83,6 +83,10 @@ class NativeWindow : public views::WidgetDelegate { virtual void Close() = 0; virtual void CloseImmediately() = 0; virtual bool IsClosed() const; + // macOS: whether the window's NSWindow is in an app shim's process. What + // GetNativeWindow() names is then a stand-in that is never on screen, and + // nothing may be attached to it. + virtual bool IsInAppShim() const; virtual void Focus(bool focus) = 0; virtual bool IsFocused() const = 0; virtual void Show() = 0; diff --git a/shell/browser/native_window_mac.mm b/shell/browser/native_window_mac.mm index e00bd0f56b..6ab47cf7c9 100644 --- a/shell/browser/native_window_mac.mm +++ b/shell/browser/native_window_mac.mm @@ -22,6 +22,7 @@ #include "content/public/browser/desktop_media_id.h" #include "shell/browser/browser.h" #include "shell/browser/javascript_environment.h" +#include "shell/browser/native_window_mac_remote.h" #include "shell/browser/ui/cocoa/electron_native_widget_mac.h" #include "shell/browser/ui/cocoa/electron_ns_panel.h" #include "shell/browser/ui/cocoa/electron_ns_window.h" @@ -31,6 +32,7 @@ #include "shell/browser/ui/cocoa/root_view_mac.h" #include "shell/browser/ui/cocoa/window_buttons_proxy.h" #include "shell/browser/ui/drag_util.h" +#include "shell/browser/ui/views/content_view_targeter_delegate.h" #include "shell/browser/window_list.h" #include "shell/common/gin_converters/gfx_converter.h" #include "shell/common/gin_helper/dictionary.h" @@ -44,7 +46,6 @@ #include "ui/views/background.h" #include "ui/views/cocoa/native_widget_mac_ns_window_host.h" #include "ui/views/view_targeter.h" -#include "ui/views/view_targeter_delegate.h" #include "ui/views/widget/widget.h" #include "ui/views/window/native_frame_view_mac.h" @@ -112,49 +113,6 @@ struct Converter { } // namespace gin -namespace { - -// A ViewTargeterDelegate installed on content_view_ that makes it -// hit-test-transparent except where one of its descendants covers the -// target rect. -// -// In BrowserWindow, the WebContentsView is added as a sibling of -// content_view_ at a lower z-order so that user-added child views paint -// above web content. content_view_ itself covers the full window, so the -// default DoesIntersectRect (a bounds check) would always return true and -// the parent targeter would stop there, never reaching the WebContentsView -// behind it. By only intersecting where a child actually does, the parent's -// default TargetForRect loop skips content_view_ and falls through to the -// sibling, ultimately resolving to the WebContentsView's NativeViewHost so -// BridgedContentView::hitTest: routes the event to RenderWidgetHostViewCocoa. -// -// This must never affect TargetForRect's contract — it only narrows -// HitTestRect, which the default loop already handles by skipping the child. -// Returning nullptr from TargetForRect (the previous approach) violated the -// contract and crashed RootView::UpdateCursor (see #51576). -class ContentViewTargeterDelegate : public views::ViewTargeterDelegate { - public: - bool DoesIntersectRect(const views::View* target, - const gfx::Rect& rect) const override { - if (!views::ViewTargeterDelegate::DoesIntersectRect(target, rect)) { - return false; - } - for (const views::View* child : target->children()) { - if (!child->GetVisible() || !child->GetCanProcessEventsWithinSubtree()) { - continue; - } - gfx::RectF rect_in_child(rect); - views::View::ConvertRectToTarget(target, child, &rect_in_child); - if (child->HitTestRect(gfx::ToEnclosingRect(rect_in_child))) { - return true; - } - } - return false; - } -}; - -} // namespace - namespace electron { NativeWindowMac::~NativeWindowMac() = default; @@ -1621,6 +1579,10 @@ void NativeWindowMac::ToggleTabBar() { } bool NativeWindowMac::AddTabbedWindow(NativeWindow* window) { + // A window in an app shim is another process's: only a stand-in for it is + // here. + if (window->IsInAppShim()) + return false; if (window_ == window->GetNativeWindow().GetNativeNSWindow()) { return false; } else { @@ -1928,6 +1890,11 @@ std::unique_ptr NativeWindow::Create( const int32_t base_window_id, const gin_helper::Dictionary& options, NativeWindow* parent) { + if (remote_cocoa::ApplicationHost* application_host = + NativeWindowMacRemote::ApplicationHostFromOptions(options)) { + return std::make_unique(base_window_id, options, + parent, application_host); + } return std::make_unique(base_window_id, options, parent); } diff --git a/shell/browser/native_window_mac_remote.h b/shell/browser/native_window_mac_remote.h new file mode 100644 index 0000000000..c941b48ba8 --- /dev/null +++ b/shell/browser/native_window_mac_remote.h @@ -0,0 +1,267 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_NATIVE_WINDOW_MAC_REMOTE_H_ +#define ELECTRON_SHELL_BROWSER_NATIVE_WINDOW_MAC_REMOTE_H_ + +#import + +#include +#include +#include + +#include "base/memory/weak_ptr.h" +#include "base/scoped_observation.h" +#include "electron/shell/common/app_shim.mojom-forward.h" +#include "shell/browser/native_window.h" +#include "shell/browser/ui/file_dialog.h" +#include "shell/browser/ui/message_box.h" +#include "third_party/skia/include/core/SkColor.h" +#include "ui/views/widget/widget_observer.h" +#include "v8/include/cppgc/persistent.h" + +namespace electron::api { +class AppShimHost; +} // namespace electron::api + +namespace remote_cocoa { +class ApplicationHost; +namespace mojom { +class NativeWidgetNSWindow; +} // namespace mojom +} // namespace remote_cocoa + +namespace electron { + +class RootViewMac; + +// A window whose NSWindow lives in an app shim process (see +// api::AppShimHost), so the Dock, Mission Control and the window server +// attribute it to the shim instead of this app. There is no NSWindow of it in +// this process to message, so everything goes through views::Widget, which +// forwards to the shim over remote_cocoa's Mojo interfaces. What those +// interfaces have no call for is not supported and is a no-op here; see +// docs/api/app-shim-host.md for the list. +class NativeWindowMacRemote : public NativeWindow, + private views::WidgetObserver { + public: + // The application host of the options' `appShim`, or null when there is + // none or it has disconnected. + static remote_cocoa::ApplicationHost* ApplicationHostFromOptions( + const gin_helper::Dictionary& options); + + NativeWindowMacRemote(int32_t base_window_id, + const gin_helper::Dictionary& options, + NativeWindow* parent, + remote_cocoa::ApplicationHost* application_host); + ~NativeWindowMacRemote() override; + + // NativeWindow: + void OnTitleChanged() override; + void SetContentView(views::View* view) override; + void Close() override; + void CloseImmediately() override; + void Focus(bool focus) override; + bool IsFocused() const override; + void Show() override; + void ShowInactive() override; + void Hide() override; + bool IsVisible() const override; + bool IsEnabled() const override; + void SetEnabled(bool enable) override; + void Maximize() override; + void Unmaximize() override; + bool IsMaximized() const override; + void Minimize() override; + void Restore() override; + bool IsMinimized() const override; + void SetFullScreen(bool fullscreen) override; + bool IsFullscreen() const override; + void SetBounds(const gfx::Rect& bounds, bool animate) override; + gfx::Rect GetBounds() const override; + gfx::Rect GetNormalBounds() const override; + void SetSizeConstraints( + const extensions::SizeConstraints& window_constraints) override; + void SetContentSizeConstraints( + const extensions::SizeConstraints& size_constraints) override; + void SetResizable(bool resizable) override; + bool MoveAbove(const std::string& sourceId) override; + void MoveTop() override; + bool IsResizable() const override; + void SetMovable(bool movable) override; + bool IsMovable() const override; + void SetMinimizable(bool minimizable) override; + bool IsMinimizable() const override; + void SetMaximizable(bool maximizable) override; + bool IsMaximizable() const override; + void SetFullScreenable(bool fullscreenable) override; + bool IsFullScreenable() const override; + void SetClosable(bool closable) override; + bool IsClosable() const override; + void SetAlwaysOnTop(ui::ZOrderLevel z_order, + const std::string& level, + int relative_level) override; + std::string GetAlwaysOnTopLevel() const override; + ui::ZOrderLevel GetZOrderLevel() const override; + void Center() override; + void Invalidate() override; + bool IsActive() const override; + void SetActive(bool is_key) override; + void RemoveChildFromParentWindow() override; + void RemoveChildWindow(NativeWindow* child) override; + void AttachChildren() override; + void DetachChildren() override; + void FlashFrame(bool flash) override; + void SetSkipTaskbar(bool skip) override; + void SetExcludedFromShownWindowsMenu(bool excluded) override; + bool IsExcludedFromShownWindowsMenu() const override; + void SetSimpleFullScreen(bool simple_fullscreen) override; + bool IsSimpleFullScreen() const override; + void SetKiosk(bool kiosk) override; + bool IsKiosk() const override; + void SetBackgroundColor(SkColor color) override; + SkColor GetBackgroundColor() const override; + void SetHasShadow(bool has_shadow) override; + bool HasShadow() const override; + void SetOpacity(const double opacity) override; + double GetOpacity() const override; + void SetIgnoreMouseEvents(bool ignore, bool forward) override; + void SetContentProtection(bool enable) override; + bool IsContentProtected() const override; + content::DesktopMediaID GetDesktopMediaID() const override; + gfx::NativeView GetNativeView() const override; + gfx::NativeWindow GetNativeWindow() const override; + gfx::AcceleratedWidget GetAcceleratedWidget() const override; + NativeWindowHandle GetNativeWindowHandle() const override; + void SetProgressBar(double progress, const ProgressState state) override; + void SetOverlayIcon(const gfx::Image& overlay, + const std::string& description) override; + void SetVisibleOnAllWorkspaces(bool visible, + bool visibleOnFullScreen, + bool skipTransformProcessType) override; + bool IsVisibleOnAllWorkspaces() const override; + void SetWindowButtonVisibility(bool visible) override; + bool GetWindowButtonVisibility() const override; + void SetWindowButtonPosition(std::optional position) override; + std::optional GetWindowButtonPosition() const override; + void RedrawTrafficLights() override; + void SetVibrancy(const std::string& type, int duration) override; + void SelectPreviousTab() override; + void SelectNextTab() override; + void ShowAllTabs() override; + void MergeAllWindows() override; + void MoveTabToNewWindow() override; + void ToggleTabBar() override; + bool AddTabbedWindow(NativeWindow* window) override; + std::optional GetTabbingIdentifier() const override; + void PreviewFile(const std::string& path, + const std::string& display_name) override; + void CloseFilePreview() override; + void UpdateFrame() override; + bool IsHiddenInMissionControl() const override; + void SetHiddenInMissionControl(bool hidden) override; + void SetAspectRatio(double aspect_ratio, + const gfx::Size& extra_size) override; + gfx::Rect ContentBoundsToWindowBounds(const gfx::Rect& bounds) const override; + gfx::Rect WindowBoundsToContentBounds(const gfx::Rect& bounds) const override; + + // The shim's process, and the id it knows this window by; null and 0 once + // either is gone. + mojom::AppShim* GetAppShim() const; + uint64_t GetRemoteWindowId() const; + + // Dialogs with this window as their parent. They are sheets of the + // NSWindow in the shim's process, which shows them; the callbacks run with + // "cancelled" when the shim or the window goes away first. + void ShowMessageBox(const MessageBoxSettings& settings, + MessageBoxCallback callback); + // Runs a nested loop until the box is answered. + int ShowMessageBoxSync(const MessageBoxSettings& settings); + // False when no window in a shim is showing a box with this id. + static bool CloseMessageBox(int id); + void ShowOpenDialog(const file_dialog::DialogSettings& settings, + file_dialog::OpenDialogCallback callback); + void ShowSaveDialog(const file_dialog::DialogSettings& settings, + file_dialog::SaveDialogCallback callback); + + // The window's close button, once the shim has its answer (see the client + // view). + + protected: + // views::WidgetDelegate: + views::View* GetContentsView() override; + views::ClientView* CreateClientView(views::Widget* widget) override; + std::unique_ptr CreateFrameView( + views::Widget* widget) override; + + private: + // views::WidgetObserver: + void OnWidgetActivationChanged(views::Widget* widget, bool active) override; + void OnWidgetBoundsChanged(views::Widget* widget, + const gfx::Rect& bounds) override; + void OnWidgetVisibilityChanged(views::Widget* widget, bool visible) override; + void OnWidgetShowStateChanged(views::Widget* widget) override; + void OnWidgetUserResizeEnded(views::Widget* widget) override; + void OnWidgetDestroyed(views::Widget* widget) override; + + int FrameViewNonClientHitTest(const gfx::Point& point); + + bool IsInAppShim() const override; + + void OnCloseButtonClicked(); + void CloseNow(); + // The remote_cocoa interface of the NSWindow in the shim's process; null + // once it is gone. + remote_cocoa::mojom::NativeWidgetNSWindow* GetNSWindowMojo() const; + void PerformTabAction(mojom::AppShimTabAction action); + + // The height the window server adds above the content for this window's + // style: its title bar's, or 0 without one. + int TitleBarHeight() const; + + std::unique_ptr root_view_; + + // How many of the widget's notifications are on the stack. Their listeners + // run JavaScript, and destroying the widget from inside one would free the + // object that is notifying. + // The shim whose process the NSWindow is in. + cppgc::WeakPersistent app_shim_; + + // What the window buttons and tabs were last told, which is what the + // getters answer with: the NSWindow is not here to ask. + std::optional window_button_visibility_; + std::optional traffic_light_position_; + std::optional tabbing_identifier_; + // An NSVisualEffectState. + int visual_effect_state_ = 0; + + int widget_callback_depth_ = 0; + bool widget_destroyed_ = false; + + NSUInteger style_mask_ = 0; + gfx::Rect last_bounds_; + bool was_minimized_ = false; + bool was_maximized_ = false; + bool was_fullscreen_ = false; + + bool movable_ = true; + bool closable_ = true; + bool fullscreenable_ = true; + bool is_kiosk_ = false; + bool has_shadow_ = true; + bool content_protected_ = false; + bool excluded_from_windows_menu_ = false; + double opacity_ = 1.0; + SkColor background_color_ = SK_ColorWHITE; + std::string always_on_top_level_; + + base::ScopedObservation + widget_observation_{this}; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_NATIVE_WINDOW_MAC_REMOTE_H_ diff --git a/shell/browser/native_window_mac_remote.mm b/shell/browser/native_window_mac_remote.mm new file mode 100644 index 0000000000..1e96d01086 --- /dev/null +++ b/shell/browser/native_window_mac_remote.mm @@ -0,0 +1,1055 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/native_window_mac_remote.h" + +#include +#include + +#include "base/auto_reset.h" +#include "base/containers/flat_map.h" +#include "base/functional/bind.h" +#include "base/functional/callback.h" +#include "base/memory/raw_ptr.h" +#include "base/no_destructor.h" +#include "base/run_loop.h" +#include "base/task/sequenced_task_runner.h" +#include "components/remote_cocoa/browser/application_host.h" +#include "components/remote_cocoa/common/native_widget_ns_window.mojom.h" +#include "content/public/browser/desktop_media_id.h" +#include "mojo/public/cpp/bindings/callback_helpers.h" +#include "shell/browser/api/electron_api_app_shim_host.h" +#include "shell/browser/ui/cocoa/root_view_mac.h" +#include "shell/browser/ui/views/content_view_targeter_delegate.h" +#include "shell/browser/window_list.h" +#include "shell/common/app_shim.mojom.h" +#include "shell/common/electron_paths.h" +#include "shell/common/gin_converters/gfx_converter.h" +#include "shell/common/gin_helper/dictionary.h" +#include "shell/common/options_switches.h" +#include "ui/base/hit_test.h" +#include "ui/gfx/geometry/rect.h" +#include "ui/views/background.h" +#include "ui/views/cocoa/native_widget_mac_ns_window_host.h" +#include "ui/views/view_targeter.h" +#include "ui/views/widget/native_widget_mac.h" +#include "ui/views/widget/widget.h" +#include "ui/views/window/client_view.h" +#include "ui/views/window/native_frame_view_mac.h" + +namespace electron { + +namespace { + +// Makes the widget's NSWindow in the app shim's process. +class RemoteNativeWidgetMac : public views::NativeWidgetMac { + public: + RemoteNativeWidgetMac(views::internal::NativeWidgetDelegate* delegate, + remote_cocoa::ApplicationHost* application_host, + NSUInteger style_mask, + bool has_frame, + bool has_title_bar_buttons) + : views::NativeWidgetMac(delegate), + application_host_(application_host), + style_mask_(style_mask), + has_frame_(has_frame), + has_title_bar_buttons_(has_title_bar_buttons) {} + + RemoteNativeWidgetMac(const RemoteNativeWidgetMac&) = delete; + RemoteNativeWidgetMac& operator=(const RemoteNativeWidgetMac&) = delete; + + // The host is only read while the widget is initialized; after that the + // widget's own window host observes it. + void DidInit() { application_host_ = nullptr; } + + protected: + // views::NativeWidgetMac: + void PopulateCreateWindowParams( + const views::Widget::InitParams& widget_params, + remote_cocoa::mojom::CreateWindowParams* params) override { + params->style_mask = style_mask_; + if (!has_frame_) { + // A title bar of no height, unless the window buttons are to show in + // it (a titleBarStyle other than the default). + if (!has_title_bar_buttons_) + params->window_class = remote_cocoa::mojom::WindowClass::kFrameless; + params->titlebar_appears_transparent = true; + params->window_title_hidden = true; + } + } + + remote_cocoa::ApplicationHost* GetRemoteCocoaApplicationHost() override { + return application_host_; + } + + private: + raw_ptr application_host_; + const NSUInteger style_mask_; + const bool has_frame_; + const bool has_title_bar_buttons_; +}; + +// A message box that a window in a shim is showing and that +// CloseMessageBox() can name. `serial` tells it from a later box that was +// given the same id. +struct RemoteMessageBox { + base::WeakPtr window; + uint64_t serial = 0; +}; + +base::flat_map& RemoteMessageBoxes() { + static base::NoDestructor> boxes; + return *boxes; +} + +mojom::AppShimFileDialogOptionsPtr ToMojo( + const file_dialog::DialogSettings& settings) { + auto options = mojom::AppShimFileDialogOptions::New(); + options->title = settings.title; + options->message = settings.message; + options->button_label = settings.button_label; + options->name_field_label = settings.name_field_label; + // Resolved here: the shim's process has no path service for the default. + options->default_path = + settings.default_path.empty() ? GetDefaultPath() : settings.default_path; + for (const auto& [name, extensions] : settings.filters) { + options->filters.push_back( + mojom::AppShimFileDialogFilter::New(name, extensions)); + } + options->properties = settings.properties; + options->shows_tag_field = settings.shows_tag_field; + return options; +} + +// Turns the window's close button into the window's `close` event; the window +// goes away only when that is not prevented. The shim's process is blocked on +// the answer to this, and destroying the window while it waits would drop the +// answer, so the event is emitted once the answer has gone. +class RemoteWindowClientView : public views::ClientView { + public: + RemoteWindowClientView(views::Widget* widget, + views::View* contents_view, + base::RepeatingClosure on_close_requested) + : views::ClientView(widget, contents_view), + on_close_requested_(std::move(on_close_requested)) {} + + views::CloseRequestResult OnWindowCloseRequested() override { + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, on_close_requested_); + return views::CloseRequestResult::kCannotClose; + } + + private: + const base::RepeatingClosure on_close_requested_; +}; + +// The size limits views passes to a remote window are of its content, where +// RootViewMac answers with the window's. +class RemoteRootView : public RootViewMac { + public: + explicit RemoteRootView(NativeWindow* window) + : RootViewMac(window), window_(window) {} + + gfx::Size GetMinimumSize() const override { + return window_->GetContentMinimumSize(); + } + gfx::Size GetMaximumSize() const override { + return window_->GetContentMaximumSize(); + } + + private: + const raw_ptr window_; +}; + +// views' frame view for macOS asks the NSWindow how much taller the window is +// than its content. The NSWindow in this process is a stand-in without a title +// bar, so the answer comes from the real window's style instead. +class RemoteFrameView : public views::NativeFrameViewMac { + public: + RemoteFrameView(views::Widget* widget, const NativeWindowMacRemote* window) + : views::NativeFrameViewMac(widget), window_(window) {} + + gfx::Rect GetWindowBoundsForClientBounds( + const gfx::Rect& client_bounds) const override { + gfx::Rect window_bounds = + window_->ContentBoundsToWindowBounds(client_bounds); + if (window_bounds.IsEmpty()) + window_bounds.set_size(gfx::Size(1, 1)); + return window_bounds; + } + + private: + const raw_ptr window_; +}; + +} // namespace + +// static +remote_cocoa::ApplicationHost* +NativeWindowMacRemote::ApplicationHostFromOptions( + const gin_helper::Dictionary& options) { + api::AppShimHost* app_shim = nullptr; + if (!options.Get(options::kAppShim, &app_shim) || !app_shim) + return nullptr; + return app_shim->GetRemoteCocoaApplicationHost(); +} + +NativeWindowMacRemote::NativeWindowMacRemote( + const int32_t base_window_id, + const gin_helper::Dictionary& options, + NativeWindow* parent, + remote_cocoa::ApplicationHost* application_host) + : NativeWindow{base_window_id, options, parent}, + root_view_(std::make_unique(this)) { + int width = options.ValueOrDefault(options::kWidth, 800); + int height = options.ValueOrDefault(options::kHeight, 600); + const NSRect main_screen_rect = NSScreen.screens.firstObject.frame; + const gfx::Rect bounds(round((NSWidth(main_screen_rect) - width) / 2), + round((NSHeight(main_screen_rect) - height) / 2), + width, height); + + const bool resizable = options.ValueOrDefault(options::kResizable, true); + const bool minimizable = options.ValueOrDefault(options::kMinimizable, true); + const bool maximizable = options.ValueOrDefault(options::kMaximizable, true); + closable_ = options.ValueOrDefault(options::kClosable, true); + movable_ = options.ValueOrDefault(options::kMovable, true); + + // A frameless window keeps a (zero height) title bar, as it does in this + // process, so that it has the system's corners, shadow and resize edges. + style_mask_ = NSWindowStyleMaskTitled; + if (minimizable) + style_mask_ |= NSWindowStyleMaskMiniaturizable; + if (closable_) + style_mask_ |= NSWindowStyleMaskClosable; + if (resizable) + style_mask_ |= NSWindowStyleMaskResizable; + if (!has_frame()) + style_mask_ |= NSWindowStyleMaskFullSizeContentView; + + views::Widget::InitParams params( + views::Widget::InitParams::WIDGET_OWNS_NATIVE_WIDGET, + views::Widget::InitParams::TYPE_WINDOW); + params.bounds = bounds; + params.delegate = this; + if (IsTranslucent()) + params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent; + // Reading the options above may have run JavaScript that disconnected the + // shim, so the host is looked up again here, with nothing left to run + // before it is used. Without one the window is made in this process. + api::AppShimHost* app_shim = nullptr; + options.Get(options::kAppShim, &app_shim); + application_host = + app_shim ? app_shim->GetRemoteCocoaApplicationHost() : nullptr; + auto* native_widget = new RemoteNativeWidgetMac( + widget(), application_host, style_mask_, has_frame(), + title_bar_style() != TitleBarStyle::kNormal); + params.native_widget = native_widget; + widget()->Init(std::move(params)); + native_widget->DidInit(); + if (application_host) { + app_shim_ = app_shim; + app_shim->DidCreateWindow(); + } + + // What remote_cocoa has no call for goes to the shim directly, on the pipe + // that the window's creation went over, so it arrives after that. + options.GetOptional(options::kTrafficLightPosition, &traffic_light_position_); + if (std::string identifier; + options.Get(options::kTabbingIdentifier, &identifier) && + !identifier.empty() && has_frame() && !transparent()) { + tabbing_identifier_ = identifier; + } + if (std::string state; options.Get(options::kVisualEffectState, &state)) { + visual_effect_state_ = state == "active" ? NSVisualEffectStateActive + : state == "inactive" + ? NSVisualEffectStateInactive + : NSVisualEffectStateFollowsWindowActiveState; + } + if (auto* shim = GetAppShim()) { + auto chrome = mojom::AppShimWindowChrome::New(); + chrome->has_frame = has_frame(); + chrome->title_bar_style = static_cast(title_bar_style()); + chrome->traffic_light_position = traffic_light_position_; + chrome->titlebar_overlay_height = titlebar_overlay_height(); + chrome->tabbing_identifier = tabbing_identifier_; + shim->InitWindowChrome(GetRemoteWindowId(), std::move(chrome)); + } + if (!movable_) + SetMovable(false); + widget()->SetNativeWindowProperty(kNativeWindowKey.c_str(), this); + widget_observation_.Observe(widget()); + + SetCanResize(resizable); + SetCanMinimize(minimizable); + SetCanMaximize(maximizable); + widget()->OnSizeConstraintsChanged(); + + // Width and height are the content's for a window.open() inner size and + // with useContentSize. + constexpr int kMinSizeReqdBySpec = 100; + const int inner_width = options.ValueOrDefault(options::kinnerWidth, 0); + const int inner_height = options.ValueOrDefault(options::kinnerHeight, 0); + bool use_content_size = + options.ValueOrDefault(options::kUseContentSize, false); + if (inner_width || inner_height) { + use_content_size = true; + if (inner_width) + width = std::max(kMinSizeReqdBySpec, inner_width); + if (inner_height) + height = std::max(kMinSizeReqdBySpec, inner_height); + } + if (use_content_size) + SetContentSize(gfx::Size(width, height)); + + last_bounds_ = widget()->GetWindowBoundsInScreen(); +} + +NativeWindowMacRemote::~NativeWindowMacRemote() = default; + +void NativeWindowMacRemote::OnTitleChanged() { + widget()->UpdateWindowTitle(); +} + +void NativeWindowMacRemote::SetContentView(views::View* view) { + views::View* root_view = GetContentsView(); + if (views::View* old_view = content_view()) { + set_content_view(nullptr); + if (old_view->owned_by_client()) + root_view->RemoveChildView(old_view); + else + root_view->RemoveChildViewT(old_view); + } + + set_content_view(view); + + // Events fall through the content view to a sibling WebContentsView. + view->SetEventTargeter(std::make_unique( + std::make_unique())); + + root_view->AddChildViewRaw(content_view()); + + FlushPendingRootLayout(root_view); +} + +void NativeWindowMacRemote::Close() { + if (!IsClosable()) { + WindowList::WindowCloseCancelled(this); + return; + } + NotifyWindowCloseButtonClicked(); +} + +void NativeWindowMacRemote::CloseImmediately() { + if (widget_callback_depth_ == 0) { + CloseNow(); + return; + } + // Called by a listener of one of the widget's own notifications, so + // destroying the widget now would free what is notifying. The window is + // closed as far as its observers are concerned, as they may be about to go + // away themselves; the widget follows once the notification has returned. + widget()->Hide(); + NotifyWindowClosed(); + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(&NativeWindowMacRemote::CloseNow, + weak_factory_.GetWeakPtr())); +} + +void NativeWindowMacRemote::CloseNow() { + if (!widget_destroyed_) + widget()->CloseNow(); +} + +void NativeWindowMacRemote::OnCloseButtonClicked() { + if (!IsClosed()) + NotifyWindowCloseButtonClicked(); +} + +bool NativeWindowMacRemote::IsInAppShim() const { + return true; +} + +mojom::AppShim* NativeWindowMacRemote::GetAppShim() const { + return app_shim_ ? app_shim_->GetAppShim() : nullptr; +} + +uint64_t NativeWindowMacRemote::GetRemoteWindowId() const { + auto* host = views::NativeWidgetMacNSWindowHost::GetFromNativeWindow( + GetNativeWindow()); + return host ? host->bridged_native_widget_id() : 0; +} + +void NativeWindowMacRemote::ShowMessageBox(const MessageBoxSettings& settings, + MessageBoxCallback callback) { + // Run whatever happens: with "cancel" if the shim drops the request. + callback = mojo::WrapCallbackWithDefaultInvokeIfNotRun( + std::move(callback), settings.cancel_id, false); + auto* app_shim = GetAppShim(); + const uint64_t window_id = GetRemoteWindowId(); + if (!app_shim || !window_id) + return; + + auto options = mojom::AppShimMessageBoxOptions::New(); + options->type = static_cast(settings.type); + options->buttons = settings.buttons; + options->default_id = settings.default_id; + options->cancel_id = settings.cancel_id; + options->message = settings.message; + options->detail = settings.detail; + options->checkbox_label = settings.checkbox_label; + options->checkbox_checked = settings.checkbox_checked; + options->icon = settings.icon; + options->text_width = settings.text_width; + options->has_id = settings.id.has_value(); + options->id = settings.id.value_or(0); + if (settings.id) { + // A box that is up under this id, here or in any shim, makes way, as one + // of this process's own does. + electron::CloseMessageBoxIfShowing(*settings.id); + static uint64_t next_serial = 0; + const uint64_t serial = ++next_serial; + RemoteMessageBoxes()[*settings.id] = {weak_factory_.GetWeakPtr(), serial}; + callback = base::BindOnce( + [](int id, uint64_t serial, MessageBoxCallback callback, int response, + bool checked) { + // Unless a later box has taken the id over. + auto it = RemoteMessageBoxes().find(id); + if (it != RemoteMessageBoxes().end() && it->second.serial == serial) + RemoteMessageBoxes().erase(it); + std::move(callback).Run(response, checked); + }, + *settings.id, serial, std::move(callback)); + } + app_shim->ShowMessageBox(window_id, std::move(options), std::move(callback)); +} + +int NativeWindowMacRemote::ShowMessageBoxSync( + const MessageBoxSettings& settings) { + // The box is in another process, so there is no modal session of this + // one to run; tasks, and so other JavaScript, run while it is up. + base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed); + int result = settings.cancel_id; + ShowMessageBox(settings, base::BindOnce( + [](base::OnceClosure quit, int* out, + int response, bool checkbox_checked) { + *out = response; + std::move(quit).Run(); + }, + run_loop.QuitClosure(), &result)); + run_loop.Run(); + return result; +} + +// static +bool NativeWindowMacRemote::CloseMessageBox(int id) { + auto it = RemoteMessageBoxes().find(id); + if (it == RemoteMessageBoxes().end()) + return false; + if (it->second.window) { + if (auto* app_shim = it->second.window->GetAppShim()) + app_shim->CloseMessageBox(id); + } + return true; +} + +void NativeWindowMacRemote::ShowOpenDialog( + const file_dialog::DialogSettings& settings, + file_dialog::OpenDialogCallback callback) { + callback = mojo::WrapCallbackWithDefaultInvokeIfNotRun( + std::move(callback), true, std::vector()); + auto* app_shim = GetAppShim(); + const uint64_t window_id = GetRemoteWindowId(); + if (!app_shim || !window_id) + return; + app_shim->ShowOpenDialog(window_id, ToMojo(settings), std::move(callback)); +} + +void NativeWindowMacRemote::ShowSaveDialog( + const file_dialog::DialogSettings& settings, + file_dialog::SaveDialogCallback callback) { + callback = mojo::WrapCallbackWithDefaultInvokeIfNotRun( + std::move(callback), true, base::FilePath()); + auto* app_shim = GetAppShim(); + const uint64_t window_id = GetRemoteWindowId(); + if (!app_shim || !window_id) + return; + app_shim->ShowSaveDialog(window_id, ToMojo(settings), std::move(callback)); +} + +remote_cocoa::mojom::NativeWidgetNSWindow* +NativeWindowMacRemote::GetNSWindowMojo() const { + auto* host = views::NativeWidgetMacNSWindowHost::GetFromNativeWindow( + GetNativeWindow()); + return host ? host->GetNSWindowMojo() : nullptr; +} + +void NativeWindowMacRemote::Focus(bool focus) { + if (!IsVisible()) + return; + if (focus) + widget()->Activate(); + else + widget()->Deactivate(); +} + +bool NativeWindowMacRemote::IsFocused() const { + return widget()->IsActive(); +} + +void NativeWindowMacRemote::Show() { + FlushPendingDisplayMode(); + widget()->Show(); +} + +void NativeWindowMacRemote::ShowInactive() { + FlushPendingDisplayMode(); + widget()->ShowInactive(); +} + +void NativeWindowMacRemote::Hide() { + widget()->Hide(); +} + +bool NativeWindowMacRemote::IsVisible() const { + return widget()->IsVisible() && !IsMinimized(); +} + +bool NativeWindowMacRemote::IsEnabled() const { + return true; +} + +void NativeWindowMacRemote::SetEnabled(bool enable) {} + +void NativeWindowMacRemote::Maximize() { + if (!IsVisible()) + widget()->Show(); + widget()->Maximize(); +} + +void NativeWindowMacRemote::Unmaximize() { + // Widget::Restore() would also leave full screen and the Dock. + if (auto* ns_window = GetNSWindowMojo(); ns_window && IsMaximized()) + ns_window->SetZoomed(false); +} + +bool NativeWindowMacRemote::IsMaximized() const { + return widget()->IsMaximized(); +} + +void NativeWindowMacRemote::Minimize() { + widget()->Minimize(); +} + +void NativeWindowMacRemote::Restore() { + // Widget::Restore() would also leave full screen and un-maximize. + if (auto* ns_window = GetNSWindowMojo()) + ns_window->SetMiniaturized(false); +} + +bool NativeWindowMacRemote::IsMinimized() const { + return widget()->IsMinimized(); +} + +void NativeWindowMacRemote::SetFullScreen(bool fullscreen) { + if (fullscreen == IsFullscreen() || (fullscreen && !IsFullScreenable())) + return; + widget()->SetFullscreen(fullscreen); +} + +bool NativeWindowMacRemote::IsFullscreen() const { + return widget()->IsFullscreen(); +} + +void NativeWindowMacRemote::SetBounds(const gfx::Rect& bounds, bool animate) { + widget()->SetBounds(bounds); +} + +gfx::Rect NativeWindowMacRemote::GetBounds() const { + return widget()->GetWindowBoundsInScreen(); +} + +gfx::Rect NativeWindowMacRemote::GetNormalBounds() const { + return widget()->GetRestoredBounds(); +} + +void NativeWindowMacRemote::SetSizeConstraints( + const extensions::SizeConstraints& window_constraints) { + NativeWindow::SetSizeConstraints(window_constraints); + widget()->OnSizeConstraintsChanged(); +} + +void NativeWindowMacRemote::SetContentSizeConstraints( + const extensions::SizeConstraints& size_constraints) { + NativeWindow::SetContentSizeConstraints(size_constraints); + widget()->OnSizeConstraintsChanged(); +} + +void NativeWindowMacRemote::SetResizable(bool resizable) { + SetCanResize(resizable); + widget()->OnSizeConstraintsChanged(); +} + +bool NativeWindowMacRemote::MoveAbove(const std::string& sourceId) { + return false; +} + +void NativeWindowMacRemote::MoveTop() { + widget()->StackAtTop(); +} + +bool NativeWindowMacRemote::IsResizable() const { + return CanResize(); +} + +void NativeWindowMacRemote::SetMovable(bool movable) { + movable_ = movable; + if (auto* ns_window = GetNSWindowMojo()) + ns_window->SetMovable(movable); +} + +bool NativeWindowMacRemote::IsMovable() const { + return movable_; +} + +void NativeWindowMacRemote::SetMinimizable(bool minimizable) { + SetCanMinimize(minimizable); + widget()->OnSizeConstraintsChanged(); + if (auto* ns_window = GetNSWindowMojo()) + ns_window->SetMiniaturizable(minimizable); +} + +bool NativeWindowMacRemote::IsMinimizable() const { + return CanMinimize(); +} + +void NativeWindowMacRemote::SetMaximizable(bool maximizable) { + SetCanMaximize(maximizable); + widget()->OnSizeConstraintsChanged(); +} + +bool NativeWindowMacRemote::IsMaximizable() const { + return CanMaximize(); +} + +void NativeWindowMacRemote::SetFullScreenable(bool fullscreenable) { + fullscreenable_ = fullscreenable; + SetCanFullscreen(fullscreenable); + widget()->OnSizeConstraintsChanged(); + // What views derives from the size constraints is "resizable and + // maximizable", which is a different question. + if (auto* ns_window = GetNSWindowMojo()) + ns_window->SetFullscreenable(fullscreenable); +} + +bool NativeWindowMacRemote::IsFullScreenable() const { + return fullscreenable_; +} + +void NativeWindowMacRemote::SetClosable(bool closable) { + closable_ = closable; + if (auto* ns_window = GetNSWindowMojo()) + ns_window->SetClosable(closable); +} + +bool NativeWindowMacRemote::IsClosable() const { + return closable_; +} + +void NativeWindowMacRemote::SetAlwaysOnTop(ui::ZOrderLevel z_order, + const std::string& level, + int relative_level) { + const bool was_on_top = GetZOrderLevel() != ui::ZOrderLevel::kNormal; + widget()->SetZOrderLevel(z_order); + always_on_top_level_ = z_order == ui::ZOrderLevel::kNormal ? "" : level; + const bool is_on_top = z_order != ui::ZOrderLevel::kNormal; + if (was_on_top != is_on_top) + NativeWindow::NotifyWindowAlwaysOnTopChanged(is_on_top); +} + +std::string NativeWindowMacRemote::GetAlwaysOnTopLevel() const { + return always_on_top_level_; +} + +ui::ZOrderLevel NativeWindowMacRemote::GetZOrderLevel() const { + return widget()->GetZOrderLevel(); +} + +void NativeWindowMacRemote::Center() { + widget()->CenterWindow(GetSize()); +} + +void NativeWindowMacRemote::Invalidate() { + root_view_->SchedulePaint(); +} + +bool NativeWindowMacRemote::IsActive() const { + return widget()->IsActive(); +} + +void NativeWindowMacRemote::SetActive(bool is_key) {} + +void NativeWindowMacRemote::RemoveChildFromParentWindow() {} + +void NativeWindowMacRemote::RemoveChildWindow(NativeWindow* child) {} + +void NativeWindowMacRemote::AttachChildren() {} + +void NativeWindowMacRemote::DetachChildren() {} + +void NativeWindowMacRemote::FlashFrame(bool flash) {} + +void NativeWindowMacRemote::SetSkipTaskbar(bool skip) {} + +void NativeWindowMacRemote::SetExcludedFromShownWindowsMenu(bool excluded) { + excluded_from_windows_menu_ = excluded; +} + +bool NativeWindowMacRemote::IsExcludedFromShownWindowsMenu() const { + return excluded_from_windows_menu_; +} + +void NativeWindowMacRemote::SetSimpleFullScreen(bool simple_fullscreen) {} + +bool NativeWindowMacRemote::IsSimpleFullScreen() const { + return false; +} + +void NativeWindowMacRemote::SetKiosk(bool kiosk) { + if (kiosk == is_kiosk_) + return; + is_kiosk_ = kiosk; + SetFullScreen(kiosk); +} + +bool NativeWindowMacRemote::IsKiosk() const { + return is_kiosk_; +} + +void NativeWindowMacRemote::SetBackgroundColor(SkColor color) { + background_color_ = color; + root_view_->SetBackground( + transparent() ? nullptr : views::CreateSolidBackground(color)); + root_view_->SchedulePaint(); +} + +SkColor NativeWindowMacRemote::GetBackgroundColor() const { + return background_color_; +} + +void NativeWindowMacRemote::SetHasShadow(bool has_shadow) { + has_shadow_ = has_shadow; +} + +bool NativeWindowMacRemote::HasShadow() const { + return has_shadow_; +} + +void NativeWindowMacRemote::SetOpacity(const double opacity) { + opacity_ = ClampOpacity(opacity); + widget()->SetOpacity(opacity_); +} + +double NativeWindowMacRemote::GetOpacity() const { + return opacity_; +} + +void NativeWindowMacRemote::SetIgnoreMouseEvents(bool ignore, bool forward) { + if (auto* ns_window = GetNSWindowMojo()) + ns_window->SetIgnoresMouseEvents(ignore); +} + +void NativeWindowMacRemote::SetContentProtection(bool enable) { + content_protected_ = enable; + widget()->SetAllowScreenshots(!enable); +} + +bool NativeWindowMacRemote::IsContentProtected() const { + return content_protected_; +} + +content::DesktopMediaID NativeWindowMacRemote::GetDesktopMediaID() const { + return content::DesktopMediaID(content::DesktopMediaID::TYPE_WINDOW, + GetAcceleratedWidget()); +} + +gfx::NativeView NativeWindowMacRemote::GetNativeView() const { + return widget()->GetNativeView(); +} + +gfx::NativeWindow NativeWindowMacRemote::GetNativeWindow() const { + return widget()->GetNativeWindow(); +} + +gfx::AcceleratedWidget NativeWindowMacRemote::GetAcceleratedWidget() const { + // The stand-in window of this process, as there is no way to name another + // process's window with this type. + return GetNativeWindow().GetNativeNSWindow().windowNumber; +} + +NativeWindowHandle NativeWindowMacRemote::GetNativeWindowHandle() const { + return GetNativeView(); +} + +void NativeWindowMacRemote::SetProgressBar(double progress, + const ProgressState state) {} + +void NativeWindowMacRemote::SetOverlayIcon(const gfx::Image& overlay, + const std::string& description) {} + +void NativeWindowMacRemote::SetVisibleOnAllWorkspaces( + bool visible, + bool visibleOnFullScreen, + bool skipTransformProcessType) { + widget()->SetVisibleOnAllWorkspaces(visible); +} + +bool NativeWindowMacRemote::IsVisibleOnAllWorkspaces() const { + return widget()->IsVisibleOnAllWorkspaces(); +} + +void NativeWindowMacRemote::SetWindowButtonVisibility(bool visible) { + window_button_visibility_ = visible; + if (auto* shim = GetAppShim()) + shim->SetWindowButtonVisibility(GetRemoteWindowId(), visible); +} + +bool NativeWindowMacRemote::GetWindowButtonVisibility() const { + // Until told otherwise they show wherever there is a title bar for them, + // except where they wait for the pointer. + return window_button_visibility_.value_or( + has_frame() || title_bar_style() == TitleBarStyle::kHidden || + title_bar_style() == TitleBarStyle::kHiddenInset); +} + +void NativeWindowMacRemote::SetWindowButtonPosition( + std::optional position) { + traffic_light_position_ = position; + if (auto* shim = GetAppShim()) + shim->SetWindowButtonPosition(GetRemoteWindowId(), position); +} + +std::optional NativeWindowMacRemote::GetWindowButtonPosition() + const { + return traffic_light_position_; +} + +// The shim's process redraws them when its window changes. +void NativeWindowMacRemote::RedrawTrafficLights() {} + +void NativeWindowMacRemote::SetVibrancy(const std::string& type, int duration) { + NativeWindow::SetVibrancy(type, duration); + if (auto* shim = GetAppShim()) { + shim->SetWindowVibrancy(GetRemoteWindowId(), type, duration, + visual_effect_state_); + } +} + +void NativeWindowMacRemote::PerformTabAction(mojom::AppShimTabAction action) { + if (auto* shim = GetAppShim()) + shim->PerformWindowTabAction(GetRemoteWindowId(), action); +} + +void NativeWindowMacRemote::SelectPreviousTab() { + PerformTabAction(mojom::AppShimTabAction::kSelectPrevious); +} + +void NativeWindowMacRemote::SelectNextTab() { + PerformTabAction(mojom::AppShimTabAction::kSelectNext); +} + +void NativeWindowMacRemote::ShowAllTabs() { + PerformTabAction(mojom::AppShimTabAction::kShowAll); +} + +void NativeWindowMacRemote::MergeAllWindows() { + PerformTabAction(mojom::AppShimTabAction::kMergeAll); +} + +void NativeWindowMacRemote::MoveTabToNewWindow() { + PerformTabAction(mojom::AppShimTabAction::kMoveToNewWindow); +} + +void NativeWindowMacRemote::ToggleTabBar() { + PerformTabAction(mojom::AppShimTabAction::kToggleTabBar); +} + +bool NativeWindowMacRemote::AddTabbedWindow(NativeWindow* window) { + // Tabs are windows of one process: the other window has to be in the same + // shim. + if (window == this || !window->IsInAppShim()) + return false; + auto* other = static_cast(window); + auto* shim = GetAppShim(); + if (!shim || other->GetAppShim() != shim) + return false; + shim->AddTabbedWindow(GetRemoteWindowId(), other->GetRemoteWindowId()); + return true; +} + +std::optional NativeWindowMacRemote::GetTabbingIdentifier() const { + return tabbing_identifier_; +} + +void NativeWindowMacRemote::PreviewFile(const std::string& path, + const std::string& display_name) { + if (auto* shim = GetAppShim()) { + shim->PreviewFile(GetRemoteWindowId(), base::FilePath(path), display_name); + } +} + +void NativeWindowMacRemote::CloseFilePreview() { + if (auto* shim = GetAppShim()) + shim->CloseFilePreview(GetRemoteWindowId()); +} + +void NativeWindowMacRemote::UpdateFrame() {} + +bool NativeWindowMacRemote::IsHiddenInMissionControl() const { + return false; +} + +void NativeWindowMacRemote::SetHiddenInMissionControl(bool hidden) {} + +void NativeWindowMacRemote::SetAspectRatio(double aspect_ratio, + const gfx::Size& extra_size) { + NativeWindow::SetAspectRatio(aspect_ratio, extra_size); + // An empty ratio lifts the restriction. + if (auto* ns_window = GetNSWindowMojo()) { + ns_window->SetAspectRatio( + aspect_ratio > 0.0 ? gfx::SizeF(aspect_ratio, 1.0) : gfx::SizeF(), + extra_size); + } +} + +int NativeWindowMacRemote::TitleBarHeight() const { + if (!has_frame()) + return 0; + const NSRect content = NSMakeRect(0, 0, 100, 100); + const NSRect frame = [NSWindow frameRectForContentRect:content + styleMask:style_mask_]; + return NSHeight(frame) - NSHeight(content); +} + +gfx::Rect NativeWindowMacRemote::ContentBoundsToWindowBounds( + const gfx::Rect& bounds) const { + gfx::Rect window_bounds(bounds); + const int title_bar_height = TitleBarHeight(); + window_bounds.set_y(window_bounds.y() - title_bar_height); + window_bounds.set_height(window_bounds.height() + title_bar_height); + return window_bounds; +} + +gfx::Rect NativeWindowMacRemote::WindowBoundsToContentBounds( + const gfx::Rect& bounds) const { + gfx::Rect content_bounds(bounds); + const int title_bar_height = TitleBarHeight(); + content_bounds.set_y(content_bounds.y() + title_bar_height); + content_bounds.set_height( + std::max(0, content_bounds.height() - title_bar_height)); + return content_bounds; +} + +views::View* NativeWindowMacRemote::GetContentsView() { + return root_view_.get(); +} + +views::ClientView* NativeWindowMacRemote::CreateClientView( + views::Widget* widget) { + return new RemoteWindowClientView( + widget, TransferOwnershipOfContentsView(), + base::BindRepeating(&NativeWindowMacRemote::OnCloseButtonClicked, + weak_factory_.GetWeakPtr())); +} + +std::unique_ptr NativeWindowMacRemote::CreateFrameView( + views::Widget* widget) { + auto frame_view = std::make_unique(widget, this); + // The shim asks, over Mojo, whether a point of the page drags the window. + frame_view->set_non_client_hit_test_callback( + base::BindRepeating(&NativeWindowMacRemote::FrameViewNonClientHitTest, + base::Unretained(this))); + return frame_view; +} + +int NativeWindowMacRemote::FrameViewNonClientHitTest(const gfx::Point& point) { + if (widget()->IsFullscreen()) + return HTCLIENT; + // A draggable region of a frameless window's page. + const int contents_hit_test = NonClientHitTest(point); + return contents_hit_test != HTNOWHERE ? contents_hit_test : HTCLIENT; +} + +void NativeWindowMacRemote::OnWidgetActivationChanged(views::Widget* widget, + bool active) { + base::AutoReset in_callback(&widget_callback_depth_, + widget_callback_depth_ + 1); + if (active) + NotifyWindowFocus(); + else + NotifyWindowBlur(); +} + +void NativeWindowMacRemote::OnWidgetBoundsChanged(views::Widget* widget, + const gfx::Rect& bounds) { + base::AutoReset in_callback(&widget_callback_depth_, + widget_callback_depth_ + 1); + const gfx::Rect previous = std::exchange(last_bounds_, bounds); + if (previous.size() != bounds.size()) + NotifyWindowResize(); + if (previous.origin() != bounds.origin() && !IsClosed()) { + // On macOS `moved` is an alias of `move`. + NotifyWindowMove(); + NotifyWindowMoved(); + } +} + +void NativeWindowMacRemote::OnWidgetUserResizeEnded(views::Widget* widget) { + base::AutoReset in_callback(&widget_callback_depth_, + widget_callback_depth_ + 1); + NotifyWindowResized(); +} + +void NativeWindowMacRemote::OnWidgetVisibilityChanged(views::Widget* widget, + bool visible) { + base::AutoReset in_callback(&widget_callback_depth_, + widget_callback_depth_ + 1); + if (visible) + NotifyWindowShow(); + else + NotifyWindowHide(); +} + +void NativeWindowMacRemote::OnWidgetShowStateChanged(views::Widget* widget) { + base::AutoReset in_callback(&widget_callback_depth_, + widget_callback_depth_ + 1); + const bool minimized = widget->IsMinimized(); + if (std::exchange(was_minimized_, minimized) != minimized) { + if (minimized) + NotifyWindowMinimize(); + else + NotifyWindowRestore(); + } + const bool fullscreen = widget->IsFullscreen(); + if (std::exchange(was_fullscreen_, fullscreen) != fullscreen) { + if (fullscreen) + NotifyWindowEnterFullScreen(); + else + NotifyWindowLeaveFullScreen(); + } + const bool maximized = widget->IsMaximized(); + if (std::exchange(was_maximized_, maximized) != maximized) { + if (maximized) + NotifyWindowMaximize(); + else + NotifyWindowUnmaximize(); + } +} + +void NativeWindowMacRemote::OnWidgetDestroyed(views::Widget* widget) { + widget_destroyed_ = true; + widget_observation_.Reset(); + NotifyWindowClosed(); +} + +} // namespace electron diff --git a/shell/browser/ui/certificate_trust_mac.mm b/shell/browser/ui/certificate_trust_mac.mm index 22af2bdbef..2a90d21c22 100644 --- a/shell/browser/ui/certificate_trust_mac.mm +++ b/shell/browser/ui/certificate_trust_mac.mm @@ -96,7 +96,8 @@ v8::Local ShowCertificateTrust( SecTrustRef trust = nullptr; SecTrustCreateWithCertificates(cert_chain.get(), sec_policy, &trust); - NSWindow* window = parent_window + // A window in an app shim has no NSWindow here that could take the sheet. + NSWindow* window = parent_window && !parent_window->IsInAppShim() ? parent_window->GetNativeWindow().GetNativeNSWindow() : nil; auto msg = base::SysUTF8ToNSString(message); diff --git a/shell/browser/ui/file_dialog.h b/shell/browser/ui/file_dialog.h index 5c4f363ef7..dbfaa2f328 100644 --- a/shell/browser/ui/file_dialog.h +++ b/shell/browser/ui/file_dialog.h @@ -11,7 +11,10 @@ #include #include "base/files/file_path.h" +#include "base/functional/callback_forward.h" #include "base/memory/raw_ptr_exclusion.h" +#include "build/build_config.h" +#include "ui/gfx/native_ui_types.h" #if BUILDFLAG(IS_LINUX) #include @@ -83,6 +86,25 @@ std::optional ShowSaveDialogSync( void ShowSaveDialog(const DialogSettings& settings, gin_helper::Promise promise); +#if BUILDFLAG(IS_MAC) +using OpenDialogCallback = + base::OnceCallback& paths)>; +using SaveDialogCallback = + base::OnceCallback; + +// The dialogs for a parent that is not a NativeWindow of this process: the +// panel is a sheet of `window`, which must not be null and which the +// settings' own parent is ignored for. An app shim's process shows the +// dialogs of its windows with these. +void ShowOpenDialogForNSWindow(const DialogSettings& settings, + gfx::NativeWindow window, + OpenDialogCallback callback); +void ShowSaveDialogForNSWindow(const DialogSettings& settings, + gfx::NativeWindow window, + SaveDialogCallback callback); +#endif + } // namespace file_dialog #endif // ELECTRON_SHELL_BROWSER_UI_FILE_DIALOG_H_ diff --git a/shell/browser/ui/file_dialog_mac.mm b/shell/browser/ui/file_dialog_mac.mm index e396945e38..70001ec3ad 100644 --- a/shell/browser/ui/file_dialog_mac.mm +++ b/shell/browser/ui/file_dialog_mac.mm @@ -15,11 +15,15 @@ #include "base/apple/foundation_util.h" #include "base/apple/scoped_cftyperef.h" #include "base/files/file_util.h" +#include "base/functional/bind.h" #include "base/mac/mac_util.h" +#include "base/run_loop.h" #include "base/strings/sys_string_conversions.h" +#include "base/task/sequenced_task_runner.h" #include "content/public/browser/browser_thread.h" #include "electron/mas.h" #include "shell/browser/native_window.h" +#include "shell/browser/native_window_mac_remote.h" #include "shell/common/electron_paths.h" #include "shell/common/gin_converters/file_path_converter.h" #include "shell/common/gin_helper/dictionary.h" @@ -254,16 +258,32 @@ void SetupSaveDialogForProperties(NSSavePanel* dialog, int properties) { [dialog setTreatsFilePackagesAsDirectories:YES]; } +// The window the dialog is a sheet of, if any. A window in an app shim has no +// NSWindow in this process that could take one. +NSWindow* SheetParent(const DialogSettings& settings) { + if (!settings.parent_window || settings.force_detached || + settings.parent_window->IsInAppShim()) { + return nil; + } + return settings.parent_window->GetNativeWindow().GetNativeNSWindow(); +} + +// The window in an app shim that shows the dialog as its sheet, if any. +electron::NativeWindowMacRemote* ShimParent(const DialogSettings& settings) { + if (!settings.parent_window || settings.force_detached || + !settings.parent_window->IsInAppShim()) { + return nullptr; + } + return static_cast(settings.parent_window); +} + // Run modal dialog with parent window and return user's choice. int RunModalDialog(NSSavePanel* dialog, const DialogSettings& settings) { __block int chosen = NSModalResponseCancel; - if (!settings.parent_window || !settings.parent_window->GetNativeWindow() || - settings.force_detached) { + NSWindow* window = SheetParent(settings); + if (!window) { chosen = [dialog runModal]; } else { - NSWindow* window = - settings.parent_window->GetNativeWindow().GetNativeNSWindow(); - [dialog beginSheetModalForWindow:window completionHandler:^(NSInteger c) { chosen = c; @@ -362,11 +382,60 @@ void ResolvePromiseInNextTick(gin_helper::Promise> promise, std::move(promise), v8::Global(promise.isolate(), value))); } +// Waits, in a nested loop, for a dialog that a shim's process is showing. +template +Result WaitForShimDialog( + base::OnceCallback)> + show) { + base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed); + Result result; + std::move(show).Run(base::BindOnce( + [](base::OnceClosure quit, Result* out, bool canceled, + const Result& value) { + if (!canceled) + *out = value; + std::move(quit).Run(); + }, + run_loop.QuitClosure(), &result)); + run_loop.Run(); + return result; +} + +gin_helper::Dictionary OpenDialogResult( + v8::Isolate* isolate, + bool canceled, + const std::vector& paths, + const std::vector& bookmarks) { + auto dict = gin_helper::Dictionary::CreateEmpty(isolate); + dict.Set("canceled", canceled); + dict.Set("filePaths", paths); +#if IS_MAS_BUILD() + dict.Set("bookmarks", bookmarks); +#endif + return dict; +} + +gin_helper::Dictionary SaveDialogResult(v8::Isolate* isolate, + bool canceled, + const base::FilePath& path) { + auto dict = gin_helper::Dictionary::CreateEmpty(isolate); + dict.Set("canceled", canceled); + dict.Set("filePath", path); + return dict; +} + } // namespace bool ShowOpenDialogSync(const DialogSettings& settings, std::vector* paths) { DCHECK(paths); + if (auto* shim_parent = ShimParent(settings)) { + *paths = WaitForShimDialog>( + base::BindOnce(&electron::NativeWindowMacRemote::ShowOpenDialog, + base::Unretained(shim_parent), settings)); + return !paths->empty(); + } + NSOpenPanel* dialog = [NSOpenPanel openPanel]; SetupDialog(dialog, settings); @@ -385,35 +454,41 @@ void OpenDialogCompletion(int chosen, bool security_scoped_bookmarks, gin_helper::Promise promise) { v8::HandleScope scope(promise.isolate()); - auto dict = gin_helper::Dictionary::CreateEmpty(promise.isolate()); - if (chosen == NSModalResponseCancel) { - dict.Set("canceled", true); - dict.Set("filePaths", std::vector()); + std::vector paths; + std::vector bookmarks; + if (chosen != NSModalResponseCancel) { #if IS_MAS_BUILD() - dict.Set("bookmarks", std::vector()); -#endif - } else { - std::vector paths; - dict.Set("canceled", false); -#if IS_MAS_BUILD() - std::vector bookmarks; if (security_scoped_bookmarks) ReadDialogPathsWithBookmarks(dialog, &paths, &bookmarks); else ReadDialogPaths(dialog, &paths); - dict.Set("filePaths", paths); - dict.Set("bookmarks", bookmarks); #else ReadDialogPaths(dialog, &paths); - dict.Set("filePaths", paths); #endif } - ResolvePromiseInNextTick(promise.As>(), - dict.GetHandle()); + ResolvePromiseInNextTick( + promise.As>(), + OpenDialogResult(promise.isolate(), chosen == NSModalResponseCancel, + paths, bookmarks) + .GetHandle()); } void ShowOpenDialog(const DialogSettings& settings, gin_helper::Promise promise) { + if (auto* shim_parent = ShimParent(settings)) { + shim_parent->ShowOpenDialog( + settings, + base::BindOnce( + [](gin_helper::Promise promise, + bool canceled, const std::vector& paths) { + v8::HandleScope scope(promise.isolate()); + promise.Resolve( + OpenDialogResult(promise.isolate(), canceled, paths, {})); + }, + std::move(promise))); + return; + } + NSOpenPanel* dialog = [NSOpenPanel openPanel]; SetupDialog(dialog, settings); @@ -425,26 +500,57 @@ void ShowOpenDialog(const DialogSettings& settings, __block gin_helper::Promise p = std::move(promise); - if (!settings.parent_window || !settings.parent_window->GetNativeWindow() || - settings.force_detached) { - [dialog beginWithCompletionHandler:^(NSInteger chosen) { - OpenDialogCompletion(chosen, dialog, security_scoped_bookmarks, - std::move(p)); - }]; - } else { - NSWindow* window = - settings.parent_window->GetNativeWindow().GetNativeNSWindow(); + if (NSWindow* window = SheetParent(settings)) { [dialog beginSheetModalForWindow:window completionHandler:^(NSInteger chosen) { OpenDialogCompletion(chosen, dialog, security_scoped_bookmarks, std::move(p)); }]; + } else { + [dialog beginWithCompletionHandler:^(NSInteger chosen) { + OpenDialogCompletion(chosen, dialog, security_scoped_bookmarks, + std::move(p)); + }]; } } +void ShowOpenDialogForNSWindow(const DialogSettings& settings, + gfx::NativeWindow window, + OpenDialogCallback callback) { + NSOpenPanel* dialog = [NSOpenPanel openPanel]; + + SetupDialog(dialog, settings); + SetupOpenDialogForProperties(dialog, settings.properties); + + __block OpenDialogCallback cb = std::move(callback); + scoped_refptr task_runner = + base::SequencedTaskRunner::GetCurrentDefault(); + [dialog + beginSheetModalForWindow:window.GetNativeNSWindow() + completionHandler:^(NSInteger chosen) { + std::vector paths; + if (chosen != NSModalResponseCancel) + ReadDialogPaths(dialog, &paths); + // Not from inside the panel's own transaction. + task_runner->PostTask( + FROM_HERE, base::BindOnce(std::move(cb), + chosen == NSModalResponseCancel, + std::move(paths))); + }]; +} + std::optional ShowSaveDialogSync( const DialogSettings& settings) { + if (auto* shim_parent = ShimParent(settings)) { + base::FilePath path = WaitForShimDialog( + base::BindOnce(&electron::NativeWindowMacRemote::ShowSaveDialog, + base::Unretained(shim_parent), settings)); + if (path.empty()) + return {}; + return path; + } + NSSavePanel* dialog = [NSSavePanel savePanel]; SetupDialog(dialog, settings); @@ -487,6 +593,19 @@ void SaveDialogCompletion(int chosen, void ShowSaveDialog(const DialogSettings& settings, gin_helper::Promise promise) { + if (auto* shim_parent = ShimParent(settings)) { + shim_parent->ShowSaveDialog( + settings, base::BindOnce( + [](gin_helper::Promise promise, + bool canceled, const base::FilePath& path) { + v8::HandleScope scope(promise.isolate()); + promise.Resolve(SaveDialogResult(promise.isolate(), + canceled, path)); + }, + std::move(promise))); + return; + } + NSSavePanel* dialog = [NSSavePanel savePanel]; SetupDialog(dialog, settings); @@ -499,22 +618,44 @@ void ShowSaveDialog(const DialogSettings& settings, __block gin_helper::Promise p = std::move(promise); - if (!settings.parent_window || !settings.parent_window->GetNativeWindow() || - settings.force_detached) { - [dialog beginWithCompletionHandler:^(NSInteger chosen) { - SaveDialogCompletion(chosen, dialog, security_scoped_bookmarks, - std::move(p)); - }]; - } else { - NSWindow* window = - settings.parent_window->GetNativeWindow().GetNativeNSWindow(); + if (NSWindow* window = SheetParent(settings)) { [dialog beginSheetModalForWindow:window completionHandler:^(NSInteger chosen) { SaveDialogCompletion(chosen, dialog, security_scoped_bookmarks, std::move(p)); }]; + } else { + [dialog beginWithCompletionHandler:^(NSInteger chosen) { + SaveDialogCompletion(chosen, dialog, security_scoped_bookmarks, + std::move(p)); + }]; } } +void ShowSaveDialogForNSWindow(const DialogSettings& settings, + gfx::NativeWindow window, + SaveDialogCallback callback) { + NSSavePanel* dialog = [NSSavePanel savePanel]; + + SetupDialog(dialog, settings); + SetupSaveDialogForProperties(dialog, settings.properties); + [dialog setCanSelectHiddenExtension:YES]; + + __block SaveDialogCallback cb = std::move(callback); + scoped_refptr task_runner = + base::SequencedTaskRunner::GetCurrentDefault(); + [dialog + beginSheetModalForWindow:window.GetNativeNSWindow() + completionHandler:^(NSInteger chosen) { + base::FilePath path; + if (chosen != NSModalResponseCancel && [[dialog URL] isFileURL]) + path = base::FilePath( + base::SysNSStringToUTF8([[dialog URL] path])); + task_runner->PostTask(FROM_HERE, + base::BindOnce(std::move(cb), path.empty(), + std::move(path))); + }]; +} + } // namespace file_dialog diff --git a/shell/browser/ui/message_box.h b/shell/browser/ui/message_box.h index 489b17a328..d00ad09d82 100644 --- a/shell/browser/ui/message_box.h +++ b/shell/browser/ui/message_box.h @@ -11,7 +11,9 @@ #include "base/functional/callback_forward.h" #include "base/memory/raw_ptr_exclusion.h" +#include "build/build_config.h" #include "ui/gfx/image/image_skia.h" +#include "ui/gfx/native_ui_types.h" namespace electron { @@ -56,6 +58,18 @@ void ShowMessageBox(const MessageBoxSettings& settings, void CloseMessageBox(int id); +#if BUILDFLAG(IS_MAC) +// CloseMessageBox(), if a box with this id is showing; nothing otherwise. +void CloseMessageBoxIfShowing(int id); + +// ShowMessageBox() for a parent that is not a NativeWindow of this process: +// the box is a sheet of `window`, or stands alone when `window` is null. An +// app shim's process shows the boxes of its windows with it. +void ShowMessageBoxForNSWindow(const MessageBoxSettings& settings, + gfx::NativeWindow window, + MessageBoxCallback callback); +#endif + // Like ShowMessageBox with simplest settings, but safe to call at very early // stage of application. void ShowErrorBox(const std::u16string& title, const std::u16string& content); diff --git a/shell/browser/ui/message_box_mac.mm b/shell/browser/ui/message_box_mac.mm index bf69051a47..f886bd64b3 100644 --- a/shell/browser/ui/message_box_mac.mm +++ b/shell/browser/ui/message_box_mac.mm @@ -13,8 +13,9 @@ #include "base/mac/mac_util.h" #include "base/no_destructor.h" #include "base/strings/sys_string_conversions.h" -#include "content/public/browser/browser_thread.h" +#include "base/task/sequenced_task_runner.h" #include "shell/browser/native_window.h" +#include "shell/browser/native_window_mac_remote.h" #include "skia/ext/skia_utils_mac.h" #include "ui/gfx/image/image_skia.h" @@ -110,6 +111,11 @@ NSAlert* CreateNSAlert(const MessageBoxSettings& settings) { } // namespace int ShowMessageBoxSync(const MessageBoxSettings& settings) { + if (settings.parent_window && settings.parent_window->IsInAppShim()) { + return static_cast(settings.parent_window) + ->ShowMessageBoxSync(settings); + } + NSAlert* alert(CreateNSAlert(settings)); // Use runModal for synchronous alert without parent, since we don't have a @@ -135,31 +141,43 @@ int ShowMessageBoxSync(const MessageBoxSettings& settings) { void ShowMessageBox(const MessageBoxSettings& settings, MessageBoxCallback callback) { + if (settings.parent_window && settings.parent_window->IsInAppShim()) { + static_cast(settings.parent_window) + ->ShowMessageBox(settings, std::move(callback)); + return; + } + ShowMessageBoxForNSWindow(settings, + settings.parent_window + ? settings.parent_window->GetNativeWindow() + : gfx::NativeWindow(), + std::move(callback)); +} + +void ShowMessageBoxForNSWindow(const MessageBoxSettings& settings, + gfx::NativeWindow native_window, + MessageBoxCallback callback) { NSAlert* alert = CreateNSAlert(settings); + NSWindow* window = native_window.GetNativeNSWindow(); // Use runModal for synchronous alert without parent, since we don't have a // window to wait for. - if (!settings.parent_window) { + if (!window) { int ret = [alert runModal]; std::move(callback).Run( ret, alert.suppressionButton.state == NSControlStateValueOn); } else { if (settings.id) { - if (GetDialogsMap().contains(*settings.id)) - CloseMessageBox(*settings.id); + CloseMessageBoxIfShowing(*settings.id); GetDialogsMap()[*settings.id] = alert; } - NSWindow* window = - settings.parent_window - ? settings.parent_window->GetNativeWindow().GetNativeNSWindow() - : nil; - // Duplicate the callback object here since c is a reference and gcd would // only store the pointer, by duplication we can force gcd to store a copy. __block MessageBoxCallback callback_ = std::move(callback); __block std::optional id = std::move(settings.id); __block int cancel_id = settings.cancel_id; + scoped_refptr task_runner = + base::SequencedTaskRunner::GetCurrentDefault(); auto handler = ^(NSModalResponse response) { if (id) @@ -175,16 +193,26 @@ void ShowMessageBox(const MessageBoxSettings& settings, // users will run in the callback, we have to delay running the callback // until next tick, otherwise crash like this may happen: // https://github.com/electron/electron/issues/26884 - content::GetUIThreadTaskRunner({})->PostTask( - FROM_HERE, - base::BindOnce(std::move(callback_), response, suppressed)); + task_runner->PostTask(FROM_HERE, base::BindOnce(std::move(callback_), + response, suppressed)); }; [alert beginSheetModalForWindow:window completionHandler:handler]; } } +void CloseMessageBoxIfShowing(int id) { + if (auto it = GetDialogsMap().find(id); it != GetDialogsMap().end()) { + [NSApp endSheet:it->second.window]; + return; + } + // One that a window in an app shim is showing, in the shim's process. + NativeWindowMacRemote::CloseMessageBox(id); +} + void CloseMessageBox(int id) { auto it = GetDialogsMap().find(id); + if (it == GetDialogsMap().end() && NativeWindowMacRemote::CloseMessageBox(id)) + return; if (it == GetDialogsMap().end()) { LOG(ERROR) << "CloseMessageBox called with nonexistent ID"; return; diff --git a/shell/browser/ui/views/content_view_targeter_delegate.cc b/shell/browser/ui/views/content_view_targeter_delegate.cc new file mode 100644 index 0000000000..ed52b3b35c --- /dev/null +++ b/shell/browser/ui/views/content_view_targeter_delegate.cc @@ -0,0 +1,33 @@ +// Copyright (c) 2013 GitHub, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/ui/views/content_view_targeter_delegate.h" + +#include "ui/gfx/geometry/rect.h" +#include "ui/gfx/geometry/rect_conversions.h" +#include "ui/gfx/geometry/rect_f.h" +#include "ui/views/view.h" + +namespace electron { + +bool ContentViewTargeterDelegate::DoesIntersectRect( + const views::View* target, + const gfx::Rect& rect) const { + if (!views::ViewTargeterDelegate::DoesIntersectRect(target, rect)) { + return false; + } + for (const views::View* child : target->children()) { + if (!child->GetVisible() || !child->GetCanProcessEventsWithinSubtree()) { + continue; + } + gfx::RectF rect_in_child(rect); + views::View::ConvertRectToTarget(target, child, &rect_in_child); + if (child->HitTestRect(gfx::ToEnclosingRect(rect_in_child))) { + return true; + } + } + return false; +} + +} // namespace electron diff --git a/shell/browser/ui/views/content_view_targeter_delegate.h b/shell/browser/ui/views/content_view_targeter_delegate.h new file mode 100644 index 0000000000..d18e9d7ef1 --- /dev/null +++ b/shell/browser/ui/views/content_view_targeter_delegate.h @@ -0,0 +1,46 @@ +// Copyright (c) 2013 GitHub, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_UI_VIEWS_CONTENT_VIEW_TARGETER_DELEGATE_H_ +#define ELECTRON_SHELL_BROWSER_UI_VIEWS_CONTENT_VIEW_TARGETER_DELEGATE_H_ + +#include "ui/views/view_targeter_delegate.h" + +namespace gfx { +class Rect; +} // namespace gfx + +namespace views { +class View; +} // namespace views + +namespace electron { + +// A ViewTargeterDelegate for a window's content view that makes it +// hit-test-transparent except where one of its descendants covers the +// target rect. +// +// In BrowserWindow, the WebContentsView is added as a sibling of +// content_view_ at a lower z-order so that user-added child views paint +// above web content. content_view_ itself covers the full window, so the +// default DoesIntersectRect (a bounds check) would always return true and +// the parent targeter would stop there, never reaching the WebContentsView +// behind it. By only intersecting where a child actually does, the parent's +// default TargetForRect loop skips content_view_ and falls through to the +// sibling, ultimately resolving to the WebContentsView's NativeViewHost so +// BridgedContentView::hitTest: routes the event to RenderWidgetHostViewCocoa. +// +// This must never affect TargetForRect's contract — it only narrows +// HitTestRect, which the default loop already handles by skipping the child. +// Returning nullptr from TargetForRect (the previous approach) violated the +// contract and crashed RootView::UpdateCursor (see #51576). +class ContentViewTargeterDelegate : public views::ViewTargeterDelegate { + public: + bool DoesIntersectRect(const views::View* target, + const gfx::Rect& rect) const override; +}; + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_UI_VIEWS_CONTENT_VIEW_TARGETER_DELEGATE_H_ diff --git a/shell/common/BUILD.gn b/shell/common/BUILD.gn index ee22922bec..e2e681e203 100644 --- a/shell/common/BUILD.gn +++ b/shell/common/BUILD.gn @@ -85,3 +85,17 @@ mojom("web_contents_utility") { "//url/mojom:url_mojom_origin", ] } + +if (is_mac) { + mojom("app_shim") { + sources = [ "app_shim.mojom" ] + + public_deps = [ + "//components/remote_cocoa/common:mojo", + "//mojo/public/mojom/base", + "//ui/gfx/geometry/mojom", + "//ui/gfx/image/mojom", + "//url/mojom:url_mojom_gurl", + ] + } +} diff --git a/shell/common/app_shim.mojom b/shell/common/app_shim.mojom new file mode 100644 index 0000000000..a1372bb9d4 --- /dev/null +++ b/shell/common/app_shim.mojom @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +module electron.mojom; + +import "components/remote_cocoa/common/application.mojom"; +import "mojo/public/mojom/base/file_path.mojom"; +import "mojo/public/mojom/base/string16.mojom"; +import "ui/gfx/geometry/mojom/geometry.mojom"; +import "ui/gfx/image/mojom/image.mojom"; +import "url/mojom/url.mojom"; + +enum AppShimAttentionType { + kCancel, + kInformational, + kCritical, +}; + +// How much of the accessibility tree an assistive technology that is looking +// at the shim needs. kPartial is the native controls; kComplete adds web +// content, which a screen reader needs. +enum AppShimAccessibilityMode { + kPartial, + kComplete, +}; + +// electron::MessageBoxSettings, less the parent window. +struct AppShimMessageBoxOptions { + // An electron::MessageBoxType. + int32 type; + array buttons; + int32 default_id; + int32 cancel_id; + string message; + string detail; + string checkbox_label; + bool checkbox_checked; + gfx.mojom.ImageSkia? icon; + int32 text_width; + // What AppShim.CloseMessageBox names the box by, when it has one. + bool has_id; + int32 id; +}; + +struct AppShimFileDialogFilter { + string name; + array extensions; +}; + +// file_dialog::DialogSettings, less the parent window. +struct AppShimFileDialogOptions { + string title; + string message; + string button_label; + string name_field_label; + mojo_base.mojom.FilePath default_path; + array filters; + // file_dialog::OpenFileDialogProperty or SaveFileDialogProperty bits. + int32 properties; + bool shows_tag_field; +}; + +// What a window was created with that remote_cocoa has no call for. Sent +// once, right after the window is made. +struct AppShimWindowChrome { + bool has_frame; + // An electron::NativeWindow::TitleBarStyle. + int32 title_bar_style; + gfx.mojom.Point? traffic_light_position; + int32 titlebar_overlay_height; + // Null for a window that takes no part in tabbing. + string? tabbing_identifier; +}; + +enum AppShimTabAction { + kSelectPrevious, + kSelectNext, + kShowAll, + kMergeAll, + kMoveToNewWindow, + kToggleTabBar, +}; + +// One item of a menu, as an ElectronMenuModel describes it. +struct AppShimMenuItem { + // A ui::MenuModel::ItemType. + int32 type; + int32 command_id; + mojo_base.mojom.String16 label; + mojo_base.mojom.String16 secondary_label; + mojo_base.mojom.String16 tool_tip; + mojo_base.mojom.String16 accessibility_label; + mojo_base.mojom.String16 role; + mojo_base.mojom.String16 custom_type; + gfx.mojom.ImageSkia? icon; + // A ui::Accelerator: a ui::KeyboardCode, ui::EventFlags, and the shifted + // character if the accelerator names one (0 if not). + bool has_accelerator; + int32 accelerator_key_code; + int32 accelerator_modifiers; + uint16 accelerator_shifted_char; + bool register_accelerator; + bool works_when_hidden; + bool checked; + bool enabled; + bool visible; + int32 group_id; + array? submenu; +}; + +// Implemented by the app shim process and called by the browser process. +interface AppShim { + // Binds the remote_cocoa application through which the browser process + // creates the NSWindows and NSViews that this shim hosts. + CreateRemoteCocoaApplication( + pending_associated_receiver application); + + // Sets the label on the shim's Dock tile. An empty label clears it. + SetBadgeLabel(string label); + + // Bounces, or stops bouncing, the shim's Dock tile. + SetUserAttention(AppShimAttentionType type); + + // Terminates the shim process. + Quit(); + + // Answers AppShimHost.QuitRequested with "keep running". + CancelQuit(); + + // The menu bar to show while the shim is the active app, which is the + // shim's own and not the browser's. The browser sends it again whenever an + // item's state may have changed. Null puts back the short menu that the + // shim builds for itself. + SetApplicationMenu(array? items); + + // The parts of the window API that act on the NSWindow itself and that + // remote_cocoa has no call for. `window_id` is the remote_cocoa id of the + // window; a call for a window that is gone does nothing. + InitWindowChrome(uint64 window_id, AppShimWindowChrome chrome); + SetWindowButtonVisibility(uint64 window_id, bool visible); + SetWindowButtonPosition(uint64 window_id, gfx.mojom.Point? position); + // An empty `type` removes the effect. `state` is an + // NSVisualEffectState. + SetWindowVibrancy( + uint64 window_id, string type, int32 duration_ms, int32 state); + PerformWindowTabAction(uint64 window_id, AppShimTabAction action); + AddTabbedWindow(uint64 window_id, uint64 tabbed_window_id); + PreviewFile( + uint64 window_id, mojo_base.mojom.FilePath path, string display_name); + CloseFilePreview(uint64 window_id); + + // Dialogs that are sheets of one of the shim's windows, which only the + // shim's process can show. `window_id` is the remote_cocoa id of the + // window; the dialog is cancelled when no such window is left. + ShowMessageBox(uint64 window_id, AppShimMessageBoxOptions options) + => (int32 response, bool checkbox_checked); + CloseMessageBox(int32 id); + ShowOpenDialog(uint64 window_id, AppShimFileDialogOptions options) + => (bool canceled, array paths); + ShowSaveDialog(uint64 window_id, AppShimFileDialogOptions options) + => (bool canceled, mojo_base.mojom.FilePath path); +}; + +// Implemented by the browser process and called by the app shim process. +interface AppShimHost { + // The shim's Dock tile was clicked, or it was opened again while running. + ReopenApp(bool has_visible_windows); + + // The shim was asked to open files or URLs. + FilesOpened(array files); + UrlsOpened(array urls); + + // An assistive technology started looking at the shim. The accessibility + // tree of its windows is built in the browser process, which must turn it + // on. + EnableAccessibilitySupport(AppShimAccessibilityMode mode); + + // An item of the menu from AppShim.SetApplicationMenu was chosen. `path` + // is the index of the item in each menu on the way to it, from the menu + // bar down; `command_id` is the item's, so that the browser can tell when + // its menu has changed since. + MenuItemActivated(array path, int32 command_id, int32 event_flags); + // A menu of the menu bar is about to open, or has closed. + MenuWillShow(); + MenuWillClose(); + // The "+" of a window's tab bar was clicked. + NewWindowForTab(uint64 window_id); + + // The shim became the active app, which is a good moment for the browser + // to send the menu again. + DidBecomeActive(); + + // The person, or the system at logout, asked the shim to quit. The browser + // answers with AppShim.Quit or AppShim.CancelQuit; the shim quits by + // itself if neither comes soon. + QuitRequested(); +}; + +// The first interface served on the connection a shim makes to the browser. +interface AppShimHostBootstrap { + // `app_shim_receiver` is null when the browser turns the shim away. + OnShimConnected(pending_receiver host_receiver) + => (pending_receiver? app_shim_receiver); +}; diff --git a/shell/common/app_shim_constants.cc b/shell/common/app_shim_constants.cc new file mode 100644 index 0000000000..7cdf902166 --- /dev/null +++ b/shell/common/app_shim_constants.cc @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/common/app_shim_constants.h" + +#include "base/containers/span.h" +#include "base/strings/strcat.h" +#include "base/strings/string_number_conversions.h" +#include "chrome/common/chrome_version.h" +#include "crypto/hash.h" +#include "electron/electron_version.h" + +namespace electron::app_shim { + +std::string BuildMarkerServerName(const std::string& server_name) { + // Mojo message IDs are scrambled per Chromium version, and Electron's own + // interface with the shim can change with every Electron version. + const auto build = + crypto::hash::Sha256(ELECTRON_VERSION_STRING "/" CHROME_VERSION_STRING); + return base::StrCat( + {server_name, ".", base::HexEncode(base::span(build).first<8>())}); +} + +} // namespace electron::app_shim diff --git a/shell/common/app_shim_constants.h b/shell/common/app_shim_constants.h new file mode 100644 index 0000000000..863bbeaac9 --- /dev/null +++ b/shell/common/app_shim_constants.h @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Anthropic, PBC. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_COMMON_APP_SHIM_CONSTANTS_H_ +#define ELECTRON_SHELL_COMMON_APP_SHIM_CONSTANTS_H_ + +#include + +#include + +namespace electron::app_shim { + +// The --type of an app shim process. It must be the type that the sandbox +// policy lists as an unsandboxed process (sandbox::policy::switches:: +// kAppShimProcessType), or content refuses to start it. +inline constexpr char kProcessType[] = "app-shim"; + +// Mach message ID a shim sends to the browser's bootstrap server to connect. +inline constexpr mach_msg_id_t kBootstrapMsgId = 'eshm'; + +// Mach message ID a shim sends to that server, in place of connecting, when +// it finds that the browser is another build of Electron than the one it +// loaded. The message has the layout of the one above and no channel. +inline constexpr mach_msg_id_t kBuildMismatchMsgId = 'eshv'; + +// The name of a second bootstrap server that the browser publishes beside +// `server_name` and never reads from. It is there for a shim to look up: the +// shim loaded the Electron Framework that is on disk, the browser is running +// the one that was on disk when it started, and when the app has been +// replaced in between, the two can't talk Mojo to each other. The name holds +// the build of the framework, so a shim finds it only when the builds match. +// +// TODO(MarshallOfSound): keep each version's framework in a folder of its own +// (Versions/, as Chrome does) and have the shim load the one that +// the running browser uses. A mismatch can't happen then, and this goes away. +std::string BuildMarkerServerName(const std::string& server_name); + +// The browser publishes its bootstrap server as +// ".". This is the default name. +inline constexpr char kDefaultServerName[] = "appshim"; + +// Keys in the shim bundle's Info.plist. +// The name the browser passed to app.startAppShimServer(), when not the +// default. +inline constexpr char kInfoPlistServerNameKey[] = "ElectronAppShimServerName"; +// Arguments to start the host app with when it is not running. An array of +// strings. +inline constexpr char kInfoPlistHostArgumentsKey[] = + "ElectronAppShimHostArguments"; + +} // namespace electron::app_shim + +#endif // ELECTRON_SHELL_COMMON_APP_SHIM_CONSTANTS_H_ diff --git a/shell/common/gin_helper/wrappable_pointer_tags.h b/shell/common/gin_helper/wrappable_pointer_tags.h index 53427df006..2cac5a62a5 100644 --- a/shell/common/gin_helper/wrappable_pointer_tags.h +++ b/shell/common/gin_helper/wrappable_pointer_tags.h @@ -13,6 +13,7 @@ namespace electron { // Electron-specific WrappablePointerTag values that extend gin's tag range. enum ElectronWrappablePointerTag : uint16_t { kElectronApp = gin::kLastPointerTag + 1, // electron::api::App + kElectronAppShimHost, // electron::api::AppShimHost kElectronAutoUpdater, // electron::api::AutoUpdater kElectronChunkedDataPipeReadableStream, // ChunkedDataPipeReadableStream kElectronClipboardItem, // electron::api::ClipboardItem diff --git a/shell/common/options_switches.h b/shell/common/options_switches.h index c681f16dae..0fbae3c321 100644 --- a/shell/common/options_switches.h +++ b/shell/common/options_switches.h @@ -14,6 +14,7 @@ namespace electron { namespace options { inline constexpr std::string_view kTitle = "title"; +inline constexpr std::string_view kAppShim = "appShim"; inline constexpr std::string_view kIcon = "icon"; inline constexpr std::string_view kFrame = "frame"; inline constexpr std::string_view kShow = "show"; diff --git a/spec/api-app-shim.spec.ts b/spec/api-app-shim.spec.ts new file mode 100644 index 0000000000..500168592a --- /dev/null +++ b/spec/api-app-shim.spec.ts @@ -0,0 +1,380 @@ +import { app, BaseWindow, BrowserWindow, dialog, Menu } from 'electron/main'; + +import { afterAll, afterEach, beforeAll, expect, it } from 'vitest'; + +import * as cp from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; + +import { ifdescribe, waitUntil } from './lib/spec-helpers.ts'; +import { closeAllWindows } from './lib/window-helpers.ts'; + +// Electron.app, which the specs run in and which hosts the shims. +const hostAppPath = path.resolve(process.execPath, '..', '..', '..'); +const loaderPath = path.join( + hostAppPath, + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Helpers', + 'app_shim_loader' +); + +const SHIM_NAME = 'Spec Shim'; +const SHIM_BUNDLE_ID = 'org.electronjs.spec.app-shim'; + +type OnScreenWindow = { pid: number; x: number; y: number; width: number; height: number }; + +// The windows the window server has on screen, with the process that owns +// each. Read with JXA so that the answer comes from outside Electron. +function onScreenWindows(): OnScreenWindow[] { + const script = ` + ObjC.import('CoreGraphics'); + const list = ObjC.deepUnwrap(ObjC.castRefToObject( + $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, 0))); + JSON.stringify(list.map((w) => ({ + pid: w.kCGWindowOwnerPID, + x: w.kCGWindowBounds.X, + y: w.kCGWindowBounds.Y, + width: w.kCGWindowBounds.Width, + height: w.kCGWindowBounds.Height + })));`; + const { stdout } = cp.spawnSync('osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8' }); + return JSON.parse(stdout); +} + +ifdescribe(process.platform === 'darwin' && !process.mas)('app shims', () => { + // Another Electron running specs must not get this one's shims. + const serverName = `spec-${process.pid}`; + let tmpDir: string; + let bundlePath: string; + let shimProcess: cp.ChildProcess | null = null; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electron-app-shim-')); + bundlePath = path.join(tmpDir, `${SHIM_NAME}.app`); + const macOSDir = path.join(bundlePath, 'Contents', 'MacOS'); + fs.mkdirSync(macOSDir, { recursive: true }); + fs.copyFileSync(loaderPath, path.join(macOSDir, SHIM_NAME)); + fs.writeFileSync( + path.join(bundlePath, 'Contents', 'Info.plist'), + ` + + + + CFBundleExecutable${SHIM_NAME} + CFBundleIdentifier${SHIM_BUNDLE_ID} + CFBundleName${SHIM_NAME} + CFBundlePackageTypeAPPL + NSHighResolutionCapable + ElectronAppShimHostPath${hostAppPath} + ElectronAppShimServerName${serverName} + + +` + ); + app.startAppShimServer({ name: serverName }); + }); + + afterAll(() => { + app.stopAppShimServer(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + afterEach(async () => { + await closeAllWindows(); + if (shimProcess && shimProcess.exitCode === null && shimProcess.signalCode === null) { + const exited = once(shimProcess, 'exit'); + shimProcess.kill('SIGKILL'); + await exited; + } + shimProcess = null; + }); + + async function launchShim(): Promise { + const connected = once(app, 'app-shim-connected'); + shimProcess = cp.spawn(path.join(bundlePath, 'Contents', 'MacOS', SHIM_NAME), { stdio: 'inherit' }); + const [, shim] = await connected; + return shim; + } + + it('throws when the server is started twice', () => { + expect(() => app.startAppShimServer({ name: serverName })).toThrow(/already started/); + }); + + it('connects a shim and says which process it is', async () => { + const shim = await launchShim(); + expect(shim.isConnected()).toBe(true); + expect(shim.pid).toBe(shimProcess!.pid); + expect(shim.bundleId).toBe(SHIM_BUNDLE_ID); + expect(fs.realpathSync(shim.bundlePath)).toBe(fs.realpathSync(bundlePath)); + }); + + it('checks the shim against a code requirement', async () => { + const shim = await launchShim(); + expect(shim.satisfiesCodeRequirement(`identifier "${SHIM_BUNDLE_ID}.not-this"`)).toBe(false); + expect(() => shim.satisfiesCodeRequirement('not a requirement')).toThrow(/Invalid code requirement/); + }); + + it('creates the window in the shim process', async () => { + const shim = await launchShim(); + const size = { width: 432, height: 321 }; + const w = new BrowserWindow({ appShim: shim, ...size }); + await w.loadURL('data:text/html,in a shim'); + expect(await w.webContents.executeJavaScript('document.title')).toBe('in a shim'); + + const sameSize = (win: OnScreenWindow) => win.width === size.width && win.height === size.height; + await waitUntil(() => onScreenWindows().some((win) => win.pid === shim.pid && sameSize(win))); + // Its stand-in in this process is never on screen. + expect(onScreenWindows().some((win) => win.pid === process.pid && sameSize(win))).toBe(false); + }); + + it('reports the bounds and state of a window in a shim', async () => { + const shim = await launchShim(); + const w = new BrowserWindow({ appShim: shim, x: 80, y: 90, width: 400, height: 300 }); + // The shim's process shows the window, and says so afterwards. + await once(w, 'show'); + expect(w.isVisible()).toBe(true); + expect(w.getBounds()).toEqual({ x: 80, y: 90, width: 400, height: 300 }); + + w.setSize(500, 350); + expect(w.getSize()).toEqual([500, 350]); + // What the shim's process did with it, according to the window server. + await waitUntil(() => + onScreenWindows().some((win) => win.pid === shim.pid && win.width === 500 && win.height === 350) + ); + + const hidden = once(w, 'hide'); + w.hide(); + await hidden; + expect(w.isVisible()).toBe(false); + }); + + it('lets the close event keep a window in a shim open', async () => { + const shim = await launchShim(); + // A BaseWindow closes inside close() when nothing prevents it. + const w = new BaseWindow({ appShim: shim }); + let closedEmitted = false; + w.once('closed', () => { + closedEmitted = true; + }); + w.once('close', (event) => event.preventDefault()); + w.close(); + await setTimeout(100); + expect(closedEmitted).toBe(false); + expect(w.isDestroyed()).toBe(false); + + w.close(); + expect(closedEmitted).toBe(true); + expect(shim.isConnected()).toBe(true); + }); + + it('sizes a framed window by its content when asked to', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim, width: 400, height: 300, useContentSize: true }); + expect(w.getContentSize()).toEqual([400, 300]); + const titleBarHeight = w.getSize()[1] - 300; + expect(titleBarHeight).toBeGreaterThan(0); + + // The limits are the window's, title bar included. + w.setMinimumSize(300, 200); + w.setSize(100, 100); + await waitUntil(() => + onScreenWindows().some((win) => win.pid === shim.pid && win.width === 300 && win.height === 200) + ); + }); + + it('survives a window being destroyed by its own show listener', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim }); + const closed = once(w, 'closed'); + w.once('show', () => w.destroy()); + await closed; + expect(w.isDestroyed()).toBe(true); + expect(shim.isConnected()).toBe(true); + }); + + it('survives the shim being quit by a show listener of its window', async () => { + const shim = await launchShim(); + const exited = once(shimProcess!, 'exit'); + const w = new BaseWindow({ appShim: shim }); + const closed = once(w, 'closed'); + // What the listener saw, checked below: a failed expectation inside a + // listener is an uncaught exception, not a failed test. + let connectedAfterQuit: boolean | undefined; + let errorFromNewWindow: unknown; + w.once('show', () => { + shim.quit(); + connectedAfterQuit = shim.isConnected(); + try { + const unexpected = new BaseWindow({ appShim: shim }); + unexpected.destroy(); + } catch (error) { + errorFromNewWindow = error; + } + }); + await Promise.all([closed, exited, once(shim, 'disconnected')]); + expect(connectedAfterQuit).toBe(false); + expect(errorFromNewWindow).toBeInstanceOf(Error); + expect((errorFromNewWindow as Error).message).toMatch(/disconnected/); + }); + + it('restores from the Dock without leaving the maximized state', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim, width: 400, height: 300 }); + await once(w, 'show'); + const maximized = once(w, 'maximize'); + w.maximize(); + await maximized; + const minimized = once(w, 'minimize'); + w.minimize(); + await minimized; + const restored = once(w, 'restore'); + w.restore(); + await restored; + expect(w.isMaximized()).toBe(true); + }); + + it('does not announce a shim after the server is stopped', async () => { + app.stopAppShimServer(); + try { + let announced = false; + const onConnected = () => { + announced = true; + }; + app.once('app-shim-connected', onConnected); + shimProcess = cp.spawn(path.join(bundlePath, 'Contents', 'MacOS', SHIM_NAME), { stdio: 'inherit' }); + await setTimeout(1500); + app.removeListener('app-shim-connected', onConnected); + expect(announced).toBe(false); + } finally { + app.startAppShimServer({ name: serverName }); + } + }); + + it('rejects options that are not what startAppShimServer takes', () => { + app.stopAppShimServer(); + try { + expect(() => app.startAppShimServer({ name: 123 as any })).toThrow(/name must be a string/); + expect(() => app.startAppShimServer({ name: 'not valid!' })).toThrow(/may only hold/); + } finally { + app.startAppShimServer({ name: serverName }); + } + }); + + it('shows a message box as a sheet in the shim process', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim, width: 400, height: 300 }); + await once(w, 'show'); + const windowsOfShim = () => onScreenWindows().filter((win) => win.pid === shim.pid).length; + await waitUntil(() => windowsOfShim() === 1); + + const controller = new AbortController(); + const result = dialog.showMessageBox(w, { + message: 'A sheet', + buttons: ['OK', 'Cancel'], + cancelId: 1, + signal: controller.signal + }); + // The sheet is a second window of the shim's process, not of this one. + await waitUntil(() => windowsOfShim() === 2); + controller.abort(); + expect((await result).response).toBe(1); + await waitUntil(() => windowsOfShim() === 1); + }); + + it('answers a dialog with "cancelled" when its shim goes away', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim, width: 400, height: 300 }); + await once(w, 'show'); + const result = dialog.showOpenDialog(w, { properties: ['openFile'] }); + await waitUntil(() => onScreenWindows().filter((win) => win.pid === shim.pid).length >= 2); + shimProcess!.kill('SIGKILL'); + expect(await result).toEqual({ canceled: true, filePaths: [] }); + }); + + it('takes a menu of its own, or null, and nothing else', async () => { + const shim = await launchShim(); + const menu = Menu.buildFromTemplate([{ role: 'appMenu' }, { label: 'Tools', submenu: [{ label: 'Ping' }] }]); + expect(() => shim.setApplicationMenu(menu)).not.toThrow(); + expect(() => shim.setApplicationMenu(null)).not.toThrow(); + expect(() => shim.setApplicationMenu('menu' as any)).toThrow(/Must pass a Menu or null/); + expect(() => (shim as any).setApplicationMenu()).toThrow(/Must pass a Menu or null/); + expect(shim.isConnected()).toBe(true); + }); + + it('gives a window with a hidden title bar its whole frame as content', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim, width: 400, height: 300, titleBarStyle: 'hidden' }); + await once(w, 'show'); + expect(w.getContentSize()).toEqual([400, 300]); + await waitUntil(() => + onScreenWindows().some((win) => win.pid === shim.pid && win.width === 400 && win.height === 300) + ); + // The window buttons are the shim's to move and hide. + w.setWindowButtonPosition({ x: 20, y: 20 }); + expect(w.getWindowButtonPosition()).toEqual({ x: 20, y: 20 }); + w.setWindowButtonVisibility(false); + w.setVibrancy('sidebar'); + w.setVibrancy(null); + await setTimeout(200); + expect(shim.isConnected()).toBe(true); + }); + + it('tabs two windows of one shim together, and no others', async () => { + const shim = await launchShim(); + const windowsOfShim = () => onScreenWindows().filter((win) => win.pid === shim.pid).length; + const a = new BaseWindow({ appShim: shim, width: 400, height: 300, tabbingIdentifier: 'group' }); + const b = new BaseWindow({ appShim: shim, width: 400, height: 300, tabbingIdentifier: 'group' }); + expect(a.tabbingIdentifier).toBe('group'); + await waitUntil(() => windowsOfShim() === 2); + a.addTabbedWindow(b); + // One window on screen, with the other as a tab in it. + await waitUntil(() => windowsOfShim() === 1); + + const local = new BaseWindow({ show: false, tabbingIdentifier: 'group' }); + expect(() => a.addTabbedWindow(local)).toThrow(/same app/); + expect(() => local.addTabbedWindow(a)).toThrow(/same app/); + local.destroy(); + }); + + it('does not take a window in a shim as a parent', async () => { + const shim = await launchShim(); + const w = new BaseWindow({ appShim: shim }); + expect(() => new BaseWindow({ parent: w })).toThrow(/can't be a parent window/); + const other = new BaseWindow({ show: false }); + expect(() => other.setParentWindow(w)).toThrow(/can't be a parent window/); + // Nor does it take one. + expect(() => new BaseWindow({ appShim: shim, parent: other })).toThrow(/can't have a parent window/); + expect(() => w.setParentWindow(other)).toThrow(/can't have a parent window/); + expect(w.getParentWindow()).toBeNull(); + other.destroy(); + }); + + it('closes the windows of a shim that goes away', async () => { + const shim = await launchShim(); + const w = new BrowserWindow({ appShim: shim }); + const closed = once(w, 'closed'); + const disconnected = once(shim, 'disconnected'); + shimProcess!.kill('SIGKILL'); + await Promise.all([closed, disconnected]); + expect(w.isDestroyed()).toBe(true); + expect(shim.isConnected()).toBe(false); + expect(() => new BrowserWindow({ appShim: shim })).toThrow(/disconnected/); + }); + + it('ends the shim process on quit()', async () => { + const shim = await launchShim(); + const exited = once(shimProcess!, 'exit'); + shim.quit(); + await exited; + expect(shim.isConnected()).toBe(false); + }); + + it('rejects an appShim that is not an AppShimHost', () => { + expect(() => new BrowserWindow({ appShim: {} as any })).toThrow(/must be an AppShimHost/); + }); +});