From b4058e4cbf81148d638cb42b65e43dd1c3098250 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 08:14:45 -0400 Subject: [PATCH 1/7] iris-gui: macOS security-scoped bookmarks for sandboxed builds Adds an `appstore` cargo feature and a `macos_sandbox` module that mints app-scoped security-scoped bookmarks for user-selected files (disk images, PROM, ISOs, NFS dir) at save time and resolves them at startup, so a sandboxed macOS build can reopen them across launches. Gated on `target_os = "macos"` + `feature = "appstore"`; a no-op everywhere else, so non-macOS and non-sandboxed builds are unaffected. - macos_sandbox.rs: make_bookmark/start_access over objc2-foundation NSURL, plus config_paths/harvest/restore helpers. Objc round-trip and the harvest/restore logic are unit-tested (cargo test --features appstore). - GuiSettings: persist bookmarks (path -> bytes); save() harvests across all machines before writing. - objc2 / objc2-foundation added as macOS-target deps (already in the build graph via eframe; just enabling a few type features). Co-Authored-By: Claude Opus 4.8 --- iris-gui/Cargo.toml | 17 +++ iris-gui/src/macos_sandbox.rs | 189 ++++++++++++++++++++++++++++++++++ iris-gui/src/main.rs | 4 + iris-gui/src/settings.rs | 20 +++- 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 iris-gui/src/macos_sandbox.rs diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index de2e33c..eb9e99a 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -3,6 +3,13 @@ name = "iris-gui" version = "0.1.0" edition = "2021" +[features] +# Mac App Store distribution build. Activates sandbox-only behaviour: macOS +# security-scoped bookmarks (see src/macos_sandbox.rs) and hiding developer-only +# affordances. Unused outside the App Store build but kept here so the gated +# code compiles cleanly everywhere. +appstore = [] + [dependencies] # Group A (additive) features are always on for iris-gui so the user can # enable them at runtime via the config UI: chd (.chd disk paths), camera @@ -25,6 +32,16 @@ dirs = "5" log = "0.4" env_logger = "0.10" +# macOS App Sandbox: NSURL security-scoped bookmarks let the App Store build +# reopen user-selected disk images / PROMs / ISOs across launches. These crates +# are already in the build graph (eframe/winit/rfd pull them in); we enable a +# few extra type features and use them only under `feature = "appstore"`. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = [ + "NSURL", "NSData", "NSError", "NSString", "NSArray", +] } + [[bin]] name = "iris-gui" path = "src/main.rs" diff --git a/iris-gui/src/macos_sandbox.rs b/iris-gui/src/macos_sandbox.rs new file mode 100644 index 0000000..1209edc --- /dev/null +++ b/iris-gui/src/macos_sandbox.rs @@ -0,0 +1,189 @@ +//! macOS App Sandbox security-scoped bookmarks (Mac App Store build). +//! +//! Under the App Sandbox the app may only touch files the user explicitly hands +//! it through an NSOpenPanel/NSSavePanel. That grant lasts only for the current +//! launch: the absolute paths we persist in `gui.json` (disk images, PROM, ISOs, +//! an NFS export directory, …) are *not* reachable on the next launch. +//! +//! The fix is the standard one: at save time we mint a **security-scoped +//! bookmark** for every file we can currently reach ([`harvest`]) and stash the +//! bytes in [`GuiSettings`](crate::settings::GuiSettings). On the next launch we +//! resolve each bookmark and call `startAccessingSecurityScopedResource` +//! ([`restore`]) before any machine can start. +//! +//! We deliberately never call `stopAccessingSecurityScopedResource`: the +//! emulator needs the backing files for the whole session, so access is held +//! until the process exits. The number of held resources equals the number of +//! attached files (a handful), so the kernel-resource cost is negligible. +//! +//! Everything here is a no-op off macOS and off the `appstore` feature — the +//! regular notarized builds are not sandboxed and open paths directly. + +use iris::config::MachineConfig; +use std::collections::BTreeMap; +use std::path::Path; + +/// Absolute, currently-existing file paths in `cfg` that are worth bookmarking. +/// +/// Relative/default paths (`prom.bin`, `scsi1.raw`, `nvram.bin`) resolve inside +/// the sandbox container, which is always accessible, so they're skipped — only +/// user-chosen absolute paths outside the container need a bookmark. +pub fn config_paths(cfg: &MachineConfig) -> Vec { + let mut out = Vec::new(); + let mut add = |p: &str| { + let path = Path::new(p); + if path.is_absolute() && path.exists() { + out.push(p.to_string()); + } + }; + add(&cfg.prom); + add(&cfg.nvram); + if let Some(s) = &cfg.serial_log { + add(s); + } + for dev in cfg.scsi.values() { + add(&dev.path); + for disc in &dev.discs { + add(disc); + } + } + if let Some(nfs) = &cfg.nfs { + add(&nfs.shared_dir); + add(&nfs.unfsd); + } + out +} + +/// (Re)create a security-scoped bookmark for every reachable `path` and merge it +/// into `bookmarks`. Paths we can't reach right now (typed by hand, nonexistent, +/// or never user-selected) are left untouched — an existing bookmark is never +/// dropped, so an inactive machine's images survive a save that can't see them. +pub fn harvest<'a>(paths: impl IntoIterator, bookmarks: &mut BTreeMap>) { + for path in paths { + if let Some(bytes) = imp::make_bookmark(path) { + bookmarks.insert(path.to_string(), bytes); + } + } +} + +/// Resolve every stored bookmark and begin accessing it for the process +/// lifetime. Call once at startup, after loading settings and before a machine +/// can start. Stale/failed bookmarks are logged and skipped. +pub fn restore(bookmarks: &BTreeMap>) { + for (path, bytes) in bookmarks { + let _ = imp::start_access(path, bytes); + } +} + +#[cfg(all(target_os = "macos", feature = "appstore"))] +mod imp { + use objc2_foundation::{ + NSData, NSString, NSURL, NSURLBookmarkCreationOptions, NSURLBookmarkResolutionOptions, + }; + + /// Mint a security-scoped bookmark for `path`, or `None` if we don't + /// currently have access to it (so the caller keeps any prior bookmark). + pub fn make_bookmark(path: &str) -> Option> { + let ns_path = NSString::from_str(path); + let url = NSURL::fileURLWithPath(&ns_path); + match url.bookmarkDataWithOptions_includingResourceValuesForKeys_relativeToURL_error( + NSURLBookmarkCreationOptions::WithSecurityScope, + None, + None, + ) { + Ok(data) => Some(data.to_vec()), + Err(_) => None, + } + } + + /// Resolve `bytes` back to a URL and start accessing it. Returns whether + /// access was granted. We never stop accessing — the file is needed for the + /// whole session (see module docs). + pub fn start_access(path: &str, bytes: &[u8]) -> bool { + let data = NSData::with_bytes(bytes); + // SAFETY: `data` is a valid NSData; null is an allowed `is_stale` out-ptr. + let url = unsafe { + NSURL::URLByResolvingBookmarkData_options_relativeToURL_bookmarkDataIsStale_error( + &data, + NSURLBookmarkResolutionOptions::WithSecurityScope, + None, + std::ptr::null_mut(), + ) + }; + match url { + // SAFETY: `url` came from resolving a security-scoped bookmark. + Ok(url) => unsafe { url.startAccessingSecurityScopedResource() }, + Err(_) => { + log::warn!("sandbox: could not resolve security-scoped bookmark for {path}"); + false + } + } + } +} + +#[cfg(not(all(target_os = "macos", feature = "appstore")))] +mod imp { + pub fn make_bookmark(_path: &str) -> Option> { + None + } + pub fn start_access(_path: &str, _bytes: &[u8]) -> bool { + false + } +} + +// Runtime verification of the Objective-C bookmark interop. Security-scoped +// bookmarks are created/resolved fine *outside* the sandbox too (they just act +// as ordinary bookmarks), so this exercises the real NSURL round-trip without +// needing a signed/sandboxed build. The sandbox-only behaviour (access denied +// without a bookmark) still has to be verified on a signed App Store build. +#[cfg(all(test, target_os = "macos", feature = "appstore"))] +mod tests { + use super::*; + + fn temp_file(suffix: &str) -> String { + let p = std::env::temp_dir().join(format!( + "iris_sbtest_{}_{}{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + suffix + )); + std::fs::write(&p, b"\0\0\0\0").unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn bookmark_roundtrip() { + let path = temp_file(".bin"); + let bytes = imp::make_bookmark(&path).expect("bookmark an accessible file"); + assert!(!bytes.is_empty(), "bookmark bytes should be non-empty"); + assert!(imp::start_access(&path, &bytes), "resolving a fresh bookmark should grant access"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn make_bookmark_missing_file_is_none() { + assert!(imp::make_bookmark("/nonexistent/iris/does/not/exist.raw").is_none()); + } + + #[test] + fn config_paths_harvest_restore() { + let path = temp_file(".raw"); + let mut cfg = MachineConfig::default(); + cfg.scsi.get_mut(&1).unwrap().path = path.clone(); + cfg.prom = "prom.bin".into(); // relative → skipped + + let paths = config_paths(&cfg); + assert!(paths.contains(&path), "absolute scsi image should be collected"); + assert!(!paths.iter().any(|p| p == "prom.bin"), "relative paths should be skipped"); + + let mut bm = BTreeMap::new(); + harvest(paths.iter().map(String::as_str), &mut bm); + assert!(bm.contains_key(&path), "accessible image should be bookmarked"); + + restore(&bm); // must not panic + let _ = std::fs::remove_file(&path); + } +} diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index b7a3bc9..04f6342 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -5,6 +5,7 @@ mod dialogs; mod framebuffer; mod handle; mod input; +mod macos_sandbox; mod safe_stop; mod scsi_menu; mod settings; @@ -50,6 +51,9 @@ fn main() -> eframe::Result<()> { // the single-instance lock for ourselves. single_instance::acquire(); let prefs = GuiSettings::load(); + // Re-acquire macOS sandbox access to previously user-selected files (disk + // images, PROM, ISOs, …) before any machine can open them. No-op elsewhere. + macos_sandbox::restore(&prefs.bookmarks); let mut viewport = egui::ViewportBuilder::default() .with_title("iris — SGI Indy emulator") // app_id sets the X11 WM_CLASS / Wayland app_id so the compositor can diff --git a/iris-gui/src/settings.rs b/iris-gui/src/settings.rs index d007501..4f6378d 100644 --- a/iris-gui/src/settings.rs +++ b/iris-gui/src/settings.rs @@ -37,6 +37,14 @@ pub struct GuiSettings { /// of the new machine-store world. #[serde(default)] pub last_config: Option, + + /// macOS App Sandbox security-scoped bookmarks, keyed by the absolute file + /// path they re-grant access to (disk image, PROM, ISO, NFS dir, …). Minted + /// at save time and resolved at startup so user-selected files reopen across + /// launches under the Mac App Store sandbox. Empty / unused everywhere else. + /// See [`crate::macos_sandbox`]. + #[serde(default)] + pub bookmarks: BTreeMap>, } /// Allowed UI-scale range, shared by the View-menu slider, the Ctrl +/-/0 @@ -71,7 +79,17 @@ impl GuiSettings { s } - pub fn save(&self) -> Result<(), String> { + pub fn save(&mut self) -> Result<(), String> { + // Refresh macOS security-scoped bookmarks for every machine's reachable + // files so they reopen under the App Sandbox next launch. No-op off the + // Mac App Store build. + let paths: Vec = self + .machines + .values() + .flat_map(crate::macos_sandbox::config_paths) + .collect(); + crate::macos_sandbox::harvest(paths.iter().map(String::as_str), &mut self.bookmarks); + let path = Self::config_path().ok_or("no config dir")?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; From 7fc6c18f23333f6960046052902f0ba0127bbba3 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 08:35:10 -0400 Subject: [PATCH 2/7] iris-gui/installer/build: bring fork build inputs in line for upstream Per the path-based fork policy, the GUI's packaging metadata + desktop entry, the macOS/Windows installer configs, the macOS build script, the workspace profiling profile, and the App-Store CI-tab gating are all build inputs that should track upstream rather than diverge. Mirror them here. - iris-gui/Cargo.toml: deb/rpm packaging metadata, description/license. - iris-gui/iris-gui.desktop, scripts/build-macos.sh, installer/{entitlements,iss}. - iris-gui/src/{config_ui,main}.rs: hide the CI/Automation tab under the `appstore` feature (Tab::visible), matching the sandbox build. - Cargo.toml/profile.sh: profiling-profile + flamegraph helper tweaks. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 2 +- installer/iris-gui.entitlements | 43 +++++++++++++ installer/iris-gui.iss | 72 +++++++++++++++++++++ iris-gui/Cargo.toml | 36 +++++++++-- iris-gui/iris-gui.desktop | 9 +++ iris-gui/src/config_ui.rs | 22 +++++-- iris-gui/src/main.rs | 26 ++++++-- profile.sh | 3 +- scripts/build-macos.sh | 109 ++++++++++++++++++++++++++++++++ 9 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 installer/iris-gui.entitlements create mode 100644 installer/iris-gui.iss create mode 100644 iris-gui/iris-gui.desktop create mode 100755 scripts/build-macos.sh diff --git a/Cargo.toml b/Cargo.toml index faccd6c..5127eb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,7 +124,6 @@ debug = true lto = "fat" codegen-units = 1 - # Profiling profile: release optimizations + full debug info for flamegraph/perf. # Usage: cargo build --profile profiling && perf record ./target/profiling/iris # cargo flamegraph --profile profiling @@ -132,6 +131,7 @@ codegen-units = 1 inherits = "release" debug = 2 strip = false +force-frame-pointers = true [[bin]] name = "iris" diff --git a/installer/iris-gui.entitlements b/installer/iris-gui.entitlements new file mode 100644 index 0000000..7631742 --- /dev/null +++ b/installer/iris-gui.entitlements @@ -0,0 +1,43 @@ + + + + + + com.apple.application-identifier + Q4F2GZ34R5.io.github.danifunker.iris + com.apple.developer.team-identifier + Q4F2GZ34R5 + + + com.apple.security.app-sandbox + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.device.camera + + + + com.apple.security.files.user-selected.read-write + + + + com.apple.security.files.bookmarks.app-scope + + + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/installer/iris-gui.iss b/installer/iris-gui.iss new file mode 100644 index 0000000..68c1bd1 --- /dev/null +++ b/installer/iris-gui.iss @@ -0,0 +1,72 @@ +; Inno Setup script for IRIS (Windows, per-user install). +; Produces a per-user Setup.exe that installs into %LocalAppData% — no admin required. +; +; Build: +; iscc /DMyAppVersion=2025-06-09-02-00 /DSourceDir=path\to\build /DAssetsDir=path\to\icons installer\iris-gui.iss + +#ifndef MyAppVersion + #define MyAppVersion "0.0.0-dev" +#endif + +; Directory containing iris-gui.exe. +#ifndef SourceDir + #define SourceDir "..\target\release" +#endif + +; Directory containing icon.ico. +#ifndef AssetsDir + #define AssetsDir "..\iris-gui\assets\icons" +#endif + +; Combined BSD-3 + GPL-3 license file (generated by CI, or pass manually). +#ifndef LicenseFile + #define LicenseFile "..\COMBINED-LICENSE.txt" +#endif + +#define MyAppName "IRIS" +#define MyAppPublisher "Dani Sarfati" +#define MyAppURL "https://github.com/danifunker/iris" +#define MyAppExeName "iris-gui.exe" + +[Setup] +; Stable AppId — do not change across releases. +AppId={{A7F2C91E-3D8B-4F5A-8E2C-1B9D6A3E8F42} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL}/releases +LicenseFile={#LicenseFile} +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +DefaultDirName={localappdata}\Programs\IRIS +DisableProgramGroupPage=yes +DefaultGroupName={#MyAppName} +DisableDirPage=no +AllowNoIcons=yes +UninstallDisplayIcon={app}\{#MyAppExeName} +OutputBaseFilename=IRIS-Setup +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked + +[Files] +Source: "{#SourceDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#AssetsDir}\icon.ico"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist +; Both licenses ship with the installed app so users can read them post-install. +Source: "{#LicenseFile}"; DestDir: "{app}"; DestName: "LICENSE.txt"; Flags: ignoreversion skipifsourcedoesntexist + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{userdesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index eb9e99a..9e1a508 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -2,12 +2,40 @@ name = "iris-gui" version = "0.1.0" edition = "2021" +description = "SGI Indy (MIPS R4400) emulator" +license = "BSD-3-Clause" + +[package.metadata.deb] +maintainer = "Dani Sarfati " +copyright = "2025 Dani Sarfati" +license-file = "../LICENSE" +extended-description = "IRIS emulates an SGI Indy workstation (MIPS R4400) and boots IRIX 6.5 and 5.3 to a usable system with shell, networking, and X11." +depends = "$auto" +section = "games" +priority = "optional" +# Asset source paths are resolved relative to this package dir (iris-gui/), +# NOT the workspace root — so they must not carry an `iris-gui/` prefix or it +# doubles to iris-gui/iris-gui/... The `target/...` binary path is special-cased +# by cargo-deb (rewritten to target//release with --target). +assets = [ + ["target/release/iris-gui", "usr/bin/iris-gui", "755"], + ["assets/icons/icon-256.png", "usr/share/icons/hicolor/256x256/apps/iris-gui.png", "644"], + ["assets/icons/icon-128.png", "usr/share/icons/hicolor/128x128/apps/iris-gui.png", "644"], + ["assets/icons/icon-64.png", "usr/share/icons/hicolor/64x64/apps/iris-gui.png", "644"], + ["iris-gui.desktop", "usr/share/applications/iris-gui.desktop", "644"], +] + +[package.metadata.generate-rpm] +assets = [ + { source = "target/release/iris-gui", dest = "/usr/bin/iris-gui", mode = "755" }, + { source = "assets/icons/icon-256.png", dest = "/usr/share/icons/hicolor/256x256/apps/iris-gui.png", mode = "644" }, + { source = "iris-gui.desktop", dest = "/usr/share/applications/iris-gui.desktop", mode = "644" }, +] [features] -# Mac App Store distribution build. Activates sandbox-only behaviour: macOS -# security-scoped bookmarks (see src/macos_sandbox.rs) and hiding developer-only -# affordances. Unused outside the App Store build but kept here so the gated -# code compiles cleanly everywhere. +# Mac App Store distribution build. Hides the CI/Automation config tab (the +# iris-ci socket is a developer automation feature unusable in the sandbox) +# and any other developer-only affordances. Set by .github/workflows/appstore.yml. appstore = [] [dependencies] diff --git a/iris-gui/iris-gui.desktop b/iris-gui/iris-gui.desktop new file mode 100644 index 0000000..8d6ddd8 --- /dev/null +++ b/iris-gui/iris-gui.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=IRIS +Comment=SGI Indy (MIPS R4400) emulator +Exec=iris-gui +Icon=iris-gui +Categories=Emulator;Game; +Terminal=false +StartupNotify=true diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index a608b80..431fa89 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -20,10 +20,24 @@ pub enum Tab { } impl Tab { - pub const ALL: &'static [Tab] = &[ - Tab::General, Tab::Disks, Tab::Network, Tab::Memory, - Tab::Display, Tab::VideoIn, Tab::Debug, Tab::Ci, - ]; + /// Tabs to show for the active build. The Debug/JIT tab is hidden in + /// lightning builds (the JIT debug paths it drives are compiled out), and + /// the CI/Automation tab is hidden in App Store builds (the iris-ci socket + /// is a developer automation feature, not something a sandboxed end user + /// can use). Both fall back to the full set for ordinary builds. + pub fn visible() -> Vec { + let mut tabs = vec![ + Tab::General, Tab::Disks, Tab::Network, Tab::Memory, + Tab::Display, Tab::VideoIn, + ]; + if !build_features::LIGHTNING { + tabs.push(Tab::Debug); + } + if !cfg!(feature = "appstore") { + tabs.push(Tab::Ci); + } + tabs + } pub fn label(self) -> &'static str { match self { Tab::General => "General", diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 04f6342..9fd5cb1 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -537,7 +537,18 @@ impl App { ui.label(RichText::new("IRIS — SGI Indy (MIPS R4400) Emulator").strong()); ui.label(format!("Version {}", env!("APP_VERSION"))); ui.separator(); - ui.hyperlink_to("techomancer/iris on GitHub", "https://github.com/techomancer/iris"); + ui.label(RichText::new("Authors").strong()); + ui.label("Original: techomancer"); + ui.label("iris-gui fork: Dani Sarfati (danifunker)"); + ui.separator(); + ui.horizontal(|ui| { + ui.label("Upstream:"); + ui.hyperlink_to("techomancer/iris", "https://github.com/techomancer/iris"); + }); + ui.horizontal(|ui| { + ui.label("This fork:"); + ui.hyperlink_to("danifunker/iris", "https://github.com/danifunker/iris"); + }); ui.separator(); ui.label(RichText::new("Build features:").strong()); use iris::build_features as bf; @@ -579,8 +590,15 @@ impl App { if self.show_config_editor { if ui.button("Network").clicked() { self.tab = Tab::Network; } if ui.button("Video-In").clicked() { self.tab = Tab::VideoIn; } - if ui.button("Debug").clicked() { self.tab = Tab::Debug; } - if ui.button("CI").clicked() { self.tab = Tab::Ci; } + // Debug/JIT is compiled out of lightning builds; CI is hidden + // in App Store builds — keep the quick-buttons in step with + // Tab::visible() so they never jump to a hidden tab. + if !iris::build_features::LIGHTNING && ui.button("Debug").clicked() { + self.tab = Tab::Debug; + } + if !cfg!(feature = "appstore") && ui.button("CI").clicked() { + self.tab = Tab::Ci; + } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -682,7 +700,7 @@ impl App { fn central_tabs(&mut self, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { - for &t in Tab::ALL { + for t in Tab::visible() { ui.selectable_value(&mut self.tab, t, t.label()); } }); diff --git a/profile.sh b/profile.sh index ca5409b..04c81c6 100755 --- a/profile.sh +++ b/profile.sh @@ -1,3 +1,2 @@ #!/bin/bash -sudo sysctl kernel.perf_event_paranoid=-1 -RUSTFLAGS="-C force-frame-pointers=yes" PERFFLAGS="-F 200 -g --call-graph dwarf" cargo flamegraph --profile profiling --features rex-jit,lightning --bin iris +PERFFLAGS="-F 200 -g --call-graph dwarf" cargo flamegraph --profile profiling --features rex-jit,lightning --bin iris \ No newline at end of file diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 0000000..36b1377 --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Build iris-gui as a local .app bundle for macOS testing. +# +# Running the binary directly (cargo run / ./iris-gui) will always open +# inside your current Terminal window. This script wraps it in a proper +# .app bundle so you can launch it with open IRIS.app — just like users +# do — and the Terminal window stays closed. +# +# Usage: +# ./scripts/build-macos.sh # standard build +# ./scripts/build-macos.sh lightning # enable iris/lightning feature +# +# After it finishes: +# open IRIS.app + +set -e + +VARIANT="${1:-standard}" + +# ── Architecture ──────────────────────────────────────────────────────────── + +ARCH=$(uname -m) +if [ "$ARCH" = "arm64" ]; then + TARGET="aarch64-apple-darwin" +elif [ "$ARCH" = "x86_64" ]; then + TARGET="x86_64-apple-darwin" +else + echo "Unsupported architecture: $ARCH" >&2 + exit 1 +fi + +# ── Bundle ID — derived from the git remote so any fork gets the right ID ── +# git@github.com:owner/repo.git → io.github.owner.repo +# https://github.com/owner/repo → io.github.owner.repo + +REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "") +if [[ "$REMOTE_URL" =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then + BUNDLE_ID="io.github.${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" +else + # Fallback: read from Cargo.toml if present, otherwise use a default + BUNDLE_ID=$(grep -m1 '^bundle_id\s*=' iris-gui/Cargo.toml 2>/dev/null \ + | sed 's/.*"\(.*\)".*/\1/' || echo "io.github.unknown.iris") +fi + +echo "Building iris-gui ($VARIANT) for macOS ($ARCH)..." +echo " Bundle ID: $BUNDLE_ID" + +# ── Build ─────────────────────────────────────────────────────────────────── + +if [ "$VARIANT" = "lightning" ]; then + cargo build --release --target "$TARGET" -p iris-gui --features iris/lightning +else + cargo build --release --target "$TARGET" -p iris-gui +fi + +VERSION=$(cargo metadata --no-deps --format-version 1 2>/dev/null \ + | python3 -c "import sys,json; pkgs=json.load(sys.stdin)['packages']; \ + print(next(p['version'] for p in pkgs if p['name']=='iris-gui'))" 2>/dev/null \ + || echo "0.0.0-local") + +# ── Bundle ────────────────────────────────────────────────────────────────── + +BUNDLE="IRIS.app" +rm -rf "$BUNDLE" +mkdir -p "${BUNDLE}/Contents/MacOS" "${BUNDLE}/Contents/Resources" + +cp "target/${TARGET}/release/iris-gui" "${BUNDLE}/Contents/MacOS/iris-gui" +chmod +x "${BUNDLE}/Contents/MacOS/iris-gui" + +if [ -f "iris-gui/assets/icons/icon.icns" ]; then + cp "iris-gui/assets/icons/icon.icns" "${BUNDLE}/Contents/Resources/AppIcon.icns" +fi + +cat > "${BUNDLE}/Contents/Info.plist" << EOF + + + + + CFBundleNameIRIS + CFBundleDisplayNameIRIS + CFBundleIdentifier${BUNDLE_ID} + CFBundleVersion${VERSION} + CFBundleShortVersionString${VERSION} + CFBundleExecutableiris-gui + CFBundleIconFileAppIcon.icns + CFBundlePackageTypeAPPL + NSHighResolutionCapable + LSMinimumSystemVersion10.13 + NSCameraUsageDescriptionProvides the IndyCam video input for SGI Indy emulation (VINO device). + + +EOF + +# ── Sign ──────────────────────────────────────────────────────────────────── + +echo "Signing bundle..." +if [ -f "installer/iris-gui.entitlements" ]; then + codesign --force --deep --sign - --entitlements installer/iris-gui.entitlements "${BUNDLE}" +else + codesign --force --deep --sign - "${BUNDLE}" +fi + +echo "" +echo "Done: ${BUNDLE} (${VARIANT}, bundle ID: ${BUNDLE_ID})" +echo "" +echo "Launch without Terminal:" +echo " open ${BUNDLE}" +echo "" +echo "Or double-click IRIS.app in Finder." From 9170e1704f789c9de4154c17810e6fd5ca2245a3 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 10:42:24 -0400 Subject: [PATCH 3/7] sandbox: boot disks under the macOS App Sandbox without crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the sandbox disk fixes: - machine.rs: skip an unattachable disk (warn) instead of process::exit when embedded — release builds are panic="abort", so catch_unwind can't save the GUI, and unwinding across the libchdman/JIT FFI would be UB. - chd_disk.rs: redirect the compressed-HD `.diff.chd` sidecar via IRIS_CHD_DIFF_DIR so it isn't created next to the parent (which the sandbox forbids). - iris-gui: set IRIS_CHD_DIFF_DIR to the data dir on the appstore build; probe an actual read in the Start preflight; hold the resolved security-scoped NSURL so bookmark access persists. Co-Authored-By: Claude Opus 4.8 --- iris-gui/src/macos_sandbox.rs | 14 ++++- iris-gui/src/main.rs | 105 ++++++++++++++++++++++++++++++++-- src/chd_disk.rs | 18 ++++++ src/machine.rs | 26 +++++++-- 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/iris-gui/src/macos_sandbox.rs b/iris-gui/src/macos_sandbox.rs index 1209edc..cc86c40 100644 --- a/iris-gui/src/macos_sandbox.rs +++ b/iris-gui/src/macos_sandbox.rs @@ -112,7 +112,19 @@ mod imp { }; match url { // SAFETY: `url` came from resolving a security-scoped bookmark. - Ok(url) => unsafe { url.startAccessingSecurityScopedResource() }, + Ok(url) => { + let ok = unsafe { url.startAccessingSecurityScopedResource() }; + if ok { + // The security-scoped access is bound to this NSURL: when the + // URL deallocates the access is released, which is why a + // freshly-resolved bookmark would read briefly and then fail + // deeper in the loader. We intend to hold access for the whole + // process (we never call stopAccessing), so leak the URL to + // keep it — and the access — alive until exit. + std::mem::forget(url); + } + ok + } Err(_) => { log::warn!("sandbox: could not resolve security-scoped bookmark for {path}"); false diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 9fd5cb1..82e2f85 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -54,6 +54,14 @@ fn main() -> eframe::Result<()> { // Re-acquire macOS sandbox access to previously user-selected files (disk // images, PROM, ISOs, …) before any machine can open them. No-op elsewhere. macos_sandbox::restore(&prefs.bookmarks); + // Under the App Sandbox a compressed HD CHD's `.diff.chd` sidecar can't be + // created next to the parent (its directory isn't writable). Redirect diffs + // into our writable data dir (container-redirected under the sandbox). Off + // the App Store build the diff stays beside the parent (no sandbox). + #[cfg(feature = "appstore")] + if let Some(d) = dirs::data_dir() { + std::env::set_var("IRIS_CHD_DIFF_DIR", d.join("iris").join("chd-diffs")); + } let mut viewport = egui::ViewportBuilder::default() .with_title("iris — SGI Indy emulator") // app_id sets the X11 WM_CLASS / Wayland app_id so the compositor can @@ -125,6 +133,28 @@ struct MissingDisk { cdrom: bool, } +/// True if `path` can actually be *read*. The Start preflight uses this instead +/// of `Path::exists()` because under the macOS App Sandbox a user-selected file +/// we hold no current grant for (e.g. a disk image picked in a previous session, +/// before a security-scoped bookmark was minted — see [`macos_sandbox`]) still +/// `stat()`s as present, so `exists()` would wave it through to `Machine::new`, +/// which then fails to attach the disk. We probe an actual read (not just an +/// `open`) because the sandbox can permit `open()` yet deny `read()` once a +/// grant has lapsed — so opening alone would still slip past the check and only +/// fail deep in the CHD/image loader. Catching it here routes the disk to the +/// missing-disk modal, where the user can re-select or detach it. +fn disk_readable(path: &str) -> bool { + use std::io::Read; + if path.is_empty() { + return false; + } + let Ok(mut f) = std::fs::File::open(path) else { + return false; + }; + let mut probe = [0u8; 1]; + matches!(f.read(&mut probe), Ok(n) if n >= 1) +} + /// Modal shown when one or more SCSI image files are missing on Start. /// `Machine::new` would otherwise call `std::process::exit(1)` and take /// the whole GUI down with it. @@ -257,8 +287,16 @@ impl App { fn start_emulator(&mut self) { // Flush any pending edits before the machine starts so the on-disk - // copy matches what we're about to boot. + // copy matches what we're about to boot. This also harvests a + // security-scoped bookmark for any newly user-selected file. if self.cfg_dirty { self.flush_machine(); } + // (Re)assert macOS sandbox access to every bookmarked file *before* the + // preflight opens them. The startup restore() ran before any bookmark + // for a freshly-picked file existed, and the file picker's grant does + // not survive a machine stop/restart — so without re-asserting here the + // second Start of a session can no longer read the disk image. No-op off + // the App Store build. See [`macos_sandbox`]. + macos_sandbox::restore(&self.prefs.bookmarks); if iris::build_features::LIGHTNING && self.cfg.gdb_port.is_some() { // GDB stub is a no-op under lightning; silently drop the setting // so we don't hand the executor a port it can't honour. @@ -299,8 +337,8 @@ impl App { if dev.cdrom && dev.path.is_empty() && dev.discs.is_empty() { continue; } - let primary_ok = !dev.path.is_empty() && std::path::Path::new(&dev.path).exists(); - let any_disc_ok = dev.discs.iter().any(|d| std::path::Path::new(d).exists()); + let primary_ok = disk_readable(&dev.path); + let any_disc_ok = dev.discs.iter().any(|d| disk_readable(d)); let present = primary_ok || (dev.cdrom && any_disc_ok); if !present { out.push(MissingDisk { id, path: dev.path.clone(), cdrom: dev.cdrom }); @@ -937,12 +975,22 @@ impl eframe::App for App { .resizable(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { - ui.label(RichText::new("The following SCSI image files are missing:").strong()); + ui.label(RichText::new("These SCSI image files are missing or can't be read:").strong()); for m in &modal.missing { let kind = if m.cdrom { "CD-ROM" } else { "HDD" }; ui.label(format!("• scsi{} ({kind}): {}", m.id, m.path)); } ui.add_space(8.0); + if cfg!(feature = "appstore") { + // Under the sandbox an existing file may be unreadable + // because the app's access to it lapsed — re-selecting it + // re-grants access and stores a bookmark for next launch. + ui.label(RichText::new( + "If a file exists but still shows here, macOS revoked access \ + after a previous session. Open the Disks tab and re-select it \ + to restore access.").weak()); + ui.add_space(4.0); + } ui.label(RichText::new( "iris would terminate the process if started in this state. \ Choose how to proceed:").weak()); @@ -1009,3 +1057,52 @@ fn native_save_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option u32; + } +} diff --git a/src/chd_disk.rs b/src/chd_disk.rs index f89e1be..efcac76 100644 --- a/src/chd_disk.rs +++ b/src/chd_disk.rs @@ -125,6 +125,24 @@ impl ChdCd { } fn diff_path_for(parent: &Path) -> PathBuf { + // A compressed HD CHD can't be written in place, so writes go to an + // uncompressed `.diff.chd` sidecar. By default it sits next to the parent. + // + // That fails under the macOS App Sandbox: the user grants access to the CHD + // *file*, but creating a new sibling in its directory needs write access to + // the directory, which the sandbox denies. iris-gui's App Store build sets + // IRIS_CHD_DIFF_DIR to a writable container path; when present, put the diff + // there, named by the parent's stem plus a hash of its full path so two + // like-named CHDs in different folders don't collide. + if let Some(dir) = std::env::var_os("IRIS_CHD_DIFF_DIR") { + let dir = PathBuf::from(dir); + let _ = std::fs::create_dir_all(&dir); + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + parent.hash(&mut h); + let stem = parent.file_stem().and_then(|s| s.to_str()).unwrap_or("disk"); + return dir.join(format!("{stem}.{:016x}.diff.chd", h.finish())); + } let mut s = parent.as_os_str().to_owned(); s.push(".diff.chd"); PathBuf::from(s) diff --git a/src/machine.rs b/src/machine.rs index c9386e5..df1eaa0 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -282,12 +282,26 @@ impl Machine { hpc3.add_scsi_device(id as usize, &path, dev.cdrom, discs, dev.overlay) }; if let Err(e) = result { - // A configured disk that won't attach is fatal: continuing would - // boot with a silently-missing device, and the only symptom is a - // confusing PROM "no such device" much later (e.g. a CHD path - // when the binary was built without --features chd). Fail loudly - // at startup instead. - eprintln!("iris: fatal: could not attach {} to SCSI ID {}: {}", path, id, e); + // A configured disk that won't attach (a CHD path when built + // without --features chd, or a disk the macOS sandbox won't let + // us read) can't host this device. + // + // Standalone CLI fails loudly and exits — booting on with a + // silently-missing device only yields a confusing PROM "no such + // device" later. But an embedder (the GUI sets + // IRIS_NO_EXIT_ON_POWEROFF) must never be torn down by the + // library: process::exit kills it outright, and panicking can't + // be caught either because release builds use panic="abort" + // (unwinding across the libchdman/JIT FFI would be UB). So we + // skip just this device and boot without it; the GUI's Start + // preflight already warns the user about an unreadable disk, and + // they can stop, re-pick or detach it, and start again. + let msg = format!("could not attach {path} to SCSI ID {id}: {e}"); + if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { + eprintln!("iris: warning: {msg}; continuing without SCSI ID {id}"); + continue; + } + eprintln!("iris: fatal: {msg}"); std::process::exit(1); } } From 6cbce785e58eabfb4ace96d130e684bdd60fedce Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 11:09:10 -0400 Subject: [PATCH 4/7] Cargo.toml: drop the no-op force-frame-pointers profiling key Not a recognized Cargo profile key; emitted 'unused manifest key' on every build and did nothing. Matches upstream. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5127eb7..faccd6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,7 @@ debug = true lto = "fat" codegen-units = 1 + # Profiling profile: release optimizations + full debug info for flamegraph/perf. # Usage: cargo build --profile profiling && perf record ./target/profiling/iris # cargo flamegraph --profile profiling @@ -131,7 +132,6 @@ codegen-units = 1 inherits = "release" debug = 2 strip = false -force-frame-pointers = true [[bin]] name = "iris" From ae33bab311433ae55b3f8337fef920b2556b468d Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 11:32:33 -0400 Subject: [PATCH 5/7] gitignore: ignore the IRIS.app bundle produced by build-macos.sh Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 85d026b..e02864c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ nvram.bin Cargo.lock iris.toml screenshot_*.png +IRIS.app/ *.log .DS_Store From f177a7eec3e511dc5cc35a5fc0d7dc1bccd5fbf0 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 14:22:01 -0400 Subject: [PATCH 6/7] Added PRIVACY for App Store --- PRIVACY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 PRIVACY.md diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..66b3d0a --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,19 @@ +# Privacy Policy + +_Last updated: June 10, 2026_ + +IRIS is an emulator of the SGI Indy workstation. It does **not** collect, store, transmit, or share any personal information. + +**No data collection.** IRIS contains no analytics, telemetry, advertising, or tracking. It creates no accounts and sends no information about you or your usage to the developer or any third party. + +**Camera.** If you enable the IndyCam (Video-In) feature, IRIS uses your Mac's camera only to feed a live signal to the emulated Indy video-input device. The video is processed on your device and is never recorded, stored, or transmitted by IRIS. + +**Networking.** IRIS does not contact any server of its own. Its emulated Ethernet is handled by an on-device userspace NAT; any network traffic originates from the guest operating system you choose to run and connects only where you direct it. + +**Your files.** Disk images, PROM files, and other files you open stay on your device. IRIS accesses only the files you explicitly select and never uploads them. + +**Children's privacy.** IRIS collects no data from anyone, including children. + +**Changes.** Any updates to this policy will be posted on this page. + +**Contact.** Questions about this policy: https://github.com/danifunker/iris/issues \ No newline at end of file From d36574c1563e0b8ed23a3917ca9888c2bbd33f52 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 10 Jun 2026 16:59:39 -0400 Subject: [PATCH 7/7] macos: run interpreter-only under the App Sandbox (Mac App Store) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cranelift (the MIPS JIT and the always-on REX3 draw-shader JIT) allocates executable memory with mmap+mprotect, not MAP_JIT. The macOS App Sandbox only permits MAP_JIT pages (com.apple.security.cs.allow-jit), so the first JITed REX3 draw is killed with SIGKILL / CODESIGNING "Invalid Page" on the REX3-Processor thread: the emulator window opens, then dies before any video. Gate the JIT off for the sandboxed build only; JIT stays on by default everywhere else: - iris-gui main.rs sets IRIS_NO_JIT=1 under cfg(appstore), before any worker thread starts. - Rex3::new builds rex_jit: None + jit_enabled: false when IRIS_NO_JIT is set (must skip *constructing* RexJit — its warm-up compiler thread allocates executable memory immediately). - run_jit_dispatch: IRIS_NO_JIT overrides IRIS_JIT. The interpreter is the normal fall-through for both JITs, so correctness is unaffected — only draw/CPU throughput. Notarized Developer-ID builds keep the JIT via an allow-unsigned-executable-memory entitlement (handled in the fork pipeline). See rules/jit/macos-app-sandbox-kills-cranelift-jit-no-map_jit.md. Co-Authored-By: Claude Opus 4.8 --- iris-gui/src/main.rs | 11 +++++ ...-sandbox-kills-cranelift-jit-no-map_jit.md | 49 +++++++++++++++++++ src/jit/dispatch.rs | 5 +- src/rex3.rs | 14 +++++- 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 rules/jit/macos-app-sandbox-kills-cranelift-jit-no-map_jit.md diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 82e2f85..a1a33ed 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -62,6 +62,17 @@ fn main() -> eframe::Result<()> { if let Some(d) = dirs::data_dir() { std::env::set_var("IRIS_CHD_DIFF_DIR", d.join("iris").join("chd-diffs")); } + // Force the interpreter on the Mac App Store build. Cranelift (MIPS JIT and + // the always-on REX3 draw-shader JIT) allocates executable memory with + // mmap+mprotect, not MAP_JIT. The App Sandbox only permits MAP_JIT pages + // (com.apple.security.cs.allow-jit is the sole code-signing entitlement MAS + // allows; allow-unsigned-executable-memory / disable-executable-page- + // protection are rejected by App Review), so the first JITed REX3 draw is + // killed with SIGKILL/CODESIGNING "Invalid Page" on the REX3-Processor + // thread — right after the emulator window opens, before any video. Set + // once, before any worker thread (CPU / REX3) can read it. + #[cfg(feature = "appstore")] + std::env::set_var("IRIS_NO_JIT", "1"); let mut viewport = egui::ViewportBuilder::default() .with_title("iris — SGI Indy emulator") // app_id sets the X11 WM_CLASS / Wayland app_id so the compositor can diff --git a/rules/jit/macos-app-sandbox-kills-cranelift-jit-no-map_jit.md b/rules/jit/macos-app-sandbox-kills-cranelift-jit-no-map_jit.md new file mode 100644 index 0000000..5117577 --- /dev/null +++ b/rules/jit/macos-app-sandbox-kills-cranelift-jit-no-map_jit.md @@ -0,0 +1,49 @@ +# macOS App Sandbox kills Cranelift JIT — it doesn't use MAP_JIT + +**Symptom (Mac App Store / TestFlight build only):** the emulator window opens, +then the process dies *before any video* with: + +``` +EXC_BAD_ACCESS / SIGKILL (Code Signature Invalid) +termination namespace = CODESIGNING, "Invalid Page" +faulting thread = REX3-Processor, PC in an anonymous (image-less) executable page +``` + +i.e. the `REX3-Processor` thread jumps into a freshly-JITed draw shader and the +kernel kills it. Same fault hits the CPU thread if the MIPS JIT (`IRIS_JIT=1`) +is on. + +**Cause:** `cranelift-jit` (0.116) allocates code pages with +`memmap2::MmapMut::map_anon` (plain `mmap(PROT_READ|WRITE)`) then flips them to +executable with `region::protect(.., READ_EXECUTE)` (an `mprotect`). It **never +uses `MAP_JIT`** and never calls `pthread_jit_write_protect_np`. See +`cranelift-jit-*/src/memory.rs`. + +Under the macOS hardened runtime the App Sandbox grants only +`com.apple.security.cs.allow-jit`, which permits **MAP_JIT pages only**. A plain +`mmap`+`mprotect(PROT_EXEC)` page is "unsigned executable memory"; executing it +is a code-signing violation → `SIGKILL`. The two entitlements that *would* allow +it — `allow-unsigned-executable-memory` and `disable-executable-page-protection` +— are **rejected by App Review**, so they are not an option for the Mac App +Store. (A Developer-ID/notarized build can carry them, which is why the JIT +works there and only the MAS build crashes.) + +**Fix:** force the interpreter on the sandboxed build. The GUI sets +`IRIS_NO_JIT=1` under `#[cfg(feature = "appstore")]` (iris-gui `main.rs`, before +any worker thread starts). The core honours it: + +- `Rex3::new` (rex3.rs): when `IRIS_NO_JIT` is set, build with `rex_jit: None` + and `jit_enabled = false`. Must skip **constructing** `RexJit`, not just + dispatch — `RexJit::new` spawns a warm-up compiler thread that allocates + executable memory immediately from the saved shader profile. +- `run_jit_dispatch` (jit/dispatch.rs): `IRIS_NO_JIT` overrides `IRIS_JIT`. + +The REX3 interpreter and the MIPS interpreter are the normal fallbacks (the JITs +are caches with interpreter fall-through), so correctness is unaffected — only +draw/CPU throughput. `lightning` is still fine (it's the no-debug perf flag, not +GNU lightning JIT). + +**Long-term option (not done):** patch/vendor the JIT memory allocator to use +`MAP_JIT` + `pthread_jit_write_protect_np` W^X toggling, which would let the MAS +build keep the JIT under `allow-jit`. cranelift-jit 0.116 exposes no hook for +this, so it means carrying a patched allocator. diff --git a/src/jit/dispatch.rs b/src/jit/dispatch.rs index 611ea88..7aea757 100644 --- a/src/jit/dispatch.rs +++ b/src/jit/dispatch.rs @@ -168,7 +168,10 @@ pub fn run_jit_dispatch( exec: &mut MipsExecutor, running: &AtomicBool, ) { - let jit_enabled = std::env::var("IRIS_JIT").map(|v| v == "1").unwrap_or(false); + // IRIS_NO_JIT (sandboxed Mac App Store build) hard-disables the JIT: + // Cranelift's non-MAP_JIT executable memory is killed by the App Sandbox. + let jit_enabled = std::env::var("IRIS_JIT").map(|v| v == "1").unwrap_or(false) + && std::env::var_os("IRIS_NO_JIT").is_none(); if !jit_enabled { eprintln!("JIT: interpreter-only mode (set IRIS_JIT=1 to enable compilation)"); diff --git a/src/rex3.rs b/src/rex3.rs index 0883005..c35cc7c 100644 --- a/src/rex3.rs +++ b/src/rex3.rs @@ -1228,10 +1228,20 @@ impl Rex3 { debug_state: Mutex::new(DebugState::default()), diag: AtomicU64::new(0), renderer: Mutex::new(None), + // IRIS_NO_JIT (set by the sandboxed Mac App Store GUI build) forces + // the interpreter: Cranelift's mmap+mprotect executable pages aren't + // MAP_JIT, so the App Sandbox kills the process with SIGKILL/ + // CODESIGNING the first time a compiled draw shader runs. Skip + // *constructing* RexJit (not just dispatch) so its warm-up compiler + // thread never allocates executable memory in the first place. #[cfg(feature = "rex-jit")] - rex_jit: Some(std::sync::Arc::new(crate::rex3_jit::RexJit::new())), + rex_jit: if std::env::var_os("IRIS_NO_JIT").is_some() { + None + } else { + Some(std::sync::Arc::new(crate::rex3_jit::RexJit::new())) + }, #[cfg(feature = "rex-jit")] - jit_enabled: AtomicBool::new(true), + jit_enabled: AtomicBool::new(std::env::var_os("IRIS_NO_JIT").is_none()), #[cfg(feature = "rex-jit")] jit_last: std::cell::Cell::new((0, 0, None)), interp_setup_cache: std::cell::Cell::new((u32::MAX, u32::MAX, u32::MAX)),