From cf151ba10af6c447f5a4a4d7c2049ecf413ef38b Mon Sep 17 00:00:00 2001 From: iblowmymind <28228415+iblowmymind@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:28:10 +0300 Subject: [PATCH 1/4] Update GUI About description for supported SGI machines --- iris-gui/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index cba0edee..0218bc1b 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -1507,7 +1507,7 @@ impl App { ui.label(RichText::new("1× = native pixels; ¼× steps (½-integers crispest on Retina)").weak().small()); }); ui.menu_button("Help ▶", |ui| { - ui.label(RichText::new("IRIS — SGI Indy (MIPS R4400) Emulator").strong()); + ui.label(RichText::new("IRIS — Emulator for various SGI machines").strong()); ui.label(format!("Version {} ({})", env!("APP_VERSION"), env!("APP_COMMIT"))); ui.separator(); ui.label(RichText::new("Diagnostics").strong()); From 84d007b9a1ae8ef3489bb89a08dbe4d3e4505156 Mon Sep 17 00:00:00 2001 From: iblowmymind <28228415+iblowmymind@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:59:13 +0300 Subject: [PATCH 2/4] Store GUI machines as isolated TOML configurations --- CHANGELOG.md | 11 + FEATURES.md | 23 +- README.md | 3 +- iris-gui-README.md | 150 ++++-- iris-gui/Cargo.toml | 10 +- iris-gui/src/bench_ui.rs | 3 +- iris-gui/src/config_ui.rs | 24 +- iris-gui/src/dialogs/create_disk.rs | 7 +- iris-gui/src/dialogs/new_machine.rs | 36 +- iris-gui/src/filedialog.rs | 95 ++-- iris-gui/src/handle.rs | 42 +- iris-gui/src/machines.rs | 551 ++++++++++++++++++++++ iris-gui/src/macos_sandbox.rs | 52 +- iris-gui/src/main.rs | 473 +++++++++++-------- iris-gui/src/scsi_menu.rs | 4 +- iris-gui/src/settings.rs | 428 +++++++++++------ iris-gui/src/single_instance.rs | 2 +- rules/build/dependency-upgrade-gotchas.md | 5 +- rules/gui/01-overview.md | 9 +- 19 files changed, 1402 insertions(+), 526 deletions(-) create mode 100644 iris-gui/src/machines.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 158868b2..45dbefe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,17 @@ hashes are given where a change is easiest to understand by reading the commit. (file, categories, host or guest clock). Network tab text says "guest" rather than "Indy", since Indigo2s use it too. jitv2 builds also set the compile thread count (`[jitv2] threads`), an August setting with no control. +- **Machine TOML files and private machine folders.** The GUI discovers and + saves `machines//.toml` using the standalone emulator's schema. + `gui.json` retains GUI preferences and the active machine name. Each machine + runs from its own folder, with relative config paths, default disks under + `disks/`, and private `nvram.bin`/`nveeprom.bin` files. Existing JSON machines + migrate with a preferences backup; external disks stay in place. App Store + storage uses the container home, bookmarks cover external relative paths, + and CHD diffs migrate to each machine's folder. Renaming moves the folder and + TOML together. Import/export menu actions are removed. Directory changes wait + for emulator teardown and queued file operations, and startup seeds NVRAM + before machine construction. - **NVRAM EEPROM gets the same stable-path treatment as NVRAM.** `nveeprom` (Indigo2/IP28's motherboard EEPROM — where `eaddr` and PROM env actually live on those profiles, not in the NVRAM file) used to default to a bare diff --git a/FEATURES.md b/FEATURES.md index 105aae93..800e49f1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -67,7 +67,7 @@ turn off those dependency defaults. | GUI feature | Purpose | Enables | |---|---|---| -| `bundled` | Distribution UI: hides TOML import/export; named machines remain in gui.json. | — | +| `bundled` | Distribution UI: hides source-checkout tools; machine TOML storage is shared by all builds. | — | | `appstore` | Mac App Store UI, sandbox bookmarks, private-API patch, CHD diff redirection, and REX3 JIT disabled at startup; hides CI/Ultra64 controls. | `bundled` | | `pcap` | Expose PCAP NIC selection and bridge support in the embedded core. | `iris/pcap` | | `premiere` | Performance bundle for the embedded core; lightning plus idle parking. | `iris/lightning`, `iris/idle-pause` | @@ -121,11 +121,11 @@ separate from the runtime environment inventory below. ## Shared machine configuration inventory Source: [src/config.rs](src/config.rs), `MachineConfig` and nested structs. -The CLI reads `iris.toml` (or `--config FILE`); iris-gui stores the same schema -inside its named machines in `/iris/gui.json`. TOML import/export -is available in GUI source builds. Relative core paths resolve from the process -working directory; the GUI anchors its default battery-backed paths in its -user config directory. +The CLI reads `iris.toml` (or `--config FILE`); iris-gui reads and saves the same +TOML schema in `/iris/machines//.toml`. `gui.json` holds +GUI preferences. The GUI uses the selected machine folder as the working +directory, stores config paths as relative paths, and defaults to `disks/` for +disk images and `nvram.bin`/`nveeprom.bin` for private battery-backed state. Defaults below are **core schema/runtime defaults**, not this checkout's sample iris.toml or the GUI New Machine dialog. GUI IP28 creation selects @@ -376,17 +376,16 @@ not Cargo build features. ## iris-gui persisted settings inventory Source: [iris-gui/src/settings.rs](iris-gui/src/settings.rs). These keys belong -to GUI JSON rather than iris.toml. View controls update the scales; the GUI -writes machine configs and sandbox access state for you. +to GUI JSON rather than machine TOML files. View controls update the scales; +the GUI saves machine TOMLs and sandbox access state for you. | GUI JSON key | Normal load default | Purpose | |---|---|---| | `ui_scale` | `1.25` | egui controls scale; sanitized to supported range. | | `vm_scale` | `0.75` | Maximum framebuffer magnification, independent of UI scale. | -| `machines` | empty map | Named shared MachineConfig objects. | -| `active_machine` | unset | Selected key in machines. | -| `recent_configs` | empty list | Recent TOML imports. | -| `last_config` | unset | Legacy one-shot TOML migration source. | +| `active_machine` | unset | Selected machine folder name. | +| `recent_configs` | empty list | Legacy recent TOML paths; retained for compatibility. | +| `last_config` | unset | Legacy TOML migration source; cleared after conversion. | | `bookmarks` | empty map | macOS sandbox security-scoped bookmarks by absolute path. | | `disk_folders` | empty list | Granted recursive sandbox disk-folder access. | diff --git a/README.md b/README.md index 9a1d6c35..f3b9e646 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ boots to a usable system: shell, networking, X11, the works. in-process NFSv2/v3 server, TFTP for PROM network boot, and FTP/XDMCP helpers; or PCAP bridging onto a real LAN - Headless mode and a CI control socket (`iris-ci`) for automation -- Optional egui front-end (`iris-gui`) with machine management and a benchmark tab +- Optional egui front-end (`iris-gui`) with machine management and a benchmark tab; + machines use the CLI TOML schema in isolated folders with relative paths - DaynaPort SCSI/Link Ethernet and the N64 development board (Ultra64), both enabled per machine in the config - Other guests: Linux (Debian 7, Gentoo), NetBSD and OpenBSD have had SCSI, interrupt and timer fixes land for them. They are not regularly tested, so diff --git a/iris-gui-README.md b/iris-gui-README.md index 4323111f..1f748d01 100644 --- a/iris-gui-README.md +++ b/iris-gui-README.md @@ -43,7 +43,7 @@ emulation will be noticeably slow. | `hostgl` | Host OpenGL for IRIX programs (implies `hostcall`); IRIX's replacement libGL replays its GL calls on the host GPU. macOS (CGL) and Linux (EGL; needs libGL to build, libEGL and Mesa's EGL/DRI drivers at run time; see CHANGELOG) — builds elsewhere but registers no backend | | `premiere` | `iris/lightning` + `iris/idle-pause` for maximum in-process speed | | `perfetto` | Perfetto trace recording; the Debug tab gains Start/Stop for a `.pftrace` (see `docs/perfetto.md`) | -| `bundled` | Distributed build: hides the iris.toml import/export items. Set by the Release workflow | +| `bundled` | Distributed build: hides source-checkout tools such as premiere preparation. Machine TOML storage is shared by source, Release, and App Store builds. Set by the Release workflow | | `appstore` | Mac App Store build: implies `bundled`, hides the CI tab, enables security-scoped bookmarks and folder grants | DaynaPort, Ultra64, IP28/R10000, and the graphics board models are built in. @@ -81,18 +81,19 @@ cargo tree -p iris | grep -E 'egui|eframe|rfd' # should print nothing ## 2. First run -On a cold start (no `gui.json`), the **New machine** dialog opens +When no machine TOML files exist, the **New machine** dialog opens automatically. You'll be asked for: -- **Name** — used for the machine entry in `gui.json`. Conflicts get a - numeric suffix (`indy`, `indy-2`, …). +- **Name** — names the machine folder and its TOML file. Conflicts get a + numeric suffix (`indy`, `indy-2`, …). Names cannot contain path separators + or reserved filename characters. - **Machine model** — Indy (IP24), Indigo2 (IP22), or Indigo2 IMPACT (IP28). **Processor** offers R4400, R5000, and R10000. Selecting IP28 defaults to R10000 and IMPACT Solid graphics. - **PROM image** — defaults to "Use embedded PROM (bundled with iris)", which lets iris fall back to its built-in PROM blob with no disk file needed for IP24, IP22, or IP28. Each profile selects its own embedded image. -- **NVRAM file** — defaults to a stable per-user path (see Storage) and is +- **NVRAM file** — defaults to `nvram.bin` in the machine folder (see Storage) and is seeded with a default NVRAM on first use. Indigo2 profiles also have a **NVRAM EEPROM file**, which stores their PROM environment and MAC. - **Total RAM** — preset totals. Tick **Advanced: configure individual banks** @@ -102,7 +103,7 @@ automatically. You'll be asked for: image afterwards from the SCSI menu (**Create blank HDD image…**). - **CD-ROM (SCSI #4)** — optional install media. -Hit **Create**. The machine is saved to `gui.json` and becomes active. Start it +Click **Create**. The machine is saved to `machines//.toml` and becomes active. Start it from the **Machine** menu. If the NVRAM or motherboard EEPROM has no Ethernet MAC, iris-gui writes one @@ -132,7 +133,7 @@ light for the internal network). | Menu | Contents | | --- | --- | -| **File** | New machine… / Switch to machine / Import iris.toml… / Export current to iris.toml… / Prepare for premiere… (source builds) / Disk folder access (App Store) / Quit | +| **File** | New machine… / Switch to machine / Rename current… / Delete current machine / Prepare for premiere… (source builds) / Disk folder access (App Store) / Quit | | **Machine** | Start / Stop / Reset / Reset NVRAM (fresh PRAM) / Processor (R4400, R5000, or R10000, applies at next Start) / Save and Restore state / Screenshot… / Serial console… | | **Memory** | Total presets, plus per-bank submenus | | **SCSI** | Per-ID submenu (SCSI #1 … #7) with context-appropriate actions, plus per-disk **Commit changes to disk** / **Discard changes** for COW overlays and CHD diffs while stopped | @@ -195,58 +196,116 @@ PCAP-mode NFS IP). ### Where things live -- `/iris/gui.json` — **the system of record.** Contains all saved - machines, the active machine pointer, UI scale, VM screen scale, and - fullscreen pref. `` is `dirs::config_dir()`: `~/.config` on Linux, - `~/Library/Application Support` on macOS, `%APPDATA%` on Windows. -- `/iris/nvram.bin` — the default NVRAM path. It is absolute on - purpose, so the NVRAM is the same however the app was launched; older relative - `nvram.bin` entries are migrated. -- `/iris/nveeprom.bin` — the default NVRAM EEPROM path (Indigo2/IP28's - motherboard EEPROM, where `eaddr` and PROM env actually live on those profiles — - Indy has no such chip). Same absolute-path and migration treatment as `nvram.bin` - above. Editable on the General tab, right below NVRAM file. -- `iris.toml` — the **standalone iris CLI's** config format. iris-gui - treats it as *import/export only* via the File menu (hidden in `bundled` - builds), so a machine configured in the GUI can still be booted with - `cargo run -- --config exported.toml`. +The storage root is `/iris`: `~/Library/Application Support/iris` +on macOS, `$XDG_CONFIG_HOME/iris` (default `~/.config/iris`) on Linux, including +AppImage builds, and `%APPDATA%/iris` on Windows. Mac App Store builds use +Foundation's container home, so the root is inside +`~/Library/Containers//Data/Library/Application Support/iris`. + +```text +iris/ +├── gui.json +└── machines/ + ├── indy/ + │ ├── indy.toml + │ ├── nvram.bin + │ ├── nveeprom.bin + │ └── disks/ + │ └── scsi1.raw + └── indigo2/ + ├── indigo2.toml + └── disks/ +``` + +`gui.json` holds GUI preferences and the active machine name. Each machine's +TOML file is its configuration source, using the same `MachineConfig` schema +as the standalone `iris` CLI. At launch, the GUI lists +`machines//.toml` and selects the last opened machine. If that +machine is unavailable, it selects the first available machine or opens +**New machine** when the list is empty. Invalid TOML files produce visible +errors and remain untouched. + +The selected machine folder is the emulator's working directory. Config paths +stay relative to that folder, including files selected with **Browse**: + +```toml +prom = "(embedded)" +nvram = "nvram.bin" +nveeprom = "nveeprom.bin" + +[scsi.1] +path = "disks/scsi1.raw" +``` + +New disk images default to `disks/scsiN.raw`. External images stay in place; +the TOML references them with relative paths such as `../../../../media/root.chd`. +The same resolution applies to disc changers, NFS shares, TFTP folders, serial +logs, ELF binaries, test dumps, filesystem CI sockets, and JIT cache folders. +TCP CI addresses and network interface names retain their original spelling. +On Windows, external paths must be on the same volume as the machine folder +to be expressible as relative paths. + +NVRAM and motherboard EEPROM default to separate files in each machine folder. +App Store CHD diffs use that machine's `chd-diffs/` folder. Security-scoped +bookmarks remain in `gui.json` and use resolved absolute paths, including +external resources reached through `..`. + +To run the same machine with the CLI, change to its folder and pass its TOML: + +```sh +cd "$HOME/Library/Application Support/iris/machines/indy" +/path/to/iris --config indy.toml +``` ### `gui.json` shape ```json { - "ui_scale": 1.15, - "fullscreen": false, + "ui_scale": 1.25, + "vm_scale": 0.75, "active_machine": "indy", - "machines": { - "indy": { "prom": "(embedded)", "nvram": "/home/me/.config/iris/nvram.bin", ... }, - "irix-65": { ... } - }, - "recent_configs": [...], - "last_config": null + "recent_configs": [], + "last_config": null, + "bookmarks": {}, + "disk_folders": [] } ``` -`MachineConfig` (defined in `src/config.rs`) is serde-serialized directly, so the -schema follows the canonical iris config. Existing `gui.json` files from earlier -iris-gui builds upgrade automatically (missing fields default). +The legacy `recent_configs` and `last_config` keys remain compatible with +older preferences. Machine configurations are never serialized into this JSON. +The File menu has no TOML import or export actions: place a machine TOML in its +matching folder instead. **Prepare for premiere** uses the existing machine +TOML; it no longer creates a separate exported config. -### Autosave +### Autosave and machine management -Every form field, dialog result, and menu action that mutates the config -calls `App::mark_dirty()`. Each frame, `App::maybe_autosave()` flushes after -**~600 ms of inactivity** — debouncing keystrokes without leaving you in a -"did it save?" state. +Config edits save to the active TOML after **~600 ms of inactivity**. Hard +flushes also occur before **Start**, **Quit**, **Switch to machine**, and +**Rename current**. Preference saves do not rewrite machine TOMLs. Selecting +or starting a machine rereads its file, so external edits take effect too. -Hard flushes also occur before **Start**, on **Quit**, on **Switch to machine**, -and on **Import iris.toml…**. +Switching, creating, renaming, and deleting machines require the emulator to +be stopped and its queued file operations to finish. Renaming moves the +machine folder and renames its TOML together, retaining its disks and +battery-backed state. Deleting removes the TOML registration and retains the +folder's disk and NVRAM files. Retained folders reserve their names to prevent +accidental reuse. ### Migration -If you had an older iris-gui that pointed `last_config` at an -`iris.toml`, the first launch of the new build will import that TOML as -a named machine (using the file stem), clear the legacy pointer, and -adopt it as `active_machine`. No manual steps required. +On the first launch with machines embedded in `gui.json`, the GUI keeps +`gui.json.pre-toml.bak`, writes each machine to its TOML folder, and removes +only the machine payloads from the preferences. It copies battery-backed +state into each machine's folder, preserving PROM settings and MAC addresses. +Existing disks stay in place and their paths become relative. App Store CHD +diffs are copied from the shared redirect into the machine's redirect, and +the original files remain available for rollback. Names that cannot be used +as folder names receive deterministic safe names. + +A legacy `last_config` pointer also migrates once into a machine folder; its +source TOML stays untouched. If migration fails, the original JSON and its +backup remain intact, and preference writes stop until the error is resolved. + --- @@ -351,7 +410,8 @@ iris/ ├── bench_ui.rs Benchmark tab ├── scsi_menu.rs SCSI menu (per-ID actions, disc pickers) ├── safe_stop.rs Stop-safety evaluator - ├── settings.rs GuiSettings: gui.json, NVRAM seeding and MAC helpers + ├── settings.rs GUI preferences, migration, NVRAM seeding and MAC helpers + ├── machines.rs TOML folders, relative paths, discovery, and rename ├── ram.rs RAM presets shared by menus and tabs ├── netplan.rs subnet math for the Networking tab ├── netfix.rs "check / fix guest networking" logic diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index 562e8e5c..b8660145 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -52,12 +52,8 @@ assets = [ ] [features] -# Pre-compiled / distributed build. Hides developer-only affordances that don't -# make sense for users running an official binary — currently the iris.toml -# import/export menu items (the GUI's gui.json machine store is the system of -# record; iris.toml is only useful alongside the standalone `iris` CLI, i.e. a -# source checkout). Set by the Release and App Store workflows. A no-op for -# source builds, where the iris.toml workflow stays available. +# Distributed build. Hides source-checkout tools such as rebuild/premiere. +# Machine TOML storage is shared by source, Release, and App Store builds. bundled = [] # Mac App Store distribution build. Implies `bundled`, and additionally hides # the CI/Automation config tab (the iris-ci socket is a developer automation @@ -124,7 +120,7 @@ if-addrs = "0.15" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" objc2-foundation = { version = "0.3", features = [ - "NSURL", "NSData", "NSError", "NSString", "NSArray", + "NSURL", "NSData", "NSError", "NSString", "NSArray", "NSPathUtilities", ] } [[bin]] diff --git a/iris-gui/src/bench_ui.rs b/iris-gui/src/bench_ui.rs index ed80edcd..eec4d81d 100644 --- a/iris-gui/src/bench_ui.rs +++ b/iris-gui/src/bench_ui.rs @@ -800,10 +800,11 @@ mod dev { fn locate(rel: &str) -> Option { let exe_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(PathBuf::from)); let name = Path::new(rel).file_name()?.to_owned(); - let mut candidates = vec![PathBuf::from(rel), PathBuf::from("..").join(rel)]; + let mut candidates = vec![crate::launch_dir().join(rel), crate::launch_dir().join("..").join(rel)]; if let Some(d) = exe_dir { candidates.push(d.join(&name)); candidates.push(d.join(rel)); + if let Some(root) = d.parent().and_then(Path::parent) { candidates.push(root.join(rel)); } } candidates.into_iter().find(|p| p.exists()) } diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 7d8f9c0b..abcb9ae8 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -163,7 +163,7 @@ pub enum ConfigAction { /// User picked a disc image for a CD-ROM while the machine is running — /// send Cmd::LoadDisc immediately without waiting for restart. LoadDisc { id: u8, path: String, remount: bool }, - /// Export TOML and spawn headless iris with ci=true, then iris-ci ping. + /// Spawn headless iris with the machine TOML and ci=true, then iris-ci ping. TestCi, /// Spawn ensure-build.bat for CLI or GUI premiere profile. RebuildProfile { gui: bool }, @@ -938,7 +938,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) // clear the auto-generated HDD placeholder path so it doesn't // look like a (missing) disc. Load media via "Insert disc…" in // the SCSI menu, or just type a path here. - if dev.cdrom && !was_cd && dev.path == format!("scsi{id}.raw") { + if dev.cdrom && !was_cd && (dev.path == crate::settings::GuiSettings::default_disk_path(id) || dev.path == format!("scsi{id}.raw")) { dev.path.clear(); } ui.end_row(); @@ -2069,9 +2069,12 @@ fn show_ci(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { ui.add_space(8.0); let mut action = ConfigAction::None; if ui.button("Test CI connection (iris-ci ping)").clicked() { - let socket = cfg.ci_socket.clone(); + let socket = if iris::config::ci_socket_is_tcp(&cfg.ci_socket) { cfg.ci_socket.clone() } else { crate::abs_path(&cfg.ci_socket) }; + let client = crate::developer_binary("iris-ci"); + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); std::thread::spawn(move || { - let status = std::process::Command::new("target/release/iris-ci.exe") + let status = std::process::Command::new(client) + .current_dir(cwd) .arg("ping") .env("IRIS_CI_SOCKET", &socket) .output(); @@ -2083,18 +2086,13 @@ fn show_ci(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { }); ui.ctx().request_repaint(); } - if ui.button("Test in CI (export + headless boot + ping)").clicked() { + if ui.button("Test in CI (headless boot + ping)").clicked() { action = ConfigAction::TestCi; } - ui.label(RichText::new("Export TOML, start iris with ci=true, then ping.").weak().small()); + ui.label(RichText::new("Start iris with this machine TOML and ci=true, then ping.").weak().small()); action } -/// Serialize `cfg` back to TOML string in the same style as iris.toml. -pub fn cfg_to_toml(cfg: &MachineConfig) -> Result { - toml::to_string_pretty(cfg).map_err(|e| e.to_string()) -} - /// How a Browse button should pick a path. #[derive(Clone, Copy)] enum Pick { @@ -2110,7 +2108,7 @@ pub fn reveal_in_file_manager(path: &str) { #[cfg(target_os = "macos")] { // NSWorkspace, not `open` — sandbox-safe (see macos_sandbox::reveal_in_finder). - crate::macos_sandbox::reveal_in_finder(path); + crate::macos_sandbox::reveal_in_finder(&crate::abs_path(path)); } #[cfg(target_os = "windows")] { @@ -2203,7 +2201,7 @@ fn path_row_in( Pick::Dir => d.pick_folder(), }; if let Some(p) = picked { - *value = p.to_string_lossy().into_owned(); + *value = crate::picked_path(&p); out.changed = true; out.picked = true; } diff --git a/iris-gui/src/dialogs/create_disk.rs b/iris-gui/src/dialogs/create_disk.rs index 97a4b3c3..cf70f56c 100644 --- a/iris-gui/src/dialogs/create_disk.rs +++ b/iris-gui/src/dialogs/create_disk.rs @@ -25,8 +25,7 @@ impl Default for CreateDiskDialog { impl CreateDiskDialog { pub fn open_for(&mut self, scsi_id: u8) { self.scsi_id = scsi_id; - // Absolute, app-managed default location (writable in the App Store - // sandbox too) so a new disk never lands in the working dir. + // Relative to the selected machine folder, writable in the sandbox. self.filename = crate::settings::GuiSettings::default_disk_path(scsi_id); self.size_mb = 1024.0; self.overwrite = false; @@ -58,7 +57,7 @@ impl CreateDiskDialog { crate::filedialog::Purpose::Save, &[("Disk image", &["raw", "img"])]); if let Some(p) = dlg.save_file() { - self.filename = p.to_string_lossy().into_owned(); + self.filename = crate::picked_path(&p); } } }); @@ -92,7 +91,7 @@ impl CreateDiskDialog { .fill(Color32::from_rgb(60, 110, 60))).clicked() { // Create file on disk now (making the parent dir first, - // e.g. the managed /disks on first use). + // e.g. the managed /disks on first use). let path = PathBuf::from(&self.filename); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index 65975c66..35534df6 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -81,7 +81,16 @@ impl NewMachineDialog { pub fn open(&mut self) { self.open = true; self.result = None; } pub fn take_result(&mut self) -> Option { self.result.take() } - pub fn show(&mut self, ctx: &egui::Context) { + /// Resolve picker defaults in the prospective folder, before creation. + fn machine_dir(&self, prefs: &crate::settings::GuiSettings) -> std::path::PathBuf { + let name = if self.name.trim().is_empty() { "indy" } else { self.name.trim() }; + if crate::machines::validate_name(name).is_ok() { + if let Ok(dir) = prefs.machine_dir(&prefs.unique_name(name)) { return dir; } + } + prefs.root().map(std::path::Path::to_path_buf).unwrap_or_else(|_| std::path::PathBuf::from(".")) + } + + pub fn show(&mut self, ctx: &egui::Context, prefs: &crate::settings::GuiSettings) { if !self.open { return; } let mut close = false; egui::Window::new("New machine") @@ -151,8 +160,8 @@ impl NewMachineDialog { ui.add_enabled(!self.use_embedded_prom, TextEdit::singleline(&mut self.prom_path).desired_width(260.0)); if ui.add_enabled(!self.use_embedded_prom, egui::Button::new("📁")).clicked() { - if let Some(p) = crate::filedialog::dialog_with( - "PROM image", &self.prom_path, + if let Some(p) = crate::filedialog::dialog_with_base( + "PROM image", &self.prom_path, &self.machine_dir(prefs), crate::filedialog::Anchor::Data, crate::filedialog::Purpose::Open, &[("PROM image", &["bin"])]).pick_file() @@ -170,8 +179,8 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.nvram_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = crate::filedialog::dialog_with( - "NVRAM file", &self.nvram_path, + if let Some(p) = crate::filedialog::dialog_with_base( + "NVRAM file", &self.nvram_path, &self.machine_dir(prefs), crate::filedialog::Anchor::Data, crate::filedialog::Purpose::Save, &[("NVRAM", &["bin"])]).save_file() @@ -187,8 +196,8 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.nveeprom_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = crate::filedialog::dialog_with( - "NVRAM EEPROM file", &self.nveeprom_path, + if let Some(p) = crate::filedialog::dialog_with_base( + "NVRAM EEPROM file", &self.nveeprom_path, &self.machine_dir(prefs), crate::filedialog::Anchor::Data, crate::filedialog::Purpose::Save, &[("NVRAM EEPROM", &["bin"])]).save_file() @@ -256,8 +265,8 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.scsi1_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = crate::filedialog::dialog_with( - "Hard disk image", &self.scsi1_path, + if let Some(p) = crate::filedialog::dialog_with_base( + "Hard disk image", &self.scsi1_path, &self.machine_dir(prefs), crate::filedialog::Anchor::Disks, crate::filedialog::Purpose::Open, &[("Disk image", &["raw", "img", "chd"])]).pick_file() @@ -277,8 +286,8 @@ impl NewMachineDialog { ui.horizontal(|ui| { ui.add(TextEdit::singleline(&mut self.cdrom4_path).desired_width(260.0)); if ui.button("📁").clicked() { - if let Some(p) = crate::filedialog::dialog_with( - "CD-ROM image", &self.cdrom4_path, + if let Some(p) = crate::filedialog::dialog_with_base( + "CD-ROM image", &self.cdrom4_path, &self.machine_dir(prefs), crate::filedialog::Anchor::Disks, crate::filedialog::Purpose::Open, &[("ISO", &["iso"]), ("CD image", &["iso", "chd"])]).pick_file() @@ -302,7 +311,10 @@ impl NewMachineDialog { ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { close = true; } - if ui.add(egui::Button::new(RichText::new("Create").strong()) + let name = if self.name.trim().is_empty() { "indy" } else { self.name.trim() }; + let valid = crate::machines::validate_name(name); + if let Err(e) = &valid { ui.colored_label(Color32::from_rgb(230, 140, 70), e); } + if ui.add_enabled(valid.is_ok(), egui::Button::new(RichText::new("Create").strong()) .fill(Color32::from_rgb(60, 110, 60))).clicked() { let mut cfg = MachineConfig::default(); diff --git a/iris-gui/src/filedialog.rs b/iris-gui/src/filedialog.rs index 4346da7d..73f59808 100644 --- a/iris-gui/src/filedialog.rs +++ b/iris-gui/src/filedialog.rs @@ -47,14 +47,14 @@ //! //! Three kinds of location, and they behave differently: //! -//! - **The managed folder** (`/…/iris/disks`). `dirs::config_dir()` -//! is redirected into the container, so this is ours, always readable and +//! - **The managed folder** (`/…/iris/machines//disks`). `GuiSettings::data_dir()` +//! selects the sandbox container, so this is ours, always readable and //! always creatable. Nothing special needed. //! - **A folder the user granted.** `macos_sandbox::restore` asserts every //! stored bookmark at startup and holds it for the process lifetime, so by //! the time a picker opens we can stat it like any other path. //! - **An absolute path we hold no bookmark for** — typed in, or carried over -//! in `gui.json` from another machine. We cannot stat it, and that is the +//! in a machine TOML. We cannot stat it, and that is the //! case `nearest_existing` has to get right; see there. use std::path::{Path, PathBuf}; @@ -65,9 +65,9 @@ use crate::settings::GuiSettings; /// usable folder of its own. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Anchor { - /// Disk images — `/disks`. + /// Disk images — `/disks`. Disks, - /// Everything else the app owns: PROM, NVRAM, logs, exports, screenshots. + /// Everything else the app owns: PROM, NVRAM, logs, and screenshots. Data, } @@ -82,7 +82,7 @@ impl Anchor { fn managed(self) -> Option { let dir = match self { Anchor::Disks => GuiSettings::disks_dir()?, - Anchor::Data => GuiSettings::data_dir()?, + Anchor::Data => GuiSettings::working_dir()?, }; if !dir.is_dir() { let _ = std::fs::create_dir_all(&dir); @@ -122,19 +122,17 @@ fn name_to_prefill(current: &str, purpose: Purpose, name_breaks_dir: bool) -> Op /// The folder a dialog for `current` should open in. Always exists. pub fn start_dir(current: &str, anchor: Anchor) -> PathBuf { - start_dir_in(current, anchor.managed()) + start_dir_in(current, anchor.managed(), GuiSettings::working_dir().as_deref()) } /// `start_dir` with the managed directory supplied, so the logic is testable /// without touching the real one. -fn start_dir_in(current: &str, managed: Option) -> PathBuf { +fn start_dir_in(current: &str, managed: Option, base: Option<&Path>) -> PathBuf { let current = current.trim(); if !current.is_empty() { - // A bare or relative name means the managed directory, not the process - // working directory — that differs between `cargo run` and a bundled - // .app, which is the same trap `GuiSettings::default_nvram_path` - // documents. - let p = match (Path::new(current).is_absolute(), &managed) { + // Relative config paths use the machine folder, independent of the + // picker's fallback (disks/ for images, the machine folder otherwise). + let p = match (Path::new(current).is_absolute(), base) { (false, Some(base)) => base.join(current), _ => PathBuf::from(current), }; @@ -193,15 +191,37 @@ fn last_resort() -> PathBuf { /// save panel, and only where that does not cost us the directory — see the /// module docs. pub fn dialog(title: &str, current: &str, anchor: Anchor, purpose: Purpose) -> rfd::FileDialog { - let mut d = rfd::FileDialog::new() - .set_title(title) - .set_directory(start_dir(current, anchor)); + dialog_seeded(title, current, purpose, start_dir(current, anchor)) +} + +fn dialog_seeded(title: &str, current: &str, purpose: Purpose, dir: PathBuf) -> rfd::FileDialog { + let mut d = rfd::FileDialog::new().set_title(title).set_directory(dir); if let Some(name) = name_to_prefill(current, purpose, NAME_FIELD_BREAKS_DIRECTORY) { d = d.set_file_name(name); } d } +/// New-machine pickers use a prospective folder without creating it or reserving +/// its name. Their fallback must not create disks in the previous machine. +pub fn dialog_with_base( + title: &str, + current: &str, + base: &Path, + anchor: Anchor, + purpose: Purpose, + filters: &[(&str, &[&str])], +) -> rfd::FileDialog { + let fallback = match anchor { + Anchor::Disks => nearest_existing(&base.join("disks")), + Anchor::Data => nearest_existing(base), + }; + let dir = start_dir_in(current, fallback, Some(base)); + let mut dialog = dialog_seeded(title, current, purpose, dir); + for (label, exts) in filters { dialog = dialog.add_filter(*label, exts); } + dialog +} + /// A picker opened *at* `dir` rather than at its parent. /// /// For choosing a folder when you already know which one you mean — granting @@ -250,7 +270,7 @@ mod tests { let managed = tmp("existing"); let img = managed.join("scsi1.raw"); std::fs::write(&img, b"x").unwrap(); - assert_eq!(start_dir_in(&img.to_string_lossy(), Some(managed.clone())), managed); + assert_eq!(start_dir_in(&img.to_string_lossy(), Some(managed.clone()), Some(&managed)), managed); std::fs::remove_dir_all(&managed).ok(); } @@ -261,32 +281,41 @@ mod tests { fn a_destination_that_does_not_exist_yet_walks_up_to_one_that_does() { let managed = tmp("walkup"); let deep = managed.join("indy").join("disks").join("root.raw"); - assert_eq!(start_dir_in(&deep.to_string_lossy(), Some(managed.clone())), managed, + assert_eq!(start_dir_in(&deep.to_string_lossy(), Some(managed.clone()), Some(&managed)), managed, "must climb to the nearest real folder, not give up"); // One level materialises: now that is the answer. let mid = managed.join("indy"); std::fs::create_dir_all(&mid).unwrap(); - assert_eq!(start_dir_in(&deep.to_string_lossy(), Some(managed.clone())), mid); + assert_eq!(start_dir_in(&deep.to_string_lossy(), Some(managed.clone()), Some(&managed)), mid); std::fs::remove_dir_all(&managed).ok(); } #[test] - fn a_bare_name_resolves_against_the_managed_folder_not_the_working_directory() { + fn a_relative_name_resolves_against_the_supplied_machine_folder() { let managed = tmp("bare"); - // `scsi1.raw` with no directory means the managed one. Resolving it - // against the process cwd would differ between `cargo run` and a - // bundled .app. - assert_eq!(start_dir_in("scsi1.raw", Some(managed.clone())), managed); - assert_eq!(start_dir_in("sub/scsi1.raw", Some(managed.clone())), managed); + // Relative paths use the explicit machine base in every launch context. + assert_eq!(start_dir_in("scsi1.raw", Some(managed.clone()), Some(&managed)), managed); + assert_eq!(start_dir_in("sub/scsi1.raw", Some(managed.clone()), Some(&managed)), managed); std::fs::remove_dir_all(&managed).ok(); } + #[test] + fn a_disk_path_is_relative_to_the_machine_not_its_disks_subfolder() { + let machine = tmp("machine-base"); + let disks = machine.join("disks"); + std::fs::create_dir_all(&disks).unwrap(); + assert_eq!(start_dir_in("disks/scsi1.raw", Some(disks.clone()), Some(&machine)), disks); + // A bare filename in a hand-written TOML is also machine-relative. + assert_eq!(start_dir_in("scsi1.raw", Some(disks), Some(&machine)), machine); + std::fs::remove_dir_all(machine).unwrap(); + } + #[test] fn an_empty_value_falls_back_to_the_managed_folder() { let managed = tmp("empty"); - assert_eq!(start_dir_in("", Some(managed.clone())), managed); - assert_eq!(start_dir_in(" ", Some(managed.clone())), managed); + assert_eq!(start_dir_in("", Some(managed.clone()), Some(&managed)), managed); + assert_eq!(start_dir_in(" ", Some(managed.clone()), Some(&managed)), managed); std::fs::remove_dir_all(&managed).ok(); } @@ -334,7 +363,7 @@ mod tests { std::fs::write(&img, b"x").unwrap(); for breaks in [true, false] { for purpose in [Purpose::Open, Purpose::Save] { - assert_eq!(start_dir_in(&img.to_string_lossy(), Some(managed.clone())), managed); + assert_eq!(start_dir_in(&img.to_string_lossy(), Some(managed.clone()), Some(&managed)), managed); let _ = name_to_prefill(&img.to_string_lossy(), purpose, breaks); } } @@ -357,7 +386,7 @@ mod tests { std::fs::write(&img, b"x").unwrap(); let value = img.to_string_lossy().into_owned(); - assert_eq!(start_dir_in(&value, Some(disks.clone())), disks, + assert_eq!(start_dir_in(&value, Some(disks.clone()), Some(&disks)), disks, "Browse must open the disks folder, not anywhere else"); // And on macOS no name may ride along, or AppKit throws that directory @@ -367,7 +396,7 @@ mod tests { // Still right if the image has not been created yet. std::fs::remove_file(&img).unwrap(); - assert_eq!(start_dir_in(&value, Some(disks.clone())), disks); + assert_eq!(start_dir_in(&value, Some(disks.clone()), Some(&disks)), disks); std::fs::remove_dir_all(&home).ok(); } @@ -394,7 +423,7 @@ mod tests { let img = disks.join("scsi1.raw"); std::fs::write(&img, b"x").unwrap(); let value = img.to_string_lossy().into_owned(); - let dir = start_dir_in(&value, Some(disks.clone())); + let dir = start_dir_in(&value, Some(disks.clone()), Some(&disks)); // What the old code did: directory *and* name. The backend joins them // and setDirectoryURL: receives a file, which AppKit discards — the @@ -443,7 +472,7 @@ mod tests { assert_eq!(nearest_existing(&inner).unwrap(), inner, "a denied folder must not be walked past"); let img = inner.join("scsi1.raw"); - assert_eq!(start_dir_in(&img.to_string_lossy(), Some(root.clone())), inner); + assert_eq!(start_dir_in(&img.to_string_lossy(), Some(root.clone()), Some(&root)), inner); } std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -466,7 +495,7 @@ mod tests { let cases = ["", " ", "scsi1.raw", "/nonexistent/deep/path/x.raw", "relative/x.raw"]; for managed in [None, Some(PathBuf::from("/nonexistent/managed"))] { for c in cases { - let d = start_dir_in(c, managed.clone()); + let d = start_dir_in(c, managed.clone(), None); assert!(d.is_dir(), "start_dir_in({c:?}, {managed:?}) = {d:?}, which is not a directory"); } } diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs index fe9bd783..eb2c26d8 100644 --- a/iris-gui/src/handle.rs +++ b/iris-gui/src/handle.rs @@ -7,6 +7,7 @@ use parking_lot::Mutex; use std::net::Ipv4Addr; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread::JoinHandle; #[derive(Debug)] @@ -143,6 +144,8 @@ fn net_state_for(running: bool, halted: bool, net_seen: u64) -> NetState { pub struct EmulatorHandle { cmd_tx: Sender, + pending_commands: Arc, + abandoned_machine: Arc, evt_rx: Receiver, thread: Option>, /// Shared latest-framebuffer slot, written by the CaptureRenderer @@ -164,6 +167,10 @@ pub struct EmulatorHandle { impl EmulatorHandle { pub fn spawn() -> Self { let (cmd_tx, cmd_rx) = unbounded::(); + let pending_commands = Arc::new(AtomicUsize::new(0)); + let pending_for_worker = pending_commands.clone(); + let abandoned_machine = Arc::new(AtomicBool::new(false)); + let abandoned_for_worker = abandoned_machine.clone(); let (evt_tx, evt_rx) = unbounded::(); let frame_sink = FrameSink::new(); let frame_sink_head1 = FrameSink::new(); @@ -181,10 +188,12 @@ impl EmulatorHandle { // worker generous headroom. This is virtual address space, lazily // committed, so the large reservation has no real cost. .stack_size(64 * 1024 * 1024) - .spawn(move || worker_loop(cmd_rx, evt_tx, sink_for_worker, sink_head1_for_worker, ps2_for_worker)) + .spawn(move || worker_loop(cmd_rx, evt_tx, sink_for_worker, sink_head1_for_worker, ps2_for_worker, pending_for_worker, abandoned_for_worker)) .expect("spawn iris-gui-emu thread"); Self { cmd_tx, + pending_commands, + abandoned_machine, evt_rx, thread: Some(thread), frame_sink, @@ -196,7 +205,8 @@ impl EmulatorHandle { } pub fn send(&self, cmd: Cmd) { - let _ = self.cmd_tx.send(cmd); + self.pending_commands.fetch_add(1, Ordering::AcqRel); + if self.cmd_tx.send(cmd).is_err() { self.pending_commands.fetch_sub(1, Ordering::Release); } } /// Drain pending events; return them for the UI to consume. @@ -246,6 +256,14 @@ impl EmulatorHandle { pub fn is_running(&self) -> bool { self.status.running } + /// Directory changes must wait for queued Start, Stop, snapshot, and disk + /// operations as well as a live machine. Unconsumed events can include a + /// Started notification, so check those before trusting the cached status. + pub fn can_change_directory(&self) -> bool { + !self.is_running() && self.pending_commands.load(Ordering::Acquire) == 0 + && self.evt_rx.is_empty() && !self.abandoned_machine.load(Ordering::Acquire) + } + /// Whether a clean exit needs a "Synchronizing disks" step (a CHD has /// diff-borne changes to fold back into its base). Latest reported status. pub fn has_pending_chd_sync(&self) -> bool { self.status.chd_sync_pending } @@ -270,7 +288,7 @@ impl EmulatorHandle { /// The worker's Quit handler bounds the stop with a timeout, so this can't /// hang on a wedged guest. pub fn shutdown(&mut self) { - let _ = self.cmd_tx.send(Cmd::Quit); + self.send(Cmd::Quit); if let Some(t) = self.thread.take() { let _ = t.join(); } @@ -312,6 +330,8 @@ fn worker_loop( frame_sink: FrameSink, frame_sink_head1: FrameSink, ps2_slot: Arc>>>, + pending_commands: Arc, + abandoned_machine: Arc, ) { let mut machine: Option> = None; // Live MIPS estimate: read REX3's free-running cycle counter and divide @@ -328,7 +348,10 @@ fn worker_loop( // Tick cadence for the status poll while idle on the command channel. const STATUS_TICK: std::time::Duration = std::time::Duration::from_millis(500); loop { - match cmd_rx.recv_timeout(STATUS_TICK) { + let command = cmd_rx.recv_timeout(STATUS_TICK); + // RAII covers all early continues and returns in command handlers. + let _in_flight = command.as_ref().ok().map(|_| CommandInFlight(&pending_commands)); + match command { // Periodic tick (no command pending): refresh the MIPS estimate. Err(crossbeam_channel::RecvTimeoutError::Timeout) => { if let Some(c) = cycles { @@ -466,6 +489,7 @@ fn worker_loop( // Always report the machine as stopped so the user regains // control, even if the stop failed or had to be abandoned. if let Err(msg) = stop_machine_timed(m) { + abandoned_machine.store(true, Ordering::Release); let _ = evt_tx.send(Evt::Error(msg)); } let _ = evt_tx.send(Evt::Stopped); @@ -676,6 +700,11 @@ fn worker_loop( } } +struct CommandInFlight<'a>(&'a AtomicUsize); +impl Drop for CommandInFlight<'_> { + fn drop(&mut self) { self.0.fetch_sub(1, Ordering::Release); } +} + /// Stop a machine, but never block longer than `STOP_TIMEOUT`. `Machine::stop()` /// starts with `cpu.stop()`, which waits for the CPU thread to acknowledge the /// halt; a wedged guest can make that never return. We run it on a detached @@ -688,7 +717,10 @@ fn stop_machine_timed(m: Box) -> Result<(), String> { .name("iris-gui-stop".into()) .spawn(move || { let mut m = m; - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| m.stop())); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + m.stop(); + drop(m); // Complete teardown before allowing a directory change. + })); let _ = done_tx.send(r.map_err(|p| panic_msg(&p))); }) .is_err() diff --git a/iris-gui/src/machines.rs b/iris-gui/src/machines.rs new file mode 100644 index 00000000..dc92aea0 --- /dev/null +++ b/iris-gui/src/machines.rs @@ -0,0 +1,551 @@ +//! Machine folders and the core's TOML schema. Paths stay relative on disk and +//! in the editor; the selected machine folder is the emulator's working directory. + +use iris::config::MachineConfig; +use std::path::{Component, Path, PathBuf}; + +pub fn validate_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() + || name.starts_with('.') + || name.contains(['/', '\\', ':']) + || name.chars().any(char::is_control) + || name.ends_with(['.', ' ']) + { + return Err( + "Use a machine name without path separators, leading dots, or trailing dots/spaces" + .into(), + ); + } + // These names cannot be directories on Windows, even with an extension. + let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase(); + if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) + || name.contains(['<', '>', '"', '|', '?', '*']) + { + return Err( + "This machine name is reserved or contains unsupported filename characters".into(), + ); + } + Ok(()) +} + +/// Older JSON names had no filename restrictions. Give those profiles a +/// deterministic safe folder name so retries select the same migrated file. +pub fn legacy_name(name: &str) -> String { + if validate_name(name).is_ok() { + return name.into(); + } + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + name.hash(&mut hash); + let base: String = name + .chars() + .map(|c| { + if c.is_control() || "/\\:<>\"|?*".contains(c) { + '_' + } else { + c + } + }) + .collect(); + let base = base.trim().trim_matches('.').trim(); + let base = if validate_name(base).is_ok() { + base + } else { + "machine" + }; + format!("{base}-{:08x}", hash.finish() as u32) +} + +pub fn directory(root: &Path, name: &str) -> Result { + validate_name(name)?; + Ok(root.join("machines").join(name)) +} + +pub fn config_path(root: &Path, name: &str) -> Result { + Ok(directory(root, name)?.join(format!("{name}.toml"))) +} + +pub fn read(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Replace a config only after serialization and the complete write succeed. +pub fn write(path: &Path, text: &str) -> Result<(), String> { + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + let result = (|| -> std::io::Result<()> { + let mut file = std::fs::File::create(&tmp)?; + use std::io::Write; + file.write_all(text.as_bytes())?; + file.sync_all()?; + std::fs::rename(&tmp, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result.map_err(|e| format!("{}: {e}", path.display())) +} + +/// Visit filesystem paths only. Network interfaces, MACs, subnets, debug module +/// filters, and TCP CI addresses are strings but must never be treated as paths. +pub fn visit_paths(cfg: &mut MachineConfig, mut visit: impl FnMut(&mut String)) { + visit(&mut cfg.prom); + visit(&mut cfg.nvram); + visit(&mut cfg.nveeprom); + for path in [ + &mut cfg.serial_log, + &mut cfg.load_elf, + &mut cfg.test_device_dump, + &mut cfg.network.tftp_dir, + ] { + if let Some(path) = path { + visit(path); + } + } + visit(&mut cfg.jitv2.cache_dir); + if !iris::config::ci_socket_is_tcp(&cfg.ci_socket) { + visit(&mut cfg.ci_socket); + } + for dev in cfg.scsi.values_mut() { + visit(&mut dev.path); + for disc in &mut dev.discs { + visit(disc); + } + } + if let Some(nfs) = &mut cfg.nfs { + visit(&mut nfs.shared_dir); + } +} + +pub fn resolve(base: &Path, path: &str) -> PathBuf { + base.join(path) +} + +/// Compute a relative path without requiring the destination to exist. Keep +/// symlinks intact: lexical normalization would change `symlink/../file`. +pub fn relative(base: &Path, path: &Path) -> Result { + let base_parts: Vec<_> = base.components().collect(); + let path_parts: Vec<_> = path.components().collect(); + if !base.is_absolute() || !path.is_absolute() || base_parts.first() != path_parts.first() { + return Err(format!( + "Cannot express {} relative to {}", + path.display(), + base.display() + )); + } + let common = base_parts + .iter() + .zip(&path_parts) + .take_while(|(a, b)| a == b) + .count(); + let mut out = PathBuf::new(); + for part in &base_parts[common..] { + if *part != Component::CurDir { + out.push(".."); + } + } + for part in &path_parts[common..] { + out.push(part.as_os_str()); + } + if out.as_os_str().is_empty() { + out.push("."); + } + Ok(out) +} + +pub fn make_relative(cfg: &mut MachineConfig, dir: &Path) -> Result<(), String> { + let mut error = None; + visit_paths(cfg, |path| { + if Path::new(path).is_absolute() { + match relative(dir, Path::new(path)) { + Ok(p) => *path = p.to_string_lossy().into_owned(), + Err(e) => error = Some(e), + } + } + }); + error.map_or(Ok(()), Err) +} + +pub fn save(root: &Path, name: &str, cfg: &mut MachineConfig) -> Result<(), String> { + let dir = directory(root, name)?; + make_relative(cfg, &dir)?; + let text = toml::to_string_pretty(cfg).map_err(|e| e.to_string())?; + std::fs::create_dir_all(dir.join("disks")).map_err(|e| e.to_string())?; + write(&config_path(root, name)?, &text) +} + +pub fn discover( + root: &Path, +) -> ( + std::collections::BTreeMap, + Vec, +) { + let mut machines = std::collections::BTreeMap::new(); + let mut errors = Vec::new(); + let entries = match std::fs::read_dir(root.join("machines")) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (machines, errors), + Err(e) => return (machines, vec![format!("Cannot list machines: {e}")]), + }; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + errors.push(e.to_string()); + continue; + } + }; + let name = entry.file_name().to_string_lossy().into_owned(); + if validate_name(&name).is_err() { + continue; + } + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let path = entry.path().join(format!("{name}.toml")); + match std::fs::metadata(&path) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + errors.push(format!("{}: {e}", path.display())); + continue; + } + } + match read(&path) { + Ok(mut cfg) => match make_relative(&mut cfg, &entry.path()) { + Ok(()) => { + machines.insert(name, cfg); + } + Err(e) => errors.push(e), + }, + Err(e) => errors.push(e), + } + } + (machines, errors) +} + +/// Migrate battery-backed state into the machine folder, preserving existing +/// files and external disk locations. The old launch directory is captured +/// before selecting a machine changes the process working directory. +pub fn migrate(root: &Path, name: &str, cfg: &MachineConfig, old_cwd: &Path) -> Result<(), String> { + let dir = directory(root, name)?; + let target = config_path(root, name)?; + if target.exists() { + read(&target)?; + return Ok(()); + } + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let mut migrated = cfg.clone(); + visit_paths(&mut migrated, |p| { + if !p.is_empty() && p != "(embedded)" { + *p = resolve(old_cwd, p).to_string_lossy().into_owned(); + } + }); + for (old, path, leaf) in [ + (&cfg.nvram, &mut migrated.nvram, "nvram.bin"), + (&cfg.nveeprom, &mut migrated.nveeprom, "nveeprom.bin"), + ] { + let source = if !Path::new(old).is_absolute() { + // The old GUI anchored relative battery paths by their filename. + let filename = Path::new(old) + .file_name() + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| std::ffi::OsStr::new(leaf)); + let shared = root.join(filename); + if shared.exists() { + shared + } else if old.is_empty() { + old_cwd.join(leaf) + } else { + old_cwd.join(old) + } + } else { + PathBuf::from(old) + }; + let dest = dir.join(leaf); + if !dest.exists() { + match std::fs::copy(&source, &dest) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("Cannot migrate {}: {e}", source.display())), + } + } + *path = leaf.into(); + } + make_relative(&mut migrated, &dir)?; + // App Store builds formerly redirected CHD diffs into one shared folder. + // Copy any existing sidecar to the machine's redirect, using its new path + // spelling for the hash. Keep the original for rollback. + #[cfg(feature = "appstore")] + for (id, dev) in &cfg.scsi { + if !iris::chd_disk::is_chd(&dev.path) { + continue; + } + let source = root.join("chd-diffs").join(diff_name(&dev.path)); + let dest = dir + .join("chd-diffs") + .join(diff_name(&migrated.scsi[id].path)); + if source.exists() && !dest.exists() { + std::fs::create_dir_all(dest.parent().unwrap()).map_err(|e| e.to_string())?; + std::fs::copy(source, dest).map_err(|e| e.to_string())?; + } + } + save(root, name, &mut migrated) +} + +#[cfg(feature = "appstore")] +fn diff_name(path: &str) -> String { + use std::hash::{Hash, Hasher}; + let path = Path::new(path); + let mut hash = std::collections::hash_map::DefaultHasher::new(); + path.hash(&mut hash); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("disk"); + format!("{stem}.{:016x}.diff.chd", hash.finish()) +} + +pub fn rename(root: &Path, old: &str, new: &str) -> Result<(), String> { + let old_dir = directory(root, old)?; + let new_dir = directory(root, new)?; + if new_dir.exists() { + return Err(format!("Machine folder '{new}' already exists")); + } + std::fs::rename(&old_dir, &new_dir).map_err(|e| e.to_string())?; + if let Err(e) = std::fs::rename( + new_dir.join(format!("{old}.toml")), + new_dir.join(format!("{new}.toml")), + ) { + let _ = std::fs::rename(&new_dir, &old_dir); + return Err(e.to_string()); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub struct TempRoot(pub PathBuf); + impl TempRoot { + pub fn new(label: &str) -> Self { + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "iris-machines-{label}-{}-{suffix}", + std::process::id() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + } + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn saved_toml_roundtrips_every_path_and_preserves_non_path_strings() { + let root = TempRoot::new("paths"); + let dir = directory(&root.0, "Indigo2 IP28").unwrap(); + let mut cfg = MachineConfig::default(); + cfg.prom = dir.join("prom.bin").to_string_lossy().into_owned(); + cfg.nvram = dir.join("nvram.bin").to_string_lossy().into_owned(); + cfg.nveeprom = dir.join("nveeprom.bin").to_string_lossy().into_owned(); + cfg.scsi.get_mut(&1).unwrap().path = + dir.join("disks/root.chd").to_string_lossy().into_owned(); + cfg.scsi.get_mut(&4).unwrap().path.clear(); + cfg.scsi.get_mut(&4).unwrap().discs = + vec![root.0.join("install.iso").to_string_lossy().into_owned()]; + cfg.nfs = Some(iris::config::NfsConfig { + shared_dir: root.0.join("shared").to_string_lossy().into_owned(), + version: Default::default(), + }); + cfg.network.tftp_dir = Some(root.0.join("tftp").to_string_lossy().into_owned()); + cfg.serial_log = Some(dir.join("console.log").to_string_lossy().into_owned()); + cfg.load_elf = Some(root.0.join("test.elf").to_string_lossy().into_owned()); + cfg.test_device_dump = Some(dir.join("dump.json").to_string_lossy().into_owned()); + cfg.jitv2.cache_dir = dir.join("jit-cache").to_string_lossy().into_owned(); + cfg.ci_socket = "tcp:127.0.0.1:19851".into(); + cfg.debug.debug_log = "scsi=debug,cpu=warn".into(); + cfg.network.pcap_interface = Some("\\Device\\NPF_{example}".into()); + cfg.network.mac = Some("08:00:69:12:34:56".into()); + save(&root.0, "Indigo2 IP28", &mut cfg).unwrap(); + let path = config_path(&root.0, "Indigo2 IP28").unwrap(); + let mut back = read(&path).unwrap(); + assert_eq!(back.scsi[&1].path, "disks/root.chd"); + assert_eq!(back.scsi[&4].path, ""); + assert_eq!(back.scsi[&4].discs[0], "../../install.iso"); + assert_eq!(back.nvram, "nvram.bin"); + assert_eq!(back.nveeprom, "nveeprom.bin"); + assert_eq!(back.ci_socket, "tcp:127.0.0.1:19851"); + assert_eq!(back.debug.debug_log, cfg.debug.debug_log); + assert_eq!(back.network.pcap_interface, cfg.network.pcap_interface); + assert_eq!(back.network.mac, cfg.network.mac); + visit_paths(&mut back, |p| assert!(!Path::new(p).is_absolute(), "{p}")); + assert!(dir.join("disks").is_dir()); + } + + #[test] + fn discovery_uses_only_matching_machine_tomls_and_reports_parse_errors() { + let root = TempRoot::new("discover"); + let mut cfg = MachineConfig::default(); + save(&root.0, "indy", &mut cfg).unwrap(); + let stray = directory(&root.0, "stray").unwrap(); + std::fs::create_dir_all(&stray).unwrap(); + std::fs::write(stray.join("other.toml"), "banks = [8,8,0,0]").unwrap(); + let broken = directory(&root.0, "broken").unwrap(); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write(broken.join("broken.toml"), "unknown_setting = true").unwrap(); + let (machines, errors) = discover(&root.0); + assert_eq!(machines.keys().collect::>(), vec!["indy"]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("broken.toml")); + assert!(errors[0].contains("unknown_setting")); + assert_eq!( + std::fs::read_to_string(broken.join("broken.toml")).unwrap(), + "unknown_setting = true" + ); + } + + #[test] + fn legacy_migration_isolates_battery_files_and_preserves_external_disks() { + let root = TempRoot::new("migrate"); + let old_cwd = root.0.join("old-launch"); + std::fs::create_dir_all(&old_cwd).unwrap(); + std::fs::write(root.0.join("nvram.bin"), b"original NVRAM").unwrap(); + std::fs::write(root.0.join("nveeprom.bin"), b"original EEPROM").unwrap(); + std::fs::write(old_cwd.join("scsi1.raw"), b"disk stays here").unwrap(); + let cfg = MachineConfig::default(); + for name in ["indy", "ip28"] { + migrate(&root.0, name, &cfg, &old_cwd).unwrap(); + } + let indy = directory(&root.0, "indy").unwrap(); + let ip28 = directory(&root.0, "ip28").unwrap(); + let migrated = read(&config_path(&root.0, "indy").unwrap()).unwrap(); + assert_eq!( + std::fs::read(indy.join(&migrated.scsi[&1].path)).unwrap(), + b"disk stays here" + ); + assert_eq!( + std::fs::read(indy.join(&migrated.nvram)).unwrap(), + b"original NVRAM" + ); + std::fs::write(indy.join("nvram.bin"), b"changed Indy").unwrap(); + std::fs::write(indy.join("nveeprom.bin"), b"changed Indy EEPROM").unwrap(); + assert_eq!( + std::fs::read(ip28.join("nvram.bin")).unwrap(), + b"original NVRAM" + ); + assert_eq!( + std::fs::read(ip28.join("nveeprom.bin")).unwrap(), + b"original EEPROM" + ); + // Retrying after a failed preferences write never resets migrated state. + migrate(&root.0, "indy", &cfg, &old_cwd).unwrap(); + assert_eq!( + std::fs::read(indy.join("nvram.bin")).unwrap(), + b"changed Indy" + ); + assert_eq!( + std::fs::read(old_cwd.join("scsi1.raw")).unwrap(), + b"disk stays here" + ); + } + + #[test] + fn rename_moves_config_disks_and_battery_state_as_one_machine() { + let root = TempRoot::new("rename"); + let mut cfg = MachineConfig::default(); + cfg.scsi.get_mut(&1).unwrap().path = "disks/root.raw".into(); + save(&root.0, "before", &mut cfg).unwrap(); + let old_dir = directory(&root.0, "before").unwrap(); + for (leaf, data) in [ + ("disks/root.raw", b"disk".as_slice()), + ("nvram.bin", b"nvram"), + ("nveeprom.bin", b"eeprom"), + ] { + std::fs::write(old_dir.join(leaf), data).unwrap(); + } + rename(&root.0, "before", "after").unwrap(); + let new_dir = directory(&root.0, "after").unwrap(); + assert!(!old_dir.exists()); + assert!(!new_dir.join("before.toml").exists()); + let cfg = read(&new_dir.join("after.toml")).unwrap(); + assert_eq!( + std::fs::read(new_dir.join(&cfg.scsi[&1].path)).unwrap(), + b"disk" + ); + assert_eq!(std::fs::read(new_dir.join(&cfg.nvram)).unwrap(), b"nvram"); + assert_eq!( + std::fs::read(new_dir.join(&cfg.nveeprom)).unwrap(), + b"eeprom" + ); + std::fs::remove_file(new_dir.join("after.toml")).unwrap(); + assert!(discover(&root.0).0.is_empty()); + assert!(new_dir.join("disks/root.raw").exists()); + } + + #[test] + fn names_cannot_escape_storage_or_alias_hidden_directories() { + for name in [ + "", + ".", + "..", + ".hidden", + "../escape", + "a/b", + "a\\b", + "a:b", + "a\n", + "a.", + "a ", + "CON", + "LPT1", + "a?", + ] { + assert!(validate_name(name).is_err(), "{name:?}"); + } + for name in ["Indigo2 IP28", "indy", "マシン", "irix-6.5"] { + validate_name(name).unwrap(); + } + } + + #[test] + fn relative_external_resources_are_bookmarked_from_each_machine_folder() { + let root = TempRoot::new("bookmarks"); + let dir = directory(&root.0, "indy").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let resource = root.0.join("external.iso"); + std::fs::write(&resource, b"media").unwrap(); + let mut cfg = MachineConfig::default(); + cfg.scsi.get_mut(&4).unwrap().path = "../../external.iso".into(); + let paths = crate::macos_sandbox::config_paths(&cfg, &dir); + assert!(paths.contains(&resource.to_string_lossy().into_owned())); + } + + #[cfg(feature = "appstore")] + #[test] + fn sandbox_migration_preserves_shared_chd_diffs_in_each_machine_folder() { + let root = TempRoot::new("diffs"); + std::fs::create_dir_all(root.0.join("chd-diffs")).unwrap(); + let mut cfg = MachineConfig::default(); + cfg.scsi.get_mut(&1).unwrap().path = root.0.join("root.chd").to_string_lossy().into_owned(); + let old_diff = root.0.join("chd-diffs").join(diff_name(&cfg.scsi[&1].path)); + std::fs::write(&old_diff, b"guest writes").unwrap(); + migrate(&root.0, "indy", &cfg, &root.0).unwrap(); + let migrated = read(&config_path(&root.0, "indy").unwrap()).unwrap(); + let new_diff = directory(&root.0, "indy") + .unwrap() + .join("chd-diffs") + .join(diff_name(&migrated.scsi[&1].path)); + assert_eq!(std::fs::read(new_diff).unwrap(), b"guest writes"); + assert!(old_diff.exists()); + } +} diff --git a/iris-gui/src/macos_sandbox.rs b/iris-gui/src/macos_sandbox.rs index ff938431..2e5135d5 100644 --- a/iris-gui/src/macos_sandbox.rs +++ b/iris-gui/src/macos_sandbox.rs @@ -2,7 +2,7 @@ //! //! 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, +//! launch: the file paths in each machine TOML (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 @@ -23,36 +23,21 @@ 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 { +/// Existing resources, resolved against their own machine folder. Relative +/// paths can reach outside the container via `..` and still need bookmarks. +pub fn config_paths(cfg: &MachineConfig, dir: &Path) -> 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()); + let mut cfg = cfg.clone(); + crate::machines::visit_paths(&mut cfg, |p| { + if p.is_empty() || p == "(embedded)" { return; } + let path = crate::machines::resolve(dir, p); + if path.exists() { + // Bookmarks use a stable absolute key, independent of the selected + // machine's cwd. Canonicalize only this key, never the TOML path. + let path = path.canonicalize().unwrap_or(path); + out.push(path.to_string_lossy().into_owned()); } - }; - 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); - } - if let Some(dir) = &cfg.network.tftp_dir { - add(dir); - } + }); out } @@ -208,15 +193,16 @@ mod tests { 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 + cfg.prom = "prom.bin".into(); // missing → skipped - let paths = config_paths(&cfg); - assert!(paths.contains(&path), "absolute scsi image should be collected"); + let paths = config_paths(&cfg, Path::new("/tmp/iris-machine")); + let key = Path::new(&path).canonicalize().unwrap().to_string_lossy().into_owned(); + assert!(paths.contains(&key), "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"); + assert!(bm.contains_key(&key), "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 0218bc1b..c25155d2 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -10,6 +10,7 @@ mod framebuffer; mod handle; mod input; mod macos_sandbox; +mod machines; mod netfix; mod netplan; mod ram; @@ -19,7 +20,7 @@ mod serial_console; mod settings; mod single_instance; -use config_ui::{cfg_to_toml, show_tab, ConfigAction, MemoryUiContext, Tab}; +use config_ui::{show_tab, ConfigAction, MemoryUiContext, Tab}; use dialogs::create_disk::CreateDiskDialog; use dialogs::new_machine::{distribute_ram, NewMachineDialog}; use ram::{ram_summary, ram_presets}; @@ -49,6 +50,18 @@ fn load_icon() -> egui::IconData { } } +/// Developer tools retain the source launch directory when machine selection +/// changes the process cwd. Child processes receive an explicit directory. +fn launch_dir() -> &'static std::path::Path { + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +fn developer_binary(name: &str) -> PathBuf { + let suffix = if cfg!(windows) { ".exe" } else { "" }; + launch_dir().join(format!("target/release/{name}{suffix}")) +} + /// Largest aspect-preserving size (egui points) that fits `avail` for a /// framebuffer whose native pixel dimensions are `px`. fn fb_fit_size(avail: egui::Vec2, px: egui::Vec2) -> egui::Vec2 { @@ -73,6 +86,13 @@ fn abs_path(p: &str) -> String { .unwrap_or_else(|_| p.to_string()) } +/// Picker results use the same path spelling as the saved config, including +/// live CD-ROM commands and snapshot provenance. +fn picked_path(path: &std::path::Path) -> String { + let relative = std::env::current_dir().ok().and_then(|dir| machines::relative(&dir, path).ok()); + relative.unwrap_or_else(|| path.to_path_buf()).to_string_lossy().into_owned() +} + /// How far (in device pixels per emulated pixel) the fit may exceed a whole /// device-pixel scale and still be drawn at exactly that scale. Makes an /// integer scale "sticky" when the window is a little larger than it needs to @@ -133,18 +153,8 @@ fn main() -> eframe::Result<()> { // copy that would otherwise keep the monitor/serial ports bound) and claim // the single-instance lock for ourselves. single_instance::acquire(); + launch_dir(); // Capture the source launch directory before selecting a machine. 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 and IMPACT raster JITs) allocates executable memory with // mmap+mprotect, not MAP_JIT. The App Sandbox only permits MAP_JIT pages @@ -192,9 +202,6 @@ fn main() -> eframe::Result<()> { struct App { prefs: GuiSettings, cfg: MachineConfig, - /// Path to an iris.toml that was *imported* (for re-export). None for - /// machines that originated from the GUI. - cfg_path: Option, /// Marks the current cfg as having unsaved (in-memory) changes. cfg_dirty: bool, /// Timestamp of the most recent edit; used to debounce auto-save. @@ -452,49 +459,28 @@ struct ChdGrantModal { impl App { fn new(mut prefs: GuiSettings) -> Self { - // Resolution order on startup: - // 1. prefs.active_machine present + in prefs.machines → load it. - // 2. legacy prefs.last_config TOML still on disk → migrate it as - // a new named machine and adopt it. - // 3. otherwise: open the New Machine dialog. let mut cfg = MachineConfig::default(); - let mut cfg_path: Option = None; - let mut opened_new_machine = false; let mut new_machine = NewMachineDialog::default(); - + let mut startup_errors = prefs.errors.clone(); if let Some(name) = prefs.active_machine.clone() { if let Some(stored) = prefs.machines.get(&name).cloned() { cfg = stored; } else { - // Stale pointer — fall through to migrate / dialog. prefs.active_machine = None; } } if prefs.active_machine.is_none() { - if let Some(p) = prefs.last_config.clone() { - if p.exists() { - cfg = MachineConfig::load_toml(&p.to_string_lossy()); - cfg_path = Some(p.clone()); - let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("imported"); - let name = prefs.unique_name(stem); - prefs.machines.insert(name.clone(), cfg.clone()); - prefs.active_machine = Some(name); - // Clear the legacy pointer so we don't re-migrate next run. - prefs.last_config = None; - let _ = prefs.save(); - } - } + prefs.active_machine = prefs.machines.keys().next().cloned(); + if let Some(name) = &prefs.active_machine { cfg = prefs.machines[name].clone(); } + else { new_machine.open(); } } - if prefs.active_machine.is_none() { - new_machine.open(); - opened_new_machine = true; - } - let _ = opened_new_machine; // (kept for future telemetry) - // Anchor the live config's NVRAM (and NVRAM EEPROM) to the stable data - // dir too — covers the legacy-TOML import path above, which doesn't go - // through load()'s per-machine migration. - GuiSettings::migrate_nvram_path(&mut cfg.nvram); - GuiSettings::migrate_nveeprom_path(&mut cfg.nveeprom); + let dir = match prefs.active_machine.as_deref() { + Some(name) => prefs.machine_dir(name), + None => prefs.root().map(std::path::Path::to_path_buf), + }; + if let Err(e) = dir.and_then(|dir| Self::enter_directory(&dir)) { startup_errors.push(e); } + let startup_toast = (!startup_errors.is_empty()) + .then(|| (startup_errors.join("\n"), std::time::Instant::now())); Self { fullscreen: false, @@ -505,7 +491,6 @@ impl App { startup_frame: 0, prefs, cfg, - cfg_path, cfg_dirty: false, cfg_dirty_since: None, started_banks: None, @@ -513,7 +498,7 @@ impl App { tab: Tab::General, bench: bench_ui::BenchState::default(), emu: EmulatorHandle::spawn(), - toast: None, + toast: startup_toast, stop_modal: None, missing_modal: None, chd_grant_modal: None, @@ -587,91 +572,87 @@ impl App { /// Mark the in-memory config as edited and arm the auto-save debounce. fn mark_dirty(&mut self) { + if let Some(name) = &self.prefs.active_machine { + if let Err(e) = self.prefs.machine_dir(name).and_then(|dir| machines::make_relative(&mut self.cfg, &dir)) { + self.toast(e); + } + } self.cfg_dirty = true; self.cfg_dirty_since = Some(std::time::Instant::now()); } - /// Persist the current machine to disk and clear the dirty flag. - fn flush_machine(&mut self) { - if let Some(name) = self.prefs.active_machine.clone() { - self.prefs.machines.insert(name, self.cfg.clone()); - if let Err(e) = self.prefs.save() { - self.toast(format!("autosave failed: {e}")); - return; - } + fn enter_directory(dir: &std::path::Path) -> Result<(), String> { + std::fs::create_dir_all(dir.join("disks")).map_err(|e| e.to_string())?; + std::env::set_current_dir(dir).map_err(|e| format!("Cannot select {}: {e}", dir.display()))?; + #[cfg(feature = "appstore")] + std::env::set_var("IRIS_CHD_DIFF_DIR", dir.join("chd-diffs")); + Ok(()) + } + + fn can_select_machine(&mut self) -> bool { + if let Err(e) = self.prefs.ensure_writable() { self.toast(e); return false; } + if !self.emu.can_change_directory() || self.syncing.is_some() { + self.toast("Stop the emulator and wait for disk operations before changing machines"); + false + } else { true } + } + + /// Persist edits through the core TOML schema and retain failed edits. + fn flush_machine(&mut self) -> bool { + let Some(name) = self.prefs.active_machine.clone() else { return false; }; + if let Err(e) = self.prefs.save_machine(&name, &mut self.cfg) { + self.toast(format!("autosave failed: {e}")); + return false; } self.cfg_dirty = false; self.cfg_dirty_since = None; + true } - /// Debounced auto-save: flush ~600 ms after the most recent edit. fn maybe_autosave(&mut self) { if let Some(t) = self.cfg_dirty_since { if t.elapsed().as_millis() >= 600 { self.flush_machine(); } } } - /// Switch the active machine in-memory and on disk. + /// Reload from TOML when selecting a machine, including external edits. fn switch_to(&mut self, name: &str) { - // Flush whatever we were holding first. - if self.cfg_dirty { self.flush_machine(); } - if let Some(cfg) = self.prefs.machines.get(name).cloned() { - self.cfg = cfg; - self.cfg_path = None; - self.prefs.active_machine = Some(name.to_string()); - let _ = self.prefs.save(); - self.toast(format!("loaded '{name}'")); - } - } - - fn save_config(&mut self, path: PathBuf) { - match cfg_to_toml(&self.cfg) { - Ok(s) => match std::fs::write(&path, s) { - Ok(_) => { - self.prefs.push_recent(path.clone()); - self.cfg_path = Some(path); - self.cfg_dirty = false; - self.toast("config saved"); - } - Err(e) => self.toast(format!("save failed: {e}")), - }, - Err(e) => self.toast(format!("serialize failed: {e}")), + if !self.can_select_machine() { return; } + if self.cfg_dirty && !self.flush_machine() { return; } + let result = (|| -> Result { + let mut cfg = machines::read(&self.prefs.machine_path(name)?)?; + let dir = self.prefs.machine_dir(name)?; + machines::make_relative(&mut cfg, &dir)?; + Self::enter_directory(&dir)?; + Ok(cfg) + })(); + match result { + Ok(cfg) => { + self.cfg = cfg; + self.prefs.machines.insert(name.into(), self.cfg.clone()); + self.prefs.active_machine = Some(name.into()); + if let Err(e) = self.prefs.save() { self.toast(format!("save failed: {e}")); } + else { self.toast(format!("loaded '{name}'")); } + } + Err(e) => self.toast(format!("Cannot load machine: {e}")), } } - /// Export config for the premiere CLI recording workflow. + /// Premiere uses the machine's existing TOML and working directory. fn prepare_for_premiere(&mut self) { - if self.emu.is_running() { - self.toast("Stop the VM first — premiere CLI uses its own iris.exe process"); - return; - } + if !self.can_select_machine() { return; } self.cfg.scale = 1; self.mark_dirty(); - if self.cfg_dirty { self.flush_machine(); } - let path = PathBuf::from("irix-install/iris-windows.toml"); - match cfg_to_toml(&self.cfg) { - Ok(s) => { - if let Err(e) = std::fs::write(&path, s) { - self.toast(format!("export failed: {e}")); - return; - } - self.cfg_path = Some(path); - self.cfg_dirty = false; - } - Err(e) => { - self.toast(format!("serialize failed: {e}")); - return; + if !self.flush_machine() { return; } + if let Some(name) = self.prefs.active_machine.as_deref() { + if let Ok(path) = self.prefs.machine_path(name) { + self.toast(format!("Premiere config: {}. Run iris with --config from this machine's folder.", path.display())); } } - let _ = self.prefs.save(); - self.toast( - "Premiere ready: iris-windows.toml exported. Warm JIT (wsl\\warm-jit-profiles.ps1), \ - then run wsl\\run-iris-premiere.bat for 3D recording.", - ); } fn spawn_rebuild_profile(&mut self, gui: bool) { - let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let root = launch_dir().to_path_buf(); let bat = if gui { root.join("wsl").join("ensure-build.bat") } else { @@ -696,34 +677,21 @@ impl App { self.toast("Stop the embedded VM first — CI test spawns a separate iris.exe"); return; } - let mut cfg = self.cfg.clone(); - cfg.ci = true; - cfg.headless = true; - let path = PathBuf::from("irix-install/iris-ci-test.toml"); - match cfg_to_toml(&cfg) { - Ok(s) => { - if let Err(e) = std::fs::write(&path, s) { - self.toast(format!("export failed: {e}")); - return; - } - } - Err(e) => { - self.toast(format!("serialize failed: {e}")); - return; - } - } - let socket = cfg.ci_socket.clone(); - let config = path.to_string_lossy().to_string(); + if self.cfg_dirty && !self.flush_machine() { return; } + let Some(name) = self.prefs.active_machine.as_deref() else { return; }; + let Ok(path) = self.prefs.machine_path(name) else { return; }; + let Ok(dir) = self.prefs.machine_dir(name) else { return; }; + let socket = if iris::config::ci_socket_is_tcp(&self.cfg.ci_socket) { self.cfg.ci_socket.clone() } else { abs_path(&self.cfg.ci_socket) }; + let iris = developer_binary("iris"); + let client = developer_binary("iris-ci"); self.toast("CI test: starting headless iris…"); std::thread::spawn(move || { - let _child = std::process::Command::new("target/release/iris.exe") - .args(["--config", &config]) - .spawn(); + let _child = std::process::Command::new(iris) + .args(["--config", path.to_string_lossy().as_ref(), "--ci", "--headless"]) + .current_dir(&dir).spawn(); std::thread::sleep(std::time::Duration::from_secs(8)); - let out = std::process::Command::new("target/release/iris-ci.exe") - .arg("ping") - .env("IRIS_CI_SOCKET", &socket) - .output(); + let out = std::process::Command::new(client) + .arg("ping").env("IRIS_CI_SOCKET", &socket).current_dir(&dir).output(); match out { Ok(o) if o.status.success() => eprintln!("CI test: ping OK"), Ok(o) => eprintln!("CI test ping failed: {}", String::from_utf8_lossy(&o.stderr)), @@ -736,7 +704,29 @@ impl App { // Flush any pending edits before the machine starts so the on-disk // 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(); } + if !self.can_select_machine() { return; } + if self.cfg_dirty && !self.flush_machine() { return; } + let Some(name) = self.prefs.active_machine.clone() else { return; }; + let result = (|| -> Result { + let cfg = machines::read(&self.prefs.machine_path(&name)?)?; + Self::enter_directory(&self.prefs.machine_dir(&name)?)?; + Ok(cfg) + })(); + match result { + Ok(mut cfg) => { + // Files added by hand can contain absolute paths; normalize them + // before either the GUI or the emulator uses the saved config. + let before = toml::to_string(&cfg).unwrap_or_default(); + let dir = self.prefs.machine_dir(&name).unwrap(); + if let Err(e) = machines::make_relative(&mut cfg, &dir) { self.toast(e); return; } + if toml::to_string(&cfg).unwrap_or_default() != before { + if let Err(e) = self.prefs.save_machine(&name, &mut cfg) { self.toast(e); return; } + } + self.cfg = cfg; + }, + Err(e) => { self.toast(format!("Cannot start machine: {e}")); return; } + } + self.prefs.machines.insert(name, self.cfg.clone()); // (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 @@ -794,7 +784,6 @@ impl App { }); self.started_banks = Some(self.cfg.banks); self.started_cpu = Some(self.cfg.machine.cpu); - self.emu.send(Cmd::Start(Box::new(self.cfg.clone()))); // Don't resize the window when the VM launches — its size is latched at // app load (the saved window size, or the first-launch fit to vm_scale) // and the guest display is letterboxed into it. Only the VM-scale slider @@ -851,6 +840,7 @@ impl App { notes.push(format!("no Ethernet MAC in NVRAM EEPROM — wrote {}", settings::mac_to_string(mac))); } } + self.emu.send(Cmd::Start(Box::new(self.cfg.clone()))); if !notes.is_empty() { self.toast(notes.join("; ")); } @@ -939,7 +929,8 @@ impl App { // writable container. Only writable HDD CHDs ever get folded. if dev.scratch || dev.cdrom { continue; } if !dev.path.ends_with(".chd") { continue; } - let Some(parent) = std::path::Path::new(&dev.path).parent() else { continue }; + let absolute = abs_path(&dev.path); + let Some(parent) = std::path::Path::new(&absolute).parent() else { continue }; if !dir_writable(parent) { out.push(ChdNeedsGrant { id, @@ -1104,7 +1095,7 @@ impl App { ui.vertical(|ui| { ui.menu_button("File ▶", |ui| { ui.set_min_width(220.0); - if ui.button("New machine…").clicked() { + if ui.add_enabled(self.emu.can_change_directory(), egui::Button::new("New machine…")).clicked() { self.new_machine.open(); ui.close(); } @@ -1120,14 +1111,14 @@ impl App { // selectable_label highlights the active machine — no // marker glyph (a leading ● rendered as tofu). let is_active = active.as_deref() == Some(name.as_str()); - if ui.selectable_label(is_active, name.as_str()).clicked() { + if ui.add_enabled(self.emu.can_change_directory(), egui::Button::selectable(is_active, name.as_str())).clicked() { want_switch = Some(name); ui.close(); } } if let Some(n) = want_switch { self.switch_to(&n); } }); - if ui.add_enabled(self.prefs.active_machine.is_some(), + if ui.add_enabled(self.prefs.active_machine.is_some() && self.emu.can_change_directory(), egui::Button::new("Rename current…")) .on_disabled_hover_text("No active machine") .clicked() @@ -1139,47 +1130,13 @@ impl App { ui.close(); } let active = self.prefs.active_machine.clone(); - if ui.add_enabled(active.is_some(), egui::Button::new("Delete current machine")).clicked() { - if let Some(name) = active { - self.prefs.machines.remove(&name); - self.prefs.active_machine = self.prefs.machines.keys().next().cloned(); - if let Some(next) = self.prefs.active_machine.clone() { - self.cfg = self.prefs.machines[&next].clone(); - } else { - self.cfg = MachineConfig::default(); - self.new_machine.open(); - } - let _ = self.prefs.save(); - self.toast(format!("deleted '{name}'")); - } + if ui.add_enabled(active.is_some() && self.emu.can_change_directory(), + egui::Button::new("Delete current machine")).clicked() { + if let Some(name) = active { self.delete_machine(&name); } ui.close(); } - // iris.toml import/export is a source-build affordance for users - // who also run the standalone `iris` CLI; the GUI's own gui.json - // machine store is the system of record. Hidden in pre-compiled / - // App Store builds (the `bundled` feature). See iris-gui Cargo.toml. if !cfg!(feature = "bundled") { ui.separator(); - if ui.button("Import iris.toml…").clicked() { - if let Some(path) = native_open_dialog("Import iris.toml", &[("TOML", &["toml"])]) { - let cfg = MachineConfig::load_toml(&path.to_string_lossy()); - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("imported"); - let name = self.prefs.unique_name(stem); - self.prefs.machines.insert(name.clone(), cfg.clone()); - self.prefs.active_machine = Some(name.clone()); - self.cfg = cfg; - self.cfg_path = Some(path); - self.flush_machine(); - self.toast(format!("imported as '{name}'")); - } - ui.close(); - } - if ui.button("Export current to iris.toml…").clicked() { - if let Some(path) = native_save_dialog("Export iris.toml", &[("TOML", &["toml"])]) { - self.save_config(path); - } - ui.close(); - } if ui.button("Prepare for premiere…").clicked() { self.prepare_for_premiere(); ui.close(); @@ -2299,22 +2256,51 @@ impl App { /// Apply a rename of the active machine to `new_name` (from the modal). /// No-op for an empty/unchanged name or when there's no active machine. fn apply_rename(&mut self, new_name: &str) { + if !self.can_select_machine() { return; } let new = new_name.trim(); let Some(old) = self.prefs.active_machine.clone() else { return; }; - if new.is_empty() || new == old { return; } - let n = self.prefs.unique_name(new); - if let Some(cfg) = self.prefs.machines.remove(&old) { - self.prefs.machines.insert(n.clone(), cfg); - self.prefs.active_machine = Some(n.clone()); - let _ = self.prefs.save(); - self.toast(format!("renamed to '{n}'")); + if new == old { return; } + if let Err(e) = machines::validate_name(new) { self.toast(e); return; } + if self.cfg_dirty && !self.flush_machine() { return; } + let name = self.prefs.unique_name(new); + let result = self.prefs.root().and_then(|root| machines::rename(root, &old, &name)); + if let Err(e) = result { self.toast(format!("Rename failed: {e}")); return; } + self.prefs.machines.remove(&old); + self.prefs.machines.insert(name.clone(), self.cfg.clone()); + self.prefs.active_machine = Some(name.clone()); + if let Err(e) = self.prefs.machine_dir(&name).and_then(|d| Self::enter_directory(&d)) { + self.toast(e); + return; + } + if let Err(e) = self.prefs.save() { self.toast(e); } + else { self.toast(format!("renamed to '{name}'")); } + } + + /// Remove the TOML registration and retain disks/battery-backed state. + fn delete_machine(&mut self, name: &str) { + if !self.can_select_machine() { return; } + let result = self.prefs.machine_path(name).and_then(|p| std::fs::remove_file(p).map_err(|e| e.to_string())); + if let Err(e) = result { self.toast(format!("Delete failed: {e}")); return; } + self.cfg_dirty = false; + self.cfg_dirty_since = None; + self.prefs.machines.remove(name); + self.prefs.active_machine = None; + if let Some(next) = self.prefs.machines.keys().next().cloned() { + self.switch_to(&next); + } else { + self.cfg = MachineConfig::default(); + if let Err(e) = self.prefs.root().and_then(Self::enter_directory) { self.toast(e); } + self.new_machine.open(); + if let Err(e) = self.prefs.save() { self.toast(e); } } + self.toast(format!("deleted '{name}' (disk and NVRAM files retained)")); } /// Heading + tabbed config editor body, used both as the right side panel /// (while a machine runs) and full-width in the central panel (while idle). /// The header names the active machine ("default" if unset). fn config_editor_panel(&mut self, ui: &mut egui::Ui) { + for error in &self.prefs.errors { ui.colored_label(Color32::from_rgb(230, 140, 70), error); } let machine = self.prefs.active_machine.as_deref().unwrap_or("default").to_string(); ui.horizontal(|ui| { ui.heading(format!("Configuration — {machine}")); @@ -3072,6 +3058,7 @@ impl App { } fn welcome_panel(&mut self, ui: &mut egui::Ui) { + for error in &self.prefs.errors { ui.colored_label(Color32::from_rgb(230, 140, 70), error); } ui.add_space(8.0); ui.heading("iris — SGI Indy emulator"); ui.add_space(4.0); @@ -3399,15 +3386,22 @@ impl eframe::App for App { } // New machine dialog. - self.new_machine.show(ctx); + self.new_machine.show(ctx, &self.prefs); if let Some(result) = self.new_machine.take_result() { - let name = self.prefs.unique_name(&result.name); - self.prefs.machines.insert(name.clone(), result.cfg.clone()); - self.prefs.active_machine = Some(name.clone()); - self.cfg = result.cfg; - self.cfg_path = None; - self.flush_machine(); - self.toast(format!("created machine '{name}'")); + if self.can_select_machine() { + if let Err(e) = machines::validate_name(&result.name) { self.toast(e); } + else if !self.cfg_dirty || self.flush_machine() { + let name = self.prefs.unique_name(&result.name); + let mut cfg = result.cfg; + match self.prefs.save_machine(&name, &mut cfg) { + Ok(()) => { + self.switch_to(&name); + self.toast(format!("created machine '{name}'")); + } + Err(e) => self.toast(format!("Cannot create machine: {e}")), + } + } + } } // Create blank disk dialog. @@ -3800,16 +3794,7 @@ impl eframe::App for App { // --- platform dialogs ------------------------------------------------------ // -// We avoid `rfd` as a dependency for now to keep the dep tree slim. Use -// `zenity` / `osascript` if available; otherwise return None and let the -// caller paste a path into the recent-files / save-state fields. -// Anchored on the app's data folder rather than left to the OS's remembered -// location — see `crate::filedialog`. These handle iris.toml import/export and -// screenshots, none of which are disk images. -fn native_open_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { - filedialog::dialog_with(title, "", filedialog::Anchor::Data, - filedialog::Purpose::Open, filters).pick_file() -} +// Screenshot save panels open in the selected machine folder. fn native_save_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { filedialog::dialog_with(title, "", filedialog::Anchor::Data, filedialog::Purpose::Save, filters).save_file() @@ -3928,3 +3913,91 @@ mod fb_scaling_tests { assert!(size.y <= 800.0); } } + +#[cfg(test)] +mod machine_storage_integration { + use super::*; + + // Run the App/worker integration in a child: cwd and environment are process + // global, and must not disturb other concurrently running unit tests. + #[test] + fn machine_storage_child() { + let Some(root) = std::env::var_os("IRIS_GUI_STORAGE_TEST") else { return; }; + let root = PathBuf::from(root); + std::env::set_var("IRIS_NO_EXIT_ON_POWEROFF", "1"); + let mut prefs = GuiSettings::load_in(root.clone(), launch_dir()); + let mut cfg = MachineConfig::default(); + cfg.banks = [8, 8, 0, 0]; + cfg.scsi.clear(); + cfg.headless = true; + cfg.monitor_port = Some(0); + cfg.serial_port_a = Some(0); + cfg.serial_port_b = Some(0); + cfg.ci_socket = "control.sock".into(); + prefs.save_machine("alpha", &mut cfg).unwrap(); + prefs.save_machine("beta", &mut cfg).unwrap(); + prefs.active_machine = Some("alpha".into()); + prefs.save().unwrap(); + let mut app = App::new(prefs); + let alpha = app.prefs.machine_dir("alpha").unwrap(); + let beta = app.prefs.machine_dir("beta").unwrap(); + assert_eq!(std::env::current_dir().unwrap(), alpha); + app.cfg.serial_log = Some(alpha.join("serial.log").to_string_lossy().into_owned()); + app.mark_dirty(); + assert!(app.flush_machine()); + assert_eq!(machines::read(&app.prefs.machine_path("alpha").unwrap()).unwrap().serial_log.as_deref(), Some("serial.log")); + let ctx = egui::Context::default(); + for name in ["alpha", "beta"] { + app.switch_to(name); + assert_eq!(std::env::current_dir().unwrap(), app.prefs.machine_dir(name).unwrap()); + app.start_emulator(); + assert!(!app.emu.can_change_directory(), "queued Start must lock cwd"); + app.switch_to(if name == "alpha" { "beta" } else { "alpha" }); + assert_eq!(app.prefs.active_machine.as_deref(), Some(name)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while !app.emu.is_running() && std::time::Instant::now() < deadline { + app.handle_events(&ctx); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(app.emu.is_running(), "start failed: {:?}", app.toast); + assert!(std::fs::metadata("nvram.bin").unwrap().len() > 0); + assert!(std::fs::metadata("nveeprom.bin").unwrap().len() > 0); + app.emu.send(Cmd::Stop); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); + while !app.emu.can_change_directory() && std::time::Instant::now() < deadline { + app.handle_events(&ctx); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(app.emu.can_change_directory(), "stop failed: {:?}", app.toast); + } + assert_ne!(settings::nvram_mac(alpha.join("nvram.bin").to_str().unwrap()), settings::nvram_mac(beta.join("nvram.bin").to_str().unwrap())); + // Switch rereads external TOML edits rather than the startup map. + let mut edited = machines::read(&app.prefs.machine_path("alpha").unwrap()).unwrap(); + edited.banks = [16, 16, 0, 0]; + machines::save(&root, "alpha", &mut edited).unwrap(); + app.switch_to("alpha"); + assert_eq!(app.cfg.banks, [16, 16, 0, 0]); + app.apply_rename("renamed"); + assert_eq!(app.prefs.active_machine.as_deref(), Some("renamed")); + assert_eq!(std::env::current_dir().unwrap(), root.join("machines/renamed")); + assert!(root.join("machines/renamed/renamed.toml").is_file()); + app.delete_machine("renamed"); + assert_eq!(app.prefs.active_machine.as_deref(), Some("beta")); + assert_eq!(std::env::current_dir().unwrap(), beta); + assert!(root.join("machines/renamed/nvram.bin").is_file()); + assert!(!root.join("machines/renamed/renamed.toml").exists()); + app.emu.shutdown(); + let restored = GuiSettings::load_in(root.clone(), launch_dir()); + assert_eq!(restored.active_machine.as_deref(), Some("beta")); + assert_eq!(restored.machines.len(), 1); + } + + #[test] + fn toml_machine_lifecycle_and_emulator_paths() { + let root = machines::tests::TempRoot::new("app-lifecycle"); + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "machine_storage_integration::machine_storage_child", "--nocapture"]) + .env("IRIS_GUI_STORAGE_TEST", &root.0).current_dir(&root.0).output().unwrap(); + assert!(out.status.success(), "{}\n{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + } +} diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index 132d80ec..ed916948 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -167,7 +167,7 @@ fn pick_disk(title: &str, cur: &str) -> Option { .add_filter("Disk images", &["raw", "img", "chd"]) .add_filter("All", &["*"]) .pick_file() - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| crate::picked_path(&p)) } pub fn pick_iso(title: &str, cur: &str) -> Option { @@ -175,7 +175,7 @@ pub fn pick_iso(title: &str, cur: &str) -> Option { .add_filter("ISO images", &["iso", "chd"]) .add_filter("All", &["*"]) .pick_file() - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| crate::picked_path(&p)) } /// Apply an action to the config. diff --git a/iris-gui/src/settings.rs b/iris-gui/src/settings.rs index 4c5547bb..2b63f49c 100644 --- a/iris-gui/src/settings.rs +++ b/iris-gui/src/settings.rs @@ -3,12 +3,9 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -/// GUI-only persisted state at `/iris/gui.json`. -/// -/// This is the **system of record** for machines: each named machine is a -/// `MachineConfig` stored here. `iris.toml` is treated as import/export -/// only, for compatibility with the standalone `iris` CLI. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +/// GUI preferences at `/iris/gui.json`. Machine configurations +/// use the core TOML schema in `machines//.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuiSettings { /// egui UI scale (default `UI_SCALE_DEFAULT`, currently 1.25). #[serde(default = "default_ui_scale")] @@ -20,21 +17,21 @@ pub struct GuiSettings { #[serde(default = "default_vm_scale")] pub vm_scale: f32, - /// All saved machines keyed by user-visible name. BTreeMap so menus - /// list them in stable alphabetical order. - #[serde(default)] + /// Runtime machine list, discovered from TOML folders. Deserialize the old + /// JSON field for migration, but never serialize configs back into JSON. + /// BTreeMap keeps menu entries in alphabetical order. + #[serde(default, skip_serializing)] pub machines: BTreeMap, /// Currently-selected machine (key into `machines`). None = no /// machine loaded yet (first run). #[serde(default)] pub active_machine: Option, - // --- Legacy iris.toml workflow (still supported for users who had it). --- - /// Most-recently-imported iris.toml files (newest first, max ~10). + // Retained for compatibility with existing GUI preferences. + /// Legacy recent TOML files; no longer updated by the GUI. #[serde(default)] pub recent_configs: Vec, - /// Last-imported TOML path; one-shot migration source on first launch - /// of the new machine-store world. + /// Legacy TOML pointer, consumed once during migration. #[serde(default)] pub last_config: Option, @@ -55,6 +52,34 @@ pub struct GuiSettings { /// Store build. See [`crate::macos_sandbox`]. #[serde(default)] pub disk_folders: Vec, + + /// Runtime storage root, captured before the working directory changes. + #[serde(skip)] + root: Option, + /// Load/migration errors shown by the launcher and logged. + #[serde(skip)] + pub errors: Vec, + /// Protect the legacy JSON if any conversion or preference read failed. + #[serde(skip)] + migration_pending: bool, +} + +impl Default for GuiSettings { + fn default() -> Self { + Self { + ui_scale: UI_SCALE_DEFAULT, + vm_scale: VM_SCALE_DEFAULT, + machines: BTreeMap::new(), + active_machine: None, + recent_configs: Vec::new(), + last_config: None, + bookmarks: BTreeMap::new(), + disk_folders: Vec::new(), + root: None, + errors: Vec::new(), + migration_pending: false, + } + } } /// Byte offset of the Indy's 6-byte Ethernet MAC inside the NVRAM. The PROM @@ -252,182 +277,153 @@ fn default_ui_scale() -> f32 { UI_SCALE_DEFAULT } fn default_vm_scale() -> f32 { VM_SCALE_DEFAULT } impl GuiSettings { - pub fn config_path() -> Option { - Self::data_dir().map(|d| d.join("gui.json")) - } - - /// Stable per-user directory for GUI state (gui.json, nvram.bin, …). The OS - /// maps this into the sandbox container automatically on the App Store - /// build, so the *same* code resolves the right place for `cargo run` and - /// the bundled app alike. + /// Foundation returns the actual container home for sandboxed macOS apps, + /// including launches from a shell whose HOME still points outside it. pub fn data_dir() -> Option { + #[cfg(all(target_os = "macos", feature = "appstore"))] + { + let home = objc2_foundation::NSHomeDirectory().to_string(); + Some(PathBuf::from(home).join("Library/Application Support/iris")) + } + #[cfg(not(all(target_os = "macos", feature = "appstore")))] dirs::config_dir().map(|d| d.join("iris")) } - /// Default absolute NVRAM path: `/nvram.bin`. Absolute on purpose - /// — a relative `nvram.bin` resolves against the process's working - /// directory, which differs between `cargo run` (repo root) and a bundled - /// `.app`, silently loading different (often blank, MAC-less) NVRAMs. Anchor - /// it once and every launch shares one NVRAM. - pub fn default_nvram_path() -> String { - Self::data_dir() - .map(|d| d.join("nvram.bin").to_string_lossy().into_owned()) - .unwrap_or_else(|| "nvram.bin".to_string()) + pub fn root(&self) -> Result<&Path, String> { + self.root.as_deref().ok_or_else(|| "No GUI config directory".into()) } - /// Default absolute NVRAM EEPROM path: `/nveeprom.bin`. Same - /// reasoning as [`default_nvram_path`] — the core default is a bare - /// `"nveeprom.bin"` (`src/config.rs::default_nveeprom`), which resolves - /// against the process's working directory and silently finds a - /// different (usually blank) file between `cargo run` and a bundled - /// `.app`. This is the motherboard EEPROM Indigo2/IP28 actually read - /// `eaddr` from (`Eeprom93c56`, see `rules/irix/networking.md`). - /// `ensure_nveeprom_exists` creates an erased chip; the IP28 core seeds - /// PROM defaults before its first boot and saves them on Stop. - pub fn default_nveeprom_path() -> String { - Self::data_dir() - .map(|d| d.join("nveeprom.bin").to_string_lossy().into_owned()) - .unwrap_or_else(|| "nveeprom.bin".to_string()) + pub fn machine_dir(&self, name: &str) -> Result { + crate::machines::directory(self.root()?, name) } - /// Managed directory for newly-created disk images: `/disks`. - /// Absolute and writable in every launch context — the OS maps it into the - /// sandbox container on the App Store build, so creating a disk here needs - /// no permission prompt. Users can still pick another location. - pub fn disks_dir() -> Option { - Self::data_dir().map(|d| d.join("disks")) + pub fn machine_path(&self, name: &str) -> Result { + crate::machines::config_path(self.root()?, name) } - /// Default absolute path for a new SCSI disk image: `/scsiN.raw`. - pub fn default_disk_path(scsi_id: u8) -> String { - Self::disks_dir() - .map(|d| d.join(format!("scsi{scsi_id}.raw")).to_string_lossy().into_owned()) - .unwrap_or_else(|| format!("scsi{scsi_id}.raw")) - } + pub fn default_nvram_path() -> String { "nvram.bin".into() } + pub fn default_nveeprom_path() -> String { "nveeprom.bin".into() } - /// Anchor a machine's NVRAM path to [`data_dir`] if it's relative (the - /// legacy default was a bare `"nvram.bin"`). Best-effort: if the anchored - /// file doesn't exist yet but the old cwd-relative one does, copy it over so - /// the PROM env (boot settings, any MAC) carries forward instead of starting - /// blank. Idempotent — absolute paths are left untouched. - pub fn migrate_nvram_path(nvram: &mut String) { - if !nvram.is_empty() && Path::new(&nvram).is_absolute() { - return; - } - let Some(dir) = Self::data_dir() else { return; }; - let _ = std::fs::create_dir_all(&dir); - let leaf = Path::new(nvram.as_str()) - .file_name() - .and_then(|s| s.to_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("nvram.bin"); - let dst = dir.join(leaf); - let src = PathBuf::from(nvram.as_str()); // relative to cwd - if !dst.exists() && !nvram.is_empty() && src.exists() { - let _ = std::fs::copy(&src, &dst); - } - *nvram = dst.to_string_lossy().into_owned(); - } + /// The selected machine's folder is the process working directory. + pub fn working_dir() -> Option { std::env::current_dir().ok() } + pub fn disks_dir() -> Option { Self::working_dir().map(|d| d.join("disks")) } + pub fn default_disk_path(scsi_id: u8) -> String { format!("disks/scsi{scsi_id}.raw") } - /// Same anchoring as [`migrate_nvram_path`], for the NVRAM EEPROM path. - /// The legacy default was a bare `"nveeprom.bin"`, which could be - /// anywhere depending on how iris-gui was launched (no `set_current_dir` - /// call of its own) — this was never anchored at all before, so most - /// existing machines will have nothing to find at the old relative path - /// and just start a fresh EEPROM at the new stable location, same as a - /// brand new machine would. - pub fn migrate_nveeprom_path(nveeprom: &mut String) { - if !nveeprom.is_empty() && Path::new(&nveeprom).is_absolute() { - return; - } - let Some(dir) = Self::data_dir() else { return; }; - let _ = std::fs::create_dir_all(&dir); - let leaf = Path::new(nveeprom.as_str()) - .file_name() - .and_then(|s| s.to_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("nveeprom.bin"); - let dst = dir.join(leaf); - let src = PathBuf::from(nveeprom.as_str()); // relative to cwd - if !dst.exists() && !nveeprom.is_empty() && src.exists() { - let _ = std::fs::copy(&src, &dst); + pub fn load() -> Self { + match (Self::data_dir(), std::env::current_dir()) { + (Some(root), Ok(cwd)) => Self::load_in(root, &cwd), + _ => Self { errors: vec!["Cannot locate GUI storage or working directory".into()], ..Default::default() }, } - *nveeprom = dst.to_string_lossy().into_owned(); } - pub fn load() -> Self { - // Load from disk when present, else start from defaults — but ALWAYS - // fall through to the sanitizer below. A missing or unreadable file used - // to early-return `Self::default()`, which leaves `vm_scale`/`ui_scale` - // at the struct's zero `Default` (0.0, not the serde field defaults). - // A 0.0 vm_scale then panics the window-fit math (`clamp` min > max), so - // a first-ever run with no gui.json crashed instead of using defaults. - let mut s: Self = Self::config_path() - .and_then(|path| std::fs::read_to_string(&path).ok()) - .and_then(|text| serde_json::from_str::(&text).ok()) - .unwrap_or_default(); - // Sanitize a stale/out-of-range persisted scale. A value below the - // minimum is junk left by an older build whose keyboard zoom floored - // at 0.5 (the UI can no longer produce sub-minimum values), so reset - // it to the default rather than honoring it — likewise for a - // non-finite value from a corrupt file. Only the high end is clamped. + pub(crate) fn load_in(root: PathBuf, old_cwd: &Path) -> Self { + let directory_error = std::fs::create_dir_all(&root).err(); + // cwd follows symlinks; use the same base for relative TOML paths. + let root = root.canonicalize().unwrap_or(root); + let path = root.join("gui.json"); + let mut s = match std::fs::read_to_string(&path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(s) => s, + Err(e) => Self { migration_pending: true, errors: vec![format!("{}: {e}", path.display())], ..Default::default() }, + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(), + Err(e) => Self { migration_pending: true, errors: vec![format!("{}: {e}", path.display())], ..Default::default() }, + }; + s.root = Some(root.clone()); + if let Some(e) = directory_error { + s.migration_pending = true; + s.errors.push(format!("Cannot create GUI storage: {e}")); + } + // Restore access before migrating any external battery-backed files. + crate::macos_sandbox::restore(&s.bookmarks); s.ui_scale = if !s.ui_scale.is_finite() || s.ui_scale < UI_SCALE_MIN { UI_SCALE_DEFAULT - } else { - s.ui_scale.min(UI_SCALE_MAX) - }; + } else { s.ui_scale.min(UI_SCALE_MAX) }; s.vm_scale = if !s.vm_scale.is_finite() || s.vm_scale < VM_SCALE_MIN { VM_SCALE_DEFAULT - } else { - s.vm_scale.min(VM_SCALE_MAX) - }; - // Anchor every machine's NVRAM to the stable data dir so all launch - // methods share one file (the persisted path becomes absolute on the - // next save). - for m in s.machines.values_mut() { - Self::migrate_nvram_path(&mut m.nvram); - Self::migrate_nveeprom_path(&mut m.nveeprom); + } else { s.vm_scale.min(VM_SCALE_MAX) }; + + if !s.migration_pending && (!s.machines.is_empty() || s.last_config.is_some()) { + // Leave this original backup intact across retries. + let backup = root.join("gui.json.pre-toml.bak"); + let result = (|| -> Result<(), String> { + if !backup.exists() { + std::fs::copy(&path, &backup).map_err(|e| format!("Cannot back up GUI preferences: {e}"))?; + } + for (old_name, cfg) in &s.machines { + let name = crate::machines::legacy_name(old_name); + crate::machines::migrate(&root, &name, cfg, old_cwd)?; + if s.active_machine.as_deref() == Some(old_name) { s.active_machine = Some(name); } + } + if s.machines.is_empty() { + if let Some(legacy) = &s.last_config { + let source = old_cwd.join(legacy); + let cfg = crate::machines::read(&source)?; + let stem = source.file_stem().and_then(|n| n.to_str()).unwrap_or("imported"); + let base = crate::machines::legacy_name(stem); + let name = if crate::machines::config_path(&root, &base)?.exists() { base } else { s.unique_name(&base) }; + crate::machines::migrate(&root, &name, &cfg, old_cwd)?; + s.active_machine = Some(name); + } + } + Ok(()) + })(); + match result { + Ok(()) => { + s.last_config = None; + s.machines.clear(); + if let Err(e) = s.save() { s.migration_pending = true; s.errors.push(e); } + } + Err(e) => { s.migration_pending = true; s.errors.push(format!("Machine migration failed: {e}")); } + } } + let (machines, errors) = crate::machines::discover(&root); + s.machines = machines; + s.errors.extend(errors); + for e in &s.errors { log::error!("{e}"); } s } + pub fn ensure_writable(&self) -> Result<(), String> { + if self.migration_pending { + Err("Resolve the GUI preference/migration error before changing machines".into()) + } else { Ok(()) } + } + + pub fn save_machine(&mut self, name: &str, cfg: &mut MachineConfig) -> Result<(), String> { + self.ensure_writable()?; + crate::machines::save(self.root()?, name, cfg)?; + self.machines.insert(name.into(), cfg.clone()); + self.save() + } + 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(); - // Harvest both per-file bookmarks and the user-granted disk folders (a - // directory bookmark is recursive — see `disk_folders`). + if self.migration_pending { return Err("GUI preferences retained because loading or migration failed".into()); } + let root = self.root()?.to_path_buf(); + let paths: Vec = self.machines.iter().flat_map(|(name, cfg)| { + let dir = crate::machines::directory(&root, name).unwrap(); + crate::macos_sandbox::config_paths(cfg, &dir) + }).collect(); crate::macos_sandbox::harvest( paths.iter().map(String::as_str).chain(self.disk_folders.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())?; - } + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; - std::fs::write(&path, text).map_err(|e| e.to_string()) + crate::machines::write(&root.join("gui.json"), &text) } - pub fn push_recent(&mut self, path: PathBuf) { - self.recent_configs.retain(|p| p != &path); - self.recent_configs.insert(0, path.clone()); - self.recent_configs.truncate(10); - self.last_config = Some(path); + fn name_taken(&self, name: &str) -> bool { + self.machines.contains_key(name) + || self.machine_dir(name).map(|d| d.exists()).unwrap_or(true) } /// Pick a free name like "indy", "indy-2", "indy-3", … pub fn unique_name(&self, base: &str) -> String { - if !self.machines.contains_key(base) { return base.to_string(); } + if !self.name_taken(base) { return base.to_string(); } for n in 2..1000 { let candidate = format!("{base}-{n}"); - if !self.machines.contains_key(&candidate) { return candidate; } + if !self.name_taken(&candidate) { return candidate; } } format!("{base}-{}", uuid_like()) } @@ -437,3 +433,131 @@ fn uuid_like() -> String { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0).to_string() } + +#[cfg(test)] +mod storage_tests { + use super::*; + use crate::machines::{self, tests::TempRoot}; + + #[test] + fn legacy_json_migration_keeps_preferences_and_removes_only_machine_payloads() { + let root = TempRoot::new("json"); + let mut cfg = MachineConfig::default(); + cfg.scsi.get_mut(&1).unwrap().path = "boot.raw".into(); + let original = serde_json::json!({ + "ui_scale": 1.5, "vm_scale": 1.0, "active_machine": "indy", + "machines": {"indy": cfg}, "recent_configs": ["old.toml"], + "last_config": null, "bookmarks": {"/old/disk": [1,2,3]}, + "disk_folders": ["/old/folder"] + }); + let original_text = serde_json::to_string(&original).unwrap(); + std::fs::write(root.0.join("gui.json"), &original_text).unwrap(); + let mut prefs = GuiSettings::load_in(root.0.clone(), &root.0); + assert!(prefs.errors.is_empty(), "{:?}", prefs.errors); + assert_eq!(prefs.active_machine.as_deref(), Some("indy")); + assert_eq!(prefs.machines["indy"].scsi[&1].path, "../../boot.raw"); + assert_eq!(std::fs::read_to_string(root.0.join("gui.json.pre-toml.bak")).unwrap(), original_text); + prefs.save().unwrap(); + let persisted: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(root.0.join("gui.json")).unwrap()).unwrap(); + assert!(persisted.get("machines").is_none()); + for key in ["ui_scale", "vm_scale", "active_machine", "recent_configs", "last_config", "bookmarks", "disk_folders"] { + assert_eq!(persisted[key], original[key], "{key}"); + } + // Preference saves do not overwrite externally edited machine TOMLs. + let path = prefs.machine_path("indy").unwrap(); + let mut cfg = machines::read(&path).unwrap(); + cfg.banks = [8, 8, 0, 0]; + machines::save(&root.0, "indy", &mut cfg).unwrap(); + prefs.save().unwrap(); + let reloaded = GuiSettings::load_in(root.0.clone(), &root.0); + assert_eq!(reloaded.machines["indy"].banks, [8, 8, 0, 0]); + assert_eq!(std::fs::read_to_string(root.0.join("gui.json.pre-toml.bak")).unwrap(), original_text); + } + + #[test] + fn failed_migration_leaves_original_json_intact() { + let root = TempRoot::new("failed"); + // A non-directory machines entry forces a real filesystem failure. + std::fs::write(root.0.join("machines"), b"occupied").unwrap(); + let text = serde_json::json!({"machines": {"indy": MachineConfig::default()}, "active_machine": "indy"}).to_string(); + std::fs::write(root.0.join("gui.json"), &text).unwrap(); + let mut prefs = GuiSettings::load_in(root.0.clone(), &root.0); + assert!(prefs.migration_pending); + assert!(prefs.save().is_err()); + assert_eq!(std::fs::read_to_string(root.0.join("gui.json")).unwrap(), text); + assert_eq!(std::fs::read_to_string(root.0.join("gui.json.pre-toml.bak")).unwrap(), text); + } + + #[test] + fn corrupt_preferences_are_reported_and_never_overwritten() { + let root = TempRoot::new("corrupt"); + std::fs::write(root.0.join("gui.json"), "{ invalid json").unwrap(); + let mut prefs = GuiSettings::load_in(root.0.clone(), &root.0); + assert!(!prefs.errors.is_empty()); + assert!(prefs.save().is_err()); + assert_eq!(std::fs::read_to_string(root.0.join("gui.json")).unwrap(), "{ invalid json"); + } + + #[test] + fn legacy_toml_pointer_migrates_once_and_preserves_source_file() { + let root = TempRoot::new("legacy-toml"); + let source = root.0.join("old.toml"); + std::fs::write(&source, "banks = [8, 8, 0, 0]").unwrap(); + std::fs::write(root.0.join("gui.json"), serde_json::json!({"last_config": source}).to_string()).unwrap(); + let prefs = GuiSettings::load_in(root.0.clone(), &root.0); + assert!(prefs.errors.is_empty(), "{:?}", prefs.errors); + assert_eq!(prefs.active_machine.as_deref(), Some("old")); + assert_eq!(prefs.machines["old"].banks, [8, 8, 0, 0]); + assert!(prefs.last_config.is_none()); + assert_eq!(std::fs::read_to_string(source).unwrap(), "banks = [8, 8, 0, 0]"); + assert_eq!(GuiSettings::load_in(root.0.clone(), &root.0).machines.len(), 1); + } + + #[test] + fn legacy_names_that_are_not_filenames_migrate_deterministically() { + let root = TempRoot::new("old-name"); + let old_name = "IRIX / 6.5"; + std::fs::write(root.0.join("gui.json"), serde_json::json!({ + "machines": {old_name: MachineConfig::default()}, "active_machine": old_name + }).to_string()).unwrap(); + let prefs = GuiSettings::load_in(root.0.clone(), &root.0); + assert!(prefs.errors.is_empty(), "{:?}", prefs.errors); + let name = machines::legacy_name(old_name); + machines::validate_name(&name).unwrap(); + assert_eq!(prefs.active_machine.as_deref(), Some(name.as_str())); + assert!(prefs.machine_path(&name).unwrap().exists()); + assert_eq!(GuiSettings::load_in(root.0.clone(), &root.0).machines.len(), 1); + } + + #[cfg(unix)] + #[test] + fn storage_roots_with_symlinked_parents_use_the_actual_working_directory_base() { + let temp = TempRoot::new("symlink-root"); + let actual = temp.0.join("actual"); + std::fs::create_dir_all(&actual).unwrap(); + std::os::unix::fs::symlink(&actual, temp.0.join("alias")).unwrap(); + let mut prefs = GuiSettings::load_in(temp.0.join("alias/iris"), &temp.0); + assert_eq!(prefs.root().unwrap(), actual.join("iris")); + let disk = temp.0.join("external.raw"); + std::fs::write(&disk, b"external disk").unwrap(); + let mut cfg = MachineConfig::default(); + cfg.scsi.get_mut(&1).unwrap().path = disk.to_string_lossy().into_owned(); + prefs.save_machine("indy", &mut cfg).unwrap(); + let dir = prefs.machine_dir("indy").unwrap(); + assert_eq!(std::fs::read(dir.join(&cfg.scsi[&1].path)).unwrap(), b"external disk"); + } + + #[test] + fn folder_discovery_does_not_need_gui_json_and_names_reserve_retained_data() { + let root = TempRoot::new("no-json"); + let mut cfg = MachineConfig::default(); + machines::save(&root.0, "indy", &mut cfg).unwrap(); + let prefs = GuiSettings::load_in(root.0.clone(), &root.0); + assert!(prefs.machines.contains_key("indy")); + assert_eq!(prefs.ui_scale, UI_SCALE_DEFAULT); + assert_eq!(prefs.vm_scale, VM_SCALE_DEFAULT); + assert_eq!(prefs.unique_name("indy"), "indy-2"); + std::fs::create_dir_all(root.0.join("machines/indy-2/disks")).unwrap(); + assert_eq!(prefs.unique_name("indy"), "indy-3"); + } +} diff --git a/iris-gui/src/single_instance.rs b/iris-gui/src/single_instance.rs index 8505611b..6c081373 100644 --- a/iris-gui/src/single_instance.rs +++ b/iris-gui/src/single_instance.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; fn pidfile_path() -> Option { - dirs::config_dir().map(|d| d.join("iris").join("iris-gui.pid")) + crate::settings::GuiSettings::data_dir().map(|d| d.join("iris-gui.pid")) } /// Reclaim resources from a previous instance (if any), then claim the lock diff --git a/rules/build/dependency-upgrade-gotchas.md b/rules/build/dependency-upgrade-gotchas.md index 7eebb2f1..12b8de99 100644 --- a/rules/build/dependency-upgrade-gotchas.md +++ b/rules/build/dependency-upgrade-gotchas.md @@ -70,8 +70,9 @@ old `default-features = false` config never had an equivalent either. - **`dirs` 5 → 6 does not move any user data.** `config_dir()` and `data_dir()` are byte-identical in both versions on Linux (XDG), macOS (Application - Support), and Windows (Roaming AppData). iris-gui's `gui.json` machine store - and `iris-gui.pid` stay exactly where they were; no migration needed. + Support), and Windows (Roaming AppData). iris-gui's `gui.json` preferences + and `iris-gui.pid` stay where they were; the dirs upgrade needs no migration. + Machine TOML folders are a separate GUI storage change. - **`bitfield` 0.14 → 0.19 is a clean drop-in** despite five major versions. All eight `bitfield! { ... }` blocks (rex3, vc2, mips_cache_v2, hal2, saa7191, mips_exec) compile untouched. diff --git a/rules/gui/01-overview.md b/rules/gui/01-overview.md index c6ae3d19..99212b13 100644 --- a/rules/gui/01-overview.md +++ b/rules/gui/01-overview.md @@ -25,9 +25,12 @@ easy to break. `Machine` call on the worker is wrapped in `catch_unwind`. - `CyclesPtr` (the live MIPS readout) must be cleared before the `Machine` is dropped (`cycles-ptr-must-be-cleared-before-dropping-the-machine.md`). -- Settings **and machine configs** persist to `/iris/gui.json` - (`dirs::config_dir()`), which is the system of record. `iris.toml` is - import/export only, hidden in `bundled` builds. +- GUI preferences persist to `/iris/gui.json`; machine configs use + the core TOML schema at `machines//.toml`. The selected machine + folder is the process working directory. Keep config paths relative, and + wait for queued worker operations and machine teardown before changing cwd. + App Store builds use Foundation's container home for storage and resolve + external relative paths before harvesting security-scoped bookmarks. ## Frames and input From 2ef31aec64eb6d225e3a99a1be2daec7e637e991 Mon Sep 17 00:00:00 2001 From: iblowmymind <28228415+iblowmymind@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:07:18 +0300 Subject: [PATCH 3/4] iris-gui: TFTP default and a stale sandbox-test assertion, for the machine-folder model - config_ui.rs: default the TFTP server's folder to a bare relative "tftpboot", like nvram/disks, so it lives inside the machine that enabled it instead of one folder shared by every machine (GuiSettings::data_dir().join("tftpboot")). The CLI's --config hint on the CI tab also no longer refers to the removed Export action. - macos_sandbox.rs: config_paths_harvest_restore's "relative paths should be skipped" assertion was stale -- relative paths now resolve against the machine folder and only missing files are skipped. Reworded it and added config_paths_resolves_relative_against_machine_dir to cover the resolve-and-include case directly. cargo test -p iris-gui --features appstore: 74 passed. --- iris-gui/src/config_ui.rs | 17 +++++++++-------- iris-gui/src/macos_sandbox.rs | 26 ++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index abcb9ae8..955083f0 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -1534,13 +1534,14 @@ fn tftp_section(ui: &mut Ui, cfg: &mut MachineConfig, host: &[crate::netplan::Ho let mut on = cfg.network.tftp_dir.is_some(); if ui.checkbox(&mut on, "Enable TFTP").changed() { cfg.network.tftp_dir = if on { - // Start from a folder that exists, so an enabled server never - // serves an empty path (the working directory). - let dir = crate::settings::GuiSettings::data_dir() - .map(|d| d.join("tftpboot")) - .unwrap_or_else(|| "tftpboot".into()); - let _ = std::fs::create_dir_all(&dir); - Some(dir.to_string_lossy().into_owned()) + // A bare relative name, like `nvram`/`disks` — it lives in this + // machine's own folder (the working directory tracks whichever + // machine is selected; see `switch_to`/`enter_directory`), not a + // folder shared by every machine. Create it now so an enabled + // server never serves an empty path. + let dir = "tftpboot"; + let _ = std::fs::create_dir_all(dir); + Some(dir.to_string()) } else { None }; @@ -2038,7 +2039,7 @@ fn show_perfetto(ui: &mut Ui) { fn show_ci(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { ui.heading("CI / Automation"); - ui.label("Export TOML then run iris with ci=true — same hardware as GUI. Windows default socket: 127.0.0.1:19851"); + ui.label("Run `iris --config /.toml` with ci=true from that folder — same hardware as GUI. Windows default socket: 127.0.0.1:19851"); Grid::new("ci_grid").num_columns(2).striped(true).show(ui, |ui| { ui.label("Enable CI mode"); ui.checkbox(&mut cfg.ci, ""); diff --git a/iris-gui/src/macos_sandbox.rs b/iris-gui/src/macos_sandbox.rs index 2e5135d5..96dc5232 100644 --- a/iris-gui/src/macos_sandbox.rs +++ b/iris-gui/src/macos_sandbox.rs @@ -193,12 +193,15 @@ mod tests { let path = temp_file(".raw"); let mut cfg = MachineConfig::default(); cfg.scsi.get_mut(&1).unwrap().path = path.clone(); - cfg.prom = "prom.bin".into(); // missing → skipped + cfg.prom = "prom.bin".into(); // relative, and missing → skipped let paths = config_paths(&cfg, Path::new("/tmp/iris-machine")); let key = Path::new(&path).canonicalize().unwrap().to_string_lossy().into_owned(); assert!(paths.contains(&key), "absolute scsi image should be collected"); - assert!(!paths.iter().any(|p| p == "prom.bin"), "relative paths should be skipped"); + assert!( + !paths.iter().any(|p| p.ends_with("prom.bin")), + "a relative path that doesn't exist anywhere should be skipped" + ); let mut bm = BTreeMap::new(); harvest(paths.iter().map(String::as_str), &mut bm); @@ -207,4 +210,23 @@ mod tests { restore(&bm); // must not panic let _ = std::fs::remove_file(&path); } + + #[test] + fn config_paths_resolves_relative_against_machine_dir() { + let path = temp_file(".bin"); + let machine_dir = Path::new(&path).parent().unwrap().to_path_buf(); + let relative_name = Path::new(&path).file_name().unwrap().to_string_lossy().into_owned(); + + let mut cfg = MachineConfig::default(); + cfg.nveeprom = relative_name.clone(); + + let paths = config_paths(&cfg, &machine_dir); + let key = Path::new(&path).canonicalize().unwrap().to_string_lossy().into_owned(); + assert!( + paths.contains(&key), + "an existing relative nveeprom path should resolve against the machine folder, not be skipped" + ); + + let _ = std::fs::remove_file(&path); + } } From 029fdfa5ca2f239223f3670c0cd2014a0596d406 Mon Sep 17 00:00:00 2001 From: iblowmymind <28228415+iblowmymind@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:07:36 +0300 Subject: [PATCH 4/4] docs: catch up on the machine-folder storage model (gui-machines-overhaul) CHANGELOG, iris-gui-README's bundled-feature wording, the perf doc's GUI row, and wsl/README's whole GUI<->TOML<->CI sync section still described the pre-overhaul workflow (gui.json + File -> Import/Export iris.toml). Reworded them for per-machine machines//.toml storage: - CHANGELOG: the EEPROM stable-path bullet now reads as history (machine folders superseded that fixed path), and a backwards "(below)" cross reference is fixed to "(above)". - iris-gui/Cargo.toml: "bundled" comment wording matches what it actually gates (premiere preparation, not the GUI's Rebuild Premiere buttons). - rules/perf/hardware-profiles.md: "Full MachineConfig + export TOML" -> "stored as a per-machine TOML the CLI can load directly". - wsl/README.md: replaced the File->Import/Export instructions with the actual per-OS machine-folder path and the one thing that doesn't carry over automatically -- the core CLI resolves a TOML's relative paths against its own CWD, not the TOML's folder, so running the CLI against a GUI machine's TOML means cd'ing into that machine's folder first (the GUI does this itself via enter_directory whenever a machine is selected, not just at Start). Dropped the "rewrite paths to absolute" and "symlink the TOML" suggestions from an earlier pass -- the GUI relativizes paths back on every save, and its atomic write replaces a symlink with a regular file, so neither actually survives. --- CHANGELOG.md | 20 ++++---- iris-gui/Cargo.toml | 2 +- rules/perf/hardware-profiles.md | 2 +- wsl/README.md | 90 +++++++++++++++++++++++---------- 4 files changed, 76 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dbefe8..bbc04b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,15 +183,17 @@ hashes are given where a change is easiest to understand by reading the commit. (Indigo2/IP28's motherboard EEPROM — where `eaddr` and PROM env actually live on those profiles, not in the NVRAM file) used to default to a bare `"nveeprom.bin"` with no anchoring, no migration, and no config-editor field, - unlike `nvram`. Since iris-gui never changes its working directory, that bare - default resolved against whatever CWD the OS happened to launch it with — - often not the same place twice between `cargo run` and a bundled `.app` — so - a machine could silently load a different (usually blank) EEPROM each - launch, and "Reset NVRAM"'s MAC write (which only ever touched `nvram`) had - no effect on Indigo2/IP28's actual `eaddr`. Now: a stable - `/iris/nveeprom.bin` default (`GuiSettings::default_nveeprom_path`), - the same relative-path migration `nvram` gets on load, and a "NVRAM EEPROM - file" row on the General tab right below NVRAM file. + unlike `nvram`. At the time iris-gui never changed its working directory, so + that bare default resolved against whatever CWD the OS happened to launch it + with — often not the same place twice between `cargo run` and a bundled + `.app` — so a machine could silently load a different (usually blank) + EEPROM each launch, and "Reset NVRAM"'s MAC write (which only ever touched + `nvram`) had no effect on Indigo2/IP28's actual `eaddr`. Fixed first with a + stable `/iris/nveeprom.bin` default and the same relative-path + migration `nvram` gets on load; machine folders (above) later superseded + that fixed path, so `nveeprom.bin` now resolves next to each machine's own + `nvram.bin`. Either way there's a "NVRAM EEPROM file" row on the General tab + right below NVRAM file. - **"Reset NVRAM" and the no-MAC-yet pre-flight now also patch the NVRAM EEPROM.** Both only ever touched the DS1386 `nvram` file — on Indigo2/IP28, which read `eaddr` from `nveeprom` instead (see above), this had no effect diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index b8660145..48f20b75 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -52,7 +52,7 @@ assets = [ ] [features] -# Distributed build. Hides source-checkout tools such as rebuild/premiere. +# Distributed build. Hides source-checkout tools such as premiere preparation. # Machine TOML storage is shared by source, Release, and App Store builds. bundled = [] # Mac App Store distribution build. Implies `bundled`, and additionally hides diff --git a/rules/perf/hardware-profiles.md b/rules/perf/hardware-profiles.md index 01979d39..ffaf598e 100644 --- a/rules/perf/hardware-profiles.md +++ b/rules/perf/hardware-profiles.md @@ -3,7 +3,7 @@ | Capability | MAME Indy | IRIS Phase 3 | |------------|-----------|--------------| | IRIX 6.5 desktop | Slow; DRC often flaky | Premiere stack (`lightning` + `rex-jit` + `idle-pause`; jitv2 optional) | -| Config GUI | None | Full `MachineConfig` + export TOML | +| Config GUI | None | Full `MachineConfig`, stored as a per-machine TOML the CLI can load directly | | CI automation | External scripts | `iris-ci` + TCP on Windows | | Extended RAM | Limited | 384/512 MB GUI presets | | Networking | Yes | NAT + pcap + port forward | diff --git a/wsl/README.md b/wsl/README.md index 4c73acd1..73068831 100644 --- a/wsl/README.md +++ b/wsl/README.md @@ -16,7 +16,11 @@ use a WSL checkout at `~/iris-wsl-build`; adjust paths for your own checkout. | `run-iris.bat` | IRIS CLI via WSL (iris-ci, Linux tools) | | `run-iris-gui.bat` | iris-gui via WSL | -**For YouTube / max graphics:** tune the machine in **iris-gui**, **export** to `irix-install/iris-windows.toml`, **stop** the GUI VM, then run **`run-iris-premiere.bat`** for the 3D segment (native CLI OpenGL — faster than the GUI CPU compositor). +**For YouTube / max graphics:** tune the machine in **iris-gui**, **stop** the +VM, then run the CLI from that machine's own folder (see "GUI ↔ TOML ↔ CI +sync" below) for the 3D segment (native CLI OpenGL — faster than the GUI CPU +compositor). `run-iris-premiere.bat` runs a fixed `irix-install\iris-windows.toml` +from the repo root — a separate, hand-maintained TOML, not a GUI machine's. --- @@ -30,7 +34,7 @@ These settings are part of this repository and share the core configuration sche |------|------| | **Launch scripts** | `run-iris-premiere.bat` / `run-iris-windows.bat` build with `lightning,rex-jit,idle-pause`; **feature stamp** rebuild via `ensure-build.bat` | | **Premiere GUI** | `cargo build -p iris-gui --features premiere` — embedded `lightning` + `idle-pause`; `run-iris-gui-premiere.bat` | -| **GUI prefs** | Debug tab settings (`gui_gl_capture`, `no_idle`, `debug_log`) persisted in `gui.json`; **File → Prepare for premiere…** exports TOML | +| **GUI prefs** | Debug tab settings (`gui_gl_capture`, `no_idle`, `debug_log`) persisted in the machine's own TOML; **File → Prepare for premiere…** flushes it to disk and reports the path to run the CLI `--config` against, from that machine's folder | | **Idle refresh** | Status-bar-only heartbeat skips full compositor + partial egui upload — [rules/perf/gui-idle-refresh.md](../rules/perf/gui-idle-refresh.md) | | **Audio** | HAL2 wall-clock pacing/resampling; Display tab `[audio]` prebuf / cpal buffer | | **jitv2 (experimental)** | `--features jitv2` — physical-page region compiler, auto-enabled at runtime once compiled in (no env-var toggle); use `jitv2_lockstep` for diagnostics or `jitv2_opcodefusion` separately (they conflict); `[jitv2] threads` configures the pool | @@ -95,7 +99,7 @@ Monitor (telnet `127.0.0.1:8888`): `perf snapshot`, `rex jit status`, `hal2 stat ### Phase 3 unified config -Export from iris-gui includes `[perf]` and `[machine]` sections (plus `[jitv2]` if built with the `jitv2` feature). See [rules/perf/phase3-platform.md](../rules/perf/phase3-platform.md). +A GUI machine's TOML includes `[perf]` and `[machine]` sections (plus `[jitv2]` if built with the `jitv2` feature). See [rules/perf/phase3-platform.md](../rules/perf/phase3-platform.md). Windows CI default socket: `127.0.0.1:19851` (TCP). Unix: `/tmp/iris.sock`. @@ -133,8 +137,9 @@ Uses `GlCompositor` on the refresh thread instead of the CPU path. Still slower | File | Role | |------|------| -| `irix-install/iris-windows.toml` | **Shared canonical TOML** for CLI / premiere.bat (export from GUI to keep in sync) | -| `%APPDATA%\iris\gui.json` | **GUI system of record** — named machines, autosaved edits | +| `irix-install/iris-windows.toml` | Hand-maintained TOML for `run-iris-premiere.bat`, independent of any GUI machine | +| `%APPDATA%\iris\machines\\.toml` | **GUI system of record** per machine — autosaved edits, same schema the CLI's `--config` loads | +| `%APPDATA%\iris\gui.json` | GUI preferences and the active machine name only | | `irix-install/iris-wsl.toml` | WSL / Linux CLI | **Ports (native Windows):** @@ -198,44 +203,72 @@ different CPU model. ## GUI ↔ TOML ↔ CI sync -The GUI and CLI use the same **`MachineConfig`** schema but **different files** unless you sync them. **`iris-ci` loads no config** — it only drives an already-running `iris` process. +The GUI and CLI use the same **`MachineConfig`** schema, and since the GUI +moved to per-machine TOML storage they can share the *same file* directly — +no export/import step needed. The catch: the core CLI resolves every relative +path in the TOML (`nvram`, `nveeprom`, `disks/...`, `prom`, …) against its own +**current directory**, not against the TOML's location — it's the GUI that +`cd`s into each machine's folder before loading its config (whenever that +machine is selected, not just at Start). So the only reliable way to point +the CLI at a GUI machine's TOML is to run the CLI *from that machine's +folder*. **`iris-ci` loads no config** — it only drives an already-running +`iris` process. + +A GUI machine's folder is `` under: + +- Windows native build: `%APPDATA%\iris\machines\\` +- WSL/Linux build: `$XDG_CONFIG_HOME/iris/machines//` (default `~/.config/iris/machines//`) +- macOS: `~/Library/Application Support/iris/machines//` (sandboxed App + Store build: the app container's own Application Support) | Layer | Stores | |-------|--------| -| **iris-gui** | `%APPDATA%\iris\gui.json` | -| **iris CLI** | `iris.toml` via `--config` | +| **iris-gui** | `/iris/machines//.toml` (one folder per machine, with its own `disks/`, `nvram.bin`, `nveeprom.bin`); `gui.json` keeps only GUI preferences and the active machine name | +| **iris CLI** | Any TOML via `--config`, including a GUI machine's own `.toml` — run from inside that machine's folder so its relative paths resolve | | **Debug settings** | `[debug]` TOML section (`gui_gl_capture`, `no_idle`, `debug_log`) — GUI Debug tab edits it; maps to `IRIS_GUI_GL`/`IRIS_NO_IDLE`/`IRIS_DEBUG_LOG` at Start | | **iris-ci** | Nothing (socket client only) | ### Recommended: one canonical TOML -Treat **`irix-install/iris-windows.toml`** as the shared file: +Point the CLI straight at the GUI machine's own file — there's nothing to +export — but run it from inside that machine's folder: ``` -GUI edits → File → Export → iris-windows.toml → premiere.bat / iris.exe - ↑ ↓ - └──────── File → Import when TOML changed on disk ─────┘ +GUI edits → %APPDATA%\iris\machines\iris-windows\iris-windows.toml + ↓ (cd into that folder first) + iris.exe --config iris-windows.toml ``` ### Path A — GUI → CLI (after tuning in the UI) 1. **Stop** the VM. -2. Set RAM, disks, network in the GUI (Memory tab presets, etc.). -3. **File → Export current to iris.toml…** → save as `irix-install/iris-windows.toml`. -4. Run CLI from repo root (paths in TOML are relative to cwd): +2. Set RAM, disks, network in the GUI (Memory tab presets, etc.) — each edit + saves straight to `iris-windows.toml` in that machine's folder. +3. `cd` into the machine's folder and run the CLI from there, pointed at the + bare filename: ```powershell - wsl\run-iris-premiere.bat + Set-Location "$env:APPDATA\iris\machines\iris-windows" + & "\target\release\iris.exe" --config iris-windows.toml ``` -5. Optional: enable **CI mode** on the CI / Automation tab before export if you need `ci = true` in TOML. - -**Export/Import** appear under **File** in source builds (hidden in App Store `bundled` builds). + `wsl\run-iris-premiere.bat` is not this — it always runs the repo's own + `irix-install\iris-windows.toml` from the repo root, independent of any GUI + machine. Use it for that fixed TOML, or do the `cd` + `--config` above for + a GUI machine. +4. Optional: enable **CI mode** on the CI / Automation tab if you need + `ci = true` in the TOML. ### Path B — TOML → GUI -1. **File → Import iris.toml…** → pick `irix-install/iris-windows.toml`. -2. **Stop → Start** so guest RAM matches imported `banks`. +1. Copy (not symlink — the GUI's autosave replaces the file, which breaks a + link) an existing TOML to `/iris/machines//.toml` — + the GUI discovers it on the next launch — or create a new machine and + paste its settings in. Paths inside it are relative to *that* folder, so a + TOML copied from elsewhere (e.g. `irix-install/iris-windows.toml`, whose + paths are relative to the repo root) needs its disk/NVRAM files moved + alongside it, or the GUI will report them missing. +2. **Stop → Start** so guest RAM matches the TOML's `banks`. ### Debug parity (GUI vs CLI) @@ -261,30 +294,33 @@ mode: **WSL workflow:** 1. TOML includes `ci = true` (and `ci_socket` if non-default). -2. Start iris in WSL with that config. +2. Start iris in WSL with that config — from the machine's own folder if it's + a GUI machine's TOML (Path A above). 3. In another WSL terminal: `iris-ci ping`, `iris-ci boot`, etc. See [rules/snapshot/iris-ci-is-the-canonical-ci-socket-interface.md](../rules/snapshot/iris-ci-is-the-canonical-ci-socket-interface.md). ### Sync checklist -- [ ] Same `banks` in exported TOML and GUI Memory tab -- [ ] Same `nvram`, `scsi.*.path`, `prom` paths (run from repo root or use absolute paths) +- [ ] CLI and GUI point at the *same* TOML file, not a stale copy +- [ ] CLI is launched from that machine's own folder (relative paths resolve against CWD) or its paths are absolute - [ ] **Stop → Start** in GUI after RAM changes - [ ] Same Debug env for CLI as GUI Debug tab - [ ] `mc regs` shows expected MEMCFG for extended RAM -- [ ] Re-export after GUI changes you want CLI/CI to keep --- ## iris-gui first time 1. Run `run-iris-gui-windows.bat` -2. **File → Import iris.toml…** → `irix-install/iris-windows.toml` (or create a machine and configure) +2. Create a machine named `iris-windows` and configure it, or copy an existing + TOML to `%APPDATA%\iris\machines\iris-windows\iris-windows.toml` before + launch so the GUI picks it up (rewrite its paths first — see Path B above) 3. **Memory** tab: pick **256 MB** (authentic) or **384 MB** (IRIX 6.5 extended) 4. **Debug** tab: set GL capture / no-idle / debug-log if needed (defaults are fine for normal use) 5. **Start** -6. After further edits: **File → Export** so CLI/premiere.bat stay aligned +6. Further edits save straight to that same `iris-windows.toml` — point the + CLI at it from its own folder (Path A above) to stay aligned ---