mirror of
https://github.com/mileswolfallen2/gelectron.git
synced 2026-09-08 12:43:16 +00:00
feat: add macOS application menu and icon support, enhance permissions for microphone and camera
This commit is contained in:
+235
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Vendored
+3
@@ -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);
|
||||
|
||||
|
||||
Vendored
+4
-1
@@ -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)?;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user