Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fdfaf2f
feat(optical): remote-ripping plan + P1.1 OpticalSource seam
danifunker Jun 27, 2026
bfbccbd
feat(optical): remote optical tier — protocol + daemon (P1.2-P1.4)
danifunker Jun 27, 2026
3f3b5b6
feat(optical): remote rip client + wire-up (P1.5-P1.7)
danifunker Jun 27, 2026
b3d25c5
test(optical): loopback plumbing test for the remote optical tier (P1.8)
danifunker Jun 27, 2026
f6a64c5
feat(optical): unified rip-device picker core + CLI --remote (P3.1, P…
danifunker Jun 27, 2026
4d4a9bd
feat(optical): GUI unified rip-device picker + Add remote daemon (P3.…
danifunker Jun 27, 2026
dd582e6
docs(optical): note remote ripping; tick Phase 2 + done-criteria
danifunker Jun 27, 2026
6864329
feat(optical): MRU list of remote daemon addresses (P3.5, GUI-only)
danifunker Jun 27, 2026
d789b97
feat(optical): show transfer speed + ETA in the CLI rip/convert progress
danifunker Jun 27, 2026
90bdf37
fix(hfs): catalog B-tree scaling, import speed, and Mac-faithful coll…
danifunker Jun 28, 2026
1b22518
fix(hfs): pack catalog B-tree densely on incremental (per-put) inserts
danifunker Jun 28, 2026
de9047f
feat(hfsplus): key-format descriptor for variable-length catalog inde…
danifunker Jun 29, 2026
9445782
feat(hfsplus): volume-scale the blank catalog B-tree so it holds thou…
danifunker Jun 29, 2026
be4fa03
feat(hfsplus): split the extents-overflow B-tree past depth-1 via the…
danifunker Jun 29, 2026
9b7b2a3
test(hfsplus): verify defrag clones a multi-level catalog fsck-clean …
danifunker Jun 29, 2026
49e1981
feat(hfsplus): route live inserts through btree_insert_full for B*-ro…
danifunker Jun 29, 2026
422ebfd
docs(hfsplus): mark the B-tree growth plan complete (P1-P5 landed)
danifunker Jun 29, 2026
3d442ac
docs(hfsplus): plan for §4b B-tree fork grow-on-full with macOS valid…
danifunker Jun 29, 2026
558f17c
docs(hfsplus): cross-link the deferred §4b note to the fork-growth plan
danifunker Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ chrono = "0.4"
tempfile = "3"
image = "0.25"
dirs = "6"
unicode-normalization = "0.1"
# Baseline is the pure-Rust backend (miniz_oxide) so the slim/cross builds need
# no C zlib. The desktop `native-zlib` feature adds `flate2/zlib-ng`, which
# flate2 prioritizes over the Rust backend when both are enabled.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ What's in the MiSTer build:
`optical rip --device /dev/sr0 --format iso|bincue`), plus `optical
convert` (ISO ↔ BIN/CUE ↔ CD-CHD) and `optical browse` / `extract`. For
devices with a CD/DVD drive such as the SuperStation One.
- **Remote ripping off-device:** run `rb-daemon` here and drive this drive
from the desktop app / CLI — the device only issues SCSI reads while the
desktop does the heavy CHD encoding, so the armv7 CPU isn't taxed.
`optical rip --device rb://this-device:7341/dev/sr0`, or the desktop
Optical tab's "Add remote daemon..." button. See
[`docs/remote_ripping.md`](docs/remote_ripping.md).

What's excluded:
- GUI windows and the update-checker self-replace UI (only meaningful
Expand Down
6 changes: 6 additions & 0 deletions docs/full_MiSTer_support_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ support the disk types (floppy / hard disk / CD-ROM) of the outstanding cores.
images. Built into the desktop release and — as of opticaldiscs 0.4.5 — the
MiSTer `rb-cli-mini` armv7 build, for devices with an attached drive (e.g.
the SuperStation One).
- **Remote optical ripping:** the desktop app / CLI can drive a *remote*
daemon's optical drive over the rb-daemon — the device (e.g. a MiSTer) only
issues SCSI reads while the desktop does all the encoding (CHD compression
never taxes the armv7 CPU). `optical drives --remote host:port` to discover;
`optical rip --device rb://host:port/dev/sr0`, or the GUI Optical tab's
"Add remote daemon..." button. See `docs/remote_ripping.md`.

Legend for the **Support** column:

Expand Down
326 changes: 326 additions & 0 deletions docs/hfsplus_btree_growth_plan.md

Large diffs are not rendered by default.

396 changes: 396 additions & 0 deletions docs/remote_ripping.md

Large diffs are not rendered by default.

387 changes: 387 additions & 0 deletions docs/todo_hfsplus_fork_growth.md

Large diffs are not rendered by default.

74 changes: 55 additions & 19 deletions src/cli/verbs/optical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ use crate::backup::LogMessage;
use crate::cli::logging::log_stderr;
use crate::optical::{
convert::{bincue_to_iso, chd_to_bincue, chd_to_iso, iso_to_bincue, to_chd, ConvertProgress},
rip::{run_rip, RipConfig, RipFormat, RipProgress},
rip::{run_rip, OpticalTarget, RipConfig, RipFormat, RipProgress},
};
use crate::partition::format_size;
use crate::rbformats::chd_options::{ChdOptions, ChdProfile};

#[derive(Debug, Subcommand)]
pub enum OpticalCommand {
/// List connected physical optical drives and their device paths.
Drives,
Drives(DrivesArgs),
/// Rip a physical CD/DVD drive to a disk image file.
Rip(RipArgs),
/// Re-encode an optical image into a different format.
Expand All @@ -44,7 +44,7 @@ pub enum OpticalCommand {

pub fn run(cmd: OpticalCommand) -> Result<()> {
match cmd {
OpticalCommand::Drives => run_drives_verb(),
OpticalCommand::Drives(a) => run_drives_verb(a),
OpticalCommand::Rip(a) => run_rip_verb(a),
OpticalCommand::Convert(a) => run_convert_verb(a),
OpticalCommand::Browse(a) => run_browse_verb(a),
Expand All @@ -54,19 +54,42 @@ pub fn run(cmd: OpticalCommand) -> Result<()> {

// ---------------- drives ----------------

/// List physical optical drives, mirroring the GUI Optical tab's drive picker
/// (`opticaldiscs::drives::list_drives`). Prints one drive per line to stdout as
/// `<device-path> <display-name>` so the path can be fed to `optical rip
/// --device`. ASCII only (no glyphs) per the project's terminal-output rule.
fn run_drives_verb() -> Result<()> {
let drives = opticaldiscs::drives::list_drives();
if drives.is_empty() {
#[derive(Debug, Args)]
pub struct DrivesArgs {
/// Also query these daemons for their optical drives (repeatable), e.g.
/// `--remote mister.local:7341`. Remote rows print an `rb://...` device arg
/// you can pass straight to `optical rip --device`.
#[arg(long = "remote", value_name = "HOST:PORT")]
pub remotes: Vec<String>,
}

/// List optical drives via the unified picker core: local drives, plus the
/// drives of each `--remote` daemon. Prints one drive per line to stdout as
/// `<device-arg> <display-name>` — `<device-arg>` is a local path or an
/// `rb://host:port/dev/sr0` URL, feedable straight to `optical rip --device`.
/// ASCII only (no glyphs) per the project's terminal-output rule.
fn run_drives_verb(args: DrivesArgs) -> Result<()> {
// `devices` is only mutated by the remote arm below.
#[cfg_attr(not(feature = "remote"), allow(unused_mut))]
let mut devices = crate::model::optical_devices::list_local_rip_devices();
#[cfg(feature = "remote")]
for addr in &args.remotes {
let conn = crate::remote::connection::RemoteConnection::connect_shared(addr)
.with_context(|| format!("connecting to {addr}"))?;
crate::model::optical_devices::append_remote_rip_devices(&mut devices, &conn);
}
#[cfg(not(feature = "remote"))]
if !args.remotes.is_empty() {
bail!("--remote needs the `remote` feature; this binary was built without it");
}

if devices.is_empty() {
log_stderr("No optical drives found.");
return Ok(());
}
log_stderr(format!("Found {} optical drive(s):", drives.len()));
for d in &drives {
println!("{} {}", d.device_path.display(), d.display_name);
log_stderr(format!("Found {} optical drive(s):", devices.len()));
for d in &devices {
println!("{} {}", d.cli_device_arg(), d.display_name);
}
Ok(())
}
Expand All @@ -92,7 +115,10 @@ impl From<RipFmt> for RipFormat {

#[derive(Debug, Args)]
pub struct RipArgs {
/// Source drive (e.g. `/dev/sr0`, `disk6`, `\\.\E:`). See `rb-cli show devices`.
/// Source drive: a local path (e.g. `/dev/sr0`, `disk6`, `\\.\E:`) or a
/// remote daemon's drive as `rb://host:port/dev/sr0` (the daemon issues the
/// SCSI reads; this side does the encoding). `rb-cli optical drives` lists
/// local drives.
#[arg(long)]
pub device: PathBuf,

Expand All @@ -116,7 +142,7 @@ fn run_rip_verb(args: RipArgs) -> Result<()> {
args.format
));
let config = RipConfig {
device_path: args.device,
device: OpticalTarget::resolve(&args.device.to_string_lossy())?,
output_path: args.output,
format: args.format.into(),
eject_after: args.eject,
Expand All @@ -134,6 +160,7 @@ fn run_rip_verb(args: RipArgs) -> Result<()> {
fn drain_rip(progress: Arc<Mutex<RipProgress>>) -> Result<()> {
let mut last_op = String::new();
let mut last_pct: i32 = -1;
let mut tracker = crate::model::rate_tracker::RateTracker::default();
loop {
std::thread::sleep(Duration::from_millis(250));
let (logs, op, cur, total, finished, error) = match progress.lock() {
Expand All @@ -153,6 +180,9 @@ fn drain_rip(progress: Arc<Mutex<RipProgress>>) -> Result<()> {
for LogMessage { level, message } in logs {
log_stderr(format!("[{level:?}] {message}"));
}
// Sample every tick (the stage label resets the window between phases),
// even though we only print every 5%, so the rate/ETA is warm by then.
tracker.record(cur, &op);
if op != last_op {
log_stderr(format!("status: {op}"));
last_op = op;
Expand All @@ -162,9 +192,10 @@ fn drain_rip(progress: Arc<Mutex<RipProgress>>) -> Result<()> {
let pct = ((cur as f64 / total as f64) * 100.0) as i32;
if pct / 5 != last_pct / 5 {
log_stderr(format!(
" progress: {pct:>3}% ({}/{})",
" progress: {pct:>3}% ({}/{}){}",
format_size(cur),
format_size(total)
format_size(total),
tracker.suffix(cur, total),
));
last_pct = pct;
}
Expand Down Expand Up @@ -253,6 +284,7 @@ fn run_convert_verb(args: ConvertArgs) -> Result<()> {
fn drain_convert(progress: Arc<Mutex<ConvertProgress>>) -> Result<()> {
let mut last_op = String::new();
let mut last_pct: i32 = -1;
let mut tracker = crate::model::rate_tracker::RateTracker::default();
loop {
std::thread::sleep(Duration::from_millis(250));
let (logs, op, cur, total, finished, error) = match progress.lock() {
Expand All @@ -272,6 +304,9 @@ fn drain_convert(progress: Arc<Mutex<ConvertProgress>>) -> Result<()> {
for LogMessage { level, message } in logs {
log_stderr(format!("[{level:?}] {message}"));
}
// Sample every tick (the stage label resets the window between phases),
// even though we only print every 5%, so the rate/ETA is warm by then.
tracker.record(cur, &op);
if op != last_op {
log_stderr(format!("status: {op}"));
last_op = op;
Expand All @@ -281,9 +316,10 @@ fn drain_convert(progress: Arc<Mutex<ConvertProgress>>) -> Result<()> {
let pct = ((cur as f64 / total as f64) * 100.0) as i32;
if pct / 5 != last_pct / 5 {
log_stderr(format!(
" progress: {pct:>3}% ({}/{})",
" progress: {pct:>3}% ({}/{}){}",
format_size(cur),
format_size(total)
format_size(total),
tracker.suffix(cur, total),
));
last_pct = pct;
}
Expand Down
17 changes: 17 additions & 0 deletions src/fs/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,23 @@ pub enum ResourceForkSource {
/// to flush changes to disk. This enables batching multiple edits into a single atomic
/// write.
pub trait EditableFilesystem: Filesystem {
/// Enter "bulk" editing mode for a batch of operations whose atomicity the
/// *caller* guarantees as a whole (e.g. an importer that discards every
/// change if any single op fails). Implementations may use this to skip
/// per-operation rollback bookkeeping — for filesystems that snapshot the
/// whole metadata on each edit (classic HFS clones its multi-megabyte
/// catalog per `create_file`), that bookkeeping dominates a large import.
///
/// Contract: while in bulk mode a *failed* edit may leave in-memory state
/// inconsistent, so the caller MUST abandon the volume (not sync/commit) on
/// any error. Always pair with [`end_bulk`](Self::end_bulk). The default is
/// a no-op, so filesystems whose edits are already cheap need do nothing.
fn begin_bulk(&mut self) {}

/// Leave bulk editing mode (see [`begin_bulk`](Self::begin_bulk)). Default
/// is a no-op.
fn end_bulk(&mut self) {}

/// Create a file in the given parent directory.
///
/// `data` is a reader providing the file contents; `data_len` is the total size.
Expand Down
Loading
Loading