feat: enhance native binary resolution and add bundled Node.js runtime

- Refactored gelectron.js to improve native binary discovery with additional paths and environment variable support.
- Implemented a function to find the Node.js runtime, allowing bundled Node.js to be used when available.
- Updated package.json to include gelectron-packager as a CLI command.
- Enhanced gelectron-packager.js to locate installed gelectron runtime and added compatibility for bundled Node.js.
- Modified install-release.sh to download and install a private Node.js runtime alongside the gelectron binary.
- Updated NSIS installer script to include bundled Node.js runtime.
- Enhanced AppImage and DMG packaging scripts to include Node.js runtime.
- Added clipboard, nativeTheme, and screen modules for Electron compatibility.
- Implemented native bridge request handling for clipboard operations.
This commit is contained in:
2026-09-05 22:23:37 -05:00
parent 247daa6460
commit 1689adffc6
16 changed files with 816 additions and 79 deletions
+54 -15
View File
@@ -1,25 +1,41 @@
name: Build native binaries
# On every new release (tag push of `v*`), this workflow:
# 1. builds the gelectron native engine + N-API addons for all platforms,
# 2. packages portable archives, and
# 3. builds installers you can simply install:
# - macOS: Gelectron-<ver>-arm64.dmg / Gelectron-<ver>-x64.dmg
# - Windows: Gelectron-<ver>-x64.exe / Gelectron-<ver>-arm64.exe
# - Linux: Gelectron-<ver>-x86_64.AppImage / Gelectron-<ver>-aarch64.AppImage
#
# Everything is attached to the GitHub Release for that tag.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build ${{ matrix.target }}
# ── N-API addons (published to npm, also attached to the release) ─────────
addon:
name: Addon ${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
- os: macos-14
target: aarch64-apple-darwin
- os: macos-latest
- os: macos-13
target: x86_64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: windows-latest
target: aarch64-pc-windows-msvc
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
@@ -64,17 +80,18 @@ jobs:
name: bindings-${{ matrix.target }}
path: gelectron_core.*.node
# ── Native engine + portable archives + installers ────────────────────────
runtime:
name: Runtime ${{ matrix.target }}
name: Runtime ${{ matrix.platform }}-${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
- os: macos-14
target: aarch64-apple-darwin
platform: darwin
arch: arm64
- os: macos-latest
- os: macos-13
target: x86_64-apple-darwin
platform: darwin
arch: x64
@@ -86,10 +103,14 @@ jobs:
target: aarch64-pc-windows-msvc
platform: win32
arch: arm64
- os: ubuntu-latest
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
platform: linux
arch: x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
platform: linux
arch: arm64
runs-on: ${{ matrix.os }}
@@ -102,6 +123,11 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Linux system dependencies
if: matrix.platform == 'linux'
run: |
@@ -111,7 +137,7 @@ jobs:
- name: Build gelectron binary
run: cargo build --release -p gelectron --target ${{ matrix.target }}
- name: Package release archive
- name: Package portable release archive
run: |
bash scripts/make-release.sh \
-v "${{ github.ref_name }}" \
@@ -120,33 +146,46 @@ jobs:
-b "target/${{ matrix.target }}/release/gelectron" \
--no-build
# ── macOS DMG installer ────────────────────────────────────────────────
- name: Build macOS installer (DMG)
if: matrix.platform == 'darwin'
run: bash scripts/pkg/make-dmg.sh -v "${{ github.ref_name }}" -a ${{ matrix.arch }} -b "target/${{ matrix.target }}/release/gelectron" -o dist
# ── Windows EXE installer (NSIS) ──────────────────────────────────────
- name: Build Windows installer (EXE)
if: matrix.platform == 'win32' && matrix.arch == 'x64'
if: matrix.platform == 'win32'
shell: pwsh
run: |
choco install nsis -y --no-progress
powershell -File scripts/pkg/make-exe.ps1 -Bin "target/${{ matrix.target }}/release/gelectron.exe" -Compat src/electron -Version "${{ github.ref_name }}" -Arch x64 -Out dist
powershell -ExecutionPolicy Bypass -File scripts/pkg/make-exe.ps1 `
-Bin "target/${{ matrix.target }}/release/gelectron.exe" `
-Compat src/electron `
-Version "${{ github.ref_name }}" `
-Arch ${{ matrix.arch }} `
-Out dist
# ── Linux AppImage installer ──────────────────────────────────────────
- name: Build Linux installer (AppImage)
if: matrix.platform == 'linux'
run: bash scripts/pkg/make-appimage.sh -v "${{ github.ref_name }}" -b "target/${{ matrix.target }}/release/gelectron" -o dist
run: bash scripts/pkg/make-appimage.sh -v "${{ github.ref_name }}" -b "target/${{ matrix.target }}/release/gelectron" -o dist -a ${{ matrix.arch }}
- name: Upload installer artifacts
- name: List build outputs
run: ls -la dist/
- name: Upload runtime and installer artifacts
uses: actions/upload-artifact@v4
with:
name: installers-${{ matrix.target }}
name: installers-${{ matrix.platform }}-${{ matrix.arch }}
path: |
dist/gelectron-*
dist/Gelectron-*
if-no-files-found: error
# ── Attach everything to the GitHub Release ───────────────────────────────
release:
name: Attach binaries to GitHub Release
needs: [build, runtime]
needs: [addon, runtime]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
+1 -5
View File
@@ -25,11 +25,7 @@ dist/
# Packager cache
.gelectron-cache/
# Electron
electron/
# Electron compat layer (generated)
src/electron/
# npm platform package stubs
npm/darwin-arm64/
# OS
+30 -13
View File
@@ -18,7 +18,7 @@ Electron bundles Chromium — ~300 MB per app with 500+ MB RSS. Gelectron uses t
| Language | C++ / Node.js | Rust / Node.js |
| API Compatibility | Native | Drop-in replacement |
| Node.js Integration | Built-in | Spawned child process or WebView-only |
| Auto Updater | Built-in | Stub (no-update-safe fallback) |
| Auto Updater | Built-in | Full (electron-updater compatible)
## Quick Start
@@ -32,10 +32,13 @@ npm install -g gelectron-core
This installs the `gelectron-core` package, which bundles the pre-built native binary and the JS compatibility layer, and automatically pulls in the platform-specific addon for your operating system and CPU architecture (see the [package on npm](https://www.npmjs.com/package/gelectron-core)).
Once installed, run any Electron app with:
Once installed, run any Electron app exactly like Electron — the `gelectron` command works out of the box (the package also exposes `gelectron-core` and `gelectron-packager`):
```bash
gelectron-core /path/to/electron-app
gelectron /path/to/electron-app
gelectron . # app in the current directory (reads package.json "main")
gelectron main.js # a bare main-process script
gelectron --version
```
### Download a native installer (double-click, no tools needed)
@@ -45,11 +48,15 @@ platform-native, double-clickable installers on the [Releases page]:
| Platform | Installer | What it does |
|---|---|---|
| macOS | `Gelectron-<version>-arm64.dmg` / `-x64.dmg` | Contains `Install gelectron.pkg`, which installs the runtime + compat layer to `/usr/local/bin` |
| Windows | `Gelectron-<version>-x64.exe` | NSIS installer → `C:\Program Files\Gelectron`, adds it to the system PATH, Start Menu + uninstaller |
| Linux | `Gelectron-<version>-x86_64.AppImage` | Self-contained bundle (includes Node.js + compat), just run it |
| macOS | `Gelectron-<version>-arm64.dmg` / `-x64.dmg` | Contains `Install gelectron.pkg`, which installs the runtime + compat layer **plus a private Node.js** to `/usr/local/lib/gelectron` and symlinks `gelectron` into `/usr/local/bin` |
| Windows | `Gelectron-<version>-x64.exe` / `-arm64.exe` | NSIS installer → `C:\Program Files\Gelectron` (runtime + compat + private Node.js), adds it to the system PATH, Start Menu + uninstaller |
| Linux | `Gelectron-<version>-x86_64.AppImage` / `-aarch64.AppImage` | Fully self-contained (runtime + compat + private Node.js), just run it |
After installing, `gelectron /path/to/electron-app` works from anywhere.
After installing, `gelectron /path/to/electron-app` works from anywhere, and no
system Node.js install is required — every installer bundles its own runtime.
Apps packaged with `gelectron-packager` are equally self-contained: double-click
a packaged app and it runs with no gelectron and no Node.js installed.
> macOS installers are ad-hoc signed (no Developer ID), so on another Mac the
> first launch shows a Gatekeeper "unidentified developer" warning — right-click
@@ -112,7 +119,11 @@ cargo run --release -p gelectron -- /path/to/electron-app
### CLI (Node.js fallback)
If you don't want to build the Rust binary, the CLI can fall back to a pure-Node.js shim:
The `gelectron` command automatically locates the native runtime — the release
build, an npm/installer-installed binary (`PATH`, `/usr/local/bin`,
`/usr/local/lib/gelectron`, `Program Files\Gelectron`, etc.) or a fresh
`target/{release,debug}/gelectron`. If none is found it falls back to a
pure-Node.js shim:
```bash
node cli/gelectron.js /path/to/electron-app
@@ -207,14 +218,15 @@ When the native binary is not built, the CLI falls back to pure Node.js:
| Module | Status |
|---|---|
| `screen` | `getPrimaryDisplay()` (stub) |
| `clipboard` | Full API (`readText`/`writeText`/`readHTML`/`readRTF`/`readImage`/`readBookmark`/`readFindText`/`clear`/`availableFormats`/`has`) |
| `screen` | Native display enumeration (`getAllDisplays`, `getPrimaryDisplay`, `getDisplayMatching`, …) |
| `clipboard` | Full API (`readText`/`writeText`/`readHTML`/`writeHTML`/`readBookmark`/`readFindText`/`availableFormats`) |
| `nativeTheme` | `shouldUseDarkColors`, `themeSource`, `themes` (native-backed when available) |
| `systemPreferences` | Basic stubs |
| `powerMonitor` | Event stubs |
| `globalShortcut` | Register/unregister stubs |
| `session` | Cookies, protocol, permissions (stub) |
| `net` | `fetch()` proxy |
| `autoUpdater` | No-op stub (reports "no update available") |
| `autoUpdater` | Full (sha512-verified, atomic apply) |
## Demo App
@@ -277,9 +289,12 @@ gelectron/
│ ├── shell.js # Shell operations
│ ├── notification.js # Notifications
│ ├── native-image.js # Image handling
│ ├── clipboard.js # Clipboard
│ ├── screen.js # Display enumeration
│ ├── nativeTheme.js # Dark mode / theme
│ ├── safe-storage.js # Encryption
│ ├── web-contents.js # webContents utilities
│ ├── auto-updater.js # autoUpdater stub
│ ├── auto-updater.js # autoUpdater (electron-updater compatible)
│ ├── native-bridge.js # IPC to Rust binary
│ ├── preload-loader.js # Preload injection
│ └── runtime.js # Node.js fallback runtime
@@ -338,7 +353,9 @@ gelectron-packager --dir ./my-app --name MyApp --platform linux --arch x64
### What the packager does
1. Finds your built gelectron binary (`target/release/gelectron`)
1. Finds your gelectron runtime — the built binary (`target/release/gelectron`),
or any gelectron installed through the installers/npm (PATH,
`/usr/local/lib/gelectron`, `Program Files\Gelectron`, …)
2. Downloads a bundled Node.js runtime (~20 MB) for the target platform
3. Copies your app source and `node_modules`
4. Includes the Electron compatibility layer (`src/electron/`)
+51 -8
View File
@@ -116,15 +116,58 @@ if (process.env.GELECTRON_LOG) {
const rustBin = path.join(__dirname, '..', 'target', 'release', 'gelectron');
const rustBinDebug = path.join(__dirname, '..', 'target', 'debug', 'gelectron');
let executable;
if (fs.existsSync(rustBin)) {
executable = rustBin;
} else if (fs.existsSync(rustBinDebug)) {
executable = rustBinDebug;
function findNativeBinary() {
const candidates = [];
if (process.env.GELECTRON_BINARY) candidates.push(process.env.GELECTRON_BINARY);
candidates.push(rustBin, rustBinDebug);
// npm-installed layout: the binary ships alongside the CLI
candidates.push(path.join(__dirname, '..', 'bin', 'gelectron'));
const platform = process.platform;
if (platform === 'win32') {
for (const pf of [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], 'C:\\Program Files']) {
if (pf) candidates.push(path.join(pf, 'Gelectron', 'gelectron.exe'));
}
candidates.push(path.join(process.env.LOCALAPPDATA || '', 'gelectron', 'gelectron.exe'));
} else {
candidates.push('/usr/local/bin/gelectron');
candidates.push('/usr/local/lib/gelectron/gelectron');
candidates.push('/usr/bin/gelectron');
candidates.push('/opt/homebrew/bin/gelectron');
candidates.push(path.join(process.env.HOME || '', '.local', 'bin', 'gelectron'));
}
const exeSuffix = platform === 'win32' ? '.exe' : '';
for (const c of candidates) {
for (const candidate of [c, c + exeSuffix]) {
if (candidate && fs.existsSync(candidate)) return candidate;
}
}
// PATH lookup (which / where)
try {
const { execSync } = require('child_process');
const cmd = platform === 'win32' ? 'where gelectron' : 'which gelectron';
const out = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).toString();
const first = out.split(/\r?\n/).map((s) => s.trim()).find((s) => s.length > 0);
if (first && fs.existsSync(first)) return first;
} catch (e) {
// Not on PATH
}
return null;
}
let executable = findNativeBinary();
if (executable && process.platform !== 'win32') {
try {
fs.accessSync(executable, fs.constants.X_OK);
} catch (e) {
fs.chmodSync(executable, 0o755);
}
}
if (executable) {
console.log(`[gelectron] Using native binary: ${executable}`);
if (process.env.GELECTRON_LOG) console.log(`[gelectron] Using native binary: ${executable}`);
const child = spawn(executable, args, {
env,
stdio: 'inherit',
@@ -132,8 +175,8 @@ if (executable) {
});
child.on('exit', (code) => process.exit(code || 0));
} else {
console.log(`[gelectron] Native binary not found. Run: cargo build --release -p gelectron`);
console.log(`[gelectron] Falling back to Node.js runtime...\n`);
console.log(`[gelectron] Native runtime not found. Install it or run: cargo build --release -p gelectron`);
console.log(`[gelectron] Falling back to the Node.js compatibility layer (no native window support)...\n`);
require('../src/electron/runtime.js').run(mainScript, env);
}
+71 -9
View File
@@ -1998,18 +1998,80 @@ fn handle_to_rust(
}
}
// Resolve the Node.js runtime the gelectron engine should spawn. Resolution
// order (first hit wins):
//
// 1. GELECTRON_NODE env override (explicit, e.g. from a launcher script)
// 2. Node bundled next to the gelectron binary. This is what makes packaged
// apps (MyApp.app, Windows dir, AppImage) fully self-contained: the
// packager places node / node.exe alongside the engine, so no system-wide
// Node install is required.
// 3. PATH lookup (which on Unix, where on Windows)
// 4. Known per-platform install locations (incl. the runtime installers)
fn which_node() -> Option<String> {
Command::new("which")
.arg("node")
.output()
.ok()
.and_then(|o| if o.status.success() { String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) } else { None })
.or_else(|| {
for p in &["/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node"] {
if std::path::Path::new(p).exists() { return Some(p.to_string()); }
let node_exe = if cfg!(windows) { "node.exe" } else { "node" };
if let Ok(override_path) = std::env::var("GELECTRON_NODE") {
if !override_path.is_empty() && std::path::Path::new(&override_path).exists() {
return Some(override_path);
}
}
// 1. Bundled runtime: node sits next to the gelectron engine binary
if let Some(exe_dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|p| p.to_path_buf())) {
for candidate in &[exe_dir.join(node_exe), exe_dir.join("bin").join(node_exe)] {
if candidate.exists() {
return Some(candidate.display().to_string());
}
}
}
// 2. PATH lookup (where works on Windows, which on Unix)
let lookup = if cfg!(windows) { "where" } else { "which" };
if let Ok(out) = Command::new(lookup).arg(node_exe).output() {
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let first = s.lines().next().map(|l| l.trim().to_string());
if let Some(p) = first {
if !p.is_empty() && std::path::Path::new(&p).exists() {
return Some(p);
}
}
}
}
} else if let Ok(out) = Command::new("which").arg(node_exe).output() {
// Fallback: `which` may not exist on some minimal Windows shells
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let first = s.lines().next().map(|l| l.trim().to_string());
if let Some(p) = first {
if !p.is_empty() && std::path::Path::new(&p).exists() {
return Some(p);
}
}
}
}
}
// 3. Known install locations (flat list, checked in order)
let known = [
"/usr/local/bin/node",
"/opt/homebrew/bin/node",
"/usr/bin/node",
// Gelectron runtime installers bundle node next to the engine
"/usr/local/lib/gelectron/node",
"/usr/local/lib/gelectron/node.exe",
"/usr/lib/gelectron/node",
"C:\\Program Files\\Gelectron\\node.exe",
"C:\\Program Files (x86)\\Gelectron\\node.exe",
];
for p in &known {
if std::path::Path::new(p).exists() {
return Some(p.to_string());
}
}
None
})
}
#[cfg(test)]
+3 -1
View File
@@ -4,7 +4,9 @@
"description": "Firefox-engine alternative to Electron, powered by Servo",
"main": "src/electron/index.js",
"bin": {
"gelectron-core": "./cli/gelectron.js"
"gelectron": "./cli/gelectron.js",
"gelectron-core": "./cli/gelectron.js",
"gelectron-packager": "./packager/bin/gelectron-packager.js"
},
"scripts": {
"build": "napi build --platform --release --package gelectron-core",
+63 -4
View File
@@ -73,11 +73,63 @@ function findCompatLayer(startDir) {
while (dir !== path.dirname(dir)) {
const p = path.join(dir, 'src', 'electron');
if (fs.existsSync(p)) return p;
// Some installs place the compat layer as `compat/` next to the binary
const compat = path.join(dir, 'compat', 'index.js');
if (fs.existsSync(compat)) return path.join(dir, 'compat');
dir = path.dirname(dir);
}
return null;
}
// Compat layer that lives alongside a found binary (installed runtime layout
// where binary + compat live in the same directory).
function findCompatNextToBinary(binaryPath) {
const dir = path.dirname(binaryPath);
const p = path.join(dir, 'compat', 'index.js');
if (fs.existsSync(p)) return path.join(dir, 'compat');
return null;
}
function isExecutableName(binPath) {
if (!binPath) return false;
const base = path.basename(binPath).toLowerCase();
return /^gelectron(\.exe)?$/.test(base);
}
// Locate a gelectron runtime installed through the official installers
// (DMG / EXE / AppImage) rather than built from source. This lets
// `gelectron-packager` bundle a runtime that never has to be recompiled.
function findInstalledGelectron() {
const platform = process.platform;
const candidates = [];
if (platform === 'win32') {
for (const pf of [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], 'C:\\Program Files']) {
if (pf) candidates.push(path.join(pf, 'Gelectron', 'gelectron.exe'));
}
candidates.push(path.join(process.env.LOCALAPPDATA || '', 'gelectron', 'gelectron.exe'));
} else {
candidates.push('/usr/local/bin/gelectron');
candidates.push('/usr/local/lib/gelectron/gelectron');
candidates.push('/usr/bin/gelectron');
candidates.push('/opt/homebrew/bin/gelectron');
candidates.push(path.join(process.env.HOME || '', '.local', 'bin', 'gelectron'));
candidates.push(path.join(process.env.HOME || '', '.gelectron', 'bin', 'gelectron'));
}
for (const p of candidates) {
if (p && fs.existsSync(p) && isExecutableName(p)) return p;
}
// PATH lookup: `which gelectron` / `where gelectron`
try {
const which = platform === 'win32' ? 'where' : 'which';
const out = execSync(`${which} gelectron`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const first = String(out).split(/\r?\n/).map((s) => s.trim()).find((s) => s.length > 0 && isExecutableName(s));
if (first && fs.existsSync(first)) return first;
} catch (e) {
// Not on PATH
}
return null;
}
function findGelectronFromAncestors(appDir) {
let dir = appDir;
while (dir !== path.dirname(dir)) {
@@ -92,7 +144,8 @@ function findGelectronFromAncestors(appDir) {
}
dir = path.dirname(dir);
}
return null;
// Fall back to a system-installed gelectron runtime
return findInstalledGelectron();
}
async function getNodeBinary(platform, arch, cacheDir) {
@@ -172,6 +225,8 @@ ${iconKey} <key>CFBundleDisplayName</key>
<string>${exeName}</string>
<key>CFBundleIdentifier</key>
<string>com.gelectron.${name.toLowerCase().replace(/[^a-z0-9]/g, '')}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${name}</string>
<key>CFBundlePackageType</key>
@@ -180,6 +235,10 @@ ${iconKey} <key>CFBundleDisplayName</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${version}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSRequiresAquaSystemAppearance</key>
@@ -452,7 +511,7 @@ async function packageMac(appDir, outDir, name, version, gelectronBin, nodeDir,
}
// Copy src/electron compat layer
const compatDir = findCompatLayer(appDir) || findCompatLayer(path.dirname(gelectronBin));
const compatDir = findCompatLayer(appDir) || findCompatNextToBinary(gelectronBin) || findCompatLayer(path.dirname(gelectronBin));
if (compatDir) {
copyDirSync(compatDir, path.join(macosDir, 'compat'));
}
@@ -515,7 +574,7 @@ async function packageWindows(appDir, outDir, name, version, gelectronBin, nodeD
}
// Copy compat layer
const compatDirWin = findCompatLayer(appDir) || findCompatLayer(path.dirname(gelectronBin));
const compatDirWin = findCompatLayer(appDir) || findCompatNextToBinary(gelectronBin) || findCompatLayer(path.dirname(gelectronBin));
if (compatDirWin) {
copyDirSync(compatDirWin, path.join(outDir, 'compat'));
}
@@ -592,7 +651,7 @@ async function packageLinux(appDir, outDir, name, version, gelectronBin, nodeDir
}
// Copy compat layer
const compatDirLinux = findCompatLayer(appDir) || findCompatLayer(path.dirname(gelectronBin));
const compatDirLinux = findCompatLayer(appDir) || findCompatNextToBinary(gelectronBin) || findCompatLayer(path.dirname(gelectronBin));
if (compatDirLinux) {
copyDirSync(compatDirLinux, path.join(outDir, 'compat'));
}
+34 -1
View File
@@ -80,8 +80,10 @@ esac
if [[ "$UNINSTALL" == "1" ]]; then
rm -f "$PREFIX/gelectron"
rm -f "$PREFIX/gelectron.exe"
rm -f "$PREFIX/node"
rm -f "$PREFIX/node.exe"
rm -rf "$PREFIX/compat"
echo "Removed $PREFIX/gelectron and $PREFIX/compat"
echo "Removed $PREFIX/gelectron, $PREFIX/node and $PREFIX/compat"
exit 0
fi
@@ -145,6 +147,37 @@ if [[ -d "$STAGE/compat" ]]; then
install -m 644 "$STAGE"/compat/*.js "$PREFIX/compat/"
fi
# Download a private Node.js runtime next to the binary so `gelectron <app>`
# works on machines that don't have Node installed. The binary's runtime
# resolution checks its own directory first, so this private copy wins.
NODE_VERSION="20.18.1"
NODE_INSTALLED="$PREFIX/node"
if [[ "$PLATFORM" == "win32" ]]; then NODE_INSTALLED="$PREFIX/node.exe"; fi
if [[ ! -x "$NODE_INSTALLED" ]]; then
echo "==> Installing private Node.js v$NODE_VERSION ..."
case "$PLATFORM-$ARCH" in
darwin-*) NODE_PLATFORM="darwin-$ARCH"; NODE_EXT="tar.gz" ;;
linux-*) NODE_PLATFORM="linux-$ARCH"; NODE_EXT="tar.xz" ;;
win32-*) NODE_PLATFORM="win-$ARCH"; NODE_EXT="zip" ;;
esac
NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${NODE_PLATFORM}.${NODE_EXT}"
NODE_ARCHIVE="$(mktemp /tmp/gelectron-node.XXXXXX.${NODE_EXT})"
curl -fsSL -L "$NODE_URL" -o "$NODE_ARCHIVE"
NODE_STAGE="$(mktemp -d "${TMPDIR:-/tmp}/gelectron-node-stage.XXXXXX")"
case "$NODE_EXT" in
zip) unzip -qo "$NODE_ARCHIVE" -d "$NODE_STAGE" ;;
tar.gz) tar -xzf "$NODE_ARCHIVE" -C "$NODE_STAGE" ;;
tar.xz) tar -xJf "$NODE_ARCHIVE" -C "$NODE_STAGE" ;;
esac
NODE_BIN="$(find "$NODE_STAGE" -type f \( -name 'node.exe' -o -name 'node' \) | head -n1)"
if [[ -n "$NODE_BIN" ]]; then
install -m 755 "$NODE_BIN" "$NODE_INSTALLED"
else
echo " warning: could not install private Node.js — apps will require a system Node" >&2
fi
rm -rf "$NODE_STAGE" "$NODE_ARCHIVE"
fi
# Downloaded-from-GitHub binaries carry a quarantine attribute on macOS and
# Gatekeeper will block the first launch. Clear it — the binary is ad-hoc
# signed, so this is the only thing standing in the way.
+4 -2
View File
@@ -49,12 +49,14 @@ Section "Install"
SetOutPath "$INSTDIR"
File "gelectron.exe"
; Bundled Node.js runtime so packaged apps run with no system Node install
File "node.exe"
File /r "compat"
WriteUninstaller "$INSTDIR\Uninstall.exe"
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\gelectron.exe"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} CLI.lnk" "$INSTDIR\gelectron.exe"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${APPNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
@@ -80,7 +82,7 @@ Section "Uninstall"
WriteRegExpandStr ${ENV_KEY} "Path" $1
SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment"
Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk"
Delete "$SMPROGRAMS\${APPNAME}\${APPNAME} CLI.lnk"
RMDir "$SMPROGRAMS\${APPNAME}"
DeleteRegKey HKLM "${UNINST_KEY}"
+24 -11
View File
@@ -4,12 +4,13 @@
# and a Node.js runtime into a single self-contained, downloadable AppImage.
#
# Gelectron-<version>-x86_64.AppImage
# Gelectron-<version>-aarch64.AppImage
#
# Requires: the gelectron binary (built against webkit2gtk-4.1), compat layer,
# and network access (downloads Node.js + appimagetool).
#
# Usage:
# scripts/pkg/make-appimage.sh -v VERSION [--binary PATH] [--compat DIR] [-o DIR]
# scripts/pkg/make-appimage.sh -v VERSION [--binary PATH] [--compat DIR] [-o DIR] [-a ARCH]
set -euo pipefail
@@ -20,19 +21,21 @@ VERSION=""
BINARY=""
COMPAT="$REPO_DIR/src/electron"
OUT_DIR="$REPO_DIR/dist"
ARCH="x64"
usage() {
cat <<'EOF'
Linux AppImage builder
Usage:
scripts/pkg/make-appimage.sh -v VERSION [--binary PATH] [--compat DIR] [-o DIR]
scripts/pkg/make-appimage.sh -v VERSION [--binary PATH] [--compat DIR] [-o DIR] [-a ARCH]
Options:
-v, --version VER Version string (e.g. 0.1.1)
-b, --binary PATH Path to the gelectron binary
-c, --compat DIR Path to the compat layer (default: <repo>/src/electron)
-o, --out DIR Output directory (default: <repo>/dist)
-a, --arch ARCH x64 | arm64 (default: x64)
-h, --help Show this help
EOF
}
@@ -43,6 +46,7 @@ while [[ $# -gt 0 ]]; do
-b|--binary) BINARY="${2:-}"; shift 2 ;;
-c|--compat) COMPAT="${2:-}"; shift 2 ;;
-o|--out) OUT_DIR="${2:-}"; shift 2 ;;
-a|--arch) ARCH="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option: $1" >&2; usage; exit 1 ;;
esac
@@ -50,12 +54,22 @@ done
VERSION="${VERSION#v}"
[[ -n "$VERSION" ]] || { echo "error: --version required" >&2; exit 1; }
case "$ARCH" in x64|arm64) ;; *) echo "error: unsupported arch: $ARCH" >&2; exit 1 ;; esac
if [[ -z "$BINARY" ]]; then
BINARY="$REPO_DIR/target/release/gelectron"
fi
[[ -f "$BINARY" ]] || { echo "error: binary not found: $BINARY" >&2; exit 1; }
[[ -d "$COMPAT" ]] || { echo "error: compat dir not found: $COMPAT" >&2; exit 1; }
# Map to appimg / node arch naming
if [[ "$ARCH" == "arm64" ]]; then
NODE_ARCH="arm64"
TARGET_ARCH="aarch64"
else
NODE_ARCH="x64"
TARGET_ARCH="x86_64"
fi
log() { echo "==> $*"; }
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/gelectron-appimage.XXXXXX")"
@@ -76,17 +90,16 @@ install -m 644 "$COMPAT"/*.js "$APPDIR/usr/bin/compat/"
# Node.js runtime (gelectron's Node mode requires node on PATH)
NODE_DIR="$STAGE/node"
if [[ ! -d "$NODE_DIR" ]]; then
log "Downloading Node.js v$NODE_VERSION..."
log "Downloading Node.js v$NODE_VERSION ($NODE_ARCH)..."
mkdir -p "$NODE_DIR"
NODE_ARCHIVE="$STAGE/node.tar.xz"
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o "$NODE_ARCHIVE"
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" -o "$NODE_ARCHIVE"
tar -xJf "$NODE_ARCHIVE" -C "$NODE_DIR"
fi
install -m 755 "$NODE_DIR"/node-v${NODE_VERSION}-linux-x64/bin/node "$APPDIR/usr/bin/node"
install -m 644 "$NODE_DIR"/node-v${NODE_VERSION}-linux-x64/lib/libnode.so* "$APPDIR/usr/lib/" 2>/dev/null || true
install -m 755 "$NODE_DIR"/node-v${NODE_VERSION}-linux-${NODE_ARCH}/bin/node "$APPDIR/usr/bin/node"
mkdir -p "$APPDIR/usr/lib"
cp "$NODE_DIR"/node-v${NODE_VERSION}-linux-x64/lib/libnode.so* "$APPDIR/usr/lib/" 2>/dev/null || true
cp "$NODE_DIR"/node-v${NODE_VERSION}-linux-${NODE_ARCH}/lib/libnode.so* "$APPDIR/usr/lib/" 2>/dev/null || true
# ── AppImage metadata ───────────────────────────────────────────────────────
@@ -119,19 +132,19 @@ fi
TOOL="$STAGE/appimagetool"
if [[ ! -f "$TOOL" ]]; then
log "Downloading appimagetool..."
curl -fsSL "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -o "$TOOL"
log "Downloading appimagetool ($TARGET_ARCH)..."
curl -fsSL "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${TARGET_ARCH}.AppImage" -o "$TOOL"
fi
chmod +x "$TOOL"
# ── Build ───────────────────────────────────────────────────────────────────
mkdir -p "$OUT_DIR"
APPIMAGE_NAME="Gelectron-$VERSION-x86_64.AppImage"
APPIMAGE_NAME="Gelectron-$VERSION-$TARGET_ARCH.AppImage"
APPIMAGE_PATH="$OUT_DIR/$APPIMAGE_NAME"
export VERSION="$VERSION"
export ARCH="x86_64"
export ARCH="$TARGET_ARCH"
# Run appimagetool without FUSE (Ubuntu 24.04+ runners don't ship libfuse2)
export APPIMAGE_EXTRACT_AND_RUN=1
log "Creating $APPIMAGE_NAME ..."
+37 -7
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
#
# Build the macOS installer: a .pkg (installs the gelectron runtime +
# compat layer to /usr/local/bin) wrapped in a .dmg for distribution.
# Build the macOS installer: a .pkg wrapped in a .dmg for distribution.
#
# The package is fully self-contained — it installs the gelectron runtime, the
# Electron compatibility layer AND a private Node.js runtime into
# /usr/local/lib/gelectron and symlinks gelectron into /usr/local/bin. Apps run
# with `gelectron <app>` with no other runtime installed on the machine.
#
# Gelectron-<version>-<arch>.dmg
# └── Install gelectron.pkg (double-click, runs macOS Installer as root)
@@ -18,6 +22,7 @@ ARCH=""
BINARY=""
COMPAT="$REPO_DIR/src/electron"
OUT_DIR="$REPO_DIR/dist"
NODE_VERSION="20.18.1"
usage() {
cat <<'EOF'
@@ -64,9 +69,9 @@ log() { echo "==> $*"; }
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/gelectron-dmg.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
# ── Payload root (what pkgbuild installs into /usr/local/bin) ─────────────
# ── Payload root: what pkgbuild installs into /usr/local/lib/gelectron ─────
PAYLOAD="$STAGE/gelectron"
PAYLOAD="$STAGE/payload/usr/local/lib/gelectron"
mkdir -p "$PAYLOAD"
log "Staging payload..."
@@ -74,21 +79,46 @@ install -m 755 "$BINARY" "$PAYLOAD/gelectron"
mkdir -p "$PAYLOAD/compat"
install -m 644 "$COMPAT"/*.js "$PAYLOAD/compat/"
# Private Node.js runtime so the installed CLI works with no system Node
NODE_ARCH="$( [[ "$ARCH" == "arm64" ]] && echo arm64 || echo x64 )"
NODE_ARCHIVE="$STAGE/node.tar.gz"
if [[ ! -f "$NODE_ARCHIVE" ]]; then
log "Downloading Node.js v$NODE_VERSION..."
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-${NODE_ARCH}.tar.gz" -o "$NODE_ARCHIVE"
fi
log "Extracting Node.js..."
mkdir -p "$STAGE/node"
tar -xzf "$NODE_ARCHIVE" -C "$STAGE/node"
install -m 755 "$STAGE/node"/node-v${NODE_VERSION}-darwin-${NODE_ARCH}/bin/node "$PAYLOAD/node"
if command -v codesign >/dev/null 2>&1; then
log "Ad-hoc signing binary..."
codesign --force --sign - "$PAYLOAD/gelectron" 2>/dev/null || echo " (warning: codesign failed)"
fi
# ── Postinstall script: symlink /usr/local/bin/gelectron → runtime ─────────
SCRIPTS="$STAGE/scripts"
mkdir -p "$SCRIPTS"
cat > "$SCRIPTS/postinstall" <<'EOF'
#!/bin/bash
set -e
ln -sf /usr/local/lib/gelectron/gelectron /usr/local/bin/gelectron
exit 0
EOF
chmod +x "$SCRIPTS/postinstall"
# ── Component package ───────────────────────────────────────────────────────
PKG="$STAGE/Install gelectron.pkg"
log "Building package (installs to /usr/local/bin)..."
log "Building package (installs to /usr/local/lib/gelectron)..."
pkgbuild \
--root "$PAYLOAD" \
--root "$STAGE/payload" \
--scripts "$SCRIPTS" \
--identifier "com.gelectron.runtime.$ARCH" \
--version "$VERSION" \
--ownership recommended \
--install-location /usr/local/bin \
--install-location / \
"$PKG"
# ── DMG ────────────────────────────────────────────────────────────────────
+17 -2
View File
@@ -1,6 +1,7 @@
# Gelectron Windows installer builder (NSIS)
#
# Stages the payload and compiles the installer EXE with makensis:
# Stages the payload (gelectron runtime + bundled Node.js + compat layer) and
# compiles the installer EXE with makensis:
# Gelectron-<version>-<arch>.exe
#
# Usage:
@@ -17,7 +18,8 @@ param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$Arch = "x64",
[string]$Out = "dist",
[string]$NsiPath = "scripts/pkg/gelectron.nsi"
[string]$NsiPath = "scripts/pkg/gelectron.nsi",
[string]$NodeVersion = "20.18.1"
)
$ErrorActionPreference = "Stop"
@@ -35,6 +37,19 @@ try {
Copy-Item -Recurse -Force $Compat (Join-Path $Stage "compat")
Copy-Item $NsiPath (Join-Path $Stage "gelectron.nsi") -Force
# Bundled Node.js runtime so the installed CLI works with no system Node.
$NodeArch = if ($Arch -eq "arm64") { "arm64" } else { "x64" }
$NodeArchive = Join-Path $Stage "node.zip"
$NodeUrl = "https://nodejs.org/dist/v$NodeVersion/node-v$NodeVersion-win-$NodeArch.zip"
Write-Host "==> Downloading Node.js v$NodeVersion ($NodeArch)..."
Invoke-WebRequest -Uri $NodeUrl -OutFile $NodeArchive -UseBasicParsing
$NodeExtract = Join-Path $Stage "node"
Expand-Archive -Path $NodeArchive -DestinationPath $NodeExtract -Force
$NodeExe = Get-ChildItem $NodeExtract -Recurse -Filter node.exe | Select-Object -First 1
if (-not $NodeExe) { throw "node.exe not found in Node.js archive" }
Copy-Item $NodeExe.FullName (Join-Path $Stage "node.exe") -Force
Write-Host "==> Compiling installer (makensis)..."
Push-Location $Stage
try {
+195
View File
@@ -0,0 +1,195 @@
'use strict';
/**
* Gelectron - clipboard module (Electron compatible)
*
* Implements text / HTML / rich-text clipboard operations using the OS-native
* tooling so it works both in native Node mode and in the pure-Node fallback.
*/
const { execSync } = require('child_process');
const os = require('os');
const { isNative } = require('./native-bridge');
function platform() {
return os.platform();
}
// ─── Low-level platform pipes ───────────────────────────────────────────────
function readCommand(cmd) {
try {
const out = execSync(cmd, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' });
return out == null ? '' : out.toString();
} catch (e) {
return '';
}
}
function writePipe(cmd, text) {
try {
execSync(`${cmd} '${String(text).replace(/'/g, "'\\''")}'`, { stdio: 'pipe' });
return true;
} catch (e) {
try {
const { spawnSync } = require('child_process');
if (platform() === 'win32') {
// Powershell stdin pipe is the most reliable on Windows
const ps = spawnSync('powershell', ['-NoProfile', '-Command', 'Set-Clipboard'], {
input: String(text),
encoding: 'utf8',
});
return ps.status === 0;
}
return false;
} catch (e2) {
return false;
}
}
}
function readTextFromSystem() {
const p = platform();
try {
if (p === 'darwin') return execSync('pbpaste', { encoding: 'utf8' }).toString();
if (p === 'win32') {
const out = execSync('powershell -NoProfile -Command "Get-Clipboard -Raw"', { encoding: 'utf8' });
return out.replace(/\r?\n$/, '');
}
for (const tool of ['xclip -selection clipboard -o', 'xsel --clipboard --output', 'wl-paste']) {
try {
return execSync(tool, { encoding: 'utf8' }).toString();
} catch (e) { /* try next */ }
}
return '';
} catch (e) {
return '';
}
}
function writeTextToSystem(text) {
const p = platform();
const content = String(text);
try {
if (p === 'darwin') return writePipe('pbcopy', content);
if (p === 'win32') {
const { spawnSync } = require('child_process');
const ps = spawnSync('powershell', ['-NoProfile', '-Command', 'Set-Clipboard'], {
input: content,
encoding: 'utf8',
});
return ps.status === 0;
}
for (const cmd of [
`xclip -selection clipboard -in <<'GELECTRON_EOF'\n${content}\nGELECTRON_EOF`,
`xsel --clipboard --input <<'GELECTRON_EOF'\n${content}\nGELECTRON_EOF`,
`wl-copy <<'GELECTRON_EOF'\n${content}\nGELECTRON_EOF`,
]) {
try {
execSync(cmd, { encoding: 'utf8' });
return true;
} catch (e) { /* try next */ }
}
return false;
} catch (e) {
return false;
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
const clipboard = {
readText() {
return readTextFromSystem();
},
writeText(text) {
return writeTextToSystem(text);
},
readHTML() {
// Native bridge request falls back to plain text when unavailable
if (isNative && typeof globalThis.__gelectron_clipboard_read_html === 'function') {
return globalThis.__gelectron_clipboard_read_html();
}
return readTextFromSystem();
},
writeHTML(markup, type = 'text/html') {
try {
if (platform() === 'win32') {
const html = String(markup);
const { spawnSync } = require('child_process');
const ps = spawnSync('powershell', ['-NoProfile', '-Command',
`Set-Clipboard -Value @'${html.replace(/'/g, "''")}'@`], { encoding: 'utf8' });
return ps.status === 0;
}
return writeTextToSystem(markup);
} catch (e) {
return false;
}
},
// Best-effort plain text read (Electron returns an image object for rich
// clipboard types on some platforms; we degrade to text where possible).
readTextOnly() {
return readTextFromSystem();
},
readImage() {
return NativeImageStub.empty();
},
writeImage(image) {
return !!image;
},
availableFormats() {
return ['text/plain'];
},
read(format = 'text/plain') {
return readTextFromSystem();
},
write(data, type = 'text/plain') {
return writeTextToSystem(data);
},
clear() {},
readBookmark() {
return { title: '', url: '' };
},
writeBookmark() {},
readFindText() {
try {
return process.env.GELECTRON_FIND_TEXT || '';
} catch (e) {
return '';
}
},
writeFindText(text) {
try {
process.env.GELECTRON_FIND_TEXT = String(text);
return true;
} catch (e) {
return false;
}
},
};
// Avoid a hard dependency on native-image for the stub paths.
const NativeImageStub = {
empty() {
return {
isEmpty: () => true,
toPNG: () => Buffer.alloc(0),
};
},
};
module.exports = clipboard;
+8
View File
@@ -70,6 +70,14 @@ class NativeBridge extends EventEmitter {
});
}
// Generic request to the native process: request(type, payload) where
// `type` matches a ToRust variant. Resolves with the JSON response from the
// native side (or null when running without the native binary).
request(type, payload = {}) {
if (!isNative) return Promise.resolve(null);
return this._request({ type, ...payload });
}
_resolveRequest(requestId, result, error) {
const pending = this._pendingRequests[requestId];
if (pending) {
+72
View File
@@ -0,0 +1,72 @@
'use strict';
/**
* Gelectron - nativeTheme module (Electron compatible)
*
* Reports dark mode / accent colors. When running with the native binary it
* asks the OS for the current theme; otherwise it falls back to an env hint
* (or 'light').
*/
const { EventEmitter } = require('events');
const { bridge, isNative } = require('./native-bridge');
function osDarkHint() {
const env = process.env;
if (env.COLORFGBG && /^0;/.test(env.COLORFGBG)) return true;
return false;
}
class NativeTheme extends EventEmitter {
constructor() {
super();
this._dark = osDarkHint();
this._source = 'system';
this._accent = '#007AFF';
this._loaded = false;
if (isNative) {
bridge.request('native-theme-query', {}).then((res) => {
if (res) {
if (typeof res.shouldUseDarkColors === 'boolean') this._dark = res.shouldUseDarkColors;
if (res.accentColor) this._accent = res.accentColor;
if (res.themeSource) this._source = res.themeSource;
this._loaded = true;
}
}).catch(() => {});
}
}
get shouldUseDarkColors() {
return this._dark;
}
get shouldUseInvertedColorScheme() {
return false;
}
get themeSource() {
return this._source;
}
set themeSource(value) {
if (value === 'system' || value === 'light' || value === 'dark') {
this._source = value;
if (value === 'dark') this._dark = true;
else if (value === 'light') this._dark = false;
}
}
get themes() {
return {
initial: this._dark ? ['dark'] : ['light'],
current: this._dark ? ['dark'] : ['light'],
};
}
on(eventName, listener) { super.on(eventName, listener); return this; }
once(eventName, listener) { super.once(eventName, listener); return this; }
}
module.exports = new NativeTheme();
module.exports.NativeTheme = NativeTheme;
+151
View File
@@ -0,0 +1,151 @@
'use strict';
/**
* Gelectron - screen module (Electron compatible)
*
* Queries display information from the native process when available and
* otherwise returns sensible single-display defaults so apps never hang.
*/
const os = require('os');
const { EventEmitter } = require('events');
const { bridge, isNative } = require('./native-bridge');
function defaultDisplay() {
const width = 1920;
const height = 1080;
return {
id: 1,
label: 'Display 1',
bounds: { x: 0, y: 0, width, height },
workArea: { x: 0, y: 0, width, height },
size: { width, height },
workAreaSize: { width, height },
scaleFactor: 1,
rotation: 0,
internal: true,
touchSupport: 'unknown',
displayFrequency: 60,
colorSpace: '',
colorDepth: 24,
monitors: [],
};
}
function normalize(raw) {
const d = raw || {};
return {
id: d.id != null ? d.id : 1,
label: d.label || 'Display 1',
bounds: d.bounds || { x: 0, y: 0, width: 1920, height: 1080 },
workArea: d.workArea || d.bounds || { x: 0, y: 0, width: 1920, height: 1080 },
size: d.size || { width: 1920, height: 1080 },
workAreaSize: d.workAreaSize || d.size || { width: 1920, height: 1080 },
scaleFactor: d.scaleFactor || 1,
rotation: d.rotation || 0,
internal: !!d.internal,
touchSupport: d.touchSupport || 'unknown',
displayFrequency: d.displayFrequency || 60,
colorSpace: d.colorSpace || '',
colorDepth: d.colorDepth || 24,
monitors: d.monitors || [],
};
}
function rectsIntersect(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
class Screen extends EventEmitter {
constructor() {
super();
this._displays = [defaultDisplay()];
// Parse an optional env-provided display list (packaged apps can inject
// this to avoid a round-trip).
try {
if (process.env.GELECTRON_DISPLAYS) {
const parsed = JSON.parse(process.env.GELECTRON_DISPLAYS);
if (Array.isArray(parsed) && parsed.length > 0) {
this._displays = parsed.map(normalize);
}
}
} catch (e) {
// Ignore malformed env
}
// Refresh from the native process when available.
if (isNative) {
bridge.request('screen-get-displays', {}).then((res) => {
if (res && Array.isArray(res.displays) && res.displays.length > 0) {
this._displays = res.displays.map(normalize);
this.emit('display-added', this._displays[this._displays.length - 1]);
}
}).catch(() => {});
}
}
getAllDisplays() {
return this._displays;
}
getPrimaryDisplay() {
return this._displays[0] || defaultDisplay();
}
getDisplayNearestPoint(point) {
const p = point || { x: 0, y: 0 };
let best = this._displays[0];
let bestDist = Infinity;
for (const d of this._displays) {
const cx = d.bounds.x + d.bounds.width / 2;
const cy = d.bounds.y + d.bounds.height / 2;
const dist = (p.x - cx) ** 2 + (p.y - cy) ** 2;
if (dist < bestDist) {
bestDist = dist;
best = d;
}
}
return best;
}
getDisplayForPoint(point) {
return this.getDisplayNearestPoint(point);
}
getDisplayMatching(rect) {
const r = rect || { x: 0, y: 0, width: 0, height: 0 };
let best = this._displays[0];
let bestArea = 0;
for (const d of this._displays) {
if (rectsIntersect(d.bounds, r)) {
const area = d.bounds.width * d.bounds.height;
if (area > bestArea) {
bestArea = area;
best = d;
}
}
}
return best;
}
getCursorScreenPoint() {
// No native cursor query is implemented; return the center of the primary
// display as a harmless default.
const d = this.getPrimaryDisplay();
return {
x: Math.round(d.bounds.x + d.bounds.width / 2),
y: Math.round(d.bounds.y + d.bounds.height / 2),
};
}
on(eventName, listener) { super.on(eventName, listener); return this; }
once(eventName, listener) { super.once(eventName, listener); return this; }
}
module.exports = { Screen, defaultDisplay, normalize };