diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4bf00c0..56ff9ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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--arm64.dmg / Gelectron--x64.dmg +# - Windows: Gelectron--x64.exe / Gelectron--arm64.exe +# - Linux: Gelectron--x86_64.AppImage / Gelectron--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 diff --git a/.gitignore b/.gitignore index 014dad7..c0bc372 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index a014cbe..7dfe63d 100644 --- a/README.md +++ b/README.md @@ -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--arm64.dmg` / `-x64.dmg` | Contains `Install gelectron.pkg`, which installs the runtime + compat layer to `/usr/local/bin` | -| Windows | `Gelectron--x64.exe` | NSIS installer → `C:\Program Files\Gelectron`, adds it to the system PATH, Start Menu + uninstaller | -| Linux | `Gelectron--x86_64.AppImage` | Self-contained bundle (includes Node.js + compat), just run it | +| macOS | `Gelectron--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--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--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/`) diff --git a/cli/gelectron.js b/cli/gelectron.js index 9e41ac6..493bf83 100755 --- a/cli/gelectron.js +++ b/cli/gelectron.js @@ -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); } diff --git a/crates/gelectron-app/src/main.rs b/crates/gelectron-app/src/main.rs index edfe3ee..131c835 100644 --- a/crates/gelectron-app/src/main.rs +++ b/crates/gelectron-app/src/main.rs @@ -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 { - 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()); } - None - }) + } + } + + // 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)] diff --git a/package.json b/package.json index 7c75c0b..2cbde64 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packager/bin/gelectron-packager.js b/packager/bin/gelectron-packager.js index 02d32d1..8072021 100755 --- a/packager/bin/gelectron-packager.js +++ b/packager/bin/gelectron-packager.js @@ -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} CFBundleDisplayName ${exeName} CFBundleIdentifier com.gelectron.${name.toLowerCase().replace(/[^a-z0-9]/g, '')} + CFBundleInfoDictionaryVersion + 6.0 CFBundleName ${name} CFBundlePackageType @@ -180,6 +235,10 @@ ${iconKey} CFBundleDisplayName ${version} CFBundleVersion ${version} + LSMinimumSystemVersion + 11.0 + NSPrincipalClass + NSApplication NSHighResolutionCapable NSRequiresAquaSystemAppearance @@ -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')); } diff --git a/scripts/install-release.sh b/scripts/install-release.sh index dad638d..b325c12 100755 --- a/scripts/install-release.sh +++ b/scripts/install-release.sh @@ -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 ` +# 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. diff --git a/scripts/pkg/gelectron.nsi b/scripts/pkg/gelectron.nsi index 06a2604..9af8917 100644 --- a/scripts/pkg/gelectron.nsi +++ b/scripts/pkg/gelectron.nsi @@ -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}" diff --git a/scripts/pkg/make-appimage.sh b/scripts/pkg/make-appimage.sh index 30dba1b..246cf00 100755 --- a/scripts/pkg/make-appimage.sh +++ b/scripts/pkg/make-appimage.sh @@ -4,12 +4,13 @@ # and a Node.js runtime into a single self-contained, downloadable AppImage. # # Gelectron--x86_64.AppImage +# Gelectron--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: /src/electron) -o, --out DIR Output directory (default: /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 ..." diff --git a/scripts/pkg/make-dmg.sh b/scripts/pkg/make-dmg.sh index 60c8afb..adb6b7b 100755 --- a/scripts/pkg/make-dmg.sh +++ b/scripts/pkg/make-dmg.sh @@ -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 ` with no other runtime installed on the machine. # # Gelectron--.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 ──────────────────────────────────────────────────────────────────── diff --git a/scripts/pkg/make-exe.ps1 b/scripts/pkg/make-exe.ps1 index 145cb98..0bf2d8d 100644 --- a/scripts/pkg/make-exe.ps1 +++ b/scripts/pkg/make-exe.ps1 @@ -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--.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 { diff --git a/src/electron/clipboard.js b/src/electron/clipboard.js new file mode 100644 index 0000000..0847c0d --- /dev/null +++ b/src/electron/clipboard.js @@ -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; \ No newline at end of file diff --git a/src/electron/native-bridge.js b/src/electron/native-bridge.js index 04db2fa..2d7d3c5 100644 --- a/src/electron/native-bridge.js +++ b/src/electron/native-bridge.js @@ -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) { diff --git a/src/electron/nativeTheme.js b/src/electron/nativeTheme.js new file mode 100644 index 0000000..17cd142 --- /dev/null +++ b/src/electron/nativeTheme.js @@ -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; \ No newline at end of file diff --git a/src/electron/screen.js b/src/electron/screen.js new file mode 100644 index 0000000..9b4134a --- /dev/null +++ b/src/electron/screen.js @@ -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 }; \ No newline at end of file