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
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
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 de2e33c..9e1a508 100644
--- a/iris-gui/Cargo.toml
+++ b/iris-gui/Cargo.toml
@@ -2,6 +2,41 @@
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. 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]
# Group A (additive) features are always on for iris-gui so the user can
@@ -25,6 +60,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/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/macos_sandbox.rs b/iris-gui/src/macos_sandbox.rs
new file mode 100644
index 0000000..cc86c40
--- /dev/null
+++ b/iris-gui/src/macos_sandbox.rs
@@ -0,0 +1,201 @@
+//! 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) => {
+ 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
+ }
+ }
+ }
+}
+
+#[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..a1a33ed 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,28 @@ 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);
+ // 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"));
+ }
+ // 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
@@ -121,6 +144,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.
@@ -253,8 +298,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.
@@ -295,8 +348,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 });
@@ -533,7 +586,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;
@@ -575,8 +639,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| {
@@ -678,7 +749,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());
}
});
@@ -915,12 +986,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());
@@ -987,3 +1068,52 @@ fn native_save_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option u32;
+ }
+}
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())?;
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/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/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."
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/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/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);
}
}
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)),