Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions LICENSE-GPL3.txt

Large diffs are not rendered by default.

50 changes: 44 additions & 6 deletions docs/appstore-review-response.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
# App Store review response — IRIS (Submission 2ed07ab1…)
# App Store review response — IRIS

Covers the two issues raised on the 1.0 (20260610.2118) review (June 16, 2026):
> **Paste-ready version → `docs/appstore-review-notes.txt`** (3,915 chars, under
> the field's 4,000 limit). Copy it verbatim into **App Review Information →
> Notes** in App Store Connect; leave the **App Sandbox Information** section
> blank. That file is the *superset* actually submitted: it adds the
> IRIX-media / IP disclaimer, "the app contacts no external services," the
> inbound port-mapping / FTP use case, and the full entitlement list (all seven
> keys, incl. the honest `allow-jit` note — see below). This markdown file is the
> working/source document (rationale, history, verification commands).

Originally written for the 1.0 (20260610.2118) review (June 16, 2026), which
raised two issues:

1. **Guideline 2.5.1** — private API `_CGSSetWindowBackgroundBlurRadius`.
2. **Guideline 2.4.5(i)** — entitlements without obvious matching functionality
(`com.apple.security.device.camera`, `com.apple.security.network.server`).

## App Sandbox Information screen — N/A

The App Store Connect **App Sandbox Information** screen is *only* for
temporary-exception entitlements (`com.apple.security.temporary-exception.*`).
IRIS uses none, so that screen stays blank. The entitlement justifications below
go in **App Review Information → Notes**, not there.

No entitlement has been added since the original submission — the later features
(per-disk CHD copy-on-write + exit-time fold, the in-core pure-Rust NFS server,
the Networking-tab redesign / FTP ALG / in-app file bridge) all run inside the
existing grants. The in-core NFS server in particular opens **zero host sockets**
(it lives entirely in the user-mode NAT), so it does not even rely on
`network.server`. PCAP capture is sandbox-incompatible and ships only in the
non-App-Store release builds, never under the `appstore` feature.

### `com.apple.security.cs.allow-jit` — kept, described honestly

The `appstore` feature force-sets `IRIS_NO_JIT=1` (`iris-gui/src/main.rs:111`), so
the App Store build runs the MIPS CPU interpreter-only — JIT is never allocated
(the binary would otherwise `SIGKILL` on the first JITed page under MAS signing,
since `allow-unsigned-executable-memory` is rejected). The entitlement is left in
place for parity with the Developer-ID builds, and the notes describe it
truthfully as present-but-disabled rather than claiming the build uses JIT.
(Decision 2026-06-20: keep + describe honestly, over removing it outright.)

---

## 1. Guideline 2.5.1 — private API (fixed in binary)
Expand Down Expand Up @@ -45,9 +80,11 @@ hardware. When the user selects the host camera as the video source, IRIS
captures live frames from the Mac's camera (AVFoundation) and feeds them to the
emulated VINO device. The matching `NSCameraUsageDescription` is in `Info.plist`.

**How to test (reviewer steps):**
1. Launch IRIS. In the launcher, open the **Video-In** tab.
2. Click **📷 Test Camera**.
**How to test (reviewer steps):** *(no boot or login required)*
1. Launch IRIS. In the left column click **Edit config…**, then click the
**Video-In** button that appears below it.
2. Click **📷 Test Camera**. (The same test is also under **Help ▶ →
Diagnostics → Test Camera…**, which requires a running machine.)
3. macOS shows the camera-permission prompt; allow it.
4. A live preview from the Mac camera appears, with a status line showing the
capture resolution and a rising frame count. Closing the window releases the
Expand Down Expand Up @@ -79,7 +116,8 @@ genuine server features above.)

**How to test (reviewer steps):**
1. Launch IRIS and **Start** a machine (the bundled config boots to the PROM).
2. Open **Machine → Serial console…**.
2. Open **Machine ▶ → Serial console…** (also under **Help ▶ → Diagnostics →
Serial console…**).
3. The window shows "● connected to 127.0.0.1:8881" and streams the live guest
serial console. Typing a line and pressing Enter sends it to the guest.
This confirms the app's loopback serial **server** is live and accepting a
Expand Down
57 changes: 57 additions & 0 deletions installer/iris-gui-sandbox-local.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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>
<!-- LOCAL sandbox test entitlements (NOT the Mac App Store).

Purpose: reproduce the App Sandbox locally so the security-scoped
bookmark / folder-grant flow (iris-gui/src/macos_sandbox.rs) can be
exercised without an App Store round-trip. The sandbox is enforced by
the app-sandbox entitlement at signing time; App Store distribution is
not required to get a sandboxed process.

Build with: ./scripts/build-macos.sh appstore
(it compiles the "appstore" feature, so the real bookmark code and
IRIS_CHD_DIFF_DIR are active, not the off-sandbox stubs).

This file deliberately OMITS the MAS-only keys present in
iris-gui.entitlements (com.apple.application-identifier,
com.apple.developer.team-identifier): those require an embedded
provisioning profile and a real team signing identity, which ad-hoc
signing cannot satisfy.

CAVEAT, bookmark persistence: app-scoped security-scoped bookmarks key
on a stable code-signing identity. Under ad-hoc signing the identity is
the binary hash, so a bookmark minted by one build may not resolve in
the next, and may not persist across relaunches. The within-session
flow still works fully (the open-panel folder pick grants access for the
whole process lifetime), so the exit-time CHD fold (grant a folder, run,
quit) is faithfully testable ad-hoc. For cross-launch persistence
testing, sign with a Developer ID via CODESIGN_IDENTITY instead. -->
<key>com.apple.security.app-sandbox</key>
<true/>

<!-- Cranelift JIT: present for parity. The appstore feature forces
interpreter-only via IRIS_NO_JIT, so it is unused at runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>

<!-- IndyCam (VINO) host-camera capture. -->
<key>com.apple.security.device.camera</key>
<true/>

<!-- User-selected disk images / PROMs / ISOs, and persisting access to them
via app-scoped security-scoped bookmarks (see macos_sandbox.rs). These
are what the folder-grant flow depends on. -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>

<!-- Guest networking (user-mode NAT) plus loopback serial-console viewer. -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
73 changes: 58 additions & 15 deletions iris-gui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ pub enum ConfigAction {
pub struct TabOutcome {
pub action: ConfigAction,
pub net: NetworkOutcome,
/// A SCSI image/disc path changed this frame (typed or picked) — mark dirty.
pub disks_changed: bool,
/// A SCSI image/disc path was just assigned via the Browse picker — the cue
/// to (re)check CHD folder-grant permissions (see `check_chd_folder_grants`).
pub disk_picked: bool,
}

pub fn show_tab(
Expand All @@ -185,10 +190,10 @@ pub fn show_tab(
) -> TabOutcome {
ScrollArea::vertical().show(ui, |ui| match tab {
Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() },
Tab::Disks => { show_disks(ui, cfg); TabOutcome::default() }
Tab::Disks => { let e = show_disks(ui, cfg); TabOutcome { disks_changed: e.changed, disk_picked: e.picked, ..Default::default() } }
Tab::Network => {
let net = show_network(ui, cfg, host, disk_folders, pcap_ifaces);
TabOutcome { action: net.action, net }
TabOutcome { action: net.action, net, ..Default::default() }
}
Tab::Memory => { show_memory(ui, cfg); TabOutcome::default() }
Tab::Display => { show_display(ui, cfg); TabOutcome::default() }
Expand Down Expand Up @@ -279,7 +284,8 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig) {
});
}

fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) {
fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit {
let mut edit = PathEdit::default();
ui.heading("SCSI devices");
ui.horizontal(|ui| {
ui.label("IDs 1–7. CD-ROMs typically use 4–6.");
Expand Down Expand Up @@ -314,16 +320,40 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) {
if let Some(dev) = cfg.scsi.get_mut(&id) {
Grid::new(("scsi_grid", id)).num_columns(2).striped(true).show(ui, |ui| {
ui.label("Image path");
path_row(ui, ("scsi_path", id), &mut dev.path,
let e = path_row(ui, ("scsi_path", id), &mut dev.path,
if dev.scratch { Pick::SaveFile } else { Pick::OpenFile },
DISK_FILTERS);
edit.changed |= e.changed;
edit.picked |= e.picked;
ui.end_row();
if dev.path.ends_with(".chd") && !build_features::CHD {
ui.label("");
ui.label(RichText::new("⚠ .chd path but this build lacks CHD support — rebuild with --features chd")
.color(Color32::from_rgb(230, 140, 70)));
ui.end_row();
}
// Active copy-on-write overlay for a compressed CHD: show exactly
// which `.diff.chd` is in use (the path honours IRIS_CHD_DIFF_DIR,
// so on the sandbox build this is the container sidecar) and its
// size, so it's unambiguous that changes are landing here and that
// this is the file folded back into the disk on a clean exit.
if build_features::CHD && dev.path.ends_with(".chd") {
let diff = iris::chd_disk::diff_path_for(Path::new(&dev.path));
if let Ok(meta) = std::fs::metadata(&diff) {
let mb = meta.len() as f64 / (1024.0 * 1024.0);
ui.label("Active overlay");
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} ({mb:.1} MB)", diff.display()))
.weak().small())
.on_hover_text("This CHD's session changes accumulate here until they're \
folded back into the disk on a clean exit.");
if ui.small_button("📂").on_hover_text("Reveal in file manager").clicked() {
reveal_in_file_manager(&diff.to_string_lossy());
}
});
ui.end_row();
}
}

ui.label("Type");
let was_cd = dev.cdrom;
Expand Down Expand Up @@ -379,7 +409,9 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) {
let mut drop_idx: Option<usize> = None;
for (i, disc) in dev.discs.iter_mut().enumerate() {
ui.horizontal(|ui| {
path_row(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS);
let e = path_row(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS);
edit.changed |= e.changed;
edit.picked |= e.picked;
if ui.button("×").clicked() { drop_idx = Some(i); }
});
}
Expand All @@ -391,6 +423,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) {
}
}
if let Some(id) = to_delete { cfg.scsi.remove(&id); }
edit
}

/// A soft-invalid subnet the user just entered, surfaced to the app so it can
Expand Down Expand Up @@ -696,7 +729,7 @@ fn show_network(
if let Some(nfs) = cfg.nfs.as_mut() {
Grid::new("nfs_grid").num_columns(2).striped(true).show(ui, |ui| {
ui.label("Shared dir");
out.changed |= path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS);
out.changed |= path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS).changed;
ui.end_row();
ui.label("NFS version");
ComboBox::from_id_salt("nfs_ver")
Expand All @@ -722,7 +755,7 @@ fn show_network(
if disk_folders.is_empty() {
ui.label(RichText::new(
"On the App Store build the shared folder must live somewhere the app has been \
granted. Grant a disk folder first (File \"Grant a disk folder…\"), then create \
granted. Grant a disk folder first (File » \"Grant a disk folder…\"), then create \
a shared folder inside it here — or pick any folder above to grant it directly.")
.weak());
} else {
Expand Down Expand Up @@ -1024,20 +1057,29 @@ pub fn reveal_in_file_manager(path: &str) {
}
}

/// A TextEdit + 📁 Browse button that updates `value` in place. Returns whether
/// `value` changed this frame, so callers can mark the config dirty (typed text
/// or a Browse pick — both must persist).
/// Outcome of a [`path_row`]: whether `value` changed this frame (typed text or
/// a Browse pick — both must persist), and whether the change specifically came
/// from the Browse *picker*. The latter is the "assignment" moment — the only
/// safe time to (re)check folder-grant permissions, since reacting to every
/// keystroke would pop a dialog mid-typing.
#[derive(Default, Clone, Copy)]
struct PathEdit {
changed: bool,
picked: bool,
}

/// A TextEdit + 📁 Browse button that updates `value` in place. See [`PathEdit`].
fn path_row(
ui: &mut Ui,
id: impl std::hash::Hash,
value: &mut String,
mode: Pick,
filters: &[(&str, &[&str])],
) -> bool {
let mut changed = false;
) -> PathEdit {
let mut out = PathEdit::default();
ui.push_id(id, |ui| {
ui.horizontal(|ui| {
changed |= ui.add(TextEdit::singleline(value).desired_width(320.0)).changed();
out.changed |= ui.add(TextEdit::singleline(value).desired_width(320.0)).changed();
if ui.button("📁").on_hover_text("Browse…").clicked() {
let mut d = rfd::FileDialog::new();
// Start the dialog in the existing path's directory if any.
Expand All @@ -1064,7 +1106,8 @@ fn path_row(
};
if let Some(p) = picked {
*value = p.to_string_lossy().into_owned();
changed = true;
out.changed = true;
out.picked = true;
}
}
// Reveal an existing path in the host file manager (Finder, Explorer,
Expand All @@ -1076,7 +1119,7 @@ fn path_row(
}
});
});
changed
out
}

/// Same as `path_row` but for `Option<String>` — Browse populates Some,
Expand Down
29 changes: 21 additions & 8 deletions iris-gui/src/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,14 +424,27 @@ fn worker_loop(
*ps2_slot.lock() = None;
cycles = None;
m.stop();
synced = m
.sync_chd_disks(
&mut |disk, total, fraction| {
let _ = evt_tx.send(Evt::SyncProgress { disk, total, fraction });
},
&|| false,
)
.unwrap_or(0);
synced = match m.sync_chd_disks(
&mut |disk, total, fraction| {
let _ = evt_tx.send(Evt::SyncProgress { disk, total, fraction });
},
&|| false,
) {
Ok(n) => n,
Err(e) => {
// Don't swallow this. The fold writes a `.synctmp.chd`
// beside the base and atomically renames it over the
// base — both need write access to the *folder*, which
// under the macOS App Sandbox a file-scoped grant (just
// picking the disk image) does not convey. Surfaced so
// the diff isn't silently left unmerged and the disk
// never shrinks.
let _ = evt_tx.send(Evt::Error(format!(
"couldn't compact CHD disks on exit: {e} — grant the disk's \
folder (File » \"Grant a disk folder…\") so IRIS can write beside it")));
0
}
};
// `m` dropped here → fully torn down.
}
let _ = evt_tx.send(Evt::Stopped);
Expand Down
Loading
Loading