comics: add .cbr import support behind an optional cbr feature
Build Debug APK / build (push) Failing after 9m27s

Adds import_cbr_file + extract_cbr_pages to rust/lib/src/agents/comics.rs
(the unrar crate wraps unrar's C source, which reads from a file path
rather than an in-memory buffer like `zip`, unlike the .cbz path). Both
share the same import_comic_from_pages builder and compare_page_names
ordering as .cbz and directory import, so a .cbr gets the identical
Comic-container/Image-children tree.

RAR support is decode-only by construction: a .cbr is decomposed and its
bytes discarded at import, same as .cbz, so export (if implemented) would
only ever need to write a .cbz. This is why pulling in unrar's C source
is worth it despite the licensing quirks around RAR compressors — we
only ever need the free-to-use decoder.

Kept optional (new `cbr` Cargo feature on lib, mirroring `multimedia`)
rather than a hard dependency, since unrar's bundled C source is a native
build-time dependency not worth risking on the Android NDK cross-compile
path, which has no comics UI wired up yet anyway. cli-app and gui-app
both opt in explicitly; the default `synchronicity_lib` build (what
Android's build-rust recipe uses) is unaffected.

Wires dispatch-by-extension into every comic import call site: the
generic documents::import::import_comic adapter (feature-gated internally
so it still compiles without cbr), cli-app's two hand-rolled import
dispatches (now sharing one import_comic_by_path helper), and gui-app's
Comics agent file-picker import (button relabeled "Import .cbz/.cbr…").
COMIC_FILE_TYPES gains the cbr extension/mime-types so every picker/filter
derived from it picks this up automatically.

Testing: RAR compression is proprietary, so there's no way to build a
.cbr fixture in-process the way build_cbz builds one — committed a small
real RAR5 fixture (rust/lib/src/agents/comics_fixtures/sample.cbr,
generated with the actual `rar` tool) and two tests against it, mirroring
the .cbz cases. 16/16 comics tests pass with --features cbr; the default
build (no cbr) still compiles clean with all 221 lib tests passing.

Verified end to end via `just build-gui-nix` (nix build .#syn-gui) with
the new unrar/unrar_sys native dependency: builds clean, resulting binary
runs (`syn-gui --help` prints usage, exit 0).
This commit is contained in:
Greg Shuflin
2026-07-01 21:15:59 -07:00
parent 22b01de199
commit fece74c538
10 changed files with 238 additions and 32 deletions
+4 -2
View File
@@ -50,14 +50,16 @@
# Source filtering — include Cargo sources, .slint UI files (the archived
# gui-slint app), and data files referenced by include_str!() / include_bytes!():
# .txt (lib's wordlist) and .svg (gui-app's icons, baked in via assets.rs).
# .txt (lib's wordlist), .svg (gui-app's icons, baked in via assets.rs), and
# .cbr (comics.rs's cfg(test)-only RAR fixture).
rustSrc = pkgs.lib.cleanSourceWith {
src = ./rust;
filter = path: type:
(craneLib.filterCargoSources path type)
|| (builtins.match ".*\\.slint$" path != null)
|| (builtins.match ".*\\.txt$" path != null)
|| (builtins.match ".*\\.svg$" path != null);
|| (builtins.match ".*\\.svg$" path != null)
|| (builtins.match ".*\\.cbr$" path != null);
};
# Common arguments shared across all builds
+24
View File
@@ -10085,6 +10085,7 @@ dependencies = [
"typst",
"typst-pdf",
"uniffi",
"unrar",
"ur",
"uuid",
"webpki-roots",
@@ -11477,6 +11478,29 @@ dependencies = [
"subtle",
]
[[package]]
name = "unrar"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ec61343a630d2b50d13216dea5125e157d3fc180a7d3f447d22fe146b648fc"
dependencies = [
"bitflags 2.11.0",
"regex",
"unrar_sys",
"widestring",
]
[[package]]
name = "unrar_sys"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b77675b883cfbe6bf41e6b7a5cd6008e0a83ba497de3d96e41a064bbeead765"
dependencies = [
"cc",
"libc",
"winapi",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
+1 -1
View File
@@ -9,7 +9,7 @@ path = "src/main.rs"
[dependencies]
syn-daemon = { path = "../syn-daemon" }
synchronicity = { path = "../lib", package = "synchronicity_lib" }
synchronicity = { path = "../lib", package = "synchronicity_lib", features = ["cbr"] }
anyhow = { workspace = true }
iroh = { workspace = true }
pkarr = { workspace = true }
+22 -6
View File
@@ -178,6 +178,25 @@ fn build_command(cmd: cli::AgentCmd) -> Result<DaemonCommand> {
}
}
/// Import a single comic archive, dispatching on extension to the `.cbz` or `.cbr`
/// importer. Shared by [`cmd_docs_import`] and [`import_directory_tree`] so the two
/// hand-rolled per-file-type dispatches stay in sync on this one point.
fn import_comic_by_path(
blob_store: &synchronicity::stores::blob_store::BlobStore,
doc_store: &synchronicity::stores::document::DocumentStore,
path: &Path,
node: synchronicity::node::NodePubkey,
parent: Option<&DocumentID>,
) -> Result<DocumentID> {
use synchronicity::agents::comics::{import_cbr_file, import_cbz_file};
if path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("cbr")) {
import_cbr_file(blob_store, doc_store, path, node, parent).map_err(anyhow::Error::from)
} else {
import_cbz_file(blob_store, doc_store, path, node, parent).map_err(anyhow::Error::from)
}
}
fn cmd_docs_import(
store_path: &Path,
paths: Vec<std::path::PathBuf>,
@@ -185,7 +204,7 @@ fn cmd_docs_import(
as_comic: bool,
) -> Result<()> {
use colored::Colorize;
use synchronicity::agents::comics::{import_cbz_file, import_comic_directory};
use synchronicity::agents::comics::import_comic_directory;
use synchronicity::agents::music::import_mp3_file;
use synchronicity::agents::pdf::import_pdf_file;
use synchronicity::agents::pictures::import_image_file;
@@ -278,8 +297,7 @@ fn cmd_docs_import(
import_mp3_file(&store.blob_store, &store.doc_store, path, local_node, None)
.map_err(anyhow::Error::from)
} else if COMIC_FILE_TYPES.matches_path(path) {
import_cbz_file(&store.blob_store, &store.doc_store, path, local_node, None)
.map_err(anyhow::Error::from)
import_comic_by_path(&store.blob_store, &store.doc_store, path, local_node, None)
} else {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("(none)");
eprintln!(
@@ -351,7 +369,6 @@ fn import_directory_tree(
imported: &mut usize,
) {
use colored::Colorize;
use synchronicity::agents::comics::import_cbz_file;
use synchronicity::agents::music::import_mp3_file;
use synchronicity::agents::pdf::import_pdf_file;
use synchronicity::agents::pictures::import_image_file;
@@ -417,8 +434,7 @@ fn import_directory_tree(
import_mp3_file(&store.blob_store, &store.doc_store, &path, node, parent)
.map_err(anyhow::Error::from)
} else if COMIC_FILE_TYPES.matches_path(&path) {
import_cbz_file(&store.blob_store, &store.doc_store, &path, node, parent)
.map_err(anyhow::Error::from)
import_comic_by_path(&store.blob_store, &store.doc_store, &path, node, parent)
} else {
log::info!("Skipping unsupported file: {}", path.display());
continue;
+1 -1
View File
@@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
gpui = { package = "gpui", git = "https://github.com/gpui-ce/gpui-ce" }
gpui_platform = { git = "https://github.com/gpui-ce/gpui-ce" }
synchronicity = { path = "../lib", package = "synchronicity_lib", features = ["multimedia"] }
synchronicity = { path = "../lib", package = "synchronicity_lib", features = ["multimedia", "cbr"] }
anyhow = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
+19 -15
View File
@@ -1,4 +1,4 @@
//! The Comics & Manga agent screen: a [`MediaLibrary`] of `.cbz` comics (cards or
//! The Comics & Manga agent screen: a [`MediaLibrary`] of `.cbz`/`.cbr` comics (cards or
//! list, under a Library / Recommended tab bar) that opens into a page reader with
//! a back button.
//!
@@ -21,9 +21,9 @@ use gpui::{
};
use synchronicity::agents::AgentId;
use synchronicity::agents::comics::{
ComicListItem, build_comic_nodes, import_cbz_file, import_comic_directory, is_supported_comic,
list_comic_pages, load_comic_cover_bytes, load_comic_page_bytes, load_reading_page,
save_reading_page,
ComicListItem, build_comic_nodes, import_cbr_file, import_cbz_file, import_comic_directory,
is_supported_comic, list_comic_pages, load_comic_cover_bytes, load_comic_page_bytes,
load_reading_page, save_reading_page,
};
use synchronicity::documents::DocumentID;
use synchronicity::documents::import::COMIC_FILE_TYPES;
@@ -206,15 +206,19 @@ impl ComicsView {
.spawn(async move {
let mut errors: Vec<String> = Vec::new();
for path in &paths {
if is_supported_comic(path)
&& let Err(e) = import_cbz_file(
&store.blob_store,
&store.doc_store,
path,
node,
None,
)
{
if !is_supported_comic(path) {
continue;
}
let is_cbr = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("cbr"));
let result = if is_cbr {
import_cbr_file(&store.blob_store, &store.doc_store, path, node, None)
} else {
import_cbz_file(&store.blob_store, &store.doc_store, path, node, None)
};
if let Err(e) = result {
errors.push(format!("Failed to import {}: {e}", path.display()));
}
}
@@ -232,7 +236,7 @@ impl ComicsView {
/// Import a directory of sequentially-named page images (e.g. `01.jpg`, `02.jpg`,
/// ...) as a single Comic document, exactly as [`import_comics`](Self::import_comics)
/// does for a picked `.cbz` file.
/// does for a picked `.cbz`/`.cbr` file.
fn import_comic_folder(&mut self, cx: &mut Context<Self>) {
let Some(path) = rfd::FileDialog::new().set_title("Import Comic Folder").pick_folder()
else {
@@ -470,7 +474,7 @@ impl ComicsView {
)
.when(self.open.is_none(), |d| {
d.child(
button(theme, "comics-import", "Import .cbz…", true, true)
button(theme, "comics-import", "Import .cbz/.cbr", true, true)
.on_click(cx.listener(|this, _, _, cx| this.import_comics(cx))),
)
.child(
+7
View File
@@ -56,9 +56,16 @@ quick-xml = { workspace = true }
symphonia = { version = "0.5", features = ["mp3"], optional = true }
rodio = { version = "0.21", optional = true }
soundtouch = { version = "0.5", features = ["bundled"], optional = true }
unrar = { version = "0.5.8", optional = true }
[features]
multimedia = ["dep:rodio", "dep:symphonia", "dep:soundtouch"]
# .cbr comic import (RAR decode only — a .cbr is never written, only read at import
# time and decomposed into the same Comic/Image document tree as .cbz). Kept optional,
# mirroring `multimedia`: unrar's bundled C source is a native build-time dependency
# not worth risking on the Android NDK cross-compile path, which doesn't wire up a
# comics UI yet anyway. cli-app and gui-app opt in explicitly.
cbr = ["dep:unrar"]
[dev-dependencies]
pretty_assertions = { workspace = true }
+143 -5
View File
@@ -1,9 +1,14 @@
//! The Comics & Manga agent: importing, reading, and tracking `.cbz` comic
//! archives as [`Comic`](DocumentType::Comic) documents.
//! The Comics & Manga agent: importing, reading, and tracking `.cbz`/`.cbr` comic
//! archives (and directories of page images — see [`import_comic_directory`]) as
//! [`Comic`](DocumentType::Comic) documents.
//!
//! A `.cbz` file is a ZIP archive whose entries are page images (JPEG/PNG/…),
//! conventionally named so a lexical sort yields reading order, optionally
//! accompanied by a `ComicInfo.xml` metadata sidecar (the ComicRack standard).
//! A `.cbz` file is a ZIP archive, and a `.cbr` file a RAR archive (behind the `cbr`
//! Cargo feature, since it pulls in unrar's native C source — see that feature's doc
//! comment in `Cargo.toml`), whose entries are page images (JPEG/PNG/…), conventionally
//! named so a lexical sort yields reading order, optionally accompanied by a
//! `ComicInfo.xml` metadata sidecar (the ComicRack standard). RAR support is decode-only
//! by construction: see [`import_cbr_file`]'s doc comment for why that's not a
//! limitation here.
//!
//! Unlike a PDF (whose bytes are one opaque blob), a comic is decomposed at
//! import time: the archive is unpacked into one [`Image`](DocumentType::Image)
@@ -141,6 +146,9 @@ pub enum ImportComicError {
Io(#[from] std::io::Error),
#[error("not a valid .cbz archive: {0}")]
Zip(#[from] zip::result::ZipError),
#[cfg(feature = "cbr")]
#[error("not a valid .cbr archive: {0}")]
Rar(#[from] unrar::error::UnrarError),
#[error("archive contains no page images")]
NoPages,
#[error(transparent)]
@@ -169,6 +177,58 @@ pub fn import_cbz_file(
import_comic_from_pages(blob_store, doc_store, &stem, &metadata, pages, local_node, parent_id)
}
/// Import a `.cbr` (RAR) file as a new [`Comic`](DocumentType::Comic) document, exactly
/// as [`import_cbz_file`] does for a `.cbz`. RAR support is read-only by design: a
/// `.cbr` is decomposed into the same page-per-`Image`-child tree and its bytes
/// discarded, so this never needs to *write* a RAR archive (export, if implemented,
/// would only ever produce a `.cbz`).
#[cfg(feature = "cbr")]
pub fn import_cbr_file(
blob_store: &BlobStore,
doc_store: &DocumentStore,
path: &Path,
local_node: NodePubkey,
parent_id: Option<&DocumentID>,
) -> Result<DocumentID, ImportComicError> {
let (pages, metadata) = extract_cbr_pages(path)?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Untitled").to_string();
import_comic_from_pages(blob_store, doc_store, &stem, &metadata, pages, local_node, parent_id)
}
/// Read every page image and the `ComicInfo.xml` sidecar (if present) out of a `.cbr`
/// archive in one pass, in reading order (see [`compare_page_names`]). The RAR C library
/// unrar wraps only reads from a file path, not an in-memory buffer, unlike `zip` — so,
/// unlike [`extract_pages`], this takes the archive's path rather than its bytes.
#[cfg(feature = "cbr")]
fn extract_cbr_pages(
path: &Path,
) -> Result<(Vec<(String, Vec<u8>)>, ComicMetadata), ImportComicError> {
let mut pages: Vec<(String, Vec<u8>)> = Vec::new();
let mut comic_info_xml: Option<String> = None;
let mut archive = unrar::Archive::new(path).open_for_processing()?;
while let Some(header) = archive.read_header()? {
let entry = header.entry();
let name = entry.filename.to_string_lossy().into_owned();
archive = if entry.is_file() && is_page_image(&name) {
let (data, rest) = header.read()?;
pages.push((name, data));
rest
} else if entry.is_file() && name.eq_ignore_ascii_case(COMIC_INFO_FILE) {
let (data, rest) = header.read()?;
comic_info_xml = String::from_utf8(data).ok();
rest
} else {
header.skip()?
};
}
pages.sort_by(|(a, _), (b, _)| compare_page_names(a, b));
let metadata = comic_info_xml.map(|xml| parse_comic_info(&xml)).unwrap_or_default();
Ok((pages, metadata))
}
/// Import a directory of sequentially-named page images (`01.jpg`, `02.jpg`, ...) as a
/// new [`Comic`](DocumentType::Comic) document, exactly as [`import_cbz_file`] would for
/// the equivalent `.cbz` — same page ordering (see [`compare_page_names`]), same
@@ -1060,4 +1120,82 @@ mod tests {
.unwrap_err();
assert!(matches!(err, ImportComicError::NoPages));
}
/// `.cbr` (RAR) tests, gated on the `cbr` feature exactly like the code under test.
/// `unrar` reads from a real file path rather than an in-memory buffer (unlike
/// `zip`), so these write the fixture out to a tempfile first.
///
/// `comics_fixtures/sample.cbr` was built with real `rar` (`rar a -ep`, RAR5
/// format) from three page files (`001.jpg` = `"one"`, `002.jpg` = `"two"`,
/// `003.jpg` = `"three"`) plus a `ComicInfo.xml` — the same fixture shape as
/// [`build_cbz`]'s cases above, so these tests mirror the `.cbz` ones directly.
/// There's no `unrar`-side equivalent of `ZipWriter` to build one in-process: RAR's
/// compressor is proprietary, so — fittingly, given `import_cbr_file` is designed to
/// never need to *write* RAR — a small checked-in binary fixture is the pragmatic
/// choice here instead.
#[cfg(feature = "cbr")]
mod cbr {
use super::*;
const SAMPLE_CBR: &[u8] = include_bytes!("comics_fixtures/sample.cbr");
fn write_cbr(dir: &Path, name: &str) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, SAMPLE_CBR).unwrap();
path
}
#[test]
fn import_creates_ordered_page_children_and_no_archive_blob() {
let (doc_store, blob_store, dir) = make_stores();
let path = write_cbr(dir.path(), "test.cbr");
let comic_id =
import_cbr_file(&blob_store, &doc_store, &path, test_node(), None).unwrap();
assert_eq!(
doc_store.get_document(&comic_id).unwrap().unwrap().doc_type,
DocumentType::Comic
);
assert!(
doc_store
.get_blob_ref_by_role(&comic_id, &BlobRole::PrimaryContent)
.unwrap()
.is_none()
);
let pages = list_comic_pages(&doc_store, &comic_id).unwrap();
assert_eq!(pages.len(), 3);
for (id, expected) in pages.iter().zip([b"one" as &[u8], b"two", b"three"]) {
assert_eq!(
doc_store.get_document(id).unwrap().unwrap().doc_type,
DocumentType::Image
);
assert_eq!(load_comic_page_bytes(&blob_store, &doc_store, id).unwrap(), expected);
}
assert_eq!(page_count(&doc_store, &comic_id).unwrap(), 3);
assert!(comic_is_local(&doc_store, &blob_store, &comic_id));
}
#[test]
fn import_reads_comic_info_sidecar() {
let (doc_store, blob_store, dir) = make_stores();
let path = write_cbr(dir.path(), "test.cbr");
let comic_id =
import_cbr_file(&blob_store, &doc_store, &path, test_node(), None).unwrap();
let meta = comic_metadata(&doc_store, &comic_id).unwrap();
assert_eq!(meta.title, "The First Issue");
assert_eq!(meta.series, "Saga of Tests");
assert_eq!(meta.number, "1");
assert_eq!(meta.writer, "Ada Lovelace");
assert_eq!(meta.summary, "A thrilling unit test.");
// The real extracted page count (3), not the stale <PageCount> of 99.
assert_eq!(meta.page_count, 3);
// Series wins over the file stem once ComicInfo.xml is present.
assert_eq!(doc_store.get_title(&comic_id).unwrap().unwrap(), "Saga of Tests");
}
}
}
Binary file not shown.
+17 -2
View File
@@ -36,8 +36,13 @@ pub const MUSIC_FILE_TYPES: FileTypeFilter =
pub const COMIC_FILE_TYPES: FileTypeFilter = FileTypeFilter {
label: "Comics",
extensions: &["cbz"],
mime_types: &["application/vnd.comicbook+zip", "application/x-cbz"],
extensions: &["cbz", "cbr"],
mime_types: &[
"application/vnd.comicbook+zip",
"application/x-cbz",
"application/vnd.comicbook-rar",
"application/x-cbr",
],
};
/// A supported document type: the filter that recognises it (for file pickers)
@@ -107,6 +112,16 @@ fn import_comic(
node: NodePubkey,
parent: Option<&DocumentID>,
) -> Result<DocumentID, String> {
// `cbr` is an optional feature (unrar's bundled C source isn't worth risking on
// every build, e.g. the Android NDK cross-compile — see its doc comment in
// Cargo.toml). When it's off, a `.cbr` falls through to the `.cbz` importer, which
// reports a clear-enough "not a valid .cbz archive" rather than silently mis-importing
// it; in practice every real consumer (cli-app, gui-app) enables the feature.
#[cfg(feature = "cbr")]
if path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("cbr")) {
return comics::import_cbr_file(blob_store, doc_store, path, node, parent)
.map_err(|e| e.to_string());
}
comics::import_cbz_file(blob_store, doc_store, path, node, parent).map_err(|e| e.to_string())
}