mirror of
https://github.com/electron/electron.git
synced 2026-04-10 03:01:51 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78896775d9 | ||
|
|
40eb41656a | ||
|
|
5a69e80cac | ||
|
|
90decd4eaf | ||
|
|
ba551d265c | ||
|
|
24784ed024 | ||
|
|
f49f6b1a29 | ||
|
|
c63e0d8b96 | ||
|
|
33a81b40c2 |
7
.github/actions/build-electron/action.yml
vendored
7
.github/actions/build-electron/action.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/actions/checkout/action.yml
vendored
2
.github/actions/checkout/action.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
27
.github/workflows/build.yml
vendored
27
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
35
.github/workflows/pull-request-opened-synchronized.yml
vendored
Normal file
35
.github/workflows/pull-request-opened-synchronized.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
chore_allow_customizing_microtask_policy_per_context.patch
|
||||
build_warn_instead_of_abort_on_builtin_pgo_profile_mismatch.patch
|
||||
|
||||
@@ -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) {
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
7
spec/fixtures/native-addon/dialog-helper/.gitignore
vendored
Normal file
7
spec/fixtures/native-addon/dialog-helper/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/node_modules
|
||||
/build
|
||||
*.swp
|
||||
*.log
|
||||
*~
|
||||
.node-version
|
||||
package-lock.json
|
||||
23
spec/fixtures/native-addon/dialog-helper/binding.gyp
vendored
Normal file
23
spec/fixtures/native-addon/dialog-helper/binding.gyp
vendored
Normal 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',
|
||||
}],
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
2
spec/fixtures/native-addon/dialog-helper/lib/index.js
vendored
Normal file
2
spec/fixtures/native-addon/dialog-helper/lib/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
const binding = require('../build/Release/dialog_helper.node');
|
||||
module.exports = binding;
|
||||
10
spec/fixtures/native-addon/dialog-helper/package.json
vendored
Normal file
10
spec/fixtures/native-addon/dialog-helper/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
68
spec/fixtures/native-addon/dialog-helper/src/dialog_helper.h
vendored
Normal file
68
spec/fixtures/native-addon/dialog-helper/src/dialog_helper.h
vendored
Normal 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_
|
||||
320
spec/fixtures/native-addon/dialog-helper/src/dialog_helper_mac.mm
vendored
Normal file
320
spec/fixtures/native-addon/dialog-helper/src/dialog_helper_mac.mm
vendored
Normal 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
|
||||
231
spec/fixtures/native-addon/dialog-helper/src/main.cc
vendored
Normal file
231
spec/fixtures/native-addon/dialog-helper/src/main.cc
vendored
Normal 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)
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user