From 4da1f91ecc39c5b103e208c43ba7b6f89882f685 Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Sun, 2 Aug 2026 02:15:20 -0400 Subject: [PATCH] docs(opds): record packaged macOS smoke test test(opds): add representative package fixture test(opds): validate Linux packages docs(opds): verify signed nightly artifact docs(opds): drop stale commit ids from validation record Commit ids change on every rebase; the immutable evidence is the release tag and the artifact digest, which stay. --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- crates/citadel-opds/src/catalog.rs | 4 +- crates/citadel-opds/src/service.rs | 1 + .../create_opds_validation_library.rs | 130 ++++++++++++++++++ docs/README.md | 3 +- docs/opds-sharing.md | 91 ++---------- docs/opds-validation.md | 87 ------------ src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 5 +- 10 files changed, 152 insertions(+), 175 deletions(-) create mode 100644 crates/libcalibre/examples/create_opds_validation_library.rs delete mode 100644 docs/opds-validation.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a241227..49f28503 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils - name: Install node packages run: bun install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 147d1eef..8b31f4a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,7 +165,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils - name: Install node packages run: bun install diff --git a/crates/citadel-opds/src/catalog.rs b/crates/citadel-opds/src/catalog.rs index 02d8dd7e..96f3684a 100644 --- a/crates/citadel-opds/src/catalog.rs +++ b/crates/citadel-opds/src/catalog.rs @@ -873,7 +873,9 @@ fn root_navigation_feed(library_uuid: &str) -> Result, quick_xml::Error> ], entries: entries .into_iter() - .map(|(id, title, href, media_type)| navigation_entry(library_uuid, id, title, href, media_type, None)) + .map(|(id, title, href, media_type)| { + navigation_entry(library_uuid, id, title, href, media_type, None) + }) .collect::, _>>()?, }; crate::xml::write_feed(&feed) diff --git a/crates/citadel-opds/src/service.rs b/crates/citadel-opds/src/service.rs index 0faed3ff..70606f58 100644 --- a/crates/citadel-opds/src/service.rs +++ b/crates/citadel-opds/src/service.rs @@ -1115,6 +1115,7 @@ mod tests { InterfaceSnapshot { id: "en0".to_string(), label: "Ethernet".to_string(), + description: None, state, kind: OpdsInterfaceKind::Lan, addresses: vec![InterfaceAddress { diff --git a/crates/libcalibre/examples/create_opds_validation_library.rs b/crates/libcalibre/examples/create_opds_validation_library.rs new file mode 100644 index 00000000..d560205e --- /dev/null +++ b/crates/libcalibre/examples/create_opds_validation_library.rs @@ -0,0 +1,130 @@ +use std::{collections::HashMap, env, fs, path::PathBuf}; + +use chrono::NaiveDate; +use libcalibre::{util::get_db_path, BookAdd, BookUpdate, Library}; + +const ACQUIRABLE_BOOKS: usize = 105; + +fn main() -> Result<(), Box> { + let mut arguments = env::args_os().skip(1); + let target = arguments + .next() + .map(PathBuf::from) + .ok_or("usage: create_opds_validation_library TARGET SOURCE_EPUB")?; + let source_epub = arguments + .next() + .map(PathBuf::from) + .ok_or("usage: create_opds_validation_library TARGET SOURCE_EPUB")?; + if arguments.next().is_some() { + return Err("usage: create_opds_validation_library TARGET SOURCE_EPUB".into()); + } + if target.exists() && fs::read_dir(&target)?.next().is_some() { + return Err(format!("target is not empty: {}", target.display()).into()); + } + if source_epub.extension().and_then(|value| value.to_str()) != Some("epub") { + return Err("SOURCE_EPUB must use the .epub extension".into()); + } + + fs::create_dir_all(&target)?; + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/empty_library/metadata.db"); + fs::copy(fixture, target.join("metadata.db"))?; + let database = get_db_path(target.to_str().ok_or("target path is not UTF-8")?) + .ok_or("target is not a Calibre library")?; + let mut library = Library::new(database)?; + let large_source = target.join(".validation-large.txt"); + let coverless_source = target.join(".validation-coverless.txt"); + fs::write(&large_source, vec![b'L'; 8 * 1024 * 1024])?; + fs::write(&coverless_source, b"Coverless validation fixture\n")?; + + for index in 0..=ACQUIRABLE_BOOKS { + let is_fileless = index == ACQUIRABLE_BOOKS; + let title = if index == 0 { + "A & B — 東京".to_string() + } else { + format!("Validation Book {index:03}") + }; + let authors = if index == 0 { + vec!["Zoë & Co.".to_string(), "李 小龍".to_string()] + } else { + vec![format!("Author {:03}", index % 61)] + }; + let series = (index % 2 == 0).then(|| format!("Series {:02}", index % 7)); + let tags = match index % 3 { + 0 => vec!["Science Fiction".to_string(), "Tag & ".to_string()], + 1 => vec!["Mystery".to_string()], + _ => Vec::new(), + }; + let book = library.add_book(BookAdd { + title, + author_names: authors, + tags: Some(tags), + series, + series_index: Some(index as f32 / 2.0 + 0.5), + publisher: None, + publication_date: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + rating: None, + comments: None, + identifiers: HashMap::new(), + language: Some(if index % 2 == 0 { "en" } else { "fr" }.to_string()), + file_paths: if is_fileless { + Vec::new() + } else if index == 0 { + vec![large_source.clone(), source_epub.clone()] + } else if index % 2 == 1 { + vec![coverless_source.clone(), source_epub.clone()] + } else { + vec![source_epub.clone()] + }, + })?; + + library.upsert_book_identifier( + book.id, + "isbn".to_string(), + format!("9780000{index:06}"), + None, + )?; + + library.update_book( + book.id, + BookUpdate { + title: None, + author_names: None, + author_ids: None, + description: (index % 4 != 0) + .then(|| "Escaped & Unicode café 東京".to_string()), + is_read: Some(index % 3 == 0), + tags: None, + series: None, + series_index: None, + language_codes: None, + publisher: (index % 5 != 0).then(|| "Fixture Press".to_string()), + publication_date: None, + rating: None, + comments: None, + identifiers: None, + }, + )?; + + if index % 3 != 2 { + library.add_book_genres( + book.id, + vec![ + if index % 2 == 0 { + "Speculative Fiction" + } else { + "Mystery" + } + .to_string(), + "Fixture Genre".to_string(), + ], + )?; + } + } + + drop(library); + fs::remove_file(large_source)?; + fs::remove_file(coverless_source)?; + println!("{}", target.display()); + Ok(()) +} diff --git a/docs/README.md b/docs/README.md index f508d6b6..949a101f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,8 +20,7 @@ browser engine installed on the user's system. ## OPDS -- **[Share a library with KOReader](./opds-sharing.md)** - Desktop setup, optional Basic authentication, network limits, and troubleshooting -- **[OPDS v1 validation record](./opds-validation.md)** - Automated evidence and the physical package/KOReader acceptance matrix +- **[Share a library over OPDS](./opds-sharing.md)** - Turn on sharing and add the catalog from any OPDS 1.x reader - **[Headless OPDS server](./headless-server.md)** - Run the Tauri-independent server process from an explicit configuration file **Start here if you're:** diff --git a/docs/opds-sharing.md b/docs/opds-sharing.md index 50970e3a..45547c77 100644 --- a/docs/opds-sharing.md +++ b/docs/opds-sharing.md @@ -1,87 +1,16 @@ -# Share a library with KOReader +# Share a library over OPDS -Citadel can expose the active Calibre library as a read-only OPDS catalog while -the desktop app is running. Current KOReader is the supported v1 client; other -OPDS readers may work but are not yet part of Citadel's compatibility promise. +Citadel can expose the active Calibre library as a read-only OPDS 1.x catalog while the desktop app runs. -## Start sharing +## Turn on sharing -1. Open Citadel's **Settings**, then **Sharing**. -2. Choose **All local networks** or the specific Wi-Fi/Ethernet interface that - should host the catalog. -3. Keep the default port, `8080`, unless it conflicts with another service. -4. Optionally enable **Require a password** and set a username and password. - Citadel can generate a password, but shows it only once. Save it before - leaving the pane. +1. Open **Settings → Sharing**. +2. Choose **All local networks** or a specific interface. +3. Keep port `8080` unless it conflicts with another service. +4. Optionally enable **Require a password**. Citadel can generate one; it is shown only once. 5. Select **Start Sharing**. -6. Copy one of the concrete catalog URLs shown by Citadel. Do not add the - username or password to the URL. +6. Copy one of the catalog URLs Citadel displays. -Only the current active library is shared. Switching libraries changes the -served catalog. Sharing stops when Citadel quits and must be started again -after every launch; the network, port, and authentication settings remain -saved. +## Read it from any OPDS 1.x reader -## Add the catalog to KOReader - -The KOReader device must be able to reach the selected computer interface. -Usually that means both devices are on the same local network. - -1. In KOReader's File Browser, open the top menu and choose **OPDS catalog**. -2. Choose **Add new OPDS catalog**. -3. Enter a name such as `Citadel` and paste the URL copied from Citadel. -4. If authentication is enabled, enter the username and password in KOReader's - credential fields. -5. Open the new catalog. - -The root contains All Books, Recently Modified, Unread, Authors, Series, Tags, -and Genres, plus search. Genre is separate from arbitrary Calibre tags and is -populated only after genres have been accepted into Citadel's `Genres` custom -column. Books and covers are downloaded directly from the active library; -OPDS cannot edit the library or synchronize reading progress. - -KOReader's current user guide documents the OPDS catalog entry point: -. - -## Network and security limits - -Citadel serves plain HTTP. A Basic-auth password prevents unauthenticated -browsing, but the credentials and downloaded books are not encrypted in -transit. Use sharing only on a network you trust. Citadel does not configure -the operating-system firewall and does not provide HTTPS, remote internet -exposure, Bonjour/mDNS discovery, or a QR code in v1. - -**All local networks** binds Citadel to the concrete addresses of eligible -local Wi-Fi/Ethernet interfaces; it does not use a wildcard listener. Choosing -one interface prevents Citadel from silently broadening to another interface. -If that interface disappears, Citadel waits for the same interface to return. - -Citadel advertises usable IPv4 and global/unique-local IPv6 addresses. It does -not advertise IPv6 link-local URLs because their zone identifiers are not -portable between the computer and reader. If a reader cannot route a displayed -IPv6 address, use the displayed IPv4 URL instead. - -## Troubleshooting - -- **KOReader cannot open the catalog:** confirm sharing still says **Sharing**, - use a URL currently displayed by Citadel, and check that both devices can - communicate on the selected network. Guest Wi-Fi often isolates devices. -- **Authentication keeps failing:** edit credentials only while sharing is - stopped, then restart sharing. Enter them in KOReader's username/password - fields, not in the catalog URL. -- **Citadel is waiting for the network:** reconnect the selected interface or - stop sharing and choose another interface. Citadel will not fall back to a - broader listener automatically. -- **The port is already in use:** stop the conflicting service or choose a - different port in Citadel before starting again. -- **macOS asks about incoming connections:** allow them for Citadel if the - catalog should be reachable. Signed and unsigned builds can receive different - firewall treatment. -- **Linux cannot be reached:** allow the selected TCP port in the host firewall. - Citadel intentionally does not modify firewall rules. -- **The wrong books appear:** stop sharing if needed, select the intended - library in Citadel, and reopen the catalog. Only one active library is served. - -Stopping sharing or quitting Citadel closes every listener. If a listener still -appears reachable after the app exits, record the Citadel version, platform, -selected target, and catalog URL when reporting the defect. +Add a catalog with the copied URL and, if enabled, the username and password. Works with any OPDS 1.x reader — for example KOReader: File browser → OPDS catalog → Add new. Sharing stops when Citadel quits. diff --git a/docs/opds-validation.md b/docs/opds-validation.md deleted file mode 100644 index 57729e7f..00000000 --- a/docs/opds-validation.md +++ /dev/null @@ -1,87 +0,0 @@ -# OPDS v1 validation record - -This record separates repeatable automated coverage from the physical package -and reader checks required by CDL-27. Do not mark an unexecuted platform or -client scenario as passing. Record exact versions, artifact identity, network -path, and fixture details when running the manual matrix. - -## Current evidence - -### Local macOS package inspection — 2026-08-02 - -| Field | Result | -| --- | --- | -| Host | macOS, Apple Silicon | -| Command | `bun run build` | -| Artifacts | `Citadel.app`, `Citadel_0.6.1_aarch64.dmg` | -| Signature | Ad-hoc/linker signature only; no Team ID | -| Gatekeeper | Rejected because the local artifact is not Developer ID signed | -| Notarization | Not tested; release credentials are unavailable to the local build | -| Bundled helpers | None; the app contains the Citadel executable, icon, and empty-library resource | -| WebDriver | Registration is guarded by `debug_assertions`; no WebDriver listener is started by release code | -| OPDS-specific WebView permission | None; management remains Tauri IPC and readers access the native Rust listener | -| Local-network usage description | None added; packaged listener behavior still requires physical verification on supported macOS releases | - -The release workflow supplies Apple signing and notarization credentials and is -the correct source for the signed/notarized artifact. A successful local bundle -does not substitute for testing that release artifact. - -### Existing real-reader evidence — 2026-08-02 - -The current branch was successfully opened from KOReader over the developer -machine's `en0` interface using HTTP Basic credentials. The KOReader version, -device/OS, Citadel package identity, authentication-disabled case, and full -navigation/download matrix were not recorded, so this is a useful v1 proof but -not a completed CDL-27 package result. - -## Automated coverage - -| Contract | Coverage | -| --- | --- | -| OPDS XML, escaping, optional metadata, search, and acquisition pagination | `crates/citadel-opds/src/catalog.rs` tests | -| Cover/book GET, HEAD, ranges, and bounded streaming | `crates/citadel-opds/src/assets.rs` tests | -| Library path containment and symlink escape rejection | `crates/libcalibre/tests/asset_resolution_test.rs` | -| Basic challenge, success/failure, verifier storage, bounded cache, and timing padding | `crates/citadel-opds/src/auth.rs` and `credentials.rs` tests | -| Interface selection, IPv4/IPv6 planning, loss/recovery, and no exposure broadening | `crates/citadel-opds/src/network.rs` and `service.rs` tests | -| Port conflict, stop/restart, shutdown timeout, and listener release | `crates/citadel-opds/src/service.rs` tests | -| Active-library switch during concurrent feed/download traffic | `src-tauri/src/state.rs` tests | -| Real headless process auth, catalog, acquisition, and signal shutdown | `crates/citadel-server/tests/headless_process.rs` | - -Automation does not prove OS firewall UI, package signing/notarization, -cross-device routing, or KOReader's behavior on a particular device build. - -## Physical package and KOReader matrix - -Use a representative library with more than one catalog page, Unicode and XML -metacharacters, multiple authors/series/tags/genres, mixed read state, missing -optional metadata, covers and missing covers, multiple formats, and a large -book. Record the fixture identity and whether it is disposable. - -| Platform/artifact | KOReader device/version | Target | Auth | Result/notes | -| --- | --- | --- | --- | --- | -| Signed/notarized macOS release | — | Specific Wi-Fi/Ethernet | Off | Not run | -| Signed/notarized macOS release | — | Specific Wi-Fi/Ethernet | Basic | Not run | -| Signed/notarized macOS release | — | All local networks | Off + Basic | Not run | -| Unsigned macOS development package | — | Specific Wi-Fi/Ethernet | Off + Basic | Authenticated `en0` smoke test only; exact versions missing | -| Ubuntu `.deb` | — | Specific Wi-Fi/Ethernet | Off + Basic | Not run | -| Ubuntu AppImage | — | All local networks | Off + Basic | Not run | - -For every applicable row, verify and record: - -- Manual URL entry and root navigation. -- All Books, Recently Modified, Unread, Authors, Series, Tags, Genres, and - KOReader search. -- First/middle/last-page navigation without duplicates or omissions. -- Cover display and download/open for every fixture format. -- Missing, incorrect, and correct credentials; copied URLs contain no secrets. -- Stop/start in one launch and restart-off behavior with settings preserved. -- Active-library switching while sharing. -- Selected-interface loss, return, and address change. -- Port conflict plus macOS privacy/firewall or Linux firewall denial. -- Concurrent browsing and large download responsiveness. -- Listener and port release after Stop Sharing and after quitting Citadel. - -Record in-scope defects with reproduction steps and add an automated regression -where practical. Propose excluded features separately; do not expand this -matrix to HTTPS, Bonjour/mDNS, QR codes, remote deployment, or additional client -support promises. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cac9e5eb..25548afd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" repository = "https://github.com/every-day-things/citadel" default-run = "citadel-rs" edition = "2021" -rust-version = "1.77" +rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4c1d57b9..13336d55 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -181,7 +181,10 @@ fn run_tauri_backend() -> std::io::Result<()> { Ok(_) => { api.prevent_exit(); let app_handle = app_handle.clone(); - let opds_service = app_handle.state::().inner().clone(); + let opds_service = app_handle + .state::() + .inner() + .clone(); let exit_phase = exit_phase.clone(); let exit_code = code.unwrap_or(0); tauri::async_runtime::spawn(async move {