From bbdcbd1e7fef135074dc0b555e4734bc62bd8eaa Mon Sep 17 00:00:00 2001 From: mileswa1q22 Date: Tue, 4 Aug 2026 00:49:50 -0500 Subject: [PATCH] feat: add macOS application menu and icon support, enhance permissions for microphone and camera --- CONTRIBUTING.md | 235 +++++++++++++++++++++++++++++ crates/gelectron-app/src/main.rs | 196 ++++++++++++++++++++++-- packager/bin/gelectron-packager.js | 119 +++++++++++++-- src/electron/app.js | 12 ++ vendor/wry/src/webkitgtk/mod.rs | 3 + vendor/wry/src/webview2/mod.rs | 5 +- 6 files changed, 542 insertions(+), 28 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..957d9b2 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 ` 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=` | 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 "" \ + 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. diff --git a/crates/gelectron-app/src/main.rs b/crates/gelectron-app/src/main.rs index d5d79f1..d9a05b6 100644 --- a/crates/gelectron-app/src/main.rs +++ b/crates/gelectron-app/src/main.rs @@ -166,6 +166,7 @@ struct AppState { bundle_js: Option, response_tx: Option>, app_icon: Option, + app_menu: Option, } #[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 { + 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 { + 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 { + 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 { + 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 { 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> { 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 = (0..(4 * 4 * 4)).map(|i| (i * 7 % 256) as u8).collect(); diff --git a/packager/bin/gelectron-packager.js b/packager/bin/gelectron-packager.js index 6e20c12..643365b 100755 --- a/packager/bin/gelectron-packager.js +++ b/packager/bin/gelectron-packager.js @@ -155,12 +155,17 @@ function copyDirSync(src, dest, exclude) { } } -function generateInfoPlist(name, version, exeName) { +function generateInfoPlist(name, version, exeName, iconName) { + const iconKey = iconName + ? ` CFBundleIconFile + ${iconName} +` + : ''; return ` - CFBundleDisplayName +${iconKey} CFBundleDisplayName ${name} CFBundleExecutable ${exeName} @@ -178,10 +183,82 @@ function generateInfoPlist(name, version, exeName) { NSRequiresAquaSystemAppearance + NSMicrophoneUsageDescription + ${name} needs microphone access for voice input and audio recording. + NSCameraUsageDescription + ${name} needs camera access for video capture and screenshots. `; } +function generateEntitlements() { + return ` + + + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + +`; +} + +// 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], + ['icon_16x16@2x.png', 32], + ['icon_32x32.png', 32], + ['icon_32x32@2x.png', 64], + ['icon_128x128.png', 128], + ['icon_128x128@2x.png', 256], + ['icon_256x256.png', 256], + ['icon_256x256@2x.png', 512], + ['icon_512x512.png', 512], + ['icon_512x512@2x.png', 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; diff --git a/src/electron/app.js b/src/electron/app.js index 59bcc7f..3d10b75 100644 --- a/src/electron/app.js +++ b/src/electron/app.js @@ -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); diff --git a/vendor/wry/src/webkitgtk/mod.rs b/vendor/wry/src/webkitgtk/mod.rs index f240dde..6755ed0 100644 --- a/vendor/wry/src/webkitgtk/mod.rs +++ b/vendor/wry/src/webkitgtk/mod.rs @@ -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); diff --git a/vendor/wry/src/webview2/mod.rs b/vendor/wry/src/webview2/mod.rs index bcefce2..22e2460 100644 --- a/vendor/wry/src/webview2/mod.rs +++ b/vendor/wry/src/webview2/mod.rs @@ -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)?; }