diff --git a/Cargo.toml b/Cargo.toml index 8d1f5cd..16a81e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/gelectron-core"] +members = ["crates/gelectron-core", "crates/gelectron-app"] resolver = "2" [workspace.dependencies] diff --git a/cli/gelectron.js b/cli/gelectron.js index 19e8a4e..9e41ac6 100755 --- a/cli/gelectron.js +++ b/cli/gelectron.js @@ -113,8 +113,8 @@ if (process.env.GELECTRON_LOG) { console.log(`[gelectron] native addon: ${nativeAddonPath || 'not found (using build target)'}`); } -const rustBin = path.join(nativeDir, 'target', 'release', 'gelectron'); -const rustBinDebug = path.join(nativeDir, 'target', 'debug', 'gelectron'); +const rustBin = path.join(__dirname, '..', 'target', 'release', 'gelectron'); +const rustBinDebug = path.join(__dirname, '..', 'target', 'debug', 'gelectron'); let executable; if (fs.existsSync(rustBin)) { @@ -124,15 +124,15 @@ if (fs.existsSync(rustBin)) { } if (executable) { - const child = spawn(executable, args.slice(1), { + console.log(`[gelectron] Using native binary: ${executable}`); + const child = spawn(executable, args, { env, stdio: 'inherit', cwd: process.cwd(), }); child.on('exit', (code) => process.exit(code || 0)); } else { - console.log(`[gelectron] Native binary not found. Running main script directly via Node.js.`); - console.log(`[gelectron] Build with: cargo build --release -p gelectron-core`); + console.log(`[gelectron] Native binary not found. Run: cargo build --release -p gelectron`); console.log(`[gelectron] Falling back to Node.js runtime...\n`); require('../src/electron/runtime.js').run(mainScript, env); diff --git a/crates/gelectron-app/Cargo.toml b/crates/gelectron-app/Cargo.toml new file mode 100644 index 0000000..85cecad --- /dev/null +++ b/crates/gelectron-app/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gelectron" +version = "0.1.0" +edition = "2021" +description = "Gelectron - Firefox-engine alternative to Electron" +license = "MIT" +authors = ["mileswa1q22"] + +[[bin]] +name = "gelectron" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } +wry = "0.47" +tao = "0.30" diff --git a/crates/gelectron-app/src/main.rs b/crates/gelectron-app/src/main.rs new file mode 100644 index 0000000..af97c7b --- /dev/null +++ b/crates/gelectron-app/src/main.rs @@ -0,0 +1,468 @@ +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, Command, Stdio}; +use std::rc::Rc; +use std::sync::mpsc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::sync::Arc; + +use tao::event::{Event, StartCause, WindowEvent}; +use tao::event_loop::{ControlFlow, EventLoopBuilder}; +use tao::window::{Fullscreen, WindowBuilder, WindowId}; +use wry::{WebView, WebViewBuilder}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +enum ToRust { + #[serde(rename = "create-window")] + CreateWindow { id: u32, options: WindowOpts }, + #[serde(rename = "load-url")] + LoadUrl { id: u32, url: String }, + #[serde(rename = "load-file")] + LoadFile { id: u32, path: String }, + #[serde(rename = "destroy-window")] + DestroyWindow { id: u32 }, + #[serde(rename = "set-title")] + SetTitle { id: u32, title: String }, + #[serde(rename = "set-size")] + SetSize { id: u32, width: u32, height: u32 }, + #[serde(rename = "show")] + Show { id: u32 }, + #[serde(rename = "hide")] + Hide { id: u32 }, + #[serde(rename = "focus")] + Focus { id: u32 }, + #[serde(rename = "minimize")] + Minimize { id: u32 }, + #[serde(rename = "maximize")] + Maximize { id: u32 }, + #[serde(rename = "close")] + Close { id: u32 }, + #[serde(rename = "ipc-message")] + IpcMessage { + id: u32, + channel: String, + data: serde_json::Value, + }, + #[serde(rename = "eval-js")] + EvalJs { id: u32, script: String }, + #[serde(rename = "quit")] + Quit, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +enum ToNode { + #[serde(rename = "window-closed")] + WindowClosed { id: u32 }, + #[serde(rename = "window-focus")] + WindowFocus { id: u32 }, + #[serde(rename = "ipc-message")] + IpcMessage { + id: u32, + channel: String, + data: serde_json::Value, + }, + #[serde(rename = "ready")] + Ready, +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +struct WindowOpts { + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + title: Option, + #[serde(default)] + url: Option, + #[serde(default)] + show: Option, + #[serde(default)] + resizable: Option, + #[serde(default)] + always_on_top: Option, + #[serde(default)] + fullscreen: Option, +} + +struct WindowPair { + #[allow(dead_code)] + window: tao::window::Window, + webview: WebView, +} + +struct AppState { + windows: HashMap, + window_wids: HashMap, + node_stdin: Option, + node_exited: Arc, +} + +impl AppState { + fn new(node_exited: Arc) -> Self { + Self { + windows: HashMap::new(), + window_wids: HashMap::new(), + node_stdin: None, + node_exited, + } + } + + fn send_to_node(&mut self, msg: &ToNode) { + let json = serde_json::to_string(msg).unwrap(); + if let Some(ref mut stdin) = self.node_stdin { + if writeln!(stdin, "{}", json).is_err() || stdin.flush().is_err() { + // Node.js process has likely exited; mark it so the event loop can shut down + self.node_exited.store(true, Ordering::SeqCst); + } + } + } +} + +fn preload_script() -> String { + r#" +(function() { + if (window.__gelectron_loaded) return; + window.__gelectron_loaded = true; + window.__ipc_pending = []; + window.gelectron = { + send: function(channel, ...args) { + window.__ipc_pending.push(JSON.stringify({channel: channel, args: args})); + }, + receive: function(channel, callback) { + window.addEventListener('message', function(e) { + if (e.source === window && typeof e.data === 'string') { + try { + var msg = JSON.parse(e.data); + if (msg.__from_node && msg.channel === channel) { + callback(msg.data); + } + } catch(err) {} + } + }); + } + }; +})(); +"# + .to_string() +} + +fn main() { + env_logger::init(); + + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: gelectron "); + std::process::exit(1); + } + + let app_path = std::path::PathBuf::from(&args[1]); + if !app_path.exists() { + eprintln!("Error: path not found: {}", app_path.display()); + std::process::exit(1); + } + + // Canonicalize so relative paths (e.g. "demo/") become absolute. + // Node.js -e resolves require() relative to [eval], not cwd. + let app_path = std::fs::canonicalize(&app_path).unwrap_or(app_path); + + let main_script = if app_path.is_dir() { + let pkg_path = app_path.join("package.json"); + if pkg_path.exists() { + let pkg: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&pkg_path).unwrap_or_default()) + .unwrap_or_default(); + let main = pkg + .get("main") + .and_then(|v| v.as_str()) + .unwrap_or("index.js"); + app_path.join(main) + } else { + app_path.join("index.js") + } + } else { + app_path.clone() + }; + + if !main_script.exists() { + eprintln!("Error: main script not found: {}", main_script.display()); + std::process::exit(1); + } + + let gelectron_dir = std::env::current_exe() + .ok() + .and_then(|p| std::fs::canonicalize(p).ok()) + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_default(); + + // From target/release/ or target/debug/, go up to project root + let project_root = if gelectron_dir.ends_with("release") || gelectron_dir.ends_with("debug") { + gelectron_dir.parent() + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| gelectron_dir.clone()) + } else { + gelectron_dir.clone() + }; + + let compat_dir = if project_root.join("src/electron/index.js").exists() { + project_root.join("src/electron") + } else if project_root.join("crates/gelectron-app/src/main.rs").exists() { + project_root.join("src/electron") + } else { + // npm installed: try to find in node_modules + project_root.join("node_modules/gelectron/src/electron") + }; + + log::info!("App: {}", app_path.display()); + log::info!("Script: {}", main_script.display()); + log::info!("Compat: {}", compat_dir.display()); + + let node_path = which_node().unwrap_or_else(|| { + eprintln!("Error: node not found in PATH"); + std::process::exit(1); + }); + + let setup_script = format!( + r#" +process.env.GELECTRON_NATIVE = '1'; +process.env.GELECTRON_MAIN_SCRIPT = '{}'; +process.env.GELECTRON_APP_PATH = '{}'; + +const Module = require('module'); +const path = require('path'); +const compatPath = '{}'; + +// Purge any cached 'electron' module from the real npm package +Object.keys(Module._cache).forEach(function(key) {{ + var normalized = key.replace(/\\/g, '/'); + if (normalized.endsWith('/electron') || normalized.endsWith('/electron/index.js') || normalized.endsWith('/electron/index.cjs')) {{ + delete Module._cache[key]; + }} +}}); + +const origResolve = Module._resolveFilename; +Module._resolveFilename = function(request, parent, isMain, options) {{ + if (request === 'electron' || request === 'electron/main' || request === 'electron/common') return path.join(compatPath, 'index.js'); + if (request === 'electron/renderer') return path.join(compatPath, 'ipc-renderer.js'); + return origResolve.call(this, request, parent, isMain, options); +}}; + +// Also patch _resolveRequest for Node >= 22 +if (typeof Module._resolveRequest === 'function') {{ + var origResolveRequest = Module._resolveRequest; + Module._resolveRequest = function(request, parent, isMain, options) {{ + if (request === 'electron' || request === 'electron/main' || request === 'electron/common') return path.join(compatPath, 'index.js'); + if (request === 'electron/renderer') return path.join(compatPath, 'ipc-renderer.js'); + return origResolveRequest.call(this, request, parent, isMain, options); + }}; +}} + +// Pre-load the gelectron shim so require('electron') hits cache +require(path.join(compatPath, 'index.js')); + +require('{}'); +"#, + main_script.display().to_string().replace('\\', "\\\\").replace('\'', "\\'"), + app_path.display().to_string().replace('\\', "\\\\").replace('\'', "\\'"), + compat_dir.display().to_string().replace('\\', "\\\\").replace('\'', "\\'"), + main_script.display().to_string().replace('\\', "\\\\").replace('\'', "\\'"), + ); + + let mut child: Child = Command::new(&node_path) + .arg("-e") + .arg(&setup_script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to spawn Node.js"); + + let child_stdin = child.stdin.take().unwrap(); + let child_stdout = child.stdout.take().unwrap(); + + let (tx, rx) = mpsc::channel::(); + let node_exited = Arc::new(AtomicBool::new(false)); + let node_exited_clone = node_exited.clone(); + + thread::spawn(move || { + let reader = BufReader::new(child_stdout); + for line in reader.lines() { + match line { + Ok(line) => { + let trimmed = line.trim().to_string(); + if trimmed.is_empty() || !trimmed.starts_with('{') { + continue; + } + if let Ok(msg) = serde_json::from_str::(&trimmed) { + if tx.send(msg).is_err() { + break; + } + } + } + Err(_) => break, + } + } + // Node.js process stdout closed – the child has exited + node_exited_clone.store(true, Ordering::SeqCst); + }); + + let event_loop = EventLoopBuilder::new().build(); + let mut state = AppState::new(node_exited.clone()); + state.node_stdin = Some(child_stdin); + let state = Rc::new(RefCell::new(state)); + + state.borrow_mut().send_to_node(&ToNode::Ready); + + event_loop.run(move |event, event_loop_target, control_flow| { + *control_flow = ControlFlow::Poll; + let mut st = state.borrow_mut(); + + // If the Node.js child process has exited, shut down + if st.node_exited.load(Ordering::SeqCst) { + log::info!("Node.js process exited, shutting down"); + st.windows.clear(); + st.window_wids.clear(); + *control_flow = ControlFlow::Exit; + return; + } + + match event { + Event::NewEvents(StartCause::Poll) => { + while let Ok(msg) = rx.try_recv() { + match msg { + ToRust::CreateWindow { id, options } => { + let mut wb = WindowBuilder::new() + .with_title(options.title.clone().unwrap_or_else(|| "Gelectron".into())) + .with_inner_size(tao::dpi::LogicalSize::new( + options.width.unwrap_or(800) as f64, + options.height.unwrap_or(600) as f64, + )); + if let Some(r) = options.resizable { wb = wb.with_resizable(r); } + if let Some(a) = options.always_on_top { wb = wb.with_always_on_top(a); } + if let Some(true) = options.fullscreen { + wb = wb.with_fullscreen(Some(Fullscreen::Borderless(None))); + } + wb = wb.with_visible(options.show.unwrap_or(true)); + + match wb.build(event_loop_target) { + Ok(window) => { + let url = options.url.unwrap_or_else(|| "about:blank".into()); + log::info!("Creating window {} - '{}'", id, url); + match WebViewBuilder::new() + .with_url(&url) + .with_initialization_script(&preload_script()) + .with_devtools(true) + .build(&window) + { + Ok(webview) => { + let wid = window.id(); + st.windows.insert(id, WindowPair { window, webview }); + st.window_wids.insert(wid, id); + log::info!("Window {} ready", id); + } + Err(e) => log::error!("WebView error: {}", e), + } + } + Err(e) => log::error!("Window error: {}", e), + } + } + ToRust::LoadUrl { id, url } => { + log::info!("Loading url in window {}: {}", id, url); + if let Some(pair) = st.windows.get(&id) { + let wv = &pair.webview; + let _ = wv.evaluate_script(&format!( + "window.location.replace({});", + serde_json::to_string(&url).unwrap() + )); + } + } + ToRust::LoadFile { id, path } => { + let url = format!("file://{}", std::fs::canonicalize(&path).unwrap_or_default().display()); + log::info!("Loading file in window {}: {}", id, url); + // Rebuild the WebView with the correct file URL. + // evaluate_script navigation from about:blank to file:// is + // unreliable on macOS WKWebView, so we recreate the WebView. + if let Some(pair) = st.windows.get_mut(&id) { + let window = &pair.window; + match WebViewBuilder::new() + .with_url(&url) + .with_initialization_script(&preload_script()) + .with_devtools(true) + .build(window) + { + Ok(webview) => { + pair.webview = webview; + log::info!("WebView rebuilt for window {}", id); + } + Err(e) => log::error!("WebView rebuild error: {}", e), + } + } + } + ToRust::DestroyWindow { id } => { + st.windows.remove(&id); + st.window_wids.retain(|_, v| *v != id); + if st.windows.is_empty() { *control_flow = ControlFlow::Exit; } + } + ToRust::Close { id } => { + st.windows.remove(&id); + st.window_wids.retain(|_, v| *v != id); + if st.windows.is_empty() { *control_flow = ControlFlow::Exit; } + } + ToRust::IpcMessage { id, channel, data } => { + if let Some(pair) = st.windows.get(&id) { + let msg = serde_json::json!({"__from_node":true,"channel":channel,"data":data}); + let js = format!("window.postMessage({},'*');", serde_json::to_string(&msg).unwrap()); + let _ = pair.webview.evaluate_script(&js); + } + } + ToRust::EvalJs { id, script } => { + if let Some(pair) = st.windows.get(&id) { + let _ = pair.webview.evaluate_script(&script); + } + } + ToRust::Quit => { + st.windows.clear(); + *control_flow = ControlFlow::Exit; + } + _ => {} + } + } + } + Event::WindowEvent { event: WindowEvent::CloseRequested, window_id, .. } => { + if let Some(&id) = st.window_wids.get(&window_id) { + st.send_to_node(&ToNode::WindowClosed { id }); + st.windows.remove(&id); + st.window_wids.remove(&window_id); + log::info!("Window {} closed", id); + if st.windows.is_empty() { *control_flow = ControlFlow::Exit; } + } + } + Event::WindowEvent { event: WindowEvent::Focused(true), window_id, .. } => { + if let Some(&id) = st.window_wids.get(&window_id) { + st.send_to_node(&ToNode::WindowFocus { id }); + } + } + _ => {} + } + }); +} + +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()); } + } + None + }) +} diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..a76e4e0 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,295 @@ + + + + + + Gelectron Demo + + + +
+

Gelectron Demo

+

HTML + CSS + JavaScript rendering via Gecko / Servo engine

+
+ +
+
+

Interactive Counter

+

Proof that JavaScript runs and DOM updates work.

+
+ +
0
+ +
+
+ +
+

Live Clock

+

requestAnimationFrame-driven rendering.

+
--:--:--
+
+ +
+

Canvas 2D

+

Animated canvas with moving shapes.

+
+ +
+
+ +
+

Runtime Info

+

Environment details from the gelectron shim.

+ + + + + +
Platform
Arch
Node.js
User Agent
+
+ +
+

CSS Features

+

Grid, gradients, border-radius, transitions, flexbox, backdrop styling.

+
+ + + + + + +
+
+
+ +
Gelectron v0.1.0 — Electron API compatibility layer powered by Gecko/Servo
+ + + + diff --git a/demo/main.js b/demo/main.js new file mode 100644 index 0000000..ec4744a --- /dev/null +++ b/demo/main.js @@ -0,0 +1,44 @@ +const { app, BrowserWindow } = require('electron'); +const path = require('path'); + +let mainWindow = null; + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }); + + app.whenReady().then(() => { + mainWindow = new BrowserWindow({ + width: 900, + height: 680, + title: 'Gelectron Demo', + backgroundColor: '#1a1a2e', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); + + mainWindow.once('ready-to-show', () => { + mainWindow.show(); + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + }); + + app.on('window-all-closed', () => { + app.quit(); + }); +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..464e304 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,6 @@ +{ + "name": "gelectron-demo", + "version": "1.0.0", + "description": "Minimal demo for gelectron — HTML, CSS, JavaScript rendering", + "main": "main.js" +} diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index 0177e87..d372727 100644 --- a/npm/darwin-arm64/package.json +++ b/npm/darwin-arm64/package.json @@ -6,7 +6,11 @@ "files": [ "gelectron_core.darwin-arm64.node" ], - "os": ["darwin"], - "cpu": ["arm64"], + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], "license": "MIT" -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9764369..8f813f8 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,11 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "gelectron-darwin-arm64": "0.1.0" + "gelectron-darwin-arm64": "0.1.0", + "gelectron-darwin-x64": "0.1.0", + "gelectron-win32-x64-msvc": "0.1.0", + "gelectron-win32-arm64-msvc": "0.1.0", + "gelectron-linux-x64-gnu": "0.1.0", + "gelectron-linux-arm64-gnu": "0.1.0" } } \ No newline at end of file diff --git a/src/electron/app.js b/src/electron/app.js index 9434002..af8f2c0 100644 --- a/src/electron/app.js +++ b/src/electron/app.js @@ -47,6 +47,15 @@ class App extends EventEmitter { } : null; this._argv = process.argv.slice(); + + // Emit 'ready' automatically on the next tick, matching real Electron + // where the app becomes ready once the process has initialised. + process.nextTick(() => { + if (!this._ready) { + this._ready = true; + this.emit('ready'); + } + }); } get commandLine() { diff --git a/src/electron/auto-updater.js b/src/electron/auto-updater.js index f7d588f..3c98fe6 100644 --- a/src/electron/auto-updater.js +++ b/src/electron/auto-updater.js @@ -3,7 +3,15 @@ /** * Gelectron - autoUpdater module (Electron compatible) * Stub implementation for electron-updater compatibility. - * electron-updater uses require("electron").autoUpdater as its native backend. + * + * electron-updater's MacUpdater / NsisUpdater / AppImageUpdater all + * access `require("electron").autoUpdater` and expect it to be an + * EventEmitter with the native Electron autoUpdater API surface: + * .on("error", …), .on("update-downloaded", …) + * .setFeedURL(), .getFeedURL() + * .checkForUpdates() + * .quitAndInstall() + * .removeListener() */ const { EventEmitter } = require('events'); @@ -13,6 +21,7 @@ class AutoUpdater extends EventEmitter { super(); this._isUpdateAvailable = false; this._updateInfo = null; + this._feedURL = null; this.autoDownload = true; this.autoInstallOnAppQuit = false; this.autoRunAppAfterInstall = true; @@ -20,10 +29,12 @@ class AutoUpdater extends EventEmitter { } getFeedURL() { - return null; + return this._feedURL; } - setFeedURL() {} + setFeedURL(options) { + this._feedURL = options; + } async checkForUpdates() { return { diff --git a/src/electron/browser-window.js b/src/electron/browser-window.js index c77f6ed..fa90d8d 100644 --- a/src/electron/browser-window.js +++ b/src/electron/browser-window.js @@ -6,6 +6,7 @@ const { EventEmitter } = require('events'); const path = require('path'); +const { bridge, isNative } = require('./native-bridge'); class WebContents extends EventEmitter { constructor(id) { @@ -52,22 +53,41 @@ class WebContents extends EventEmitter { this._url = targetUrl; this._isLoading = true; this.emit('did-start-loading'); + if (isNative) { + bridge.loadUrl(this.id, targetUrl); + } setTimeout(() => { this._isLoading = false; this.emit('did-stop-loading'); this.emit('did-finish-load'); this.emit('dom-ready'); - }, 100); + }, isNative ? 500 : 100); return Promise.resolve(); } loadFile(filePath) { - return this.loadURL(`file://${path.resolve(filePath)}`); + const url = `file://${path.resolve(filePath)}`; + this._url = url; + this._isLoading = true; + this.emit('did-start-loading'); + if (isNative) { + bridge.loadFile(this.id, filePath); + } + setTimeout(() => { + this._isLoading = false; + this.emit('did-stop-loading'); + this.emit('did-finish-load'); + this.emit('dom-ready'); + }, isNative ? 500 : 100); + return Promise.resolve(); } reload() { this._isLoading = true; this.emit('did-start-loading'); + if (isNative && this._url) { + bridge.loadUrl(this.id, this._url); + } setTimeout(() => { this._isLoading = false; this.emit('did-stop-loading'); @@ -82,6 +102,9 @@ class WebContents extends EventEmitter { goForward() {} executeJavaScript(code, userGesture = true) { + if (isNative) { + bridge.evalJs(this.id, code); + } return Promise.resolve(null); } @@ -89,7 +112,11 @@ class WebContents extends EventEmitter { insertJS(code, hasUserGesture = true) { return this.executeJavaScript(code, hasUserGesture); } send(channel, ...args) { - console.log(`[gelectron] webContents.send('${channel}')`); + if (isNative) { + bridge.sendToRenderer(this.id, channel, ...args); + } else { + console.log(`[gelectron] webContents.send('${channel}')`); + } } sendInputEvent() {} @@ -178,6 +205,18 @@ class BrowserWindow extends EventEmitter { BrowserWindow._windows.set(this.id, this); + if (isNative) { + bridge.createWindow(this.id, { + width: this._options.width, + height: this._options.height, + title: this._options.title, + show: this._options.show, + resizable: this._options.resizable, + alwaysOnTop: this._options.alwaysOnTop, + fullscreen: this._options.fullscreen, + }); + } + if (this._options.show) { process.nextTick(() => { if (!this._isDestroyed) { @@ -209,23 +248,28 @@ class BrowserWindow extends EventEmitter { loadURL(targetUrl) { this._url = targetUrl; + if (isNative) { + bridge.loadUrl(this.id, targetUrl); + } return this.webContents.loadURL(targetUrl); } loadFile(filePath) { - return this.loadURL(`file://${path.resolve(filePath)}`); + return this.webContents.loadFile(filePath); } show() { if (this._isDestroyed) return; this._isVisible = true; this._isMinimized = false; + if (isNative) bridge.showWindow(this.id); this.emit('show'); } hide() { if (this._isDestroyed) return; this._isVisible = false; + if (isNative) bridge.hideWindow(this.id); this.emit('hide'); } @@ -238,15 +282,16 @@ class BrowserWindow extends EventEmitter { destroy() { if (this._isDestroyed) return; this._isDestroyed = true; + if (isNative) bridge.destroyWindow(this.id); BrowserWindow._windows.delete(this.id); this.emit('closed'); } - focus() { if (!this._isDestroyed) this.emit('focus'); } + focus() { if (!this._isDestroyed) { if (isNative) bridge.focusWindow(this.id); this.emit('focus'); } } blur() {} - minimize() { if (!this._isDestroyed) { this._isMinimized = true; this.emit('minimize'); } } - maximize() { if (!this._isDestroyed) { this._isMaximized = true; this.emit('maximize'); } } + minimize() { if (!this._isDestroyed) { this._isMinimized = true; if (isNative) bridge.minimizeWindow(this.id); this.emit('minimize'); } } + maximize() { if (!this._isDestroyed) { this._isMaximized = true; if (isNative) bridge.maximizeWindow(this.id); this.emit('maximize'); } } unmaximize() { if (!this._isDestroyed) { this._isMaximized = false; this.emit('unmaximize'); } } restore() { if (!this._isDestroyed) { this._isMinimized = false; this._isMaximized = false; this.emit('restore'); } } @@ -282,7 +327,7 @@ class BrowserWindow extends EventEmitter { setClosable(v) { this._options.closable = v; } isClosable() { return this._options.closable; } - setTitle(title) { this._options.title = title; this.webContents._title = title; } + setTitle(title) { this._options.title = title; this.webContents._title = title; if (isNative) bridge.setTitle(this.id, title); } getTitle() { return this._options.title; } setSkipTaskbar() {} diff --git a/src/electron/index.js b/src/electron/index.js index 3e2cea0..47e8f56 100644 --- a/src/electron/index.js +++ b/src/electron/index.js @@ -18,6 +18,14 @@ const safeStorage = require('./safe-storage'); const contextBridge = require('./context-bridge'); const webContents = require('./web-contents'); const { autoUpdater, AutoUpdater } = require('./auto-updater'); +const { bridge, isNative } = require('./native-bridge'); + +if (isNative) { + bridge.on('ipc-message', (windowId, channel, data) => { + const event = { sender: { id: windowId }, channel }; + ipcMain._emit(channel, event, data); + }); +} // Session stub (electron-updater calls session.fromPartition) const sessionStub = { diff --git a/src/electron/native-bridge.js b/src/electron/native-bridge.js new file mode 100644 index 0000000..4a1a61e --- /dev/null +++ b/src/electron/native-bridge.js @@ -0,0 +1,142 @@ +'use strict'; + +/** + * Gelectron Native Bridge - IPC between Node.js and the Rust binary. + * When GELECTRON_NATIVE=1, communicates via stdin/stdout JSON lines. + */ + +const readline = require('readline'); +const { EventEmitter } = require('events'); + +const isNative = process.env.GELECTRON_NATIVE === '1'; + +class NativeBridge extends EventEmitter { + constructor() { + super(); + this._ready = false; + this._readyCallbacks = []; + this._windowListeners = new Map(); + + if (isNative) { + this._setupStdio(); + } + } + + _setupStdio() { + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('{')) return; + try { + const msg = JSON.parse(trimmed); + this._handleMessage(msg); + } catch (e) { + // Not IPC, ignore + } + }); + + process.stdout.on('error', () => {}); + } + + _handleMessage(msg) { + switch (msg.type) { + case 'ready': + this._ready = true; + for (const cb of this._readyCallbacks) cb(); + this._readyCallbacks = []; + this.emit('ready'); + break; + case 'window-closed': + this.emit('window-closed', msg.id); + break; + case 'window-focus': + this.emit('window-focus', msg.id); + break; + case 'ipc-message': + this.emit('ipc-message', msg.id, msg.channel, msg.data); + break; + } + } + + _send(msg) { + if (!isNative) return; + try { + process.stdout.write(JSON.stringify(msg) + '\n'); + } catch (e) { + // stdout may be closed + } + } + + onReady(cb) { + if (this._ready) { + cb(); + } else { + this._readyCallbacks.push(cb); + } + } + + createWindow(id, options) { + this._send({ type: 'create-window', id, options }); + } + + loadUrl(id, url) { + this._send({ type: 'load-url', id, url }); + } + + loadFile(id, filePath) { + this._send({ type: 'load-file', id, path: filePath }); + } + + destroyWindow(id) { + this._send({ type: 'destroy-window', id }); + } + + setTitle(id, title) { + this._send({ type: 'set-title', id, title }); + } + + setSize(id, width, height) { + this._send({ type: 'set-size', id, width, height }); + } + + showWindow(id) { + this._send({ type: 'show', id }); + } + + hideWindow(id) { + this._send({ type: 'hide', id }); + } + + focusWindow(id) { + this._send({ type: 'focus', id }); + } + + minimizeWindow(id) { + this._send({ type: 'minimize', id }); + } + + maximizeWindow(id) { + this._send({ type: 'maximize', id }); + } + + closeWindow(id) { + this._send({ type: 'close', id }); + } + + sendToRenderer(id, channel, ...data) { + this._send({ type: 'ipc-message', id, channel, data: data.length === 1 ? data[0] : data }); + } + + evalJs(id, script) { + this._send({ type: 'eval-js', id, script }); + } + + quit() { + this._send({ type: 'quit' }); + } +} + +const bridge = new NativeBridge(); + +module.exports = { bridge, isNative }; diff --git a/src/electron/runtime.js b/src/electron/runtime.js index 1f68f4c..81c0472 100644 --- a/src/electron/runtime.js +++ b/src/electron/runtime.js @@ -14,24 +14,53 @@ function run(mainScript, env) { // Patch require so that 'electron' resolves to gelectron's compat layer const electronCompatPath = path.join(__dirname, 'index.js'); + const ipcRendererPath = path.join(__dirname, 'ipc-renderer.js'); + + // Purge any cached 'electron' module from the real npm package so that + // our _resolveFilename patch takes absolute priority. The real 'electron' + // npm package (devDependency in the target app) exports a *path string* + // to the Electron binary – it does NOT have .autoUpdater, .app, etc. + for (const key of Object.keys(Module._cache)) { + const normalized = key.replace(/\\/g, '/'); + if ( + normalized.endsWith('/electron') || + normalized.endsWith('/electron/index.js') || + normalized.endsWith('/electron/index.cjs') + ) { + delete Module._cache[key]; + } + } const originalResolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, parent, isMain, options) { - if (request === 'electron') { - return electronCompatPath; - } - if (request === 'electron/main') { + if (request === 'electron' || request === 'electron/main' || request === 'electron/common') { return electronCompatPath; } if (request === 'electron/renderer') { - return path.join(__dirname, 'ipc-renderer.js'); - } - if (request === 'electron/common') { - return electronCompatPath; + return ipcRendererPath; } return originalResolveFilename.call(this, request, parent, isMain, options); }; + // Also monkey-patch Module._resolveRequest for Node ≥ 22 where it may + // be used internally instead of _resolveFilename. + if (typeof Module._resolveRequest === 'function') { + const originalResolveRequest = Module._resolveRequest; + Module._resolveRequest = function (request, parent, isMain, options) { + if (request === 'electron' || request === 'electron/main' || request === 'electron/common') { + return electronCompatPath; + } + if (request === 'electron/renderer') { + return ipcRendererPath; + } + return originalResolveRequest.call(this, request, parent, isMain, options); + }; + } + + // Pre-load the gelectron shim into the module cache so that every + // subsequent require('electron') hits cache immediately. + require(electronCompatPath); + // Require the app's main script try { require(mainScript);