feat: add macOS application menu and icon support, enhance permissions for microphone and camera

This commit is contained in:
2026-08-04 00:49:50 -05:00
parent 075a051f74
commit bbdcbd1e7f
6 changed files with 542 additions and 28 deletions
+235
View File
@@ -0,0 +1,235 @@
# Contributing to Gelectron
Thanks for your interest in Gelectron! This guide covers how the project is put
together and how to contribute changes that build, pass tests, and don't regress
the app you might be targeting (e.g. `vanilla-sh`, the reference consumer).
## What Gelectron is
Gelectron is a drop-in replacement for Electron that uses the OS-native web view
(WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux) instead of
bundling Chromium. It has two halves that talk to each other:
- **`gelectron-app`** — a standalone Rust binary built on **tao** (windowing) and
**wry** (WebView). It owns windows, the native menu bar, dialogs, clipboard,
and the dock/taskbar icon.
- **`src/electron/`** — a JavaScript compatibility layer that implements the
Electron API surface (`app`, `BrowserWindow`, `Menu`, `ipcMain`, …). Your
Electron app's `main.js` imports `electron`, which resolves to this layer.
The two halves communicate over **JSON-line IPC**. The Rust side spawns Node.js
as a child process (or runs the JS layer inside the WebView in `--no-node` mode)
and both sides exchange messages like `create-window`, `load-url`,
`ipc-message`, and `set-application-menu`.
Read `README.md` for the full architecture diagram and supported-API table.
## Repository layout
```
gelectron/
├── Cargo.toml # Rust workspace (patches wry → vendor/wry)
├── crates/
│ ├── gelectron-app/ # ★ The main native binary (tao + wry)
│ │ └── src/main.rs # event loop, IPC, menus, icons, dialogs
│ └── gelectron-core/ # Legacy N-API addon (not the primary path)
├── src/electron/ # JS Electron compatibility layer
├── vendor/wry/ # Vendored wry (patched via [patch.crates-io])
├── packager/bin/gelectron-packager.js # Bundles apps into distributables
├── cli/gelectron.js # Pure-Node.js fallback runner
├── demo/ # Minimal reference app
├── benchmark/ # Memory/perf comparison vs Electron
└── scripts/install.sh # Installs binary + compat layer
```
Most day-to-day work happens in **`crates/gelectron-app/src/main.rs`** (native
behavior) and **`src/electron/`** (JS API behavior). `main.rs` is ~1900 lines;
it's the file where menu, icon, window, and IPC logic lives.
## Prerequisites
- **Rust 1.75+** — `rustup.rs`
- **Node.js 18+** and npm
- macOS builds also want Xcode command-line tools (`xcode-select --install`)
## First-time setup
```bash
git clone https://github.com/mileswolfallen2/gelectron.git
cd gelectron
npm install
```
## Development loop
The most common workflow is to build the binary, then run an app against it:
```bash
# Build the native binary
cargo build --release -p gelectron
# Run the bundled demo
cargo run --release -p gelectron -- demo/
# Run a real Electron app against your build
cargo run --release -p gelectron -- /path/to/your-app
```
If you're iterating on the JS compat layer only, `scripts/install.sh` installs
both the binary and `src/electron/` so `gelectron <app>` works from anywhere:
```bash
./scripts/install.sh
```
### Environment variables
| Variable | Use |
|---|---|
| `RUST_LOG=info` | Rust-side logging (`gelectron` crate) |
| `GELECTRON_LOG=1` | Verbose JS-side logging |
| `GELECTRON_DEV=1` | Development mode |
| `VITE_DEV_SERVER_URL=<url>` | Point the app at a Vite dev server |
## Testing
Run the Rust unit tests:
```bash
cargo test -p gelectron
```
The suite covers image/icon decoders (BMP-in-ICO, PNG-in-ICO, PNG round-trip).
Keep these green — they're fast and catch byte-layout regressions in icon
handling.
There is no end-to-end test harness yet. Manual verification looks like:
```bash
# 1. Build
cargo build --release -p gelectron
# 2. Package the reference app and launch it
# (from the vanilla-sh checkout)
npm run build
open dist/VanillaChat.app
# 3. Sanity-check the pieces you touched, e.g.:
# - Menu bar: Apple, AppName, File, Edit, View, Window, Help
# - Edit menu: Undo/Redo/Cut/Copy/Paste/Select All with Cmd key equivalents
# - Dock icon renders without distortion
# - Cmd+C / Cmd+V work inside the webview
```
To verify menu behavior programmatically (macOS), you can query System Events:
```bash
osascript -e 'tell application "System Events" to tell process "<AppName>" \
to get name of every menu bar item of menu bar 1'
```
## Rules of the road
These rules exist because each one corresponds to a bug that has actually
happened. Please respect them.
### 1. Native menus must stay alive after `init_for_nsapp()`
On macOS, AppKit stores **raw pointers** into the `muda` menu structures as
instance variables on the native `NSMenuItem`s. If you drop the `muda::Menu`
after `init_for_nsapp()`, clicking any menu item dereferences freed memory →
random, non-deterministic crashes.
- Keep the built menu in `AppState.app_menu` for the app's lifetime.
- The default menu (`install_default_app_menu`) must be stored too, not just the
one from `SetApplicationMenu`.
### 2. Menu submenus come in two shapes
The compat layer serializes a submenu either as a bare array **or** as an object
with an `items` array (`{"items": [...]}`). Always normalize with
`submenu_items()` before iterating, and treat an item as a submenu when
`type == "submenu"` **or** a `submenu` field is present. The vanilla-sh menu
template relies on the second form, and the app's own menu builder relies on the
first.
### 3. Icons: normalize 16-bit PNGs
`logo.png`-style source icons are often 16-bit RGBA. Two code paths must handle
this:
- Rust: `decode_png_icon` must set
`Transformations::EXPAND | STRIP_16` on the decoder so downstream code can
assume 8-bit RGBA bytes. Treating a 16-bit buffer as 8-bit mangles the icon.
- Packager: `generateIcns` downconverts the source to 8-bit (via `sips` or PIL)
before building the `.icns`, since `sips` preserves 16-bit at 1024px and macOS
renders that poorly.
### 4. macOS APIs require the main thread
Menu construction, `setApplicationIconImage`, and window operations all need the
main/event-loop thread. Don't call them from background threads.
### 5. Don't add comments unless they earn their place
The codebase is deliberately comment-light. A comment should explain *why*
something is non-obvious (e.g. "AppKit stores raw pointers here") rather than
restating what the code does.
### 6. Keep the wry patch minimal
`wry` is vendored and patched via `[patch.crates-io]` in `Cargo.toml`. Only
change `vendor/wry/` when you genuinely need a WebView-level behavior change
(e.g. media permissions). Prefer fixing things in `gelectron-app` or the JS
layer.
## Code style
- **Rust:** follow `rustfmt` defaults. Run `cargo fmt` before committing.
- **JS:** CommonJS, 2-space indent, `'use strict'` in CLI scripts.
- Match the surrounding code's conventions — this project has no linter config,
so consistency is on you.
## Packaging changes
If your change touches distribution, rebuild and package a real app to verify:
```bash
# From packager/ (or via npm link)
gelectron-packager --dir ./my-app --name MyApp
# macOS: verify the bundle's icon and signature
file dist/MyApp.app/Contents/Resources/AppIcon.icns
codesign --verify --deep --strict dist/MyApp.app
```
The packager: copies `node_modules` into `Resources/app/`, generates `AppIcon.icns`
from `icon.png`, writes `Info.plist` with the icon + mic/camera usage strings, and
re-signs the bundle with the entitlements. If any of that seems off, check
`packager/bin/gelectron-packager.js` and `vanilla-sh/scripts/build-desktop.sh`.
## Cross-platform notes
- **macOS** is the primary dev platform and the most battle-tested path.
- **Windows** builds target GNU (`x86_64-pc-windows-gnu`). MSVC cross-compiles
from macOS are blocked by a missing `link.exe` — use the GNU toolchain.
- **Linux** needs WebKitGTK dev packages; cross-compiling from macOS is
unconfigured.
- Platform-specific behavior should be `#[cfg(target_os = "...")]` in Rust and
`process.platform` checks in JS (the compat layer already exposes
`isMac`/`isWin` style helpers in places).
## Opening a PR
1. Fork and create a feature branch.
2. Make your changes, keeping the "Rules of the road" in mind.
3. Run `cargo fmt`, `cargo build --release -p gelectron`, and `cargo test -p gelectron`.
4. Verify against a real app (`cargo run --release -p gelectron -- /path/to/app`).
5. Open the PR with a short description of what changed and why, and note how you
verified it.
## Getting help
- Open an issue at https://github.com/mileswolfallen2/gelectron/issues
- The `vanilla-sh` checkout (`/Users/milesallen/Documents/GitHub/vanilla-sh`)
is the reference consumer — changes that regress it are treated as bugs.
+180 -16
View File
@@ -166,6 +166,7 @@ struct AppState {
bundle_js: Option<String>,
response_tx: Option<mpsc::Sender<(String, serde_json::Value)>>,
app_icon: Option<DecodedIcon>,
app_menu: Option<muda::Menu>,
}
#[derive(Clone)]
@@ -186,6 +187,7 @@ impl AppState {
bundle_js: None,
response_tx: None,
app_icon: None,
app_menu: None,
}
}
@@ -423,6 +425,11 @@ require('{}');
detect_and_apply_icon(&app_path, &mut state);
let state = Rc::new(RefCell::new(state));
state.borrow_mut().send_to_node(&ToNode::Ready);
#[cfg(target_os = "macos")]
{
state.borrow_mut().app_menu =
install_default_app_menu(&default_app_name(&app_path));
}
event_loop.run(move |event, event_loop_target, control_flow| {
*control_flow = ControlFlow::Poll;
@@ -520,6 +527,11 @@ window.__gelectron_run_main(`{}`);
app_state.response_tx = Some(response_tx);
detect_and_apply_icon(&app_path, &mut app_state);
let state = Rc::new(RefCell::new(app_state));
#[cfg(target_os = "macos")]
{
state.borrow_mut().app_menu =
install_default_app_menu(&default_app_name(&app_path));
}
event_loop.run(move |event, event_loop_target, control_flow| {
*control_flow = ControlFlow::Poll;
@@ -673,6 +685,58 @@ fn build_muda_menu(
Some(menu)
}
fn menu_role_predefined(role: &str) -> Option<muda::PredefinedMenuItem> {
use muda::PredefinedMenuItem as P;
match role {
"copy" => Some(P::copy(None)),
"cut" => Some(P::cut(None)),
"paste" => Some(P::paste(None)),
"selectAll" => Some(P::select_all(None)),
"undo" => Some(P::undo(None)),
"redo" => Some(P::redo(None)),
"minimize" => Some(P::minimize(None)),
"zoom" | "maximize" => Some(P::maximize(None)),
"togglefullscreen" | "fullscreen" => Some(P::fullscreen(None)),
"hide" => Some(P::hide(None)),
"hideOthers" => Some(P::hide_others(None)),
"unhide" => Some(P::show_all(None)),
"close" => Some(P::close_window(None)),
"quit" => Some(P::quit(None)),
"front" => Some(P::bring_all_to_front(None)),
"services" => Some(P::services(None)),
_ => None,
}
}
fn menu_item_from_json(item: &serde_json::Value) -> Option<muda::MenuItem> {
use std::str::FromStr;
let label = item.get("label").and_then(|v| v.as_str()).unwrap_or("");
let enabled = item.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let accelerator = item.get("accelerator").and_then(|v| v.as_str()).unwrap_or("");
let mi = muda::MenuItem::new(label, true, None);
if !enabled {
mi.set_enabled(false);
}
if !accelerator.is_empty() {
if let Ok(accel) = muda::accelerator::Accelerator::from_str(accelerator) {
let _ = mi.set_accelerator(Some(accel));
}
}
Some(mi)
}
// The compat layer serializes a submenu either as a bare array or as an
// object with an `items` array. Normalize to the array form.
fn submenu_items(value: &serde_json::Value) -> Option<&serde_json::Value> {
if value.as_array().is_some() {
Some(value)
} else {
value
.get("items")
.filter(|items| items.as_array().is_some())
}
}
fn build_menu_items_inner(parent: &muda::Menu, items: &serde_json::Value) {
let items_arr = match items.as_array() {
Some(a) => a,
@@ -682,26 +746,33 @@ fn build_menu_items_inner(parent: &muda::Menu, items: &serde_json::Value) {
for item in items_arr {
let label = item.get("label").and_then(|v| v.as_str()).unwrap_or("");
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("normal");
let enabled = item.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("");
if item_type == "separator" {
let _ = parent.append(&muda::PredefinedMenuItem::separator());
continue;
}
if item_type == "submenu" {
if !role.is_empty() {
if let Some(predefined) = menu_role_predefined(role) {
let _ = parent.append(&predefined);
continue;
}
// Fall through for roles without a muda predefined equivalent
}
if item_type == "submenu" || item.get("submenu").is_some() {
let submenu = muda::Submenu::new(label, true);
if let Some(sub_items) = item.get("submenu") {
if let Some(sub_items) = item.get("submenu").and_then(submenu_items) {
if let Some(sub_arr) = sub_items.as_array() {
for sub_item in sub_arr {
let sub_label = sub_item.get("label").and_then(|v| v.as_str()).unwrap_or("");
let sub_type = sub_item.get("type").and_then(|v| v.as_str()).unwrap_or("normal");
let sub_enabled = sub_item.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let sub_role = sub_item.get("role").and_then(|v| v.as_str()).unwrap_or("");
if sub_type == "separator" {
let _ = submenu.append(&muda::PredefinedMenuItem::separator());
} else {
let mi = muda::MenuItem::new(sub_label, true, None);
if !sub_enabled { mi.set_enabled(false); }
} else if let Some(predefined) = menu_role_predefined(sub_role) {
let _ = submenu.append(&predefined);
} else if let Some(mi) = menu_item_from_json(sub_item) {
let _ = submenu.append(&mi);
}
}
@@ -711,14 +782,96 @@ fn build_menu_items_inner(parent: &muda::Menu, items: &serde_json::Value) {
continue;
}
let menu_item = muda::MenuItem::new(label, true, None);
if !enabled {
menu_item.set_enabled(false);
if let Some(mi) = menu_item_from_json(item) {
let _ = parent.append(&mi);
}
let _ = parent.append(&menu_item);
}
}
fn menu_items_have_edit_roles(items: &serde_json::Value) -> bool {
let arr = match items.as_array() {
Some(a) => a,
None => return false,
};
for item in arr {
if let Some(role) = item.get("role").and_then(|v| v.as_str()) {
if matches!(role, "copy" | "paste" | "cut" | "undo" | "redo" | "selectAll") {
return true;
}
}
if let Some(sub) = item.get("submenu").and_then(submenu_items) {
if menu_items_have_edit_roles(sub) {
return true;
}
}
}
false
}
fn build_edit_submenu() -> muda::Submenu {
use muda::PredefinedMenuItem as P;
let edit = muda::Submenu::new("Edit", true);
let _ = edit.append(&P::undo(None));
let _ = edit.append(&P::redo(None));
let _ = edit.append(&P::separator());
let _ = edit.append(&P::cut(None));
let _ = edit.append(&P::copy(None));
let _ = edit.append(&P::paste(None));
let _ = edit.append(&P::select_all(None));
edit
}
fn build_application_menu(menu_json: &serde_json::Value) -> Option<muda::Menu> {
let menu = match build_muda_menu(menu_json) {
Some(m) => m,
None => return None,
};
let has_edit = menu_json
.get("items")
.map(menu_items_have_edit_roles)
.unwrap_or(false);
if !has_edit {
let _ = menu.append(&build_edit_submenu());
}
Some(menu)
}
#[cfg(target_os = "macos")]
fn default_app_name(app_path: &std::path::Path) -> String {
app_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Gelectron".to_string())
}
#[cfg(target_os = "macos")]
fn install_default_app_menu(app_name: &str) -> Option<muda::Menu> {
use muda::PredefinedMenuItem as P;
let menu = muda::Menu::new();
let app_sub = muda::Submenu::new(app_name, true);
let _ = app_sub.append(&P::about(None, None));
let _ = app_sub.append(&P::separator());
let _ = app_sub.append(&P::hide(None));
let _ = app_sub.append(&P::hide_others(None));
let _ = app_sub.append(&P::show_all(None));
let _ = app_sub.append(&P::separator());
let _ = app_sub.append(&P::quit(None));
let _ = menu.append(&app_sub);
let _ = menu.append(&build_edit_submenu());
let window_sub = muda::Submenu::new("Window", true);
let _ = window_sub.append(&P::minimize(None));
let _ = window_sub.append(&P::maximize(None));
let _ = window_sub.append(&P::close_window(None));
let _ = menu.append(&window_sub);
menu.init_for_nsapp();
log::info!("Default application menu installed (universal clipboard support)");
Some(menu)
}
fn detect_dark_mode() -> bool {
#[cfg(target_os = "macos")]
{
@@ -750,7 +903,12 @@ fn detect_dark_mode() -> bool {
fn decode_png_icon(png_bytes: &[u8]) -> Option<DecodedIcon> {
use std::io::Cursor;
let cursor = Cursor::new(png_bytes);
let decoder = png::Decoder::new(cursor);
let mut decoder = png::Decoder::new(cursor);
// Normalize 16-bit channels to 8-bit and expand indexed/grayscale to RGBA
// so downstream code can assume 8-bit RGBA bytes.
decoder.set_transformations(
png::Transformations::EXPAND | png::Transformations::STRIP_16,
);
let mut reader = decoder.read_info().ok()?;
let info = reader.info().clone();
let width = info.width;
@@ -975,6 +1133,7 @@ fn apply_dock_icon(icon: &DecodedIcon) {
}
}
#[cfg(target_os = "macos")]
fn rgba_to_png(rgba: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
use std::io::Cursor;
let mut out = Cursor::new(Vec::new());
@@ -1222,20 +1381,24 @@ fn handle_to_rust(
}
ToRust::SetApplicationMenu { menu } => {
log::info!("Setting application menu");
if let Some(muda_menu) = build_muda_menu(&menu) {
if let Some(muda_menu) = build_application_menu(&menu) {
#[cfg(target_os = "macos")]
{
muda_menu.init_for_nsapp();
}
// Keep the menu alive for the lifetime of the app: AppKit stores
// raw pointers into the muda menu items, so dropping it here would
// leave dangling pointers that crash when items are activated.
st.app_menu = Some(muda_menu);
log::info!("Application menu set");
}
}
ToRust::PopupMenu { menu, x: _x, y: _y } => {
log::info!("Popup menu requested");
if let Some(muda_menu) = build_muda_menu(&menu) {
if let Some(_muda_menu) = build_muda_menu(&menu) {
#[cfg(target_os = "macos")]
{
muda_menu.init_for_nsapp();
_muda_menu.init_for_nsapp();
}
}
}
@@ -1716,6 +1879,7 @@ mod tests {
assert_eq!(&icon.rgba[..4], &[10, 20, 30, 255]);
}
#[cfg(target_os = "macos")]
#[test]
fn rgba_to_png_round_trip() {
let rgba: Vec<u8> = (0..(4 * 4 * 4)).map(|i| (i * 7 % 256) as u8).collect();
+108 -11
View File
@@ -155,12 +155,17 @@ function copyDirSync(src, dest, exclude) {
}
}
function generateInfoPlist(name, version, exeName) {
function generateInfoPlist(name, version, exeName, iconName) {
const iconKey = iconName
? ` <key>CFBundleIconFile</key>
<string>${iconName}</string>
`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
${iconKey} <key>CFBundleDisplayName</key>
<string>${name}</string>
<key>CFBundleExecutable</key>
<string>${exeName}</string>
@@ -178,10 +183,82 @@ function generateInfoPlist(name, version, exeName) {
<true/>
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>NSMicrophoneUsageDescription</key>
<string>${name} needs microphone access for voice input and audio recording.</string>
<key>NSCameraUsageDescription</key>
<string>${name} needs camera access for video capture and screenshots.</string>
</dict>
</plist>`;
}
function generateEntitlements() {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>`;
}
// Convert a PNG into a .icns using macOS built-in tools (sips + iconutil).
// Returns the path to the generated .icns, or null on failure.
function generateIcns(pngPath, outPath) {
if (process.platform !== 'darwin') return null;
const iconsetDir = outPath + '.iconset';
fs.rmSync(iconsetDir, { recursive: true, force: true });
fs.mkdirSync(iconsetDir, { recursive: true });
// sips keeps 16-bit depth on large PNGs, which macOS icon rendering handles
// poorly (messed-up logos). Downconvert to 8-bit first when possible.
let source = pngPath;
const tmp8bit = outPath + '.8bit.png';
try {
execSync(
`python3 -c "from PIL import Image; im=Image.open('${pngPath}').convert('RGBA'); im.save('${tmp8bit}')"`,
{ stdio: 'pipe' },
);
source = tmp8bit;
} catch (e) {
try {
fs.rmSync(tmp8bit, { force: true });
} catch (_) {}
}
const sizes = [
['icon_16x16.png', 16],
['[email protected]', 32],
['icon_32x32.png', 32],
['[email protected]', 64],
['icon_128x128.png', 128],
['[email protected]', 256],
['icon_256x256.png', 256],
['[email protected]', 512],
['icon_512x512.png', 512],
['[email protected]', 1024],
];
try {
for (const [name, size] of sizes) {
execSync(`sips -z ${size} ${size} "${source}" --out "${path.join(iconsetDir, name)}"`, {
stdio: 'pipe',
});
}
execSync(`iconutil -c icns "${iconsetDir}" -o "${outPath}"`, { stdio: 'pipe' });
return outPath;
} catch (e) {
return null;
} finally {
fs.rmSync(iconsetDir, { recursive: true, force: true });
try {
fs.rmSync(tmp8bit, { force: true });
} catch (_) {}
}
}
function generateWrapperScript(exeName, nodePath) {
return `#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -275,11 +352,13 @@ async function packageMac(appDir, outDir, name, version, gelectronBin, nodeDir,
copyDirSync(libDir, path.join(macosDir, 'lib'), ['node_modules', 'include', 'pkgconfig']);
}
// Copy node_modules
// Copy node_modules next to the app source (Resources/app), where Node
// resolves them. Keep them out of MacOS/: codesign treats that dir as
// executable code and chokes on some libs (e.g. sharp's libvips).
const nodeModulesDir = path.join(appDir, 'node_modules');
if (fs.existsSync(nodeModulesDir)) {
log(' Copying node_modules...');
copyDirSync(nodeModulesDir, path.join(macosDir, 'node_modules'), ['.cache', '.bin', 'electron']);
copyDirSync(nodeModulesDir, path.join(appResources, 'node_modules'), ['.cache', '.bin', 'electron']);
}
// Copy src/electron compat layer
@@ -303,15 +382,32 @@ exec "$DIR/gelectron-bin" "$@"
fs.writeFileSync(path.join(macosDir, exeName), wrapper, { mode: 0o755 });
// Generate Info.plist
fs.writeFileSync(path.join(contentsDir, 'Info.plist'), generateInfoPlist(name, version, exeName));
const iconSource = path.join(appResources, 'icon.png');
let iconName = null;
if (fs.existsSync(iconSource)) {
const icnsPath = path.join(resourcesDir, 'AppIcon.icns');
if (generateIcns(iconSource, icnsPath)) {
iconName = 'AppIcon';
log(' Generated app icon (AppIcon.icns)');
}
}
fs.writeFileSync(path.join(contentsDir, 'Info.plist'), generateInfoPlist(name, version, exeName, iconName));
// Ad-hoc sign so the app runs on other Macs
// Generate entitlements (microphone/camera access for webview media capture)
const entitlementsPath = path.join(contentsDir, 'entitlements.plist');
fs.writeFileSync(entitlementsPath, generateEntitlements());
// Ad-hoc sign so the app runs on other Macs.
// --deep signs gelectron-bin (the process that hosts the webview and
// captures audio) with the mic/camera entitlements too. Safe now that
// node_modules lives in Resources/app, not MacOS/.
log(' Signing app...');
try {
execSync(`codesign --force --deep --sign - "${appBundle}"`, { stdio: 'pipe' });
log(' Signed (ad-hoc)');
execSync(`codesign --force --deep --sign - --entitlements "${entitlementsPath}" "${appBundle}"`, { stdio: 'pipe' });
log(' Signed (ad-hoc, mic/camera entitlements)');
} catch (e) {
log(' Warning: codesign failed (app may be blocked on other Macs)');
log(` ${e.stderr ? e.stderr.toString().trim().split('\n').pop() : e.message}`);
}
log(` Created: ${appBundle}`);
@@ -411,16 +507,17 @@ exec "$DIR/${exeName}" "$@"
const args = process.argv.slice(2);
const opts = { dir: '.', platform: process.platform, arch: process.arch };
// First non-flag argument is the app directory
// First non-flag argument is the app directory (unless --dir/-d was already given)
let positionalIdx = 0;
let dirSet = false;
for (let i = 0; i < args.length; i++) {
if (!args[i].startsWith('-') && positionalIdx === 0) {
if (!args[i].startsWith('-') && positionalIdx === 0 && !dirSet) {
opts.dir = args[i];
positionalIdx++;
continue;
}
switch (args[i]) {
case '--dir': case '-d': opts.dir = args[++i]; break;
case '--dir': case '-d': opts.dir = args[++i]; dirSet = true; break;
case '--name': case '-n': opts.name = args[++i]; break;
case '--out': case '-o': opts.out = args[++i]; break;
case '--platform': case '-p': opts.platform = args[++i]; break;
+12
View File
@@ -12,6 +12,7 @@
const { EventEmitter } = require('events');
const path = require('path');
const fs = require('fs');
const { bridge } = require('./native-bridge');
const NativeImage = require('./native-image');
@@ -46,6 +47,17 @@ class App extends EventEmitter {
};
this._commandLine = new Map();
// Real Electron creates these dirs at startup; mirror that so apps can
// chdir/write into userData immediately.
for (const p of [this._paths.userData, this._paths.crashDumps, this._paths.logs]) {
try {
fs.mkdirSync(p, { recursive: true });
} catch (e) {
// Non-fatal: some paths may not be creatable in odd environments
}
}
this._dock = process.platform === 'darwin' ? {
setIcon: (icon) => {
const b64 = this._iconToBase64Png(icon);
+3
View File
@@ -381,6 +381,9 @@ impl InnerWebView {
// Enable webgl, webaudio, canvas features as default.
settings.set_enable_webgl(true);
settings.set_enable_webaudio(true);
// Enable microphone/camera access for getUserMedia
settings.set_enable_media(true);
settings.set_enable_media_stream(true);
settings
.set_enable_back_forward_navigation_gestures(attributes.back_forward_navigation_gestures);
+4 -1
View File
@@ -466,7 +466,10 @@ impl InnerWebView {
let mut kind = COREWEBVIEW2_PERMISSION_KIND::default();
args.PermissionKind(&mut kind)?;
if kind == COREWEBVIEW2_PERMISSION_KIND_CLIPBOARD_READ {
if kind == COREWEBVIEW2_PERMISSION_KIND_CLIPBOARD_READ
|| kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
|| kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA
{
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)?;
}