Compare commits

...

9 Commits

Author SHA1 Message Date
John Kleinschmidt
78896775d9 ci: update actions to node24 (#50522)
ci: update actions to node24 (#50373)

* ci: update actions to node24

* chore: fixup actions/cache to 5.0.4 everywhere

(cherry picked from commit 639d3b99b7)
2026-03-31 15:26:31 +02:00
trop[bot]
40eb41656a ci: update nick-fields/retry to v4.0.0 (#50544)
Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: John Kleinschmidt <kleinschmidtorama@gmail.com>
2026-03-31 14:32:29 +02:00
Michaela Laurencin
5a69e80cac ci: add functionality for programmatic add/remove needs-signed-commits label (#50316) (#50587)
* remove comment based label removal

* ci: add functionality for programmatic add/remove needs-signed-commits label

* add new line to pull-request-opened-synchronized
2026-03-31 10:30:47 +02:00
trop[bot]
90decd4eaf fix: add missing HandleScope in contentTracing.getTraceBufferUsage() (#50594)
The `OnTraceBufferUsageAvailable` callback creates V8 handles via
`Dictionary::CreateEmpty()` before `promise.Resolve()` enters its
`SettleScope` (which provides a `HandleScope`). When the callback
fires asynchronously from a Mojo response (i.e. when a trace session
is active), there is no `HandleScope` on the stack, causing a fatal
V8 error: "Cannot create a handle without a HandleScope".

Add an explicit `v8::HandleScope` at the top of the callback, matching
the pattern used by the other contentTracing APIs which resolve their
promises through `SettleScope` or the static `ResolvePromise` helper.

Made-with: Cursor

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Alexey Kozy <alexey@anysphere.co>
2026-03-31 10:15:13 +02:00
trop[bot]
ba551d265c perf: enable V8 builtins PGO (#50574)
* build: enable V8 builtins PGO

Removes the gn arg that disabled V8 builtins profile-guided optimization
and adds a V8 patch to warn instead of abort when the builtin PGO profile
data does not match. Also strips the PGO-related flags from the generated
mksnapshot_args so they are not passed through to downstream mksnapshot
invocations.

Co-authored-by: Sam Attard <sattard@anthropic.com>

* docs: clarify Node.js async_hooks as reason for promise_hooks flag

Addresses review feedback: the v8_enable_javascript_promise_hooks flag
is set to support Node.js async_hooks, not used directly by Electron.

Co-authored-by: Sam Attard <sattard@anthropic.com>

* chore: update patches

---------

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Sam Attard <sattard@anthropic.com>
Co-authored-by: PatchUp <73610968+patchup[bot]@users.noreply.github.com>
2026-03-30 19:44:54 +02:00
trop[bot]
24784ed024 refactor: improve input handling in FilePath gin converter (#50547)
refactor: improve input handling in file_path_converter

Properly handle paths containing ASCII control characters in the FilePath gin converter

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Keeley Hammond <vertedinde@electronjs.org>
2026-03-27 22:56:38 +00:00
trop[bot]
f49f6b1a29 docs: clarify allowed characters in protocol names (#50538)
Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Niklas Wenzel <dev@nikwen.de>
2026-03-27 09:58:46 -04:00
trop[bot]
c63e0d8b96 test: add interactive macOS dialog tests (#50528)
Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Shelley Vohr <shelley.vohr@gmail.com>
2026-03-27 08:20:36 -04:00
trop[bot]
33a81b40c2 fix: register PrintDialogLinuxFactory on Linux (#50486)
fix: register PrintDialogLinuxFactory on Linux

Chromium 145 refactored Linux print dialog creation to use a factory
pattern instead of directly calling LinuxUi::CreatePrintDialog().
Chrome registers this factory in
ChromeBrowserMainExtraPartsViewsLinux::ToolkitInitialized(), but
Electron did not, causing PrintingContextLinux::EnsurePrintDialog()
to leave print_dialog_ null on every call.

Without a dialog, UseDefaultSettings() and UpdatePrinterSettings()
return success but with empty/unprocessed settings, causing
PrintMsgPrintParamsIsValid() to fail. This broke both window.print()
(no dialog appears) and webContents.print() (callback stuck until
app close with "Invalid printer settings").

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Shelley Vohr <shelley.vohr@gmail.com>
2026-03-26 17:01:28 -04:00
28 changed files with 1634 additions and 14 deletions

View File

@@ -125,6 +125,9 @@ runs:
fi
sed $SEDOPTION '/.*builtins-pgo/d' out/Default/mksnapshot_args
sed $SEDOPTION '/--turbo-profiling-input/d' out/Default/mksnapshot_args
sed $SEDOPTION '/--reorder-builtins/d' out/Default/mksnapshot_args
sed $SEDOPTION '/--warn-about-builtin-profile-data/d' out/Default/mksnapshot_args
sed $SEDOPTION '/--abort-on-bad-builtin-profile-data/d' out/Default/mksnapshot_args
if [ "${{ inputs.target-platform }}" = "win" ]; then
cd out/Default
@@ -271,12 +274,12 @@ runs:
run: ./src/electron/script/actions/move-artifacts.sh
- name: Upload Generated Artifacts ${{ inputs.step-suffix }}
if: always() && !cancelled()
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0
with:
name: generated_artifacts_${{ env.ARTIFACT_KEY }}
path: ./generated_artifacts_${{ inputs.artifact-platform }}_${{ inputs.target-arch }}
- name: Upload Src Artifacts ${{ inputs.step-suffix }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0
with:
name: src_artifacts_${{ env.ARTIFACT_KEY }}
path: ./src_artifacts_${{ inputs.artifact-platform }}_${{ inputs.target-arch }}

View File

@@ -43,7 +43,7 @@ runs:
curl --unix-socket /var/run/sas/sas.sock --fail "http://foo/$CACHE_FILE?platform=${{ inputs.target-platform }}&getAccountName=true" > sas-token
- name: Save SAS Key
if: ${{ inputs.generate-sas-token == 'true' }}
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: sas-token
key: sas-key-${{ inputs.target-platform }}-${{ github.run_number }}-${{ github.run_attempt }}

View File

@@ -7,7 +7,7 @@ runs:
shell: bash
id: yarn-cache-dir-path
run: echo "dir=$(node src/electron/script/yarn.js config get cacheFolder)" >> $GITHUB_OUTPUT
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}

View File

@@ -8,14 +8,14 @@ runs:
steps:
- name: Obtain SAS Key
continue-on-error: true
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: sas-token
key: sas-key-${{ inputs.target-platform }}-${{ github.run_number }}-1
enableCrossOsArchive: true
- name: Obtain SAS Key
continue-on-error: true
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: sas-token
key: sas-key-${{ inputs.target-platform }}-${{ github.run_number }}-${{ github.run_attempt }}
@@ -24,7 +24,7 @@ runs:
# The cache will always exist here as a result of the checkout job
# Either it was uploaded to Azure in the checkout job for this commit
# or it was uploaded in the checkout job for a previous commit.
uses: nick-fields/retry@7152eba30c6575329ac0576536151aca5a72780e # v3.0.0
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 30
max_attempts: 3
@@ -101,7 +101,7 @@ runs:
- name: Move Src Cache (Windows)
if: ${{ inputs.target-platform == 'win' }}
uses: nick-fields/retry@7152eba30c6575329ac0576536151aca5a72780e # v3.0.0
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 30
max_attempts: 3

View File

@@ -431,3 +431,30 @@ jobs:
- name: GitHub Actions Jobs Done
run: |
echo "All GitHub Actions Jobs are done"
check-signed-commits:
name: Check signed commits in green PR
needs: gha-done
if: ${{ contains(github.event.pull_request.labels.*.name, 'needs-signed-commits')}}
runs-on: ubuntu-slim
permissions:
contents: read
pull-requests: write
steps:
- name: Check signed commits in PR
uses: 1Password/check-signed-commits-action@ed2885f3ed2577a4f5d3c3fe895432a557d23d52 # v1
with:
comment: |
⚠️ This PR contains unsigned commits. This repository enforces [commit signatures](https://docs.github.com/en/authentication/managing-commit-signature-verification)
for all incoming PRs. To get your PR merged, please sign those commits
(`git rebase --exec 'git commit -S --amend --no-edit -n' @{upstream}`) and force push them to this branch
(`git push --force-with-lease`)
For more information on signing commits, see GitHub's documentation on [Telling Git about your signing key](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key).
- name: Remove needs-signed-commits label
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh pr edit $PR_URL --remove-label needs-signed-commits

View File

@@ -289,7 +289,7 @@ jobs:
if: always() && !cancelled()
- name: Upload Test Artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7.0.0
with:
name: test_artifacts_${{ env.ARTIFACT_KEY }}_${{ matrix.shard }}
path: src/electron/spec/artifacts

View File

@@ -0,0 +1,35 @@
name: Pull Request Opened/Synchronized
on:
pull_request_target:
types: [opened, synchronize]
permissions: {}
jobs:
check-signed-commits:
name: Check signed commits in PR
if: ${{ !contains(github.event.pull_request.labels.*.name, 'needs-signed-commits')}}
runs-on: ubuntu-slim
permissions:
contents: read
pull-requests: write
steps:
- name: Check signed commits in PR
uses: 1Password/check-signed-commits-action@ed2885f3ed2577a4f5d3c3fe895432a557d23d52 # v1
with:
comment: |
⚠️ This PR contains unsigned commits. This repository enforces [commit signatures](https://docs.github.com/en/authentication/managing-commit-signature-verification)
for all incoming PRs. To get your PR merged, please sign those commits
(`git rebase --exec 'git commit -S --amend --no-edit -n' @{upstream}`) and force push them to this branch
(`git push --force-with-lease`)
For more information on signing commits, see GitHub's documentation on [Telling Git about your signing key](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key).
- name: Add needs-signed-commits label
if: ${{ failure() }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh pr edit $PR_URL --add-label needs-signed-commits

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ spec/.hash
# Generated native addon files
/spec/fixtures/native-addon/echo/build/
/spec/fixtures/native-addon/dialog-helper/build/
# If someone runs tsc this is where stuff will end up
ts-gen

View File

@@ -51,9 +51,6 @@ is_cfi = false
use_qt5 = false
use_qt6 = false
# Disables the builtins PGO for V8
v8_builtins_profiling_log_file = ""
# https://chromium.googlesource.com/chromium/src/+/main/docs/dangling_ptr.md
# TODO(vertedinde): hunt down dangling pointers on Linux
enable_dangling_raw_ptr_checks = false

View File

@@ -56,6 +56,15 @@ app.whenReady().then(() => {
})
```
## Protocol names
[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) defines what a valid
protocol name is:
> Scheme names consist of a sequence of characters beginning with a letter and followed
> by any combination of letters, digits, plus ("+"), period ("."), or hyphen ("-").
> Although schemes are case-insensitive, the canonical form is lowercase […].
## Methods
The `protocol` module has the following methods:

View File

@@ -1 +1,2 @@
chore_allow_customizing_microtask_policy_per_context.patch
build_warn_instead_of_abort_on_builtin_pgo_profile_mismatch.patch

View File

@@ -0,0 +1,35 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Sam Attard <sattard@anthropic.com>
Date: Sun, 22 Mar 2026 10:51:26 +0000
Subject: build: warn instead of abort on builtin PGO profile mismatch
Electron sets v8_enable_javascript_promise_hooks = true to support
Node.js async_hooks (see node/src/env.cc SetPromiseHooks usage:
https://github.com/nodejs/node/blob/abff716eaccd0c4f4949d1315cb057a45979649d/src/env.cc#L223-L236).
This flag adds conditional branches to builtins-microtask-queue-gen.cc
and promise-misc.tq, changing the control-flow graph hash of several
Promise/async builtins. This invalidates V8's pre-generated PGO profile
for those builtins (built with Chrome defaults where the flag is off).
Rather than disabling builtins PGO entirely, warn and skip mismatched
builtins so all other builtins still benefit from PGO.
diff --git a/BUILD.gn b/BUILD.gn
index 15de2179a0e5ce50d5c659a9d15a920c50124c3e..9fb3a69450bdcab42c2571e8b1f57c4f3c283d9a 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -2764,9 +2764,11 @@ template("run_mksnapshot") {
"--turbo-profiling-input",
rebase_path(v8_builtins_profiling_log_file, root_build_dir),
- # Replace this with --warn-about-builtin-profile-data to see the full
- # list of builtins with incompatible profiles.
- "--abort-on-bad-builtin-profile-data",
+ # Electron: Use warn instead of abort so that builtins whose control
+ # flow is changed by Electron's build flags (e.g. RunMicrotasks via
+ # v8_enable_javascript_promise_hooks) are skipped rather than failing
+ # the build. All other builtins still receive PGO.
+ "--warn-about-builtin-profile-data",
]
if (!v8_enable_builtins_profiling && v8_enable_builtins_reordering) {

View File

@@ -151,7 +151,10 @@ void OnTraceBufferUsageAvailable(
gin_helper::Promise<gin_helper::Dictionary> promise,
float percent_full,
size_t approximate_count) {
auto dict = gin_helper::Dictionary::CreateEmpty(promise.isolate());
v8::Isolate* isolate = promise.isolate();
v8::HandleScope handle_scope(isolate);
auto dict = gin_helper::Dictionary::CreateEmpty(isolate);
dict.Set("percentage", percent_full);
dict.Set("value", approximate_count);

View File

@@ -127,6 +127,10 @@
#include "shell/common/plugin_info.h"
#endif // BUILDFLAG(ENABLE_PLUGINS)
#if BUILDFLAG(ENABLE_PRINTING)
#include "components/printing/common/print_dialog_linux_factory.h"
#endif
namespace electron {
namespace {
@@ -415,6 +419,10 @@ void ElectronBrowserMainParts::ToolkitInitialized() {
ui::LinuxUi::SetInstance(linux_ui);
#if BUILDFLAG(ENABLE_PRINTING)
print_dialog_factory_ = std::make_unique<printing::PrintDialogLinuxFactory>();
#endif
// Cursor theme changes are tracked by LinuxUI (via a CursorThemeManager
// implementation). Start observing them once it's initialized.
ui::CursorFactory::GetInstance()->ObserveThemeChanges();

View File

@@ -14,8 +14,13 @@
#include "content/public/browser/browser_main_parts.h"
#include "electron/buildflags/buildflags.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "printing/buildflags/buildflags.h"
#include "services/device/public/mojom/geolocation_control.mojom.h"
#if BUILDFLAG(ENABLE_PRINTING)
#include "printing/printing_context_linux.h"
#endif
class BrowserProcessImpl;
class IconManager;
@@ -179,6 +184,11 @@ class ElectronBrowserMainParts : public content::BrowserMainParts {
std::unique_ptr<display::ScopedNativeScreen> screen_;
#endif
#if BUILDFLAG(ENABLE_PRINTING)
std::unique_ptr<printing::PrintingContextLinux::PrintDialogFactory>
print_dialog_factory_;
#endif
static ElectronBrowserMainParts* self_;
};

View File

@@ -5,6 +5,8 @@
#ifndef ELECTRON_SHELL_COMMON_GIN_CONVERTERS_FILE_PATH_CONVERTER_H_
#define ELECTRON_SHELL_COMMON_GIN_CONVERTERS_FILE_PATH_CONVERTER_H_
#include <algorithm>
#include "base/files/file_path.h"
#include "gin/converter.h"
#include "shell/common/gin_converters/std_converter.h"
@@ -30,6 +32,11 @@ struct Converter<base::FilePath> {
base::FilePath::StringType path;
if (Converter<base::FilePath::StringType>::FromV8(isolate, val, &path)) {
bool has_control_chars = std::any_of(
path.begin(), path.end(),
[](base::FilePath::CharType c) { return c >= 0 && c < 0x20; });
if (has_control_chars)
return false;
*out = base::FilePath(path);
return true;
} else {

View File

@@ -607,6 +607,9 @@ bool Converter<scoped_refptr<network::ResourceRequestBody>>::FromV8(
const std::string* file = dict.FindString("filePath");
if (!file)
return false;
if (std::any_of(file->begin(), file->end(),
[](char c) { return c >= 0 && c < 0x20; }))
return false;
double modification_time =
dict.FindDouble("modificationTime").value_or(0.0);
int offset = dict.FindInt("offset").value_or(0);

View File

@@ -132,6 +132,36 @@ ifdescribe(!(['arm', 'arm64'].includes(process.arch)) || (process.platform !== '
});
});
describe('getTraceBufferUsage', function () {
this.timeout(10e3);
it('does not crash and returns valid usage data', async () => {
await app.whenReady();
await contentTracing.startRecording({
categoryFilter: '*',
traceOptions: 'record-until-full'
});
// Yield to the event loop so the JS HandleScope from this tick is gone.
// When the Mojo response arrives it fires OnTraceBufferUsageAvailable
// as a plain Chromium task — if that callback lacks its own HandleScope
// the process will crash with "Cannot create a handle without a HandleScope".
const result = await contentTracing.getTraceBufferUsage();
expect(result).to.have.property('percentage').that.is.a('number');
expect(result).to.have.property('value').that.is.a('number');
await contentTracing.stopRecording();
});
it('returns zero usage when no trace is active', async () => {
await app.whenReady();
const result = await contentTracing.getTraceBufferUsage();
expect(result).to.have.property('percentage').that.is.a('number');
expect(result.percentage).to.equal(0);
});
});
describe('captured events', () => {
it('include V8 samples from the main process', async function () {
this.timeout(60000);

View File

@@ -2,9 +2,10 @@ import { dialog, BaseWindow, BrowserWindow } from 'electron/main';
import { expect } from 'chai';
import * as path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { ifit } from './lib/spec-helpers';
import { ifdescribe, ifit } from './lib/spec-helpers';
import { closeAllWindows } from './lib/window-helpers';
describe('dialog module', () => {
@@ -243,4 +244,785 @@ describe('dialog module', () => {
}).to.throw(/message must be a string/);
});
});
ifdescribe(process.platform === 'darwin' && !process.env.ELECTRON_SKIP_NATIVE_MODULE_TESTS)('end-to-end dialog interaction (macOS)', () => {
let dialogHelper: any;
before(() => {
dialogHelper = require('@electron-ci/dialog-helper');
});
afterEach(closeAllWindows);
// Poll for a sheet to appear on the given window.
async function waitForSheet (w: BrowserWindow): Promise<void> {
const handle = w.getNativeWindowHandle();
for (let i = 0; i < 50; i++) {
const info = dialogHelper.getDialogInfo(handle);
if (info.type !== 'none') return;
await setTimeout(100);
}
throw new Error('Timed out waiting for dialog sheet to appear');
}
describe('showMessageBox', () => {
it('shows the correct message and buttons', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Test message',
buttons: ['OK', 'Cancel']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('message-box');
expect(info.message).to.equal('Test message');
const buttons = JSON.parse(info.buttons);
expect(buttons).to.include('OK');
expect(buttons).to.include('Cancel');
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
});
it('shows detail text', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Main message',
detail: 'Extra detail text',
buttons: ['OK']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.message).to.equal('Main message');
expect(info.detail).to.equal('Extra detail text');
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
});
it('returns the correct response when a specific button is clicked', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Choose a button',
buttons: ['First', 'Second', 'Third']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.clickMessageBoxButton(handle, 1);
const result = await p;
expect(result.response).to.equal(1);
});
it('returns the correct response when the last button is clicked', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Choose a button',
buttons: ['Yes', 'No', 'Maybe']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.clickMessageBoxButton(handle, 2);
const result = await p;
expect(result.response).to.equal(2);
});
it('shows a single button when no buttons are specified', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'No buttons specified'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('message-box');
// macOS adds a default "OK" button when none are specified.
const buttons = JSON.parse(info.buttons);
expect(buttons).to.have.lengthOf(1);
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.response).to.equal(0);
});
it('renders checkbox with the correct label and initial state', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Checkbox test',
buttons: ['OK'],
checkboxLabel: 'Do not show again',
checkboxChecked: false
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxLabel).to.equal('Do not show again');
expect(info.checkboxChecked).to.be.false();
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.checkboxChecked).to.be.false();
});
it('returns checkboxChecked as true when checkbox is initially checked', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Pre-checked checkbox',
buttons: ['OK'],
checkboxLabel: 'Remember my choice',
checkboxChecked: true
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxLabel).to.equal('Remember my choice');
expect(info.checkboxChecked).to.be.true();
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.checkboxChecked).to.be.true();
});
it('can toggle checkbox and returns updated state', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Toggle test',
buttons: ['OK'],
checkboxLabel: 'Toggle me',
checkboxChecked: false
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
// Verify initially unchecked.
let info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxChecked).to.be.false();
// Click the checkbox to check it.
dialogHelper.clickCheckbox(handle);
info = dialogHelper.getDialogInfo(handle);
expect(info.checkboxChecked).to.be.true();
dialogHelper.clickMessageBoxButton(handle, 0);
const result = await p;
expect(result.checkboxChecked).to.be.true();
});
it('strips access keys on macOS with normalizeAccessKeys', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Access key test',
buttons: ['&Save', '&Cancel'],
normalizeAccessKeys: true
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
// On macOS, ampersands are stripped by normalizeAccessKeys.
const buttons = JSON.parse(info.buttons);
expect(buttons).to.include('Save');
expect(buttons).to.include('Cancel');
expect(buttons).not.to.include('&Save');
expect(buttons).not.to.include('&Cancel');
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
});
it('respects defaultId by making it the default button', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Default button test',
buttons: ['One', 'Two', 'Three'],
defaultId: 2
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
const buttons = JSON.parse(info.buttons);
expect(buttons).to.deep.equal(['One', 'Two', 'Three']);
dialogHelper.clickMessageBoxButton(handle, 2);
const result = await p;
expect(result.response).to.equal(2);
});
it('respects cancelId and returns it when cancelled via signal', async () => {
const controller = new AbortController();
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: 'Cancel ID test',
buttons: ['OK', 'Dismiss', 'Abort'],
cancelId: 2,
signal: controller.signal
});
await waitForSheet(w);
controller.abort();
const result = await p;
expect(result.response).to.equal(2);
});
it('works with all message box types', async () => {
const types: Array<'none' | 'info' | 'warning' | 'error' | 'question'> =
['none', 'info', 'warning', 'error', 'question'];
for (const type of types) {
const w = new BrowserWindow({ show: false });
const p = dialog.showMessageBox(w, {
message: `Type: ${type}`,
type,
buttons: ['OK']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('message-box');
expect(info.message).to.equal(`Type: ${type}`);
dialogHelper.clickMessageBoxButton(handle, 0);
await p;
w.destroy();
// Allow the event loop to settle between iterations to avoid
// Chromium DCHECK failures from rapid window lifecycle churn.
await setTimeout(100);
}
});
});
describe('showOpenDialog', () => {
it('can cancel an open dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
title: 'Test Open',
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('open-dialog');
dialogHelper.cancelFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.true();
expect(result.filePaths).to.have.lengthOf(0);
});
it('sets a custom button label', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
buttonLabel: 'Select This',
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.prompt).to.equal('Select This');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets a message on the dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
message: 'Choose a file to import',
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.panelMessage).to.equal('Choose a file to import');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('defaults to openFile with canChooseFiles enabled', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canChooseFiles).to.be.true();
expect(info.canChooseDirectories).to.be.false();
expect(info.allowsMultipleSelection).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables directory selection with openDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canChooseDirectories).to.be.true();
// openFile is not set, so canChooseFiles should be false
expect(info.canChooseFiles).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables both file and directory selection together', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'openDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canChooseFiles).to.be.true();
expect(info.canChooseDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables multiple selection with multiSelections', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'multiSelections']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.allowsMultipleSelection).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('shows hidden files with showHiddenFiles', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'showHiddenFiles']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('does not show hidden files by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('disables alias resolution with noResolveAliases', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'noResolveAliases']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.resolvesAliases).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('resolves aliases by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.resolvesAliases).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('treats packages as directories with treatPackageAsDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'treatPackageAsDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.treatsPackagesAsDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables directory creation with createDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
properties: ['openFile', 'createDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canCreateDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets the default path directory', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
defaultPath: defaultDir,
properties: ['openFile']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.directory).to.equal(defaultDir);
dialogHelper.cancelFileDialog(handle);
await p;
});
it('applies multiple properties simultaneously', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
title: 'Multi-Property Test',
buttonLabel: 'Pick',
message: 'Select items',
properties: [
'openFile',
'openDirectory',
'multiSelections',
'showHiddenFiles',
'createDirectory',
'treatPackageAsDirectory',
'noResolveAliases'
]
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('open-dialog');
expect(info.prompt).to.equal('Pick');
expect(info.panelMessage).to.equal('Select items');
expect(info.canChooseFiles).to.be.true();
expect(info.canChooseDirectories).to.be.true();
expect(info.allowsMultipleSelection).to.be.true();
expect(info.showsHiddenFiles).to.be.true();
expect(info.canCreateDirectories).to.be.true();
expect(info.treatsPackagesAsDirectories).to.be.true();
expect(info.resolvesAliases).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('can accept an open dialog and return a file path', async () => {
const targetDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showOpenDialog(w, {
defaultPath: targetDir,
properties: ['openDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.acceptFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.false();
expect(result.filePaths).to.have.lengthOf(1);
expect(result.filePaths[0]).to.equal(targetDir);
});
});
describe('showSaveDialog', () => {
it('can cancel a save dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
title: 'Test Save'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('save-dialog');
dialogHelper.cancelFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.true();
expect(result.filePath).to.equal('');
});
it('can accept a save dialog with a filename', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const filename = 'test-save-output.txt';
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
title: 'Test Save',
defaultPath: path.join(defaultDir, filename)
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
dialogHelper.acceptFileDialog(handle);
const result = await p;
expect(result.canceled).to.be.false();
expect(result.filePath).to.equal(path.join(defaultDir, filename));
});
it('sets a custom button label', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
buttonLabel: 'Export'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.prompt).to.equal('Export');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets a message on the dialog', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
message: 'Choose where to save'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.panelMessage).to.equal('Choose where to save');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets a custom name field label', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
nameFieldLabel: 'Export As:'
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.nameFieldLabel).to.equal('Export As:');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets the default filename from defaultPath', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
defaultPath: path.join(__dirname, 'fixtures', 'my-document.txt')
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.nameFieldValue).to.equal('my-document.txt');
dialogHelper.cancelFileDialog(handle);
await p;
});
it('sets the default directory from defaultPath', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
defaultPath: path.join(defaultDir, 'some-file.txt')
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.directory).to.equal(defaultDir);
dialogHelper.cancelFileDialog(handle);
await p;
});
it('hides the tag field when showsTagField is false', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
showsTagField: false
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsTagField).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('shows the tag field by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsTagField).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('enables directory creation with createDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
properties: ['createDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.canCreateDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('shows hidden files with showHiddenFiles', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
properties: ['showHiddenFiles']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('does not show hidden files by default', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.showsHiddenFiles).to.be.false();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('treats packages as directories with treatPackageAsDirectory', async () => {
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
properties: ['treatPackageAsDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.treatsPackagesAsDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
it('applies multiple options simultaneously', async () => {
const defaultDir = path.join(__dirname, 'fixtures');
const w = new BrowserWindow({ show: false });
const p = dialog.showSaveDialog(w, {
buttonLabel: 'Save Now',
message: 'Pick a location',
nameFieldLabel: 'File Name:',
defaultPath: path.join(defaultDir, 'output.txt'),
showsTagField: false,
properties: ['showHiddenFiles', 'createDirectory']
});
await waitForSheet(w);
const handle = w.getNativeWindowHandle();
const info = dialogHelper.getDialogInfo(handle);
expect(info.type).to.equal('save-dialog');
expect(info.prompt).to.equal('Save Now');
expect(info.panelMessage).to.equal('Pick a location');
expect(info.nameFieldLabel).to.equal('File Name:');
expect(info.nameFieldValue).to.equal('output.txt');
expect(info.directory).to.equal(defaultDir);
expect(info.showsTagField).to.be.false();
expect(info.showsHiddenFiles).to.be.true();
expect(info.canCreateDirectories).to.be.true();
dialogHelper.cancelFileDialog(handle);
await p;
});
});
});
});

View File

@@ -0,0 +1,7 @@
/node_modules
/build
*.swp
*.log
*~
.node-version
package-lock.json

View File

@@ -0,0 +1,23 @@
{
'targets': [
{
'target_name': 'dialog_helper',
'conditions': [
['OS=="mac"', {
'sources': [
'src/main.cc',
'src/dialog_helper_mac.mm',
],
'libraries': [
'$(SDKROOT)/System/Library/Frameworks/AppKit.framework',
],
'xcode_settings': {
'OTHER_CFLAGS': ['-fobjc-arc'],
},
}, {
'type': 'none',
}],
],
}
]
}

View File

@@ -0,0 +1,2 @@
const binding = require('../build/Release/dialog_helper.node');
module.exports = binding;

View File

@@ -0,0 +1,10 @@
{
"name": "@electron-ci/dialog-helper",
"version": "0.0.1",
"main": "./lib/index.js",
"private": true,
"licenses": "MIT",
"scripts": {
"install": "node-gyp configure && node-gyp build"
}
}

View File

@@ -0,0 +1,68 @@
#ifndef SRC_DIALOG_HELPER_H_
#define SRC_DIALOG_HELPER_H_
#include <cstddef>
#include <string>
namespace dialog_helper {
struct DialogInfo {
// "message-box", "open-dialog", "save-dialog", or "none"
std::string type;
// Button titles for message boxes
std::string buttons; // JSON array string, e.g. '["OK","Cancel"]'
// Message text (NSAlert messageText or panel title)
std::string message;
// Detail / informative text (NSAlert informativeText)
std::string detail;
// Checkbox (suppression button) label, empty if none
std::string checkbox_label;
// Whether the checkbox is checked
bool checkbox_checked = false;
// File dialog properties (open/save panels)
std::string prompt; // Button label (NSSavePanel prompt)
std::string panel_message; // Panel message text (NSSavePanel message)
std::string directory; // Current directory URL path
// NSSavePanel-specific properties
std::string name_field_label; // Label for the name field
std::string name_field_value; // Current value of the name field
bool shows_tag_field = true;
// NSOpenPanel-specific properties
bool can_choose_files = false;
bool can_choose_directories = false;
bool allows_multiple_selection = false;
// Shared panel properties (open and save)
bool shows_hidden_files = false;
bool resolves_aliases = true;
bool treats_packages_as_directories = false;
bool can_create_directories = false;
};
// Get information about the sheet dialog attached to the window identified
// by the given native handle buffer (NSView* on macOS).
DialogInfo GetDialogInfo(char* handle, size_t size);
// Click a button at the given index on an NSAlert sheet attached to the window.
// Returns true if a message box was found and the button was clicked.
bool ClickMessageBoxButton(char* handle, size_t size, int button_index);
// Toggle the checkbox (suppression button) on an NSAlert sheet.
// Returns true if a checkbox was found and clicked.
bool ClickCheckbox(char* handle, size_t size);
// Cancel the file dialog (NSOpenPanel/NSSavePanel) sheet attached to the window.
// Returns true if a file dialog was found and cancelled.
bool CancelFileDialog(char* handle, size_t size);
// Accept the file dialog sheet attached to the window.
// For save dialogs, |filename| is set in the name field before accepting.
// Returns true if a file dialog was found and accepted.
bool AcceptFileDialog(char* handle, size_t size, const std::string& filename);
} // namespace dialog_helper
#endif // SRC_DIALOG_HELPER_H_

View File

@@ -0,0 +1,320 @@
#include "dialog_helper.h"
#import <Cocoa/Cocoa.h>
namespace {
// Extract the NSWindow* from the native handle buffer.
// The buffer contains an NSView* (the content view of the window).
NSWindow* GetNSWindowFromHandle(char* handle, size_t size) {
if (size != sizeof(void*))
return nil;
// Read the raw pointer from the buffer, then bridge to ARC.
void* raw = *reinterpret_cast<void**>(handle);
NSView* view = (__bridge NSView*)raw;
if (!view || ![view isKindOfClass:[NSView class]])
return nil;
return [view window];
}
} // namespace
namespace dialog_helper {
DialogInfo GetDialogInfo(char* handle, size_t size) {
DialogInfo info;
info.type = "none";
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return info;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return info;
// NSOpenPanel is a subclass of NSSavePanel, so check NSOpenPanel first.
if ([sheet isKindOfClass:[NSOpenPanel class]]) {
info.type = "open-dialog";
NSOpenPanel* panel = (NSOpenPanel*)sheet;
info.message = [[panel title] UTF8String] ?: "";
info.prompt = [[panel prompt] UTF8String] ?: "";
info.panel_message = [[panel message] UTF8String] ?: "";
if ([panel directoryURL])
info.directory = [[[panel directoryURL] path] UTF8String] ?: "";
info.can_choose_files = [panel canChooseFiles];
info.can_choose_directories = [panel canChooseDirectories];
info.allows_multiple_selection = [panel allowsMultipleSelection];
info.shows_hidden_files = [panel showsHiddenFiles];
info.resolves_aliases = [panel resolvesAliases];
info.treats_packages_as_directories = [panel treatsFilePackagesAsDirectories];
info.can_create_directories = [panel canCreateDirectories];
return info;
}
if ([sheet isKindOfClass:[NSSavePanel class]]) {
info.type = "save-dialog";
NSSavePanel* panel = (NSSavePanel*)sheet;
info.message = [[panel title] UTF8String] ?: "";
info.prompt = [[panel prompt] UTF8String] ?: "";
info.panel_message = [[panel message] UTF8String] ?: "";
if ([panel directoryURL])
info.directory = [[[panel directoryURL] path] UTF8String] ?: "";
info.name_field_label = [[panel nameFieldLabel] UTF8String] ?: "";
info.name_field_value = [[panel nameFieldStringValue] UTF8String] ?: "";
info.shows_tag_field = [panel showsTagField];
info.shows_hidden_files = [panel showsHiddenFiles];
info.treats_packages_as_directories =
[panel treatsFilePackagesAsDirectories];
info.can_create_directories = [panel canCreateDirectories];
return info;
}
// For NSAlert, the sheet window is not an NSSavePanel.
// Check if it contains typical NSAlert button structure.
// NSAlert's window contains buttons as subviews in its content view.
NSView* contentView = [sheet contentView];
NSMutableArray<NSButton*>* buttons = [NSMutableArray array];
// Recursively find all NSButton instances in the view hierarchy.
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
// Filter to push-type buttons (not checkboxes, radio buttons, etc.)
if ([btn bezelStyle] == NSBezelStyleRounded ||
[btn bezelStyle] == NSBezelStyleRegularSquare) {
[buttons addObject:btn];
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
if ([buttons count] > 0) {
info.type = "message-box";
// Sort buttons by tag to maintain the order they were added.
[buttons sortUsingComparator:^NSComparisonResult(NSButton* a, NSButton* b) {
if ([a tag] < [b tag])
return NSOrderedAscending;
if ([a tag] > [b tag])
return NSOrderedDescending;
return NSOrderedSame;
}];
std::string btn_json = "[";
for (NSUInteger i = 0; i < [buttons count]; i++) {
if (i > 0)
btn_json += ",";
btn_json += "\"";
NSString* title = [[buttons objectAtIndex:i] title];
btn_json += [title UTF8String] ?: "";
btn_json += "\"";
}
btn_json += "]";
info.buttons = btn_json;
// NSAlert's content view contains static NSTextFields for message and
// detail text. The first non-editable text field with content is the
// message; the second is the detail (informative text).
int text_field_index = 0;
// Walk all subviews (non-recursive — NSAlert places labels directly).
for (NSView* subview in [contentView subviews]) {
if ([subview isKindOfClass:[NSTextField class]]) {
NSTextField* field = (NSTextField*)subview;
if (![field isEditable] && [[field stringValue] length] > 0) {
if (text_field_index == 0) {
info.message = [[field stringValue] UTF8String];
} else if (text_field_index == 1) {
info.detail = [[field stringValue] UTF8String];
}
text_field_index++;
}
}
}
// Check for the suppression (checkbox) button.
// NSAlert's suppression button is a non-bordered NSButton, unlike
// push buttons which are bordered. This reliably identifies it
// across macOS versions where the accessibility role may differ.
NSMutableArray<NSView*>* cbStack =
[NSMutableArray arrayWithObject:contentView];
while ([cbStack count] > 0) {
NSView* current = [cbStack lastObject];
[cbStack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if (![btn isBordered]) {
NSString* title = [btn title];
if (title && [title length] > 0) {
info.checkbox_label = [title UTF8String];
info.checkbox_checked =
([btn state] == NSControlStateValueOn);
}
}
}
for (NSView* sub in [current subviews]) {
[cbStack addObject:sub];
}
}
}
return info;
}
bool ClickMessageBoxButton(char* handle, size_t size, int button_index) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
// Find buttons in the sheet, sorted by tag.
NSView* contentView = [sheet contentView];
NSMutableArray<NSButton*>* buttons = [NSMutableArray array];
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if ([btn bezelStyle] == NSBezelStyleRounded ||
[btn bezelStyle] == NSBezelStyleRegularSquare) {
[buttons addObject:btn];
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
[buttons sortUsingComparator:^NSComparisonResult(NSButton* a, NSButton* b) {
if ([a tag] < [b tag])
return NSOrderedAscending;
if ([a tag] > [b tag])
return NSOrderedDescending;
return NSOrderedSame;
}];
if (button_index < 0 || button_index >= (int)[buttons count])
return false;
NSButton* target = [buttons objectAtIndex:button_index];
[target performClick:nil];
return true;
}
bool ClickCheckbox(char* handle, size_t size) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
// Find the suppression/checkbox button — it is a non-bordered NSButton,
// unlike the push buttons which are bordered.
NSView* contentView = [sheet contentView];
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if (![btn isBordered] && [[btn title] length] > 0) {
[btn performClick:nil];
return true;
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
return false;
}
bool CancelFileDialog(char* handle, size_t size) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
// sheet is the NSSavePanel/NSOpenPanel window itself when presented as a
// sheet. We need to find the actual panel object. On macOS, when an
// NSSavePanel is run as a sheet, [window attachedSheet] returns the panel's
// window. The panel can be retrieved because NSSavePanel IS the window.
if ([sheet isKindOfClass:[NSSavePanel class]]) {
NSSavePanel* panel = (NSSavePanel*)sheet;
[panel cancel:nil];
return true;
}
// If it's not a recognized panel type, try ending the sheet directly.
[NSApp endSheet:sheet returnCode:NSModalResponseCancel];
return true;
}
bool AcceptFileDialog(char* handle, size_t size, const std::string& filename) {
NSWindow* window = GetNSWindowFromHandle(handle, size);
if (!window)
return false;
NSWindow* sheet = [window attachedSheet];
if (!sheet)
return false;
if (![sheet isKindOfClass:[NSSavePanel class]])
return false;
NSSavePanel* panel = (NSSavePanel*)sheet;
// Set the filename if provided (for save dialogs).
if (!filename.empty()) {
NSString* name = [NSString stringWithUTF8String:filename.c_str()];
[panel setNameFieldStringValue:name];
// Resign first responder to commit the name field edit. Without this,
// the panel may still use the previous value (e.g. "Untitled") when
// the accept button is clicked immediately after.
[sheet makeFirstResponder:nil];
}
NSView* contentView = [sheet contentView];
// Search for the default button (key equivalent "\r") in the view hierarchy.
NSMutableArray<NSView*>* stack =
[NSMutableArray arrayWithObject:contentView];
while ([stack count] > 0) {
NSView* current = [stack lastObject];
[stack removeLastObject];
if ([current isKindOfClass:[NSButton class]]) {
NSButton* btn = (NSButton*)current;
if ([[btn keyEquivalent] isEqualToString:@"\r"]) {
[btn performClick:nil];
return true;
}
}
for (NSView* subview in [current subviews]) {
[stack addObject:subview];
}
}
[NSApp endSheet:sheet returnCode:NSModalResponseOK];
return true;
}
} // namespace dialog_helper

View File

@@ -0,0 +1,231 @@
#include <js_native_api.h>
#include <node_api.h>
#include <string>
#include "dialog_helper.h"
namespace {
// Helper: extract (char* data, size_t length) from the first Buffer argument.
bool GetHandleArg(napi_env env, napi_callback_info info, size_t expected_argc,
napi_value* args, char** data, size_t* length) {
size_t argc = expected_argc;
napi_status status = napi_get_cb_info(env, info, &argc, args, NULL, NULL);
if (status != napi_ok || argc < 1)
return false;
bool is_buffer;
status = napi_is_buffer(env, args[0], &is_buffer);
if (status != napi_ok || !is_buffer) {
napi_throw_error(env, NULL, "First argument must be a Buffer (native window handle)");
return false;
}
status = napi_get_buffer_info(env, args[0], (void**)data, length);
return status == napi_ok;
}
napi_value GetDialogInfo(napi_env env, napi_callback_info info) {
napi_value args[1];
char* data;
size_t length;
if (!GetHandleArg(env, info, 1, args, &data, &length))
return NULL;
dialog_helper::DialogInfo di = dialog_helper::GetDialogInfo(data, length);
napi_value result;
napi_create_object(env, &result);
// Message box properties
napi_value type_val;
napi_create_string_utf8(env, di.type.c_str(), di.type.size(), &type_val);
napi_set_named_property(env, result, "type", type_val);
napi_value buttons_val;
napi_create_string_utf8(env, di.buttons.c_str(), di.buttons.size(), &buttons_val);
napi_set_named_property(env, result, "buttons", buttons_val);
napi_value message_val;
napi_create_string_utf8(env, di.message.c_str(), di.message.size(), &message_val);
napi_set_named_property(env, result, "message", message_val);
napi_value detail_val;
napi_create_string_utf8(env, di.detail.c_str(), di.detail.size(), &detail_val);
napi_set_named_property(env, result, "detail", detail_val);
napi_value checkbox_label_val;
napi_create_string_utf8(env, di.checkbox_label.c_str(),
di.checkbox_label.size(), &checkbox_label_val);
napi_set_named_property(env, result, "checkboxLabel", checkbox_label_val);
napi_value checkbox_checked_val;
napi_get_boolean(env, di.checkbox_checked, &checkbox_checked_val);
napi_set_named_property(env, result, "checkboxChecked", checkbox_checked_val);
// File dialog properties
napi_value prompt_val;
napi_create_string_utf8(env, di.prompt.c_str(), di.prompt.size(), &prompt_val);
napi_set_named_property(env, result, "prompt", prompt_val);
napi_value panel_message_val;
napi_create_string_utf8(env, di.panel_message.c_str(),
di.panel_message.size(), &panel_message_val);
napi_set_named_property(env, result, "panelMessage", panel_message_val);
napi_value directory_val;
napi_create_string_utf8(env, di.directory.c_str(), di.directory.size(),
&directory_val);
napi_set_named_property(env, result, "directory", directory_val);
// NSSavePanel-specific string/boolean properties
napi_value name_field_label_val;
napi_create_string_utf8(env, di.name_field_label.c_str(),
di.name_field_label.size(), &name_field_label_val);
napi_set_named_property(env, result, "nameFieldLabel", name_field_label_val);
napi_value name_field_value_val;
napi_create_string_utf8(env, di.name_field_value.c_str(),
di.name_field_value.size(), &name_field_value_val);
napi_set_named_property(env, result, "nameFieldValue", name_field_value_val);
napi_value shows_tag_field_val;
napi_get_boolean(env, di.shows_tag_field, &shows_tag_field_val);
napi_set_named_property(env, result, "showsTagField", shows_tag_field_val);
// NSOpenPanel-specific properties
napi_value can_choose_files_val;
napi_get_boolean(env, di.can_choose_files, &can_choose_files_val);
napi_set_named_property(env, result, "canChooseFiles", can_choose_files_val);
napi_value can_choose_dirs_val;
napi_get_boolean(env, di.can_choose_directories, &can_choose_dirs_val);
napi_set_named_property(env, result, "canChooseDirectories",
can_choose_dirs_val);
napi_value allows_multi_val;
napi_get_boolean(env, di.allows_multiple_selection, &allows_multi_val);
napi_set_named_property(env, result, "allowsMultipleSelection",
allows_multi_val);
// Shared panel properties (open and save)
napi_value shows_hidden_val;
napi_get_boolean(env, di.shows_hidden_files, &shows_hidden_val);
napi_set_named_property(env, result, "showsHiddenFiles", shows_hidden_val);
napi_value resolves_aliases_val;
napi_get_boolean(env, di.resolves_aliases, &resolves_aliases_val);
napi_set_named_property(env, result, "resolvesAliases", resolves_aliases_val);
napi_value treats_packages_val;
napi_get_boolean(env, di.treats_packages_as_directories, &treats_packages_val);
napi_set_named_property(env, result, "treatsPackagesAsDirectories",
treats_packages_val);
napi_value can_create_dirs_val;
napi_get_boolean(env, di.can_create_directories, &can_create_dirs_val);
napi_set_named_property(env, result, "canCreateDirectories",
can_create_dirs_val);
return result;
}
napi_value ClickMessageBoxButton(napi_env env, napi_callback_info info) {
napi_value args[2];
char* data;
size_t length;
if (!GetHandleArg(env, info, 2, args, &data, &length))
return NULL;
int32_t button_index;
napi_status status = napi_get_value_int32(env, args[1], &button_index);
if (status != napi_ok) {
napi_throw_error(env, NULL, "Second argument must be a number (button index)");
return NULL;
}
bool ok = dialog_helper::ClickMessageBoxButton(data, length, button_index);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value ClickCheckbox(napi_env env, napi_callback_info info) {
napi_value args[1];
char* data;
size_t length;
if (!GetHandleArg(env, info, 1, args, &data, &length))
return NULL;
bool ok = dialog_helper::ClickCheckbox(data, length);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value CancelFileDialog(napi_env env, napi_callback_info info) {
napi_value args[1];
char* data;
size_t length;
if (!GetHandleArg(env, info, 1, args, &data, &length))
return NULL;
bool ok = dialog_helper::CancelFileDialog(data, length);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value AcceptFileDialog(napi_env env, napi_callback_info info) {
napi_value args[2];
char* data;
size_t length;
if (!GetHandleArg(env, info, 2, args, &data, &length))
return NULL;
std::string filename;
// Second argument (filename) is optional.
napi_valuetype vtype;
napi_typeof(env, args[1], &vtype);
if (vtype == napi_string) {
size_t str_len;
napi_get_value_string_utf8(env, args[1], NULL, 0, &str_len);
filename.resize(str_len);
napi_get_value_string_utf8(env, args[1], &filename[0], str_len + 1,
&str_len);
}
bool ok = dialog_helper::AcceptFileDialog(data, length, filename);
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor descriptors[] = {
{"getDialogInfo", NULL, GetDialogInfo, NULL, NULL, NULL,
napi_enumerable, NULL},
{"clickMessageBoxButton", NULL, ClickMessageBoxButton, NULL, NULL, NULL,
napi_enumerable, NULL},
{"clickCheckbox", NULL, ClickCheckbox, NULL, NULL, NULL,
napi_enumerable, NULL},
{"cancelFileDialog", NULL, CancelFileDialog, NULL, NULL, NULL,
napi_enumerable, NULL},
{"acceptFileDialog", NULL, AcceptFileDialog, NULL, NULL, NULL,
napi_enumerable, NULL},
};
napi_define_properties(env, exports,
sizeof(descriptors) / sizeof(*descriptors),
descriptors);
return exports;
}
} // namespace
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

View File

@@ -7,6 +7,7 @@
"node-gyp-install": "node-gyp install"
},
"devDependencies": {
"@electron-ci/dialog-helper": "*",
"@electron-ci/echo": "*",
"@electron-ci/external-ab": "*",
"@electron-ci/is-valid-window": "*",

View File

@@ -647,6 +647,12 @@ __metadata:
languageName: unknown
linkType: soft
"@electron-ci/dialog-helper@npm:*, @electron-ci/dialog-helper@workspace:spec/fixtures/native-addon/dialog-helper":
version: 0.0.0-use.local
resolution: "@electron-ci/dialog-helper@workspace:spec/fixtures/native-addon/dialog-helper"
languageName: unknown
linkType: soft
"@electron-ci/echo@npm:*, @electron-ci/echo@workspace:spec/fixtures/native-addon/echo":
version: 0.0.0-use.local
resolution: "@electron-ci/echo@workspace:spec/fixtures/native-addon/echo"
@@ -4899,6 +4905,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "electron-test-main@workspace:spec"
dependencies:
"@electron-ci/dialog-helper": "npm:*"
"@electron-ci/echo": "npm:*"
"@electron-ci/external-ab": "npm:*"
"@electron-ci/is-valid-window": "npm:*"