Repository: electron/electron
Pull request: fix: correct window sizing on Linux when constraints on resizability are enforced

#### Description of Change

Followup to #49835, which normalized bounds operations on Linux to use logical coordinates. That PR left two edge cases where windows could still draw at the wrong sizes if certain properties were set on the browser window:

1. Max size constraints

Windows with max  width and height constraints appeared smaller than they should be. This is because Chromium calls `GetMinimumSize` and `GetMaximumSize` directly and expects them to use widget's total coordinate space, not the logical max window size. To account for this, these methods need to inflate constraints by insets on Linux. This is now handled by the tree host for all Linux frame classes to keep this quirk away from the rest of the API.

2. Resizable property

Setting `resizable: false` was causing the window to _grow taller_ on Linux in some configurations. I traced this down to a couple related issues in `setResizable`:
- Code which was supposed to be Linux-specific was not actually gated to Linux, so it was running unnecessarily on Windows as well.
- Content size constraints were being applied to the _window_. This caused ClientFrameViewLinux to change its height whenever this method is called.

I gated the Linux-specific logic and updated it to use window size constraints to prevent resizability, which is the right thing to do even if content size is in use. I also tested for resizability regressions on Windows with frame: true/false, thickFrame: true/false, and transparent: true/false.

(Note: On X11 or Wayland environments with SSD, the window manager draws the frame externally, so a framed window will still be taller than whatever height/max height is requested This is expected and not a bug introduced in this PR.)

<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Contributors guide: https://github.com/electron/electron/blob/main/CONTRIBUTING.md
-->

Fixes #49902.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included
- [x] I have built and tested this PR
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: <!-- Please add a one-line description for app developers to read in the release notes, or 'none' if no notes relevant to app developers. Examples and help on special cases: https://github.com/electron/clerk/blob/main/README.md#examples --> Fixed window sizing on Linux when min/max size constraints are set or `resizable` is `false`. Windows no longer shrink below the requested max size or grow taller when resizability is disabled.

Changed files:
filename: shell/browser/native_window_views.cc
patch: @@ -931,8 +931,10 @@ void NativeWindowViews::SetContentSizeConstraints(
   // of this to determine whether native widget has initialized.
   if (widget() && widget()->widget_delegate())
     widget()->OnSizeConstraintsChanged();
+#if BUILDFLAG(IS_LINUX)
   if (resizable_)
-    old_size_constraints_ = size_constraints;
+    old_size_constraints_ = GetSizeConstraints();
+#endif
 }
 
 #if BUILDFLAG(IS_WIN)
@@ -963,14 +965,18 @@ void NativeWindowViews::SetResizable(bool resizable) {
     resizable_ = resizable;
     // On Linux there is no "resizable" property of a window, we have to set
     // both the minimum and maximum size to the window size to achieve it.
+    // On Windows, the constraints don't directly enforce resizability, but
+    // changing them triggers the styling to be applied in Chromium
+    // (SizeConstraintsChanged).
     if (resizable) {
-      SetContentSizeConstraints(old_size_constraints_);
+      SetSizeConstraints(old_size_constraints_);
     } else {
-      old_size_constraints_ = GetContentSizeConstraints();
-      gfx::Size content_size = GetContentSize();
-      SetContentSizeConstraints(
-          extensions::SizeConstraints(content_size, content_size));
+      old_size_constraints_ = GetSizeConstraints();
+      gfx::Size window_size = GetSize();
+      SetSizeConstraints(extensions::SizeConstraints(window_size, window_size));
     }
+    if (widget() && widget()->widget_delegate())
+      widget()->OnSizeConstraintsChanged();
 #if BUILDFLAG(IS_WIN)
     UpdateThickFrame();
 #endif
@@ -1934,8 +1940,10 @@ std::unique_ptr<views::FrameView> NativeWindowViews::CreateFrameView(
 
 #if BUILDFLAG(IS_LINUX)
 LinuxFrameLayout* NativeWindowViews::GetLinuxFrameLayout() {
-  auto* view = views::AsViewClass<FramelessView>(
-      widget()->non_client_view()->frame_view());
+  auto* ncv = widget()->non_client_view();
+  if (!ncv)
+    return nullptr;
+  auto* view = views::AsViewClass<FramelessView>(ncv->frame_view());
   return view ? view->GetLinuxFrameLayout() : nullptr;
 }
 #endif

filename: shell/browser/ui/electron_desktop_window_tree_host_linux.cc
patch: @@ -93,6 +93,40 @@ gfx::Insets ElectronDesktopWindowTreeHostLinux::CalculateInsetsInDIP(
   return insets;
 }
 
+// Electron treats min/max constraints as the logical window size, but Chromium
+// expects widget bounds including CSD insets (WaylandToplevelWindow::
+// SetSizeConstraints). So we inflate constraints by insets to avoid double
+// subtraction. This is still OK for SSD frames or X11 where the insets are 0.
+std::optional<gfx::Size>
+ElectronDesktopWindowTreeHostLinux::GetMinimumSizeForWindow() const {
+  auto min_size = views::DesktopWindowTreeHostLinux::GetMinimumSizeForWindow();
+  if (min_size.has_value()) {
+    auto* const layout = native_window_view_->GetLinuxFrameLayout();
+    if (layout) {
+      gfx::Insets insets = layout->RestoredFrameBorderInsets();
+      min_size->Enlarge(insets.width(), insets.height());
+    }
+  }
+  return min_size;
+}
+
+std::optional<gfx::Size>
+ElectronDesktopWindowTreeHostLinux::GetMaximumSizeForWindow() const {
+  auto max_size = views::DesktopWindowTreeHostLinux::GetMaximumSizeForWindow();
+  if (max_size.has_value()) {
+    auto* const layout = native_window_view_->GetLinuxFrameLayout();
+    if (layout) {
+      gfx::Insets insets = layout->RestoredFrameBorderInsets();
+      // 0 means no constraint, so don't inflate.
+      if (max_size->width() > 0)
+        max_size->set_width(max_size->width() + insets.width());
+      if (max_size->height() > 0)
+        max_size->set_height(max_size->height() + insets.height());
+    }
+  }
+  return max_size;
+}
+
 void ElectronDesktopWindowTreeHostLinux::OnBoundsChanged(
     const BoundsChange& change) {
   views::DesktopWindowTreeHostLinux::OnBoundsChanged(change);

filename: shell/browser/ui/electron_desktop_window_tree_host_linux.h
patch: @@ -9,6 +9,8 @@
 #ifndef ELECTRON_SHELL_BROWSER_UI_ELECTRON_DESKTOP_WINDOW_TREE_HOST_LINUX_H_
 #define ELECTRON_SHELL_BROWSER_UI_ELECTRON_DESKTOP_WINDOW_TREE_HOST_LINUX_H_
 
+#include <optional>
+
 #include "base/memory/raw_ptr.h"
 #include "base/scoped_observation.h"
 #include "ui/gfx/image/image_skia.h"
@@ -52,6 +54,8 @@ class ElectronDesktopWindowTreeHostLinux
   // ui::PlatformWindowDelegate
   gfx::Insets CalculateInsetsInDIP(
       ui::PlatformWindowState window_state) const override;
+  std::optional<gfx::Size> GetMinimumSizeForWindow() const override;
+  std::optional<gfx::Size> GetMaximumSizeForWindow() const override;
   void OnBoundsChanged(const BoundsChange& change) override;
   void OnWindowStateChanged(ui::PlatformWindowState old_state,
                             ui::PlatformWindowState new_state) override;

filename: spec/api-browser-window-spec.ts
patch: @@ -1670,6 +1670,26 @@ describe('BrowserWindow module', () => {
         expectBoundsEqual(w.getMinimumSize(), [100, 100]);
         expectBoundsEqual(w.getMaximumSize(), [900, 600]);
       });
+
+      it('enforces minimum size', async () => {
+        w.setMinimumSize(300, 300);
+        const resize = once(w, 'resize');
+        w.setSize(100, 100);
+        await resize;
+        const size = w.getSize();
+        expect(size[0]).to.be.at.least(300);
+        expect(size[1]).to.be.at.least(300);
+      });
+
+      it('enforces maximum size', async () => {
+        w.setMaximumSize(200, 200);
+        const resize = once(w, 'resize');
+        w.setSize(500, 500);
+        await resize;
+        const size = w.getSize();
+        expect(size[0]).to.be.at.most(200);
+        expect(size[1]).to.be.at.most(200);
+      });
     });
 
     describe('BrowserWindow.setAspectRatio(ratio)', () => {
@@ -5462,6 +5482,20 @@ describe('BrowserWindow module', () => {
         expect(w.maximizable).to.be.true('maximizable');
       });
 
+      it('does not change window size when disabled and enabled', () => {
+        const w = new BrowserWindow({
+          show: false,
+          width: 400,
+          height: 300,
+          frame: true
+        });
+
+        w.setResizable(false);
+        expectBoundsEqual(w.getSize(), [400, 300]);
+        w.setResizable(true);
+        expectBoundsEqual(w.getSize(), [400, 300]);
+      });
+
       ifit(process.platform !== 'darwin')('works for a window smaller than 64x64', () => {
         const w = new BrowserWindow({
           show: false,