Compare commits

..

7 Commits

Author SHA1 Message Date
John Beutner
2051696e1d fix: ensure WebContents::WasShown runs when window is shown
Avoids a freeze when failing to enter fullscreen on macOS.
2026-02-24 10:01:51 -05:00
Mitchell Cohen
acd01e15e2 fix: prevent crash on Windows when closing child windows (#49901)
* guard against window destruction in min/max size checks

* use weakptr to prevent hit test crash on teardown

* revove web contents views during teardown

* fix test failure

* fix other tests
2026-02-24 15:48:04 +01:00
David Sanders
fc3a0abf19 build: fix roller branch detection in CI (#49920) 2026-02-23 18:10:22 -08:00
David Sanders
fa2b9ac466 ci: fix checking latest release for website docs update (#49918) 2026-02-23 15:22:01 -08:00
Andre Hora
481e224992 docs: remove documentation link to discontinued Touch Bar Simulator (#49899)
Remove link to discontinued Touch Bar Simulator

https://github.com/sindresorhus/touch-bar-simulator is archived.

> This app is discontinued as it no longer works because of changes in macOS.
2026-02-23 15:04:31 -05:00
Shashwat Raj
90c9de70ac fix: updated Alt detection to explicitly exclude AltGraph/AltGr (#49778)
Updated Alt detection to explicitly exclude AltGraph/AltGr
2026-02-23 13:00:27 -05:00
Shelley Vohr
9d0a0a5459 fix: apply zoomFactor from setWindowOpenHandler to window.open() windows (#49886)
fix: apply zoomFactor from setWindowOpenHandler to window.open() windows
2026-02-23 09:02:48 +01:00
16 changed files with 143 additions and 211 deletions

View File

@@ -31,7 +31,7 @@ jobs:
echo "isLatestRelease=false" >> $GITHUB_OUTPUT
fi
- name: Trigger website docs update
if: ${{ steps.check-if-latest-release.outputs.isLatestRelease }}
if: ${{ steps.check-if-latest-release.outputs.isLatestRelease == 'true' }}
env:
GH_REPO: electron/website
GH_TOKEN: ${{ fromJSON(steps.secret-service.outputs.secrets).WEBSITE_DOCS_UPDATER_APP_TOKEN }}

View File

@@ -23,11 +23,6 @@ Creates a new touch bar with the specified items. Use
> The TouchBar API is currently experimental and may change or be
> removed in future Electron releases.
> [!TIP]
> If you don't have a MacBook with Touch Bar, you can use
> [Touch Bar Simulator](https://github.com/sindresorhus/touch-bar-simulator)
> to test Touch Bar usage in your app.
### Static Properties
#### `TouchBarButton`

View File

@@ -14,6 +14,23 @@ const DEPS_REGEX = /chromium_version':\n +'(.+?)',/m;
const CL_REGEX = /https:\/\/chromium-review\.googlesource\.com\/c\/(?:chromium\/src|v8\/v8)\/\+\/(\d+)(#\S+)?/g;
const ROLLER_BRANCH_PATTERN = /^roller\/chromium\/(.+)$/;
function getCurrentBranch () {
// In CI, use `GITHUB_HEAD_REF` since we checkout the PR branch in detached HEAD state
if (process.env.GITHUB_HEAD_REF) {
return process.env.GITHUB_HEAD_REF;
}
try {
return execSync('git rev-parse --abbrev-ref HEAD', {
cwd: ELECTRON_DIR,
encoding: 'utf8'
}).trim();
} catch {
console.error('Could not determine current git branch');
process.exit(1);
}
}
function getCommitsSinceMergeBase (mergeBase) {
try {
const output = execSync(`git log --format=%H%n%B%n---COMMIT_END--- ${mergeBase}..HEAD`, {
@@ -92,17 +109,7 @@ async function getGerritPatchDetails (clUrl) {
}
async function main () {
let currentBranch;
try {
currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: ELECTRON_DIR,
encoding: 'utf8'
}).trim();
} catch {
console.error('Could not determine current git branch');
process.exit(1);
}
const currentBranch = getCurrentBranch();
// Check if we're on a roller/chromium/* branch
const branchMatch = ROLLER_BRANCH_PATTERN.exec(currentBranch);

View File

@@ -280,16 +280,22 @@ v8::Local<v8::Value> BrowserWindow::GetWebContents(v8::Isolate* isolate) {
}
void BrowserWindow::OnWindowShow() {
if (!web_contents_shown_) {
web_contents()->WasShown();
web_contents_shown_ = true;
}
BaseWindow::OnWindowShow();
}
void BrowserWindow::OnWindowHide() {
web_contents()->WasOccluded();
web_contents_shown_ = false;
BaseWindow::OnWindowHide();
}
void BrowserWindow::Show() {
web_contents()->WasShown();
web_contents_shown_ = true;
BaseWindow::Show();
}
@@ -298,6 +304,7 @@ void BrowserWindow::ShowInactive() {
if (IsModal())
return;
web_contents()->WasShown();
web_contents_shown_ = true;
BaseWindow::ShowInactive();
}

View File

@@ -80,6 +80,7 @@ class BrowserWindow : public BaseWindow,
// Helpers.
v8::Global<v8::Value> web_contents_;
bool web_contents_shown_ = false;
v8::Global<v8::Value> web_contents_view_;
base::WeakPtr<api::WebContents> api_web_contents_;

View File

@@ -4746,6 +4746,19 @@ gin_helper::Handle<WebContents> WebContents::CreateFromWebPreferences(
existing_preferences->SetFromDictionary(web_preferences_dict);
web_contents->SetBackgroundColor(
existing_preferences->GetBackgroundColor());
double zoom_factor;
if (web_preferences.Get(options::kZoomFactor, &zoom_factor)) {
auto* zoom_controller = WebContentsZoomController::FromWebContents(
web_contents->web_contents());
if (zoom_controller) {
zoom_controller->SetDefaultZoomFactor(zoom_factor);
// Also set the current zoom level immediately, since the page
// has already navigated by the time we wrap the webContents.
zoom_controller->SetZoomLevel(
blink::ZoomFactorToZoomLevel(zoom_factor));
}
}
}
} else {
// Create one if not.

View File

@@ -33,7 +33,7 @@ WebContentsView::WebContentsView(v8::Isolate* isolate,
gin_helper::Handle<WebContents> web_contents)
: View(web_contents->inspectable_web_contents()->GetView()),
web_contents_(isolate, web_contents.ToV8()),
api_web_contents_(web_contents.get()) {
api_web_contents_(web_contents->GetWeakPtr()) {
set_delete_view(false);
view()->SetProperty(
views::kFlexBehaviorKey,

View File

@@ -7,7 +7,7 @@
#include <optional>
#include "base/memory/raw_ptr.h"
#include "base/memory/weak_ptr.h"
#include "content/public/browser/web_contents_observer.h"
#include "shell/browser/api/electron_api_view.h"
#include "shell/browser/draggable_region_provider.h"
@@ -63,7 +63,7 @@ class WebContentsView : public View,
// Keep a reference to v8 wrapper.
v8::Global<v8::Value> web_contents_;
raw_ptr<api::WebContents> api_web_contents_;
base::WeakPtr<api::WebContents> api_web_contents_;
};
} // namespace electron::api

View File

@@ -92,6 +92,13 @@ InspectableWebContentsView::InspectableWebContentsView(
}
InspectableWebContentsView::~InspectableWebContentsView() {
if (devtools_window_web_view_)
devtools_window_web_view_->SetWebContents(nullptr);
if (devtools_web_view_)
devtools_web_view_->SetWebContents(nullptr);
if (contents_web_view_)
contents_web_view_->SetWebContents(nullptr);
if (devtools_window_)
inspectable_web_contents()->SaveDevToolsBounds(
devtools_window_->GetWindowBoundsInScreen());

View File

@@ -9,6 +9,7 @@
#include "components/input/native_web_keyboard_event.h"
#include "shell/browser/native_window.h"
#include "shell/browser/ui/views/menu_bar.h"
#include "ui/events/keycodes/dom/keycode_converter.h"
#include "ui/views/layout/box_layout.h"
namespace electron {
@@ -21,9 +22,21 @@ bool IsAltKey(const input::NativeWebKeyboardEvent& event) {
bool IsAltModifier(const input::NativeWebKeyboardEvent& event) {
using Mods = input::NativeWebKeyboardEvent::Modifiers;
// AltGraph (AltGr) should not be treated as a single Alt keypress for
// menu-bar toggling.
if (event.windows_key_code == ui::VKEY_ALTGR ||
ui::KeycodeConverter::DomKeyToKeyString(event.dom_key) == "AltGraph") {
return false;
}
return (event.GetModifiers() & Mods::kKeyModifiers) == Mods::kAltKey;
}
bool IsSingleAltKey(const input::NativeWebKeyboardEvent& event) {
return IsAltKey(event) && IsAltModifier(event);
}
} // namespace
RootView::RootView(NativeWindow* window)
@@ -98,7 +111,7 @@ void RootView::HandleKeyEvent(const input::NativeWebKeyboardEvent& event) {
return;
// Show accelerator when "Alt" is pressed.
if (menu_bar_visible_ && IsAltKey(event))
if (menu_bar_visible_ && IsSingleAltKey(event))
menu_bar_->SetAcceleratorVisibility(
event.GetType() == blink::WebInputEvent::Type::kRawKeyDown);
@@ -121,11 +134,11 @@ void RootView::HandleKeyEvent(const input::NativeWebKeyboardEvent& event) {
// Toggle the menu bar only when a single Alt is released.
if (event.GetType() == blink::WebInputEvent::Type::kRawKeyDown &&
IsAltKey(event)) {
IsSingleAltKey(event)) {
// When a single Alt is pressed:
menu_bar_alt_pressed_ = true;
} else if (event.GetType() == blink::WebInputEvent::Type::kKeyUp &&
IsAltKey(event) && menu_bar_alt_pressed_) {
IsSingleAltKey(event) && menu_bar_alt_pressed_) {
// When a single Alt is released right after a Alt is pressed:
menu_bar_alt_pressed_ = false;
if (menu_bar_autohide_)

View File

@@ -275,12 +275,16 @@ bool WinFrameView::GetShouldPaintAsActive() {
}
gfx::Size WinFrameView::GetMinimumSize() const {
if (!window_)
return gfx::Size();
// Chromium expects minimum size to be in content dimensions on Windows
// because it adds the frame border automatically in OnGetMinMaxInfo.
return window_->GetContentMinimumSize();
}
gfx::Size WinFrameView::GetMaximumSize() const {
if (!window_)
return gfx::Size();
// Chromium expects minimum size to be in content dimensions on Windows
// because it adds the frame border automatically in OnGetMinMaxInfo.
gfx::Size size = window_->GetContentMaximumSize();

View File

@@ -216,9 +216,9 @@ void ElectronRendererClient::WorkerScriptReadyForEvaluationOnWorkerThread(
if (base::CommandLine::ForCurrentProcess()->HasSwitch(
switches::kNodeIntegrationInWorker)) {
auto* current = WebWorkerObserver::GetCurrent();
if (!current)
current = WebWorkerObserver::Create();
current->WorkerScriptReadyForEvaluation(context);
if (current)
return;
WebWorkerObserver::Create()->WorkerScriptReadyForEvaluation(context);
}
}

View File

@@ -10,8 +10,8 @@
#include "base/no_destructor.h"
#include "base/strings/strcat.h"
#include "base/threading/thread_local.h"
#include "gin/converter.h"
#include "shell/common/api/electron_bindings.h"
#include "shell/common/gin_helper/event_emitter_caller.h"
#include "shell/common/node_bindings.h"
#include "shell/common/node_includes.h"
#include "shell/common/node_util.h"
@@ -48,21 +48,6 @@ WebWorkerObserver::~WebWorkerObserver() = default;
void WebWorkerObserver::WorkerScriptReadyForEvaluation(
v8::Local<v8::Context> worker_context) {
active_context_count_++;
if (environments_.empty()) {
// First context on this thread - do full Node.js initialization.
InitializeNewEnvironment(worker_context);
} else {
// Thread is being reused (AudioWorklet thread pooling). Share the
// existing Node.js environment with the new context instead of
// reinitializing, which would break existing contexts on this thread.
ShareEnvironmentWithContext(worker_context);
}
}
void WebWorkerObserver::InitializeNewEnvironment(
v8::Local<v8::Context> worker_context) {
v8::Context::Scope context_scope(worker_context);
v8::Isolate* const isolate = v8::Isolate::GetCurrent();
v8::MicrotasksScope microtasks_scope(
@@ -121,160 +106,26 @@ void WebWorkerObserver::InitializeNewEnvironment(
environments_.insert(std::move(env));
}
void WebWorkerObserver::ShareEnvironmentWithContext(
v8::Local<v8::Context> worker_context) {
v8::Context::Scope context_scope(worker_context);
v8::Isolate* const isolate = v8::Isolate::GetCurrent();
v8::MicrotasksScope microtasks_scope(
worker_context, v8::MicrotasksScope::kDoNotRunMicrotasks);
// Get the existing environment from the first context on this thread.
DCHECK(!environments_.empty());
node::Environment* env = environments_.begin()->get();
// Initialize the V8 context for Node.js use.
v8::Maybe<bool> initialized = node::InitializeContext(worker_context);
CHECK(!initialized.IsNothing() && initialized.FromJust());
// Assign the existing Node.js environment to this new context so that
// node::Environment::GetCurrent(context) returns the shared environment.
env->AssignToContext(worker_context, env->principal_realm(),
node::ContextInfo("electron_worker"));
// Get process and require from the original context to make Node.js
// APIs available in the new context.
v8::Local<v8::Context> original_context = env->context();
v8::Local<v8::Object> original_global = original_context->Global();
v8::Local<v8::Object> new_global = worker_context->Global();
v8::Local<v8::Value> process_value;
CHECK(original_global
->Get(original_context, gin::StringToV8(isolate, "process"))
.ToLocal(&process_value));
v8::Local<v8::Value> require_value;
CHECK(original_global
->Get(original_context, gin::StringToV8(isolate, "require"))
.ToLocal(&require_value));
// Set up 'global' as an alias for globalThis. Node.js bootstrapping normally
// does this during LoadEnvironment, but we skip full bootstrap for shared
// contexts.
new_global
->Set(worker_context, gin::StringToV8(isolate, "global"), new_global)
.Check();
new_global
->Set(worker_context, gin::StringToV8(isolate, "process"), process_value)
.Check();
new_global
->Set(worker_context, gin::StringToV8(isolate, "require"), require_value)
.Check();
// Copy Buffer from the original context if it exists.
v8::Local<v8::Value> buffer_value;
if (original_global->Get(original_context, gin::StringToV8(isolate, "Buffer"))
.ToLocal(&buffer_value) &&
!buffer_value->IsUndefined()) {
new_global
->Set(worker_context, gin::StringToV8(isolate, "Buffer"), buffer_value)
.Check();
}
// Restore the Blink implementations of web APIs that Node.js may
// have deleted. For first-context init this is done by the node_init script
// but we can't run that for shared contexts (it calls internalBinding).
// Instead, copy the blink-prefixed values set during first init.
for (const std::string_view key :
{"fetch", "Response", "FormData", "Request", "Headers", "EventSource"}) {
// First, check if the new context has a working Blink version.
v8::MaybeLocal<v8::Value> blink_value =
new_global->Get(worker_context, gin::StringToV8(isolate, key));
if (!blink_value.IsEmpty() && !blink_value.ToLocalChecked()->IsUndefined())
continue;
// If not, copy from the original context.
std::string blink_key = base::StrCat({"blink", key});
v8::Local<v8::Value> orig_value;
if (original_global->Get(original_context, gin::StringToV8(isolate, key))
.ToLocal(&orig_value) &&
!orig_value->IsUndefined()) {
new_global->Set(worker_context, gin::StringToV8(isolate, key), orig_value)
.Check();
}
}
}
void WebWorkerObserver::ContextWillDestroy(v8::Local<v8::Context> context) {
node::Environment* env = node::Environment::GetCurrent(context);
if (!env)
return;
active_context_count_--;
if (active_context_count_ == 0) {
// Last context on this thread — full cleanup.
{
v8::Context::Scope context_scope(env->context());
// Emit the "exit" event on the process object. We avoid using
// gin_helper::EmitEvent here because it goes through
// CallMethodWithArgs, which creates a node::CallbackScope. During
// worker shutdown (PrepareForShutdownOnWorkerThread), the
// CallbackScope destructor's InternalCallbackScope::Close() tries to
// process ticks and microtask checkpoints, which can SEGV because the
// worker context is being torn down by Blink.
v8::Isolate* isolate = env->isolate();
v8::HandleScope handle_scope(isolate);
v8::Local<v8::Context> ctx = env->context();
v8::Local<v8::Value> emit_v;
if (env->process_object()
->Get(ctx, gin::StringToV8(isolate, "emit"))
.ToLocal(&emit_v) &&
emit_v->IsFunction()) {
v8::Local<v8::Value> args[] = {gin::StringToV8(isolate, "exit")};
v8::TryCatch try_catch(isolate);
emit_v.As<v8::Function>()
->Call(ctx, env->process_object(), 1, args)
.FromMaybe(v8::Local<v8::Value>());
}
}
// Prevent UvRunOnce from using the environment after it's destroyed.
node_bindings_->set_uv_env(nullptr);
// Destroying the node environment will also run the uv loop.
{
util::ExplicitMicrotasksScope microtasks_scope(
context->GetMicrotaskQueue());
environments_.clear();
}
// ElectronBindings is tracking node environments.
electron_bindings_->EnvironmentDestroyed(env);
// Do NOT destroy the observer here. The worker thread may be reused
// for another worklet context (e.g., AudioWorklet thread pooling or
// PaintWorklet after page reload). Destroying the observer would
// force a new NodeBindings to be created, but Node.js cannot be
// fully reinitialized on the same thread (the allocator shim can
// only be loaded once). Instead, keep the observer and its
// NodeBindings alive so they can be reused. The environments_ set
// is now empty, so WorkerScriptReadyForEvaluation will call
// InitializeNewEnvironment on the next context.
} else {
// Other contexts still use the shared environment. Just unassign
// this context from the environment if it's not the primary context
// (the primary context must stay assigned because env->context()
// references it, and UvRunOnce enters that context scope).
if (context != env->context()) {
env->UnassignFromContext(context);
}
// If the destroyed context IS the primary context, we leave the env
// assigned to it. The env's PrincipalRealm holds a Global<Context>
// reference that keeps the V8 context alive even though Blink has
// torn down its side. This is safe because UvRunOnce only needs
// the V8 context scope, not Blink-side objects.
if (env) {
v8::Context::Scope context_scope(env->context());
gin_helper::EmitEvent(env->isolate(), env->process_object(), "exit");
}
// Destroying the node environment will also run the uv loop.
{
util::ExplicitMicrotasksScope microtasks_scope(
context->GetMicrotaskQueue());
base::EraseIf(environments_,
[env](auto const& item) { return item.get() == env; });
}
// ElectronBindings is tracking node environments.
electron_bindings_->EnvironmentDestroyed(env);
if (lazy_tls->Get())
lazy_tls->Set(nullptr);
}
} // namespace electron

View File

@@ -40,17 +40,9 @@ class WebWorkerObserver {
void ContextWillDestroy(v8::Local<v8::Context> context);
private:
// Full initialization for the first context on a thread.
void InitializeNewEnvironment(v8::Local<v8::Context> context);
// Share existing environment with a new context on a reused thread.
void ShareEnvironmentWithContext(v8::Local<v8::Context> context);
std::unique_ptr<NodeBindings> node_bindings_;
std::unique_ptr<ElectronBindings> electron_bindings_;
base::flat_set<std::shared_ptr<node::Environment>> environments_;
// Number of active contexts using the environment on this thread.
size_t active_context_count_ = 0;
};
} // namespace electron

View File

@@ -3977,6 +3977,28 @@ describe('BrowserWindow module', () => {
expect(webPreferences!.contextIsolation).to.equal(false);
});
it('should apply zoomFactor from setWindowOpenHandler overrideBrowserWindowOptions', async () => {
const w = new BrowserWindow({
show: false,
webPreferences: {
sandbox: true
}
});
w.webContents.setWindowOpenHandler(() => ({
action: 'allow',
overrideBrowserWindowOptions: {
webPreferences: {
zoomFactor: 2.0
}
}
}));
w.loadFile(path.join(fixtures, 'api', 'new-window.html'));
const [childWindow] = await once(w.webContents, 'did-create-window') as [BrowserWindow, any];
await once(childWindow.webContents, 'did-finish-load');
expect(childWindow.webContents.getZoomFactor()).to.be.closeTo(2.0, 0.1);
});
it('should set ipc event sender correctly', async () => {
const w = new BrowserWindow({
show: false,
@@ -5903,6 +5925,23 @@ describe('BrowserWindow module', () => {
});
});
ifdescribe(process.platform === 'linux')('menu bar AltGr behavior', () => {
it('does not toggle auto-hide menu bar visibility', async () => {
const w = new BrowserWindow({ show: false, autoHideMenuBar: true });
w.setMenuBarVisibility(false);
expect(w.isMenuBarVisible()).to.be.false('isMenuBarVisible');
w.show();
await once(w, 'show');
w.webContents.focus();
w.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'AltGr' });
w.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'AltGr' });
await setTimeout();
expect(w.isMenuBarVisible()).to.be.false('isMenuBarVisible');
});
});
ifdescribe(process.platform !== 'darwin')('when fullscreen state is changed', () => {
it('correctly remembers state prior to fullscreen change', async () => {
const w = new BrowserWindow({ show: false });

View File

@@ -59,10 +59,11 @@ describe('WebContentsView', () => {
const browserWindow = new BrowserWindow();
const webContentsView = new WebContentsView();
webContentsView.webContents.loadURL('about:blank');
webContentsView.webContents.destroy();
const wc = webContentsView.webContents;
wc.loadURL('about:blank');
wc.destroy();
const destroyed = once(webContentsView.webContents, 'destroyed');
const destroyed = once(wc, 'destroyed');
await destroyed;
expect(() => browserWindow.contentView.addChildView(webContentsView)).to.throw(
'Can\'t add a destroyed child view to a parent view'
@@ -90,13 +91,14 @@ describe('WebContentsView', () => {
const w = new BaseWindow({ show: false });
const v = new View();
const wcv = new WebContentsView();
const wc = wcv.webContents;
w.setContentView(v);
v.addChildView(wcv);
await wcv.webContents.loadURL('about:blank');
const destroyed = once(wcv.webContents, 'destroyed');
wcv.webContents.executeJavaScript('window.close()');
await wc.loadURL('about:blank');
const destroyed = once(wc, 'destroyed');
wc.executeJavaScript('window.close()');
await destroyed;
expect(wcv.webContents.isDestroyed()).to.be.true();
expect(wc.isDestroyed()).to.be.true();
v.removeChildView(wcv);
});
@@ -170,18 +172,19 @@ describe('WebContentsView', () => {
it('does not crash when closed via window.close()', async () => {
const bw = new BrowserWindow();
const wcv = new WebContentsView();
const wc = wcv.webContents;
await bw.loadURL('data:text/html,<h1>Main Window</h1>');
bw.contentView.addChildView(wcv);
const dto = new Promise<boolean>((resolve) => {
wcv.webContents.on('blur', () => {
const devToolsOpen = wcv.webContents.isDevToolsOpened();
wc.on('blur', () => {
const devToolsOpen = !wc.isDestroyed() && wc.isDevToolsOpened();
resolve(devToolsOpen);
});
});
wcv.webContents.loadURL('data:text/html,<script>window.close()</script>');
wc.loadURL('data:text/html,<script>window.close()</script>');
const open = await dto;
expect(open).to.be.false();