This commit is contained in:
2026-07-26 18:30:07 -05:00
parent d4620e5e8e
commit cedb606500
15 changed files with 1114 additions and 29 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/gelectron-core"]
members = ["crates/gelectron-core", "crates/gelectron-app"]
resolver = "2"
[workspace.dependencies]
+5 -5
View File
@@ -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);
+19
View File
@@ -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"
+468
View File
@@ -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<u32>,
#[serde(default)]
height: Option<u32>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
show: Option<bool>,
#[serde(default)]
resizable: Option<bool>,
#[serde(default)]
always_on_top: Option<bool>,
#[serde(default)]
fullscreen: Option<bool>,
}
struct WindowPair {
#[allow(dead_code)]
window: tao::window::Window,
webview: WebView,
}
struct AppState {
windows: HashMap<u32, WindowPair>,
window_wids: HashMap<WindowId, u32>,
node_stdin: Option<std::process::ChildStdin>,
node_exited: Arc<AtomicBool>,
}
impl AppState {
fn new(node_exited: Arc<AtomicBool>) -> 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<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: gelectron <path-to-app>");
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::<ToRust>();
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::<ToRust>(&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<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()); }
}
None
})
}
+295
View File
@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gelectron Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #0f0f1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-bottom: 1px solid #2a2a4a;
padding: 24px 32px;
}
header h1 {
font-size: 28px;
font-weight: 700;
background: linear-gradient(90deg, #7b68ee, #00d4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
header p {
margin-top: 6px;
color: #8888aa;
font-size: 14px;
}
main {
flex: 1;
padding: 32px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-content: start;
}
.card {
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 12px;
padding: 24px;
}
.card h2 {
font-size: 16px;
font-weight: 600;
color: #7b68ee;
margin-bottom: 12px;
}
.card p, .card li {
font-size: 14px;
line-height: 1.7;
color: #b0b0cc;
}
.card ul {
list-style: none;
padding: 0;
}
.card ul li::before {
content: '\2713';
color: #00d4ff;
margin-right: 8px;
font-weight: bold;
}
.counter {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
}
.counter button {
background: #2a2a4a;
color: #e0e0e0;
border: 1px solid #3a3a6a;
border-radius: 8px;
padding: 10px 20px;
font-size: 18px;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.counter button:hover { background: #3a3a6a; }
.counter button:active { transform: scale(0.95); }
.counter .value {
font-size: 36px;
font-weight: 700;
color: #00d4ff;
min-width: 60px;
text-align: center;
}
.canvas-wrap {
margin-top: 12px;
}
canvas {
background: #12121f;
border: 1px solid #2a2a4a;
border-radius: 8px;
display: block;
width: 100%;
height: 200px;
}
.color-bar {
display: flex;
gap: 8px;
margin-top: 12px;
}
.color-bar span {
flex: 1;
height: 32px;
border-radius: 6px;
}
#clock {
font-size: 32px;
font-weight: 300;
color: #00d4ff;
font-variant-numeric: tabular-nums;
margin-top: 4px;
}
.env-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.env-table td {
padding: 6px 0;
font-size: 13px;
border-bottom: 1px solid #1f1f35;
}
.env-table td:first-child {
color: #7b68ee;
font-weight: 600;
width: 40%;
}
.env-table td:last-child {
color: #b0b0cc;
font-family: 'SF Mono', Menlo, monospace;
}
footer {
padding: 16px 32px;
border-top: 1px solid #2a2a4a;
text-align: center;
font-size: 12px;
color: #555577;
}
</style>
</head>
<body>
<header>
<h1>Gelectron Demo</h1>
<p>HTML + CSS + JavaScript rendering via Gecko / Servo engine</p>
</header>
<main>
<div class="card">
<h2>Interactive Counter</h2>
<p>Proof that JavaScript runs and DOM updates work.</p>
<div class="counter">
<button onclick="dec()">\u2212</button>
<div class="value" id="count">0</div>
<button onclick="inc()">+</button>
</div>
</div>
<div class="card">
<h2>Live Clock</h2>
<p>requestAnimationFrame-driven rendering.</p>
<div id="clock">--:--:--</div>
</div>
<div class="card">
<h2>Canvas 2D</h2>
<p>Animated canvas with moving shapes.</p>
<div class="canvas-wrap">
<canvas id="scene" width="400" height="200"></canvas>
</div>
</div>
<div class="card">
<h2>Runtime Info</h2>
<p>Environment details from the gelectron shim.</p>
<table class="env-table">
<tr><td>Platform</td><td id="env-platform"></td></tr>
<tr><td>Arch</td><td id="env-arch"></td></tr>
<tr><td>Node.js</td><td id="env-node"></td></tr>
<tr><td>User Agent</td><td id="env-ua"></td></tr>
</table>
</div>
<div class="card" style="grid-column: 1 / -1;">
<h2>CSS Features</h2>
<p>Grid, gradients, border-radius, transitions, flexbox, backdrop styling.</p>
<div class="color-bar">
<span style="background: #7b68ee;"></span>
<span style="background: #00d4ff;"></span>
<span style="background: #ff6b9d;"></span>
<span style="background: #ffd93d;"></span>
<span style="background: #6bcb77;"></span>
<span style="background: #ee5a24;"></span>
</div>
</div>
</main>
<footer>Gelectron v0.1.0 &mdash; Electron API compatibility layer powered by Gecko/Servo</footer>
<script>
// Counter
var count = 0;
var countEl = document.getElementById('count');
function inc() { countEl.textContent = ++count; }
function dec() { countEl.textContent = --count; }
// Live clock
function tick() {
var now = new Date();
var h = String(now.getHours()).padStart(2, '0');
var m = String(now.getMinutes()).padStart(2, '0');
var s = String(now.getSeconds()).padStart(2, '0');
document.getElementById('clock').textContent = h + ':' + m + ':' + s;
requestAnimationFrame(tick);
}
tick();
// Canvas animation
var canvas = document.getElementById('scene');
var ctx = canvas.getContext('2d');
var t = 0;
function drawFrame() {
t += 0.02;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// bouncing ball
var bx = 50 + Math.sin(t) * 150;
var by = 100 + Math.cos(t * 1.3) * 60;
ctx.beginPath();
ctx.arc(bx, by, 18, 0, Math.PI * 2);
ctx.fillStyle = '#7b68ee';
ctx.fill();
// second ball
var bx2 = 200 + Math.cos(t * 0.7) * 120;
var by2 = 80 + Math.sin(t * 1.1) * 70;
ctx.beginPath();
ctx.arc(bx2, by2, 14, 0, Math.PI * 2);
ctx.fillStyle = '#00d4ff';
ctx.fill();
// trailing line
ctx.beginPath();
ctx.moveTo(0, 100 + Math.sin(t) * 40);
for (var x = 0; x < canvas.width; x += 4) {
ctx.lineTo(x, 100 + Math.sin(t + x * 0.03) * 40);
}
ctx.strokeStyle = 'rgba(123,104,238,0.3)';
ctx.lineWidth = 2;
ctx.stroke();
requestAnimationFrame(drawFrame);
}
drawFrame();
// Runtime info
document.getElementById('env-platform').textContent = navigator.platform || 'unknown';
document.getElementById('env-arch').textContent = 'unknown (browser sandbox)';
document.getElementById('env-node').textContent = 'N/A in renderer';
document.getElementById('env-ua').textContent = navigator.userAgent;
</script>
</body>
</html>
+44
View File
@@ -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();
});
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "gelectron-demo",
"version": "1.0.0",
"description": "Minimal demo for gelectron — HTML, CSS, JavaScript rendering",
"main": "main.js"
}
+7 -3
View File
@@ -6,7 +6,11 @@
"files": [
"gelectron_core.darwin-arm64.node"
],
"os": ["darwin"],
"cpu": ["arm64"],
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"license": "MIT"
}
}
+6 -1
View File
@@ -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"
}
}
+9
View File
@@ -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() {
+14 -3
View File
@@ -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 {
+53 -8
View File
@@ -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() {}
+8
View File
@@ -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 = {
+142
View File
@@ -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 };
+37 -8
View File
@@ -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);