Browse Source

feat: enable window controls overlay on macOS (#29253)

* feat: enable windows control overlay on macOS

* address review feedback

* chore: address review feedback

* Address review feedback

* update doc per review

* only enable WCO when titleBarStyle is overlay

* Revert "only enable WCO when titleBarStyle is overlay"

This reverts commit 1b58b5b1fcb8f091880a4e5d1f8855399c44afad.

* Add new titleBarOverlay property to manage feature

* spelling fix

* Update docs/api/frameless-window.md

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

* Update shell/browser/api/electron_api_browser_window.cc

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

* update per review feedback

Co-authored-by: Samuel Attard <[email protected]>
John Kleinschmidt 3 years ago
parent
commit
1f8a46c9c6

+ 6 - 0
docs/api/browser-window.md

@@ -392,6 +392,10 @@ It creates a new `BrowserWindow` with native properties as set by the `options`.
       contain the layout of the document—without requiring scrolling. Enabling
       this will cause the `preferred-size-changed` event to be emitted on the
       `WebContents` when the preferred size changes. Default is `false`.
+  * `titleBarOverlay` Boolean (optional) -  On macOS, when using a frameless window in conjunction with
+    `win.setWindowButtonVisibility(true)` or using a `titleBarStyle` so that the traffic lights are visible,
+    this property enables the Window Controls Overlay [JavaScript APIs][overlay-javascript-apis] and
+    [CSS Environment Variables][overlay-css-env-vars].  Default is `false`.
 
 When setting minimum or maximum window size with `minWidth`/`maxWidth`/
 `minHeight`/`maxHeight`, it only constrains the users. It won't prevent you from
@@ -1815,3 +1819,5 @@ removed in future Electron releases.
 [window-levels]: https://developer.apple.com/documentation/appkit/nswindow/level
 [chrome-content-scripts]: https://developer.chrome.com/extensions/content_scripts#execution-environment
 [event-emitter]: https://nodejs.org/api/events.html#events_class_eventemitter
+[overlay-javascript-apis]: https://github.com/WICG/window-controls-overlay/blob/main/explainer.md#javascript-apis
+[overlay-css-env-vars]: https://github.com/WICG/window-controls-overlay/blob/main/explainer.md#css-environment-variables

+ 17 - 0
docs/api/frameless-window.md

@@ -61,6 +61,21 @@ const win = new BrowserWindow({ titleBarStyle: 'customButtonsOnHover', frame: fa
 win.show()
 ```
 
+## Windows Control Overlay
+
+On macOS, when using a frameless window in conjuction with `win.setWindowButtonVisibility(true)` or using one of the `titleBarStyle`s described above so
+that the traffic lights are visible, you can access the Window Controls Overlay [JavaScript APIs][overlay-javascript-apis] and
+[CSS Environment Variables][overlay-css-env-vars] by setting the `titleBarOverlay` option to true:
+
+```javascript
+const { BrowserWindow } = require('electron')
+const win = new BrowserWindow({
+  titleBarStyle: 'hiddenInset',
+  titleBarOverlay: true
+})
+win.show()
+```
+
 ## Transparent window
 
 By setting the `transparent` option to `true`, you can also make the frameless
@@ -186,3 +201,5 @@ behave correctly on all platforms you should never use a custom context menu on
 draggable areas.
 
 [ignore-mouse-events]: browser-window.md#winsetignoremouseeventsignore-options
+[overlay-javascript-apis]: https://github.com/WICG/window-controls-overlay/blob/main/explainer.md#javascript-apis
+[overlay-css-env-vars]: https://github.com/WICG/window-controls-overlay/blob/main/explainer.md#css-environment-variables

+ 16 - 0
shell/browser/api/electron_api_browser_window.cc

@@ -57,6 +57,17 @@ BrowserWindow::BrowserWindow(gin::Arguments* args,
     web_preferences.Set(options::kShow, show);
   }
 
+  bool titleBarOverlay = false;
+  options.Get(options::ktitleBarOverlay, &titleBarOverlay);
+  if (titleBarOverlay) {
+    std::string enabled_features = "";
+    if (web_preferences.Get(options::kEnableBlinkFeatures, &enabled_features)) {
+      enabled_features += ",";
+    }
+    enabled_features += features::kWebAppWindowControlsOverlay.name;
+    web_preferences.Set(options::kEnableBlinkFeatures, enabled_features);
+  }
+
   // Copy the webContents option to webPreferences. This is only used internally
   // to implement nativeWindowOpen option.
   if (options.Get("webContents", &value)) {
@@ -312,6 +323,11 @@ void BrowserWindow::OnWindowLeaveFullScreen() {
   BaseWindow::OnWindowLeaveFullScreen();
 }
 
+void BrowserWindow::UpdateWindowControlsOverlay(
+    const gfx::Rect& bounding_rect) {
+  web_contents()->UpdateWindowControlsOverlay(bounding_rect);
+}
+
 void BrowserWindow::CloseImmediately() {
   // Close all child windows before closing current window.
   v8::Locker locker(isolate());

+ 1 - 0
shell/browser/api/electron_api_browser_window.h

@@ -69,6 +69,7 @@ class BrowserWindow : public BaseWindow,
   void RequestPreferredWidth(int* width) override;
   void OnCloseButtonClicked(bool* prevent_default) override;
   void OnWindowIsKeyChanged(bool is_key) override;
+  void UpdateWindowControlsOverlay(const gfx::Rect& bounding_rect) override;
 
   // BaseWindow:
   void OnWindowBlur() override;

+ 4 - 0
shell/browser/api/electron_api_web_contents.cc

@@ -1675,6 +1675,10 @@ void WebContents::ReadyToCommitNavigation(
 
 void WebContents::DidFinishNavigation(
     content::NavigationHandle* navigation_handle) {
+  if (owner_window_) {
+    owner_window_->NotifyLayoutWindowControlsOverlay();
+  }
+
   if (!navigation_handle->HasCommitted())
     return;
   bool is_main_frame = navigation_handle->IsInMainFrame();

+ 18 - 0
shell/browser/native_window.cc

@@ -53,6 +53,7 @@ NativeWindow::NativeWindow(const gin_helper::Dictionary& options,
   options.Get(options::kFrame, &has_frame_);
   options.Get(options::kTransparent, &transparent_);
   options.Get(options::kEnableLargerThanScreen, &enable_larger_than_screen_);
+  options.Get(options::ktitleBarOverlay, &titlebar_overlay_);
 
   if (parent)
     options.Get("modal", &is_modal_);
@@ -394,6 +395,14 @@ void NativeWindow::PreviewFile(const std::string& path,
 
 void NativeWindow::CloseFilePreview() {}
 
+gfx::Rect NativeWindow::GetWindowControlsOverlayRect() {
+  return overlay_rect_;
+}
+
+void NativeWindow::SetWindowControlsOverlayRect(const gfx::Rect& overlay_rect) {
+  overlay_rect_ = overlay_rect;
+}
+
 void NativeWindow::NotifyWindowRequestPreferredWith(int* width) {
   for (NativeWindowObserver& observer : observers_)
     observer.RequestPreferredWidth(width);
@@ -493,6 +502,7 @@ void NativeWindow::NotifyWindowWillMove(const gfx::Rect& new_bounds,
 }
 
 void NativeWindow::NotifyWindowResize() {
+  NotifyLayoutWindowControlsOverlay();
   for (NativeWindowObserver& observer : observers_)
     observer.OnWindowResize();
 }
@@ -591,6 +601,14 @@ void NativeWindow::NotifyWindowSystemContextMenu(int x,
     observer.OnSystemContextMenu(x, y, prevent_default);
 }
 
+void NativeWindow::NotifyLayoutWindowControlsOverlay() {
+  gfx::Rect bounding_rect = GetWindowControlsOverlayRect();
+  if (!bounding_rect.IsEmpty()) {
+    for (NativeWindowObserver& observer : observers_)
+      observer.UpdateWindowControlsOverlay(bounding_rect);
+  }
+}
+
 #if defined(OS_WIN)
 void NativeWindow::NotifyWindowMessage(UINT message,
                                        WPARAM w_param,

+ 8 - 0
shell/browser/native_window.h

@@ -255,6 +255,9 @@ class NativeWindow : public base::SupportsUserData,
     return weak_factory_.GetWeakPtr();
   }
 
+  virtual gfx::Rect GetWindowControlsOverlayRect();
+  virtual void SetWindowControlsOverlayRect(const gfx::Rect& overlay_rect);
+
   // Methods called by the WebContents.
   virtual void HandleKeyboardEvent(
       content::WebContents*,
@@ -299,6 +302,7 @@ class NativeWindow : public base::SupportsUserData,
                                      const base::DictionaryValue& details);
   void NotifyNewWindowForTab();
   void NotifyWindowSystemContextMenu(int x, int y, bool* prevent_default);
+  void NotifyLayoutWindowControlsOverlay();
 
 #if defined(OS_WIN)
   void NotifyWindowMessage(UINT message, WPARAM w_param, LPARAM l_param);
@@ -343,6 +347,8 @@ class NativeWindow : public base::SupportsUserData,
         [&browser_view](NativeBrowserView* n) { return (n == browser_view); });
   }
 
+  bool titlebar_overlay_ = false;
+
  private:
   std::unique_ptr<views::Widget> widget_;
 
@@ -391,6 +397,8 @@ class NativeWindow : public base::SupportsUserData,
   // Accessible title.
   std::u16string accessible_title_;
 
+  gfx::Rect overlay_rect_;
+
   base::WeakPtrFactory<NativeWindow> weak_factory_{this};
 
   DISALLOW_COPY_AND_ASSIGN(NativeWindow);

+ 1 - 0
shell/browser/native_window_mac.h

@@ -147,6 +147,7 @@ class NativeWindowMac : public NativeWindow,
   void CloseFilePreview() override;
   gfx::Rect ContentBoundsToWindowBounds(const gfx::Rect& bounds) const override;
   gfx::Rect WindowBoundsToContentBounds(const gfx::Rect& bounds) const override;
+  gfx::Rect GetWindowControlsOverlayRect() override;
   void NotifyWindowEnterFullScreen() override;
   void NotifyWindowLeaveFullScreen() override;
   void SetActive(bool is_key) override;

+ 23 - 0
shell/browser/native_window_mac.mm

@@ -1488,6 +1488,7 @@ void NativeWindowMac::SetVibrancy(const std::string& type) {
 void NativeWindowMac::SetWindowButtonVisibility(bool visible) {
   window_button_visibility_ = visible;
   InternalSetWindowButtonVisibility(visible);
+  NotifyLayoutWindowControlsOverlay();
 }
 
 bool NativeWindowMac::GetWindowButtonVisibility() const {
@@ -1505,6 +1506,7 @@ void NativeWindowMac::SetTrafficLightPosition(
   if (buttons_view_) {
     [buttons_view_ setMargin:traffic_light_position_];
     [buttons_view_ viewDidMoveToWindow];
+    NotifyLayoutWindowControlsOverlay();
   }
 }
 
@@ -1859,6 +1861,27 @@ void NativeWindowMac::SetForwardMouseMessages(bool forward) {
   [window_ setAcceptsMouseMovedEvents:forward];
 }
 
+gfx::Rect NativeWindowMac::GetWindowControlsOverlayRect() {
+  gfx::Rect bounding_rect;
+  if (titlebar_overlay_ && !has_frame() && buttons_view_ &&
+      ![buttons_view_ isHidden]) {
+    NSRect button_frame = [buttons_view_ frame];
+    gfx::Point buttons_view_margin = [buttons_view_ getMargin];
+    const int overlay_width = GetContentSize().width() - NSWidth(button_frame) -
+                              buttons_view_margin.x();
+    CGFloat overlay_height =
+        NSHeight(button_frame) + buttons_view_margin.y() * 2;
+    if (base::i18n::IsRTL()) {
+      bounding_rect = gfx::Rect(0, 0, overlay_width, overlay_height);
+    } else {
+      bounding_rect =
+          gfx::Rect(button_frame.size.width + buttons_view_margin.x(), 0,
+                    overlay_width, overlay_height);
+    }
+  }
+  return bounding_rect;
+}
+
 // static
 NativeWindow* NativeWindow::Create(const gin_helper::Dictionary& options,
                                    NativeWindow* parent) {

+ 2 - 0
shell/browser/native_window_observer.h

@@ -104,6 +104,8 @@ class NativeWindowObserver : public base::CheckedObserver {
   // Called on Windows when App Commands arrive (WM_APPCOMMAND)
   // Some commands are implemented on on other platforms as well
   virtual void OnExecuteAppCommand(const std::string& command_name) {}
+
+  virtual void UpdateWindowControlsOverlay(const gfx::Rect& bounding_rect) {}
 };
 
 }  // namespace electron

+ 1 - 0
shell/browser/ui/cocoa/window_buttons_view.h

@@ -26,6 +26,7 @@
 - (void)setMargin:(const absl::optional<gfx::Point>&)margin;
 - (void)setShowOnHover:(BOOL)yes;
 - (void)setNeedsDisplayForButtons;
+- (gfx::Point)getMargin;
 @end
 
 #endif  // SHELL_BROWSER_UI_COCOA_WINDOW_BUTTONS_VIEW_H_

+ 4 - 0
shell/browser/ui/cocoa/window_buttons_view.mm

@@ -116,4 +116,8 @@ const NSWindowButton kButtonTypes[] = {
   [self setNeedsDisplayForButtons];
 }
 
+- (gfx::Point)getMargin {
+  return margin_;
+}
+
 @end

+ 2 - 0
shell/common/options_switches.cc

@@ -194,6 +194,8 @@ const char kEnableWebSQL[] = "enableWebSQL";
 
 const char kEnablePreferredSizeMode[] = "enablePreferredSizeMode";
 
+const char ktitleBarOverlay[] = "titleBarOverlay";
+
 }  // namespace options
 
 namespace switches {

+ 1 - 0
shell/common/options_switches.h

@@ -57,6 +57,7 @@ extern const char kVibrancyType[];
 extern const char kVisualEffectState[];
 extern const char kTrafficLightPosition[];
 extern const char kRoundedCorners[];
+extern const char ktitleBarOverlay[];
 
 // WebPreferences.
 extern const char kZoomFactor[];

+ 35 - 0
spec-main/api-browser-window-spec.ts

@@ -1876,7 +1876,36 @@ describe('BrowserWindow module', () => {
   });
 
   ifdescribe(process.platform === 'darwin' && parseInt(os.release().split('.')[0]) >= 14)('"titleBarStyle" option', () => {
+    const testWindowsOverlay = async (style: any) => {
+      const w = new BrowserWindow({
+        show: false,
+        width: 400,
+        height: 400,
+        titleBarStyle: style,
+        webPreferences: {
+          nodeIntegration: true,
+          contextIsolation: false
+        },
+        titleBarOverlay: true
+      });
+      const overlayHTML = path.join(__dirname, 'fixtures', 'pages', 'overlay.html');
+      await w.loadFile(overlayHTML);
+      const overlayEnabled = await w.webContents.executeJavaScript('navigator.windowControlsOverlay.visible');
+      expect(overlayEnabled).to.be.true('overlayEnabled');
+      const overlayRect = await w.webContents.executeJavaScript('getJSOverlayProperties()');
+      expect(overlayRect.y).to.equal(0);
+      expect(overlayRect.x).to.be.greaterThan(0);
+      expect(overlayRect.width).to.be.greaterThan(0);
+      expect(overlayRect.height).to.be.greaterThan(0);
+      const cssOverlayRect = await w.webContents.executeJavaScript('getCssOverlayProperties();');
+      expect(cssOverlayRect).to.deep.equal(overlayRect);
+      const geometryChange = emittedOnce(ipcMain, 'geometrychange');
+      w.setBounds({ width: 800 });
+      const [, newOverlayRect] = await geometryChange;
+      expect(newOverlayRect.width).to.equal(overlayRect.width + 400);
+    };
     afterEach(closeAllWindows);
+    afterEach(() => { ipcMain.removeAllListeners('geometrychange'); });
     it('creates browser window with hidden title bar', () => {
       const w = new BrowserWindow({
         show: false,
@@ -1897,6 +1926,12 @@ describe('BrowserWindow module', () => {
       const contentSize = w.getContentSize();
       expect(contentSize).to.deep.equal([400, 400]);
     });
+    it('sets Window Control Overlay with hidden title bar', async () => {
+      await testWindowsOverlay('hidden');
+    });
+    it('sets Window Control Overlay with hidden inset title bar', async () => {
+      await testWindowsOverlay('hiddenInset');
+    });
   });
 
   ifdescribe(process.platform === 'darwin')('"enableLargerThanScreen" option', () => {

+ 84 - 0
spec-main/fixtures/pages/overlay.html

@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width">
+    <style>
+      :root {
+        --fallback-title-bar-height: 40px;
+      }
+
+      .draggable {
+        app-region: drag;
+        /* Pre-fix app-region during standardization process */
+        -webkit-app-region: drag;
+      }
+
+      .nonDraggable {
+        app-region: no-drag;
+        /* Pre-fix app-region during standardization process */
+        -webkit-app-region: no-drag;
+      }
+
+
+      #titleBarContainer {
+        position: absolute;
+        top: env(titlebar-area-y, 0);
+        height: env(titlebar-area-height, var(--fallback-title-bar-height));
+        width: 100%;
+      }
+
+      #titleBar {
+        position: absolute;
+        top: 0;
+        display: flex;
+        user-select: none;
+        height: 100%;
+        left: env(titlebar-area-x, 0);
+        width: env(titlebar-area-width, 100%);
+      }
+
+      #mainContent {
+        position: absolute;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        top: env(titlebar-area-height, var(--fallback-title-bar-height));
+        overflow-y: scroll;
+      }
+    </style>
+  </head>
+  <body>
+    <script>
+      const {ipcRenderer} = require('electron');
+      navigator.windowControlsOverlay.ongeometrychange = function() {
+        const {x, y, width, height} = navigator.windowControlsOverlay.getBoundingClientRect();
+        ipcRenderer.send('geometrychange', {x, y, width, height});
+      };
+    </script>      
+    <div id="titleBarContainer">
+      <div id="titleBar" class=" draggable">
+        <span class="draggable">Title goes here</span>
+        <input class="nonDraggable" type="text" placeholder="Search"></input>
+      </div>
+    </div>
+    <div id="mainContent"><!-- The rest of the webpage --></div>
+    <script>
+      function getCssOverlayProperties() {
+        const cssOverlayProps = {};
+        const titleBarContainer = document.getElementById('titleBarContainer');
+        const titleBar = document.getElementById('titleBar');
+        cssOverlayProps.y = titleBarContainer.computedStyleMap().get('top').value;
+        cssOverlayProps.height = titleBarContainer.computedStyleMap().get('height').value;
+        cssOverlayProps.x = titleBar.computedStyleMap().get('left').value;
+        cssOverlayProps.width = titleBar.computedStyleMap().get('width').value;
+        return cssOverlayProps;
+      }
+
+      function getJSOverlayProperties() {
+        const {x, y, width, height} = navigator.windowControlsOverlay.getBoundingClientRect(); 
+        return {x, y, width, height};
+      }
+    </script>
+  </body>
+</html>