diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index db9fa51df..7b9a8a0c0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,7 +37,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev pkg-config libdbus-1-dev
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/packages/app-expo/src/lib/book/auto-metadata.ts b/packages/app-expo/src/lib/book/auto-metadata.ts
index 61549549c..3401fd472 100644
--- a/packages/app-expo/src/lib/book/auto-metadata.ts
+++ b/packages/app-expo/src/lib/book/auto-metadata.ts
@@ -1,12 +1,21 @@
-import { extractBookMetadata } from "@/lib/book/metadata-extractor";
+import {
+ createRangeReadableFile,
+ extractBookMetadataFromFile,
+} from "@/lib/book/metadata-extractor";
import { getPlatformService } from "@readany/core/services";
import type { Book } from "@readany/core/types";
import type { ExtractedBookMetadata } from "@readany/core/utils";
+import type { ExtractedMeta } from "./metadata-extractor";
-const MOBILE_DETAILS_METADATA_MAX_BYTES = 32 * 1024 * 1024;
+export type MobileExtractedBookMetadata = ExtractedBookMetadata &
+ Pick;
-export async function extractLocalBookMetadata(book: Book): Promise {
- if (book.syncStatus === "remote" || book.format !== "epub" || !book.filePath) return null;
+export async function extractLocalBookMetadata(
+ book: Book,
+): Promise {
+ if (book.syncStatus === "remote" || !isRepairableFormat(book.format) || !book.filePath) {
+ return null;
+ }
try {
const platform = getPlatformService();
@@ -15,21 +24,21 @@ export async function extractLocalBookMetadata(book: Book): Promise MOBILE_DETAILS_METADATA_MAX_BYTES) {
- console.warn(
- `[BookMetadata] Skip details metadata for large EPUB: ${book.meta.title} (${fileSize} bytes)`,
- );
- return null;
- }
+ if (fileSize == null) return null;
- const fileName = book.filePath.split("/").pop() || `${book.id}.epub`;
- return extractBookMetadata(await platform.readFile(filePath), book.format, fileName);
+ const fileName = book.filePath.split(/[\\/]/).pop() || `${book.id}.${book.format}`;
+ const rangeReadable = await createRangeReadableFile(filePath, fileSize);
+ return extractBookMetadataFromFile(rangeReadable, book.format, fileName);
} catch (error) {
console.warn("[BookMetadata] Failed to extract local metadata:", error);
return null;
}
}
+function isRepairableFormat(format: Book["format"]): boolean {
+ return format === "epub" || format === "mobi" || format === "azw" || format === "azw3";
+}
+
function isRelativeAppPath(path: string): boolean {
return (
!path.startsWith("/") &&
@@ -39,8 +48,8 @@ function isRelativeAppPath(path: string): boolean {
);
}
-async function getMobileFileSize(path: string): Promise {
+async function getMobileFileSize(path: string): Promise {
const LegacyFileSystem = await import("expo-file-system/legacy");
const info = await LegacyFileSystem.getInfoAsync(path);
- return info.exists && !info.isDirectory ? (info.size ?? 0) : 0;
+ return info.exists && !info.isDirectory ? (info.size ?? 0) : null;
}
diff --git a/packages/app-expo/src/lib/book/cover-storage.test.ts b/packages/app-expo/src/lib/book/cover-storage.test.ts
new file mode 100644
index 000000000..bb7bbbe1b
--- /dev/null
+++ b/packages/app-expo/src/lib/book/cover-storage.test.ts
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const platform = vi.hoisted(() => ({
+ getAppDataDir: vi.fn(async () => "/app"),
+ joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
+ mkdir: vi.fn(async () => undefined),
+ writeFile: vi.fn(async () => undefined),
+ deleteFile: vi.fn(async () => undefined),
+}));
+
+vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform }));
+import * as coverStorage from "./cover-storage";
+
+describe("mobile cover file extensions", () => {
+ const getCoverFileExtension = (
+ coverStorage as typeof coverStorage & {
+ getCoverFileExtension?: (bytes: Uint8Array, mimeType?: string | null) => string;
+ }
+ ).getCoverFileExtension;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("maps recognized image MIME types", () => {
+ expect(getCoverFileExtension).toBeTypeOf("function");
+ if (!getCoverFileExtension) return;
+
+ expect(getCoverFileExtension(new Uint8Array(), "image/webp")).toBe("webp");
+ expect(getCoverFileExtension(new Uint8Array(), "image/gif")).toBe("gif");
+ expect(getCoverFileExtension(new Uint8Array(), "image/png")).toBe("png");
+ expect(getCoverFileExtension(new Uint8Array(), "image/jpeg")).toBe("jpg");
+ });
+
+ it("sniffs image bytes when the MIME type is absent", () => {
+ expect(getCoverFileExtension).toBeTypeOf("function");
+ if (!getCoverFileExtension) return;
+
+ expect(
+ getCoverFileExtension(
+ new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
+ ),
+ ).toBe("webp");
+ expect(getCoverFileExtension(new TextEncoder().encode("GIF89a"))).toBe("gif");
+ expect(
+ getCoverFileExtension(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
+ ).toBe("png");
+ expect(getCoverFileExtension(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpg");
+ });
+
+ it("deletes only a newly extracted cover when a custom cover wins during persistence", async () => {
+ const saveExtractedCoverIfStillMissing = (
+ coverStorage as typeof coverStorage & {
+ saveExtractedCoverIfStillMissing?: (
+ bookId: string,
+ bytes: Uint8Array,
+ mimeType: string | null,
+ getCurrentCoverUrl: () => string | undefined,
+ ) => Promise;
+ }
+ ).saveExtractedCoverIfStillMissing;
+ expect(saveExtractedCoverIfStillMissing).toBeTypeOf("function");
+ if (!saveExtractedCoverIfStillMissing) return;
+
+ let currentCoverUrl = "";
+ platform.writeFile.mockImplementationOnce(async () => {
+ currentCoverUrl = "covers/book-custom-user.webp";
+ });
+
+ await expect(
+ saveExtractedCoverIfStillMissing(
+ "book",
+ new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
+ null,
+ () => currentCoverUrl,
+ ),
+ ).resolves.toBeUndefined();
+ expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp");
+ expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.webp");
+ });
+
+ it("cleans an extracted cover when custom selection completes after persistence", async () => {
+ const commitCustomCover = (
+ coverStorage as typeof coverStorage & {
+ commitCustomCover?: (
+ bookId: string,
+ customCoverUrl: string,
+ persist: (coverUrl: string) => Promise,
+ ) => Promise;
+ }
+ ).commitCustomCover;
+ expect(commitCustomCover).toBeTypeOf("function");
+ if (!commitCustomCover) return;
+
+ await expect(
+ coverStorage.saveExtractedCoverIfStillMissing(
+ "book",
+ new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
+ null,
+ () => "",
+ ),
+ ).resolves.toBe("covers/book.webp");
+
+ const persisted: string[] = [];
+ await commitCustomCover("book", "covers/book-custom-user.png", async (coverUrl) => {
+ persisted.push(coverUrl);
+ });
+
+ expect(persisted).toEqual(["covers/book-custom-user.png"]);
+ expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp");
+ expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.png");
+ });
+});
diff --git a/packages/app-expo/src/lib/book/cover-storage.ts b/packages/app-expo/src/lib/book/cover-storage.ts
new file mode 100644
index 000000000..ded5a734f
--- /dev/null
+++ b/packages/app-expo/src/lib/book/cover-storage.ts
@@ -0,0 +1,123 @@
+import { getPlatformService } from "@readany/core/services";
+
+const extractedCoverPaths = new Map>();
+
+export async function saveCoverBytesToAppData(
+ bookId: string,
+ coverBytes: Uint8Array,
+ coverMimeType?: string | null,
+): Promise {
+ const platform = getPlatformService();
+ const appData = await platform.getAppDataDir();
+ const coversDir = await platform.joinPath(appData, "covers");
+ try {
+ await platform.mkdir(coversDir);
+ } catch {
+ // Directory may already exist.
+ }
+
+ const extension = getCoverFileExtension(coverBytes, coverMimeType);
+ const relativePath = `covers/${bookId}.${extension}`;
+ const absolutePath = await platform.joinPath(appData, relativePath);
+ await platform.writeFile(absolutePath, coverBytes);
+ return relativePath;
+}
+
+export function getCoverFileExtension(
+ coverBytes: Uint8Array,
+ coverMimeType?: string | null,
+): string {
+ switch (coverMimeType?.toLowerCase().split(";", 1)[0]?.trim()) {
+ case "image/webp":
+ return "webp";
+ case "image/gif":
+ return "gif";
+ case "image/png":
+ return "png";
+ case "image/jpg":
+ case "image/jpeg":
+ return "jpg";
+ }
+
+ if (coverBytes[0] === 0xff && coverBytes[1] === 0xd8 && coverBytes[2] === 0xff) return "jpg";
+ if (
+ coverBytes[0] === 0x89 &&
+ coverBytes[1] === 0x50 &&
+ coverBytes[2] === 0x4e &&
+ coverBytes[3] === 0x47
+ ) {
+ return "png";
+ }
+ if (
+ coverBytes[0] === 0x47 &&
+ coverBytes[1] === 0x49 &&
+ coverBytes[2] === 0x46 &&
+ coverBytes[3] === 0x38
+ ) {
+ return "gif";
+ }
+ if (
+ coverBytes[0] === 0x52 &&
+ coverBytes[1] === 0x49 &&
+ coverBytes[2] === 0x46 &&
+ coverBytes[3] === 0x46 &&
+ coverBytes[8] === 0x57 &&
+ coverBytes[9] === 0x45 &&
+ coverBytes[10] === 0x42 &&
+ coverBytes[11] === 0x50
+ ) {
+ return "webp";
+ }
+ return "jpg";
+}
+
+export async function saveExtractedCoverIfStillMissing(
+ bookId: string,
+ coverBytes: Uint8Array,
+ coverMimeType: string | null | undefined,
+ getCurrentCoverUrl: () => string | undefined,
+): Promise {
+ if (getCurrentCoverUrl()?.trim()) return undefined;
+
+ const relativePath = await saveCoverBytesToAppData(bookId, coverBytes, coverMimeType);
+ trackExtractedCover(bookId, relativePath);
+ if (!getCurrentCoverUrl()?.trim()) return relativePath;
+
+ await deleteTrackedExtractedCover(bookId, relativePath);
+ return undefined;
+}
+
+export async function commitCustomCover(
+ bookId: string,
+ customCoverUrl: string,
+ persist: (coverUrl: string) => Promise,
+): Promise {
+ await persist(customCoverUrl);
+ const paths = extractedCoverPaths.get(bookId);
+ if (!paths) return;
+
+ for (const relativePath of [...paths]) {
+ if (relativePath !== customCoverUrl) {
+ await deleteTrackedExtractedCover(bookId, relativePath);
+ }
+ }
+}
+
+function trackExtractedCover(bookId: string, relativePath: string): void {
+ const paths = extractedCoverPaths.get(bookId) ?? new Set();
+ paths.add(relativePath);
+ extractedCoverPaths.set(bookId, paths);
+}
+
+async function deleteTrackedExtractedCover(bookId: string, relativePath: string): Promise {
+ try {
+ const platform = getPlatformService();
+ const appData = await platform.getAppDataDir();
+ await platform.deleteFile(await platform.joinPath(appData, relativePath));
+ const paths = extractedCoverPaths.get(bookId);
+ paths?.delete(relativePath);
+ if (paths?.size === 0) extractedCoverPaths.delete(bookId);
+ } catch (error) {
+ console.warn("[BookMetadata] Failed to clean up rejected extracted cover:", error);
+ }
+}
diff --git a/packages/app-expo/src/lib/book/imported-book-meta.test.ts b/packages/app-expo/src/lib/book/imported-book-meta.test.ts
new file mode 100644
index 000000000..f3c5e8401
--- /dev/null
+++ b/packages/app-expo/src/lib/book/imported-book-meta.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "vitest";
+import * as importedBookMeta from "./imported-book-meta";
+
+const { buildImportedBookMeta } = importedBookMeta;
+
+describe("buildImportedBookMeta", () => {
+ it("persists rich extracted metadata", () => {
+ expect(
+ buildImportedBookMeta({
+ existing: undefined,
+ opds: undefined,
+ embedded: {
+ title: "Book",
+ author: "Author",
+ publisher: "Press",
+ language: "en-US",
+ isbn: "978 1 4028 9462 6",
+ publishDate: "2020-4-3",
+ description: "Summary",
+ subjects: ["History"],
+ coverUrl: "covers/1.jpg",
+ },
+ fallbackTitle: "file",
+ }),
+ ).toMatchObject({
+ title: "Book",
+ author: "Author",
+ publisher: "Press",
+ language: "en",
+ isbn: "9781402894626",
+ publishDate: "2020-04-03",
+ description: "Summary",
+ subjects: ["History"],
+ coverUrl: "covers/1.jpg",
+ });
+ });
+
+ it("preserves restored values and lets OPDS fill blanks before embedded metadata", () => {
+ expect(
+ buildImportedBookMeta({
+ existing: { title: "Edited", author: "", publisher: "Saved" },
+ opds: { title: "Catalog", author: "Catalog Author", publisher: "Catalog Press" },
+ embedded: { author: "Embedded Author", language: "fr" },
+ fallbackTitle: "file",
+ }),
+ ).toMatchObject({
+ title: "Edited",
+ author: "Catalog Author",
+ publisher: "Saved",
+ language: "fr",
+ });
+ });
+
+ it("retains saved rating, reviews, and counts when filling import metadata", () => {
+ const reviews = [
+ {
+ id: "review-1",
+ content: "Keep this review",
+ createdAt: 1,
+ updatedAt: 2,
+ },
+ ];
+
+ expect(
+ buildImportedBookMeta({
+ existing: {
+ title: "",
+ author: "",
+ rating: 4,
+ reviews,
+ totalPages: 320,
+ totalChapters: 12,
+ },
+ opds: { rating: undefined, reviews: undefined, totalPages: undefined },
+ embedded: { title: "Imported", author: "Author" },
+ fallbackTitle: "file",
+ }),
+ ).toMatchObject({
+ title: "Imported",
+ author: "Author",
+ rating: 4,
+ reviews,
+ totalPages: 320,
+ totalChapters: 12,
+ });
+ });
+
+ it("restores saved publication values byte-for-byte while catalog metadata fills blanks", () => {
+ expect(
+ buildImportedBookMeta({
+ existing: {
+ title: " Saved Mobile Title ",
+ author: "",
+ publisher: " Saved Mobile Press ",
+ language: "en-US",
+ isbn: " ISBN 978-1-4028-9462-6 ",
+ publishDate: " 2020-4-3 ",
+ description: " Saved mobile description ",
+ subjects: [" History ", "History"],
+ },
+ opds: { author: " Catalog author ", language: "fr-FR" },
+ embedded: { author: "Embedded author" },
+ fallbackTitle: "filename",
+ }),
+ ).toMatchObject({
+ title: " Saved Mobile Title ",
+ author: "Catalog author",
+ publisher: " Saved Mobile Press ",
+ language: "en-US",
+ isbn: " ISBN 978-1-4028-9462-6 ",
+ publishDate: " 2020-4-3 ",
+ description: " Saved mobile description ",
+ subjects: [" History ", "History"],
+ });
+ });
+
+ it("skips embedded cover persistence when saved or OPDS metadata owns the cover", () => {
+ const shouldPersistEmbeddedCover = (
+ importedBookMeta as typeof importedBookMeta & {
+ shouldPersistEmbeddedCover?: (
+ existing?: { coverUrl?: string },
+ imported?: { coverUrl?: string },
+ ) => boolean;
+ }
+ ).shouldPersistEmbeddedCover;
+ expect(shouldPersistEmbeddedCover).toBeTypeOf("function");
+ if (!shouldPersistEmbeddedCover) return;
+
+ expect(shouldPersistEmbeddedCover({ coverUrl: "covers/saved.jpg" }, undefined)).toBe(false);
+ expect(shouldPersistEmbeddedCover(undefined, { coverUrl: "https://catalog/cover.jpg" })).toBe(
+ false,
+ );
+ expect(shouldPersistEmbeddedCover({ coverUrl: " " }, { coverUrl: "" })).toBe(true);
+ });
+});
diff --git a/packages/app-expo/src/lib/book/imported-book-meta.ts b/packages/app-expo/src/lib/book/imported-book-meta.ts
new file mode 100644
index 000000000..7b1d6ea34
--- /dev/null
+++ b/packages/app-expo/src/lib/book/imported-book-meta.ts
@@ -0,0 +1,28 @@
+import type { BookMeta } from "@readany/core/types";
+import { mergeBookMetadataSources } from "@readany/core/utils";
+import type { ExtractedMeta } from "./metadata-extractor";
+
+export function shouldPersistEmbeddedCover(
+ existing?: Partial,
+ imported?: Partial,
+): boolean {
+ return !existing?.coverUrl?.trim() && !imported?.coverUrl?.trim();
+}
+
+export function buildImportedBookMeta(input: {
+ existing?: Partial;
+ opds?: Partial;
+ embedded?: Partial | (ExtractedMeta & { coverUrl?: string });
+ fallbackTitle: string;
+}): BookMeta {
+ const merged = mergeBookMetadataSources(input.existing, input.opds, input.embedded, {
+ title: input.fallbackTitle,
+ author: "",
+ });
+ return {
+ ...input.existing,
+ ...merged,
+ title: merged.title || input.existing?.title || "Untitled",
+ author: merged.author || input.existing?.author || "",
+ };
+}
diff --git a/packages/app-expo/src/lib/book/metadata-extractor.test.ts b/packages/app-expo/src/lib/book/metadata-extractor.test.ts
new file mode 100644
index 000000000..edc4166b9
--- /dev/null
+++ b/packages/app-expo/src/lib/book/metadata-extractor.test.ts
@@ -0,0 +1,294 @@
+import type { Book } from "@readany/core/types";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { extractLocalBookMetadata } from "./auto-metadata";
+import { extractBookMetadataFromFile } from "./metadata-extractor";
+
+type SparseSegment = { offset: number; bytes: Uint8Array };
+
+const LARGE_FILE_SIZE = 33 * 1024 * 1024;
+
+const mobileFile = vi.hoisted(() => ({
+ exists: true,
+ size: 33 * 1024 * 1024,
+ read: (_start: number, _end: number) => new Uint8Array(),
+}));
+
+const platform = vi.hoisted(() => ({
+ getAppDataDir: vi.fn(async () => "/app"),
+ joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
+ mkdir: vi.fn(async () => undefined),
+ writeFile: vi.fn(async () => undefined),
+ readFile: vi.fn(async () => {
+ throw new Error("large repair must not read the whole file");
+ }),
+}));
+
+vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform }));
+vi.mock("@/lib/book/metadata-extractor", async () => import("./metadata-extractor"));
+vi.mock("expo-file-system/legacy", () => ({
+ EncodingType: { Base64: "base64" },
+ getInfoAsync: vi.fn(async () => ({
+ exists: mobileFile.exists,
+ isDirectory: false,
+ size: mobileFile.size,
+ })),
+ readAsStringAsync: vi.fn(async (_uri: string, options: { position: number; length: number }) =>
+ Buffer.from(mobileFile.read(options.position, options.position + options.length)).toString(
+ "base64",
+ ),
+ ),
+}));
+
+describe("range-readable book metadata extraction", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mobileFile.exists = true;
+ });
+
+ it("extracts metadata from an EPUB larger than 32 MiB using bounded slices", async () => {
+ const file = createLargeEpubFile();
+
+ await expect(extractBookMetadataFromFile(file, "epub", "large.epub")).resolves.toMatchObject({
+ title: "Large Book",
+ publisher: "Range Press",
+ subjects: ["History"],
+ });
+ expect(file.slice).toHaveBeenCalled();
+ expectBoundedReads(file.slice);
+ });
+
+ it.each(["mobi", "azw", "azw3"])(
+ "extracts %s metadata from a large file using bounded slices",
+ async (format) => {
+ const file = createLargeMobiFile();
+
+ await expect(
+ extractBookMetadataFromFile(file, format, `book.${format}`),
+ ).resolves.toMatchObject({
+ title: "Large MOBI",
+ author: "Author",
+ });
+ expect(file.slice).toHaveBeenCalled();
+ expectBoundedReads(file.slice);
+ },
+ );
+
+ it("routes large local EPUB Book Details repair through range reads", async () => {
+ const file = createLargeEpubFile();
+ mobileFile.size = file.size;
+ mobileFile.read = file.read;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: "legacy-large",
+ filePath: "books/large.epub",
+ format: "epub",
+ syncStatus: "local",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toMatchObject({
+ title: "Large Book",
+ publisher: "Range Press",
+ coverBytes: expect.any(Uint8Array),
+ coverMimeType: "image/png",
+ });
+ expect(platform.readFile).not.toHaveBeenCalled();
+ expect(platform.writeFile).not.toHaveBeenCalled();
+ });
+
+ it("returns extracted text and cover bytes without persisting during extraction", async () => {
+ const file = createLargeEpubFile();
+ mobileFile.size = file.size;
+ mobileFile.read = file.read;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: "cover-failure",
+ filePath: "books/large.epub",
+ format: "epub",
+ syncStatus: "local",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toMatchObject({
+ title: "Large Book",
+ publisher: "Range Press",
+ coverBytes: expect.any(Uint8Array),
+ });
+ expect(platform.writeFile).not.toHaveBeenCalled();
+ });
+
+ it.each(["mobi", "azw", "azw3"])(
+ "routes local %s Book Details repair through range reads",
+ async (format) => {
+ const file = createLargeMobiFile();
+ mobileFile.size = file.size;
+ mobileFile.read = file.read;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: `legacy-${format}`,
+ filePath: `books/large.${format}`,
+ format,
+ syncStatus: "local",
+ meta: { title: "", author: "" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toMatchObject({ title: "Large MOBI", author: "Author" });
+ expect(platform.readFile).not.toHaveBeenCalled();
+ },
+ );
+
+ it("leaves a missing local file untouched without trying a whole-file read", async () => {
+ mobileFile.exists = false;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: "missing",
+ filePath: "books/missing.epub",
+ format: "epub",
+ syncStatus: "local",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toBeNull();
+ expect(platform.readFile).not.toHaveBeenCalled();
+ });
+});
+
+function expectBoundedReads(slice: ReturnType) {
+ for (const [start = 0, end = start] of slice.mock.calls as Array<[number?, number?]>) {
+ expect(end - start).toBeLessThanOrEqual(256 * 1024);
+ }
+}
+
+function createLargeEpubFile() {
+ const containerXml = encode(
+ '',
+ );
+ const opfXml = encode(
+ 'Large BookAuthorRange PressHistory ',
+ );
+ const coverBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ const entries = [
+ { name: "META-INF/container.xml", bytes: containerXml },
+ { name: "content.opf", bytes: opfXml },
+ { name: "cover.png", bytes: coverBytes },
+ ];
+ const segments: SparseSegment[] = [];
+ const directoryEntries: Uint8Array[] = [];
+ let localOffset = 0;
+
+ for (const entry of entries) {
+ const name = encode(entry.name);
+ const local = new Uint8Array(30 + name.length + entry.bytes.length);
+ const localView = new DataView(local.buffer);
+ localView.setUint32(0, 0x04034b50, true);
+ localView.setUint32(18, entry.bytes.length, true);
+ localView.setUint32(22, entry.bytes.length, true);
+ localView.setUint16(26, name.length, true);
+ local.set(name, 30);
+ local.set(entry.bytes, 30 + name.length);
+ segments.push({ offset: localOffset, bytes: local });
+
+ const central = new Uint8Array(46 + name.length);
+ const centralView = new DataView(central.buffer);
+ centralView.setUint32(0, 0x02014b50, true);
+ centralView.setUint32(20, entry.bytes.length, true);
+ centralView.setUint32(24, entry.bytes.length, true);
+ centralView.setUint16(28, name.length, true);
+ centralView.setUint32(42, localOffset, true);
+ central.set(name, 46);
+ directoryEntries.push(central);
+ localOffset += local.length;
+ }
+
+ const directory = concat(directoryEntries);
+ const eocd = new Uint8Array(22);
+ const directoryOffset = LARGE_FILE_SIZE - eocd.length - directory.length;
+ const eocdView = new DataView(eocd.buffer);
+ eocdView.setUint32(0, 0x06054b50, true);
+ eocdView.setUint16(8, entries.length, true);
+ eocdView.setUint16(10, entries.length, true);
+ eocdView.setUint32(12, directory.length, true);
+ eocdView.setUint32(16, directoryOffset, true);
+ segments.push({ offset: directoryOffset, bytes: directory });
+ segments.push({ offset: LARGE_FILE_SIZE - eocd.length, bytes: eocd });
+ return createSparseFile(LARGE_FILE_SIZE, segments);
+}
+
+function createLargeMobiFile() {
+ const pdbHeader = new Uint8Array(78);
+ new DataView(pdbHeader.buffer).setUint16(76, 2, false);
+
+ const recordTable = new Uint8Array(16);
+ const recordTableView = new DataView(recordTable.buffer);
+ recordTableView.setUint32(0, 256, false);
+ recordTableView.setUint32(8, 1024, false);
+
+ const record = new Uint8Array(768);
+ const view = new DataView(record.buffer);
+ record.set(encode("MOBI"), 16);
+ view.setUint32(20, 132, false);
+ view.setUint32(28, 65001, false);
+ view.setUint32(36, 8, false);
+ view.setUint32(84, 300, false);
+ view.setUint32(88, 10, false);
+ view.setUint32(108, 1, false);
+ view.setUint32(128, 0b1000000, false);
+ record.set(encode("EXTH"), 148);
+ view.setUint32(152, 26, false);
+ view.setUint32(156, 1, false);
+ view.setUint32(160, 100, false);
+ view.setUint32(164, 14, false);
+ record.set(encode("Author"), 168);
+ record.set(encode("Large MOBI"), 300);
+
+ return createSparseFile(LARGE_FILE_SIZE, [
+ { offset: 0, bytes: pdbHeader },
+ { offset: 78, bytes: recordTable },
+ { offset: 256, bytes: record },
+ ]);
+}
+
+function createSparseFile(size: number, segments: SparseSegment[]) {
+ const read = (start: number, end: number) => {
+ const result = new Uint8Array(Math.max(0, end - start));
+ for (const segment of segments) {
+ const overlapStart = Math.max(start, segment.offset);
+ const overlapEnd = Math.min(end, segment.offset + segment.bytes.length);
+ if (overlapEnd <= overlapStart) continue;
+ result.set(
+ segment.bytes.subarray(overlapStart - segment.offset, overlapEnd - segment.offset),
+ overlapStart - start,
+ );
+ }
+ return result;
+ };
+ return {
+ size,
+ read,
+ slice: vi.fn((start = 0, end = size) => ({
+ arrayBuffer: async () => read(start, end).buffer,
+ })),
+ };
+}
+
+function concat(chunks: Uint8Array[]): Uint8Array {
+ const result = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));
+ let offset = 0;
+ for (const chunk of chunks) {
+ result.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return result;
+}
+
+function encode(value: string): Uint8Array {
+ return new TextEncoder().encode(value);
+}
diff --git a/packages/app-expo/src/lib/book/metadata-extractor.ts b/packages/app-expo/src/lib/book/metadata-extractor.ts
index 360903b8a..8b8a86bbc 100644
--- a/packages/app-expo/src/lib/book/metadata-extractor.ts
+++ b/packages/app-expo/src/lib/book/metadata-extractor.ts
@@ -1,3 +1,4 @@
+import { normalizeIsbn } from "@readany/core/utils";
/**
* Book metadata + cover extraction for React Native (Expo).
*
@@ -852,15 +853,9 @@ function extractOpfIsbn(opfXml: string): string {
const identifierRegex = /<[^>]*identifier\b([^>]*)>([^<]*)<\/[^>]*identifier>/gi;
let match = identifierRegex.exec(opfXml);
while (match !== null) {
- const attrs = match[1] || "";
const value = (match[2] || "").trim();
- const scheme = getAttr(attrs, "opf:scheme") || getAttr(attrs, "scheme");
- if (
- scheme.toLowerCase() === "isbn" ||
- /(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dXx]/.test(value)
- ) {
- return value;
- }
+ const isbn = normalizeIsbn(value);
+ if (isbn) return isbn;
match = identifierRegex.exec(opfXml);
}
return "";
diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.test.ts b/packages/app-expo/src/lib/platform/expo-platform-service.test.ts
new file mode 100644
index 000000000..488228612
--- /dev/null
+++ b/packages/app-expo/src/lib/platform/expo-platform-service.test.ts
@@ -0,0 +1,329 @@
+import { OpdsClient, type OpdsCredentials } from "@readany/core";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { expoFetch, secureDelete, secureGet, secureSet } = vi.hoisted(() => ({
+ expoFetch: vi.fn(),
+ secureDelete: vi.fn(),
+ secureGet: vi.fn(),
+ secureSet: vi.fn(),
+}));
+
+vi.mock("expo/fetch", () => ({ fetch: expoFetch }));
+vi.mock("@readany/core/i18n", () => ({ default: { t: (key: string) => key } }));
+vi.mock("expo-clipboard", () => ({}));
+vi.mock("expo-constants", () => ({ default: {} }));
+vi.mock("expo-document-picker", () => ({}));
+vi.mock("expo-file-system", () => ({
+ Directory: class {},
+ File: class {},
+ Paths: { document: { uri: "file:///test" } },
+}));
+vi.mock("expo-file-system/legacy", () => ({}));
+vi.mock("expo-network", () => ({}));
+vi.mock("expo-secure-store", () => ({
+ deleteItemAsync: secureDelete,
+ getItemAsync: secureGet,
+ setItemAsync: secureSet,
+}));
+vi.mock("expo-sharing", () => ({}));
+
+import { ExpoPlatformService } from "./expo-platform-service";
+
+const ATOM = `
+
+ Catalog
+ Book
+`;
+
+const credentials: OpdsCredentials = {
+ username: "reader",
+ password: "secret-password",
+ catalogOrigin: "https://catalog.test",
+};
+
+function header(init: RequestInit | undefined, name: string): string | null {
+ return new Headers(init?.headers).get(name);
+}
+
+function signalForCall(index = 0): AbortSignal {
+ const signal = (expoFetch.mock.calls[index]?.[1] as RequestInit | undefined)?.signal;
+ if (!signal) throw new Error(`Missing signal for Expo fetch call ${index}`);
+ return signal;
+}
+
+describe("ExpoPlatformService standards fetch contract", () => {
+ beforeEach(() => {
+ expoFetch.mockReset();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("uses expo/fetch for manual requests and preserves request and response web APIs", async () => {
+ const controller = new AbortController();
+ const redirect = new Response(null, {
+ status: 302,
+ headers: {
+ Location: "https://other.test/feed.xml",
+ "Content-Type": "application/atom+xml",
+ "WWW-Authenticate": 'Basic realm="Books"',
+ },
+ });
+ expoFetch.mockResolvedValue(redirect);
+ const requestHeaders = new Headers({
+ Accept: "application/atom+xml",
+ Authorization: "Basic test-token",
+ });
+
+ const result = await new ExpoPlatformService().fetch("https://catalog.test/feed.xml", {
+ headers: requestHeaders,
+ redirect: "manual",
+ signal: controller.signal,
+ responseType: "text",
+ timeoutMs: 15_000,
+ });
+
+ expect(expoFetch).toHaveBeenCalledTimes(1);
+ const [url, init] = expoFetch.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe("https://catalog.test/feed.xml");
+ expect(init.redirect).toBe("manual");
+ expect(init.signal).not.toBe(controller.signal);
+ expect(init.signal?.aborted).toBe(false);
+ expect(header(init, "Accept")).toBe("application/atom+xml");
+ expect(header(init, "Authorization")).toBe("Basic test-token");
+ expect(result.status).toBe(302);
+ expect(result.headers.get("Location")).toBe("https://other.test/feed.xml");
+ expect(result.headers.get("Content-Type")).toBe("application/atom+xml");
+ expect(result.headers.get("WWW-Authenticate")).toBe('Basic realm="Books"');
+ });
+
+ it("lets core inspect each Expo redirect and strips auth across origins", async () => {
+ expoFetch.mockImplementation(async (url: string, init: RequestInit) => {
+ if (url === "https://catalog.test/feed.xml") {
+ return new Response(null, {
+ status: 302,
+ headers: { Location: "https://cdn.test/feed.xml" },
+ });
+ }
+ expect(signalForCall(0).aborted).toBe(true);
+ expect(init.signal).not.toBe(signalForCall(0));
+ expect(init.signal?.aborted).toBe(false);
+ return new Response(ATOM, {
+ headers: { "Content-Type": "application/atom+xml" },
+ });
+ });
+
+ await new OpdsClient(new ExpoPlatformService()).open(
+ "https://catalog.test/feed.xml",
+ credentials,
+ );
+
+ expect(expoFetch).toHaveBeenCalledTimes(2);
+ expect(header(expoFetch.mock.calls[0]?.[1], "Authorization")).not.toBeNull();
+ expect(header(expoFetch.mock.calls[1]?.[1], "Authorization")).toBeNull();
+ expect(expoFetch.mock.calls.every(([, init]) => init.redirect === "manual")).toBe(true);
+ expect(signalForCall(0).aborted).toBe(true);
+ expect(signalForCall(1).aborted).toBe(false);
+ });
+
+ it("aborts the Expo transport when Content-Length rejects a catalog before reading", async () => {
+ expoFetch.mockResolvedValue(
+ new Response(new ReadableStream(), {
+ headers: {
+ "Content-Type": "application/atom+xml",
+ "Content-Length": "5242881",
+ },
+ }),
+ );
+
+ await expect(
+ new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"),
+ ).rejects.toMatchObject({ code: "too-large" });
+
+ expect(signalForCall().aborted).toBe(true);
+ });
+
+ it("lets core reject HTTPS downgrades and public HTTP targets before Expo follows", async () => {
+ expoFetch.mockResolvedValue(
+ new Response(null, {
+ status: 302,
+ headers: { Location: "http://catalog.example/feed.xml" },
+ }),
+ );
+
+ await expect(
+ new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml", credentials),
+ ).rejects.toMatchObject({ code: "insecure-url" });
+ expect(expoFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("exposes authentication challenges to core", async () => {
+ expoFetch.mockResolvedValue(
+ new Response(null, {
+ status: 401,
+ headers: { "WWW-Authenticate": 'Digest realm="Books"' },
+ }),
+ );
+
+ await expect(
+ new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"),
+ ).rejects.toMatchObject({ code: "unsupported-auth" });
+ expect(signalForCall().aborted).toBe(true);
+ });
+
+ it("forwards AbortSignal to expo/fetch", async () => {
+ expoFetch.mockImplementation(
+ async (_url: string, init: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ if (init.signal?.aborted) {
+ reject(new Error("aborted"));
+ return;
+ }
+ init.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
+ once: true,
+ });
+ }),
+ );
+ const controller = new AbortController();
+ const request = new ExpoPlatformService().fetch("https://catalog.test/feed.xml", {
+ redirect: "manual",
+ signal: controller.signal,
+ responseType: "text",
+ });
+
+ controller.abort();
+
+ await expect(request).rejects.toThrow("aborted");
+ });
+
+ it("preserves streaming so core cancels an oversized Expo response before full buffering", async () => {
+ let pulls = 0;
+ let cancelled = false;
+ const body = new ReadableStream({
+ pull(controller) {
+ pulls += 1;
+ controller.enqueue(new Uint8Array(3 * 1024 * 1024));
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ expoFetch.mockResolvedValue(
+ new Response(body, { headers: { "Content-Type": "application/atom+xml" } }),
+ );
+
+ await expect(
+ new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"),
+ ).rejects.toMatchObject({ code: "too-large" });
+ expect(pulls).toBeLessThanOrEqual(3);
+ expect(cancelled).toBe(true);
+ expect(signalForCall().aborted).toBe(true);
+ });
+
+ it("aborts the Expo transport when streaming the response body fails", async () => {
+ expoFetch.mockResolvedValue(
+ new Response(
+ new ReadableStream({
+ pull(controller) {
+ controller.error(new Error("native stream failed"));
+ },
+ }),
+ { headers: { "Content-Type": "application/atom+xml" } },
+ ),
+ );
+
+ await expect(
+ new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"),
+ ).rejects.toMatchObject({ code: "unreachable" });
+
+ expect(signalForCall().aborted).toBe(true);
+ });
+
+ it("aborts the Expo transport when a returned asset body is cancelled", async () => {
+ expoFetch.mockResolvedValue(
+ new Response(new ReadableStream(), {
+ headers: { "Content-Type": "application/epub+zip" },
+ }),
+ );
+ const asset = await new OpdsClient(new ExpoPlatformService()).fetchAsset(
+ "https://catalog.test/book.epub",
+ "https://catalog.test",
+ );
+
+ await asset.body?.cancel();
+
+ expect(signalForCall().aborted).toBe(true);
+ });
+
+ it("keeps explicit asset cancellation stable for future consumers", async () => {
+ expoFetch.mockResolvedValue(
+ new Response(new ReadableStream(), {
+ headers: { "Content-Type": "application/epub+zip" },
+ }),
+ );
+ const asset = await new OpdsClient(new ExpoPlatformService()).fetchAsset(
+ "https://catalog.test/book.epub",
+ "https://catalog.test",
+ );
+
+ await Promise.all([asset.cancel(), asset.cancel()]);
+
+ expect(signalForCall().aborted).toBe(true);
+ await expect(asset.arrayBuffer()).rejects.toMatchObject({ code: "cancelled" });
+ });
+
+ it("reads exact asset bytes when React Native Response cannot wrap a stream", async () => {
+ const bytes = Uint8Array.of(1, 0, 255, 127, 64);
+ const source = new Response(bytes, {
+ headers: { "Content-Type": "application/octet-stream" },
+ });
+ Object.defineProperties(source, {
+ url: { value: "https://catalog.test/final-book.epub" },
+ redirected: { value: true },
+ });
+ expoFetch.mockResolvedValue(source);
+ vi.stubGlobal(
+ "Response",
+ class NonStreamingWhatwgResponse {
+ constructor() {
+ throw new Error("React Native whatwg Response does not accept ReadableStream");
+ }
+ },
+ );
+
+ const asset = await new OpdsClient(new ExpoPlatformService()).fetchAsset(
+ "https://catalog.test/book.epub",
+ "https://catalog.test",
+ );
+
+ expect(Array.from(new Uint8Array(await asset.arrayBuffer()))).toEqual(Array.from(bytes));
+ expect(asset.url).toBe("https://catalog.test/final-book.epub");
+ expect(asset.redirected).toBe(true);
+ expect(signalForCall().aborted).toBe(false);
+ });
+});
+
+describe("ExpoPlatformService secret contract", () => {
+ beforeEach(() => {
+ secureDelete.mockReset();
+ secureGet.mockReset();
+ secureSet.mockReset();
+ });
+
+ it("delegates secrets directly to SecureStore without the general KV index", async () => {
+ secureGet.mockResolvedValue("stored-password");
+ const service = new ExpoPlatformService();
+
+ await expect(service.secretGetItem("opds.catalog.one.password")).resolves.toBe(
+ "stored-password",
+ );
+ await service.secretSetItem("opds.catalog.one.password", "new-password");
+ await service.secretRemoveItem("opds.catalog.one.password");
+
+ expect(secureGet).toHaveBeenCalledWith("opds.catalog.one.password");
+ expect(secureSet).toHaveBeenCalledWith("opds.catalog.one.password", "new-password");
+ expect(secureDelete).toHaveBeenCalledWith("opds.catalog.one.password");
+ expect(secureSet).not.toHaveBeenCalledWith("__readany_kv_keys__", expect.anything());
+ });
+});
diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.ts b/packages/app-expo/src/lib/platform/expo-platform-service.ts
index 6b20d9daa..2255f7f14 100644
--- a/packages/app-expo/src/lib/platform/expo-platform-service.ts
+++ b/packages/app-expo/src/lib/platform/expo-platform-service.ts
@@ -17,6 +17,7 @@ import type {
IDatabase,
IPlatformService,
IWebSocket,
+ PlatformFetchResponse,
WebSocketOptions,
} from "@readany/core/services";
import * as Clipboard from "expo-clipboard";
@@ -27,6 +28,7 @@ import * as LegacyFS from "expo-file-system/legacy";
import * as Network from "expo-network";
import * as SecureStore from "expo-secure-store";
import * as Sharing from "expo-sharing";
+import type { FetchRequestInit } from "expo/fetch";
/** Simple KV storage keys tracking (SecureStore doesn't have getAllKeys) */
const KV_KEYS_INDEX = "__readany_kv_keys__";
@@ -281,10 +283,55 @@ export class ExpoPlatformService implements IPlatformService {
// ---- Network ----
- async fetch(url: string, options?: FetchOptions): Promise {
+ async fetch(url: string, options?: FetchOptions): Promise {
const { allowInsecure, timeoutMs, responseType, onDownloadProgress, ...fetchOptions } =
options ?? {};
const effectiveUrl = allowInsecure ? url.replace(/^https:\/\//i, "http://") : url;
+ if (fetchOptions.redirect === "manual") {
+ const { fetch: expoFetch } = await import("expo/fetch");
+ const transportController = new AbortController();
+ const sourceSignal = fetchOptions.signal;
+ const onSourceAbort = () => transportController.abort(sourceSignal?.reason);
+ if (sourceSignal?.aborted) {
+ onSourceAbort();
+ } else {
+ sourceSignal?.addEventListener("abort", onSourceAbort, { once: true });
+ }
+ let disposed = false;
+ const onDispose = () => {
+ if (disposed) return;
+ disposed = true;
+ sourceSignal?.removeEventListener("abort", onSourceAbort);
+ };
+ const cancelTransport = () => {
+ transportController.abort();
+ onDispose();
+ };
+ const expoOptions: FetchRequestInit = {
+ body: fetchOptions.body ?? undefined,
+ credentials: fetchOptions.credentials,
+ headers: fetchOptions.headers,
+ integrity: fetchOptions.integrity,
+ keepalive: fetchOptions.keepalive,
+ method: fetchOptions.method,
+ mode: fetchOptions.mode,
+ redirect: fetchOptions.redirect,
+ referrer: fetchOptions.referrer,
+ signal: transportController.signal,
+ window: fetchOptions.window,
+ };
+ try {
+ const response = (await expoFetch(effectiveUrl, expoOptions)) as PlatformFetchResponse;
+ Object.defineProperties(response, {
+ cancelTransport: { value: cancelTransport },
+ onDispose: { value: onDispose },
+ });
+ return response;
+ } catch (error) {
+ onDispose();
+ throw error;
+ }
+ }
const method = fetchOptions?.method?.toUpperCase() || "GET";
// Always use XHR for WebDAV to handle large binary files properly
@@ -643,6 +690,20 @@ export class ExpoPlatformService implements IPlatformService {
return 0;
}
+ // ---- Secret Storage (direct Expo SecureStore boundary; not indexed as general KV) ----
+
+ async secretGetItem(key: string): Promise {
+ return SecureStore.getItemAsync(key);
+ }
+
+ async secretSetItem(key: string, value: string): Promise {
+ await SecureStore.setItemAsync(key, value);
+ }
+
+ async secretRemoveItem(key: string): Promise {
+ await SecureStore.deleteItemAsync(key);
+ }
+
// ---- KV Storage (backed by expo-secure-store) ----
private async _getKeysIndex(): Promise {
diff --git a/packages/app-expo/src/navigation/RootNavigator.tsx b/packages/app-expo/src/navigation/RootNavigator.tsx
index d720f534b..9d448a440 100644
--- a/packages/app-expo/src/navigation/RootNavigator.tsx
+++ b/packages/app-expo/src/navigation/RootNavigator.tsx
@@ -7,6 +7,8 @@ import { FullScreenNotesScreen } from "@/screens/FullScreenNotesScreen";
import { ReaderScreen } from "@/screens/ReaderScreen";
import SkillsScreen from "@/screens/SkillsScreen";
import StatsScreen from "@/screens/StatsScreen";
+import { OpdsBrowserScreen } from "@/screens/library/OpdsBrowserScreen";
+import { OpdsCatalogsScreen } from "@/screens/library/OpdsCatalogsScreen";
import { WebDavImportBrowserScreen } from "@/screens/library/WebDavImportBrowserScreen";
import AISettingsScreen from "@/screens/settings/AISettingsScreen";
import AboutScreen from "@/screens/settings/AboutScreen";
@@ -47,6 +49,8 @@ export type RootStackParamList = {
FullScreenNotes: { bookId: string };
FontSettings: undefined;
WebDavImportBrowser: { source: WebDavImportSource };
+ OpdsCatalogs: { editCatalogId?: string } | undefined;
+ OpdsBrowser: { catalogId: string };
};
const Stack = createNativeStackNavigator();
@@ -123,6 +127,16 @@ export function RootNavigator() {
component={WebDavImportBrowserScreen}
options={{ animation: "slide_from_right" }}
/>
+
+
(null);
const autoFilledBookIdRef = useRef(null);
const latestValuesRef = useRef(null);
+ const commitValues = useCallback(
+ (
+ update:
+ | BookMetadataFormValues
+ | ((current: BookMetadataFormValues) => BookMetadataFormValues),
+ ) => applyBookMetadataFormUpdate(latestValuesRef, setValues, update),
+ [],
+ );
useEffect(() => {
void loadBooks();
@@ -305,12 +315,9 @@ export function BookDetailsScreen({ route }: Props) {
if (!book) return;
if (hydratedBookIdRef.current === book.id) return;
hydratedBookIdRef.current = book.id;
- setValues(createBookMetadataFormValues(book));
- }, [book]);
-
- useEffect(() => {
- latestValuesRef.current = values;
- }, [values]);
+ const nextValues = createBookMetadataFormValues(book);
+ commitValues(nextValues);
+ }, [book, commitValues]);
useEffect(() => {
if (!book || !values) return;
@@ -319,20 +326,35 @@ export function BookDetailsScreen({ route }: Props) {
autoFilledBookIdRef.current = book.id;
let cancelled = false;
- void extractLocalBookMetadata(book).then((metadata) => {
+ void extractLocalBookMetadata(book).then(async (metadata) => {
if (cancelled || !metadata) return;
+ let extracted = metadata;
+ if (metadata.coverBytes?.length) {
+ try {
+ const coverUrl = await saveExtractedCoverIfStillMissing(
+ book.id,
+ metadata.coverBytes,
+ metadata.coverMimeType,
+ () => (cancelled ? "__cancelled__" : latestValuesRef.current?.coverUrl),
+ );
+ if (coverUrl) extracted = { ...metadata, coverUrl };
+ } catch (error) {
+ console.warn("[BookMetadata] Failed to persist extracted mobile cover:", error);
+ }
+ }
+ if (cancelled) return;
const nextValues = latestValuesRef.current
- ? mergeMissingBookMetadataValues(latestValuesRef.current, metadata)
+ ? mergeMissingBookMetadataValues(latestValuesRef.current, extracted)
: null;
if (!nextValues) return;
- setValues(nextValues);
+ commitValues(nextValues);
updateBook(book.id, buildBookMetadataUpdate(book, nextValues));
});
return () => {
cancelled = true;
};
- }, [book, updateBook, values]);
+ }, [book, commitValues, updateBook, values]);
useEffect(() => {
const raw = values?.coverUrl;
@@ -362,73 +384,75 @@ export function BookDetailsScreen({ route }: Props) {
const setField = useCallback(
(field: K, value: BookMetadataFormValues[K]) => {
- setValues((current) => {
- if (!current) return current;
- const next = { ...current, [field]: value };
- if (book) updateBook(book.id, buildBookMetadataUpdate(book, next));
- return next;
- });
+ const next = commitValues((current) => ({ ...current, [field]: value }));
+ if (book && next) updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook],
+ [book, commitValues, updateBook],
);
const persistCoverUrl = useCallback(
async (coverUrl: string) => {
- if (!book || !values) return;
- const nextValues = { ...values, coverUrl };
- setValues(nextValues);
+ if (!book) return;
+ const nextValues = commitValues((current) => ({ ...current, coverUrl }));
+ if (!nextValues) return;
await updateBook(book.id, buildBookMetadataUpdate(book, nextValues));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const setRating = useCallback(
(rating: number) => {
- if (!book || !values) return;
- const next = { ...values, rating: values.rating === rating ? null : rating };
- setValues(next);
+ if (!book) return;
+ const next = commitValues((current) => ({
+ ...current,
+ rating: current.rating === rating ? null : rating,
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const addReview = useCallback(
(content: string) => {
- if (!book || !values) return;
+ if (!book) return;
const review = { ...createEmptyBookReview(), content };
- const next = { ...values, reviews: [...values.reviews, review] };
- setValues(next);
+ const next = commitValues((current) => ({
+ ...current,
+ reviews: [...current.reviews, review],
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const updateReview = useCallback(
(reviewId: string, content: string) => {
- if (!book || !values) return;
- const next = {
- ...values,
- reviews: values.reviews.map((review) =>
+ if (!book) return;
+ const next = commitValues((current) => ({
+ ...current,
+ reviews: current.reviews.map((review) =>
review.id === reviewId ? { ...review, content } : review,
),
- };
- setValues(next);
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const removeReview = useCallback(
(reviewId: string) => {
- if (!book || !values) return;
- const next = {
- ...values,
- reviews: values.reviews.filter((review) => review.id !== reviewId),
- };
- setValues(next);
+ if (!book) return;
+ const next = commitValues((current) => ({
+ ...current,
+ reviews: current.reviews.filter((review) => review.id !== reviewId),
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const handleTextEditorDone = useCallback(
@@ -478,7 +502,7 @@ export function BookDetailsScreen({ route }: Props) {
const targetPath = await platform.joinPath(appData, relativePath);
const bytes = await platform.readFile(selected.uri);
await platform.writeFile(targetPath, bytes);
- await persistCoverUrl(relativePath);
+ await commitCustomCover(book.id, relativePath, persistCoverUrl);
Alert.alert(t("common.success", "成功"), t("library.detailsCoverSaved", "封面已保存"));
} catch (error) {
console.warn("[BookDetailsScreen] Failed to change cover:", error);
diff --git a/packages/app-expo/src/screens/LibraryScreen.tsx b/packages/app-expo/src/screens/LibraryScreen.tsx
index d5f3a1fb6..6578c1149 100644
--- a/packages/app-expo/src/screens/LibraryScreen.tsx
+++ b/packages/app-expo/src/screens/LibraryScreen.tsx
@@ -537,6 +537,11 @@ export function LibraryScreen() {
setTemporaryWebDavOpen(true);
}, []);
+ const handleOpenOpdsCatalogs = useCallback(() => {
+ setSourceSheetOpen(false);
+ nav.navigate("OpdsCatalogs");
+ }, [nav]);
+
const handleConnectTemporaryWebDav = useCallback(
async (source: WebDavImportSource) => {
const { WebDavImportService } = await import("@readany/core");
@@ -1166,6 +1171,7 @@ export function LibraryScreen() {
onPickLocal={handlePickLocalFromSourceMenu}
onPickSavedWebDav={() => void handleOpenSavedWebDav()}
onPickTemporaryWebDav={handleOpenTemporaryWebDav}
+ onPickOpds={handleOpenOpdsCatalogs}
/>
;
+
+interface BrowserOperation {
+ key: string;
+ mode: OpdsLoadMode;
+ execute(credentials: OpdsCredentials | undefined, signal: AbortSignal): Promise;
+}
+
+interface FormatChoice {
+ publication: OpdsPublication;
+ acquisitions: ReturnType;
+}
+
+const MAX_COVER_BYTES = 4 * 1024 * 1024;
+const MAX_COVER_CACHE_BYTES = 8 * 1024 * 1024;
+const MAX_COVER_CACHE_ENTRIES = 12;
+
+function plainDescription(description: string | undefined): string | undefined {
+ return description ? opdsDescriptionToPlainText(description) : undefined;
+}
+
+function toErrorCode(error: unknown): OpdsErrorCode {
+ return error instanceof OpdsError ? error.code : "unreachable";
+}
+
+function getContentSnapshot(state: ReturnType) {
+ if (state.content.status === "ready") return state.content;
+ if (state.content.status === "loading" || state.content.status === "error") {
+ return state.content.previous;
+ }
+ return undefined;
+}
+
+function AuthenticatedCover({
+ publication,
+ cache,
+ style,
+}: {
+ publication: OpdsPublication;
+ cache: ReturnType;
+ style: object;
+}) {
+ const [uri, setUri] = useState();
+ const imageUrl = publication.images[0]?.url;
+
+ useEffect(() => {
+ setUri(undefined);
+ if (!imageUrl) return;
+ const controller = new AbortController();
+ let release: (() => void) | undefined;
+ void cache
+ .acquire(imageUrl, controller.signal)
+ .then((lease) => {
+ release = lease.release;
+ if (!controller.signal.aborted) setUri(lease.uri);
+ else lease.release();
+ })
+ .catch(() => {});
+ return () => {
+ controller.abort();
+ release?.();
+ };
+ }, [cache, imageUrl]);
+
+ return uri ? : null;
+}
+
+export function OpdsBrowserScreen({ navigation, route }: Props) {
+ const { t } = useTranslation();
+ const colors = useColors();
+ const layout = useResponsiveLayout();
+ const insets = useSafeAreaInsets();
+ const catalogId = route.params.catalogId;
+ const store = useMemo(() => opdsMobileRuntime.getCatalogStore(), []);
+ const client = useMemo(() => opdsMobileRuntime.getClient(), []);
+ const [state, dispatch] = useReducer(opdsViewReducer, undefined, createInitialOpdsViewState);
+ const [catalogName, setCatalogName] = useState("");
+ const [catalogUrl, setCatalogUrl] = useState("");
+ const [query, setQuery] = useState("");
+ const [expandedPublication, setExpandedPublication] = useState();
+ const [formatChoice, setFormatChoice] = useState();
+ const formatHeadingRef = useRef(null);
+ const requestSequence = useRef(0);
+ const catalogOrigin = useRef(undefined);
+ const mounted = useRef(true);
+ const lifecycleGeneration = useRef(0);
+ const requestController = useRef(undefined);
+ const stateRef = useRef(state);
+ stateRef.current = state;
+ const operations = useRef(new Map());
+ const lastOperation = useRef(undefined);
+ const lastDownload = useRef<
+ { publication: OpdsPublication; acquisition: OpdsAcquisition } | undefined
+ >(undefined);
+ const { download } = useOpdsDownload();
+ const feed = selectOpdsFeed(state);
+
+ const executeOperation = useCallback(
+ async (operation: BrowserOperation, requestId: number) => {
+ requestController.current?.abort();
+ const controller = new AbortController();
+ requestController.current = controller;
+ lastOperation.current = operation;
+ operations.current.set(operation.key, operation);
+ try {
+ const credentials = await store.getCredentials(catalogId);
+ if (controller.signal.aborted) return;
+ const nextFeed = await operation.execute(credentials, controller.signal);
+ if (!controller.signal.aborted && mounted.current) {
+ dispatch({ type: "loadSucceeded", requestId, feed: nextFeed });
+ }
+ } catch (error) {
+ if (!controller.signal.aborted && mounted.current) {
+ dispatch({ type: "loadFailed", requestId, error: toErrorCode(error) });
+ }
+ }
+ },
+ [catalogId, store],
+ );
+
+ const startOperation = useCallback(
+ (operation: BrowserOperation) => {
+ const requestId = ++requestSequence.current;
+ dispatch({
+ type: "loadStarted",
+ requestId,
+ url: operation.key,
+ mode: operation.mode,
+ });
+ void executeOperation(operation, requestId);
+ },
+ [executeOperation],
+ );
+
+ const openUrl = useCallback(
+ (url: string, mode: OpdsLoadMode) => {
+ startOperation({
+ key: url,
+ mode,
+ execute: (credentials, signal) =>
+ client.open(url, credentials, signal, catalogOrigin.current),
+ });
+ },
+ [client, startOperation],
+ );
+
+ const initializeCatalog = useCallback(
+ async (generation: number) => {
+ try {
+ await opdsMobileRuntime.ensureCatalogsLoaded();
+ if (!mounted.current || lifecycleGeneration.current !== generation) return;
+ const catalog = store.getCatalog(catalogId);
+ if (!catalog || !catalog.enabled) throw new Error("catalog-unavailable");
+ catalogOrigin.current = new URL(catalog.url).origin;
+ setCatalogName(catalog.name);
+ setCatalogUrl(catalog.url);
+ openUrl(catalog.url, "replace");
+ } catch (error) {
+ if (!mounted.current || lifecycleGeneration.current !== generation) return;
+ const requestId = ++requestSequence.current;
+ dispatch({ type: "loadStarted", requestId, url: "catalog", mode: "replace" });
+ dispatch({ type: "loadFailed", requestId, error: toErrorCode(error) });
+ }
+ },
+ [catalogId, openUrl, store],
+ );
+
+ useEffect(() => {
+ const generation = ++lifecycleGeneration.current;
+ mounted.current = true;
+ void initializeCatalog(generation);
+ return () => {
+ mounted.current = false;
+ if (lifecycleGeneration.current === generation) lifecycleGeneration.current += 1;
+ requestController.current?.abort();
+ };
+ }, [initializeCatalog]);
+
+ const feedScope = getContentSnapshot(state)?.currentUrl ?? catalogUrl;
+ const coverCache = useMemo(() => {
+ const cacheScope = feedScope;
+ return createOpdsCoverCache({
+ maxEntries: MAX_COVER_CACHE_ENTRIES,
+ maxBytes: MAX_COVER_CACHE_BYTES,
+ maxLoadBytes: MAX_COVER_BYTES,
+ load: async (url, signal) => {
+ // Capturing the feed scope makes navigation create a fresh cache and release the old feed.
+ void cacheScope;
+ const credentials = await store.getCredentials(catalogId);
+ if (signal.aborted) throw new Error("cancelled");
+ const response = await client.fetchAsset(
+ url,
+ new URL(catalogUrl).origin,
+ credentials,
+ signal,
+ );
+ if (signal.aborted) {
+ await response.cancel("cancelled");
+ throw new Error("cancelled");
+ }
+ return readOpdsCover(response, signal, MAX_COVER_BYTES);
+ },
+ });
+ }, [catalogId, catalogUrl, client, feedScope, store]);
+ useEffect(() => () => coverCache.clear(), [coverCache]);
+
+ const downloadController = useMemo(
+ () =>
+ createOpdsDownloadController({
+ onEvent: (event) => {
+ if (mounted.current) dispatch(event);
+ },
+ }),
+ [],
+ );
+ useEffect(() => () => void downloadController.cancel(), [downloadController]);
+
+ useEffect(() => {
+ if (!formatChoice) return;
+ const focusTimer = setTimeout(() => {
+ const node = findNodeHandle(formatHeadingRef.current);
+ if (node) AccessibilityInfo.setAccessibilityFocus(node);
+ }, 100);
+ return () => clearTimeout(focusTimer);
+ }, [formatChoice]);
+
+ const runDownload = useCallback(
+ async (publication: OpdsPublication, acquisition: OpdsAcquisition) => {
+ lastDownload.current = { publication, acquisition };
+ try {
+ await downloadController.start({
+ publicationTitle: publication.title,
+ prepare: () => store.getCredentials(catalogId),
+ execute: async ({ credentials, signal, onProgress, onImportStart }) => {
+ const result = await download({
+ publication,
+ acquisition,
+ catalogOrigin: new URL(catalogUrl).origin,
+ credentials,
+ signal,
+ onProgress: (progress) => onProgress(progress.loaded, progress.total),
+ onImportStart,
+ });
+ return { importedCount: result.importResult.imported.length };
+ },
+ });
+ } catch {
+ // The controller owns the stable, sanitized error state.
+ }
+ },
+ [catalogId, catalogUrl, download, downloadController, store],
+ );
+
+ const chooseDownload = (publication: OpdsPublication) => {
+ const acquisitions = listSupportedAcquisitions(publication);
+ if (acquisitions.length === 0) return;
+ if (acquisitions.length === 1) {
+ void runDownload(publication, acquisitions[0]);
+ return;
+ }
+ setFormatChoice({ publication, acquisitions });
+ };
+
+ const handleSearch = () => {
+ const descriptor = feed?.search;
+ const trimmed = query.trim();
+ if (!descriptor || !trimmed) return;
+ const key = `opds-search:${encodeURIComponent(trimmed)}`;
+ startOperation({
+ key,
+ mode: "push",
+ execute: (credentials, signal) =>
+ client.search(descriptor, trimmed, credentials, signal, catalogOrigin.current),
+ });
+ };
+
+ const backController = useMemo(
+ () =>
+ createOpdsBackController({
+ getState: () => stateRef.current,
+ cancelRequest: () => requestController.current?.abort(),
+ dispatch,
+ startBack: (target) => {
+ const operation = operations.current.get(target);
+ if (operation) startOperation({ ...operation, mode: "back" });
+ else openUrl(target, "back");
+ },
+ exit: navigation.goBack,
+ }),
+ [navigation.goBack, openUrl, startOperation],
+ );
+ useEffect(
+ () => navigation.addListener("beforeRemove", backController.handleBeforeRemove),
+ [backController, navigation],
+ );
+
+ const handleRefresh = () => {
+ const snapshot = getContentSnapshot(state);
+ const operation = snapshot ? operations.current.get(snapshot.currentUrl) : undefined;
+ if (operation) startOperation({ ...operation, mode: "refresh" });
+ };
+
+ const handleRetry = () => {
+ const operation = lastOperation.current;
+ if (state.content.status !== "error") return;
+ if (!operation) {
+ void initializeCatalog(lifecycleGeneration.current);
+ return;
+ }
+ const requestId = ++requestSequence.current;
+ dispatch({ type: "retryStarted", requestId });
+ void executeOperation(operation, requestId);
+ };
+
+ const cancelDownload = () => {
+ downloadController.cancel();
+ };
+
+ const retryDownload = () => {
+ const retry = lastDownload.current;
+ if (retry) void runDownload(retry.publication, retry.acquisition);
+ };
+
+ const errorMessage = (code: OpdsErrorCode) => t(`library.opds.errors.${code}`);
+
+ const s = useMemo(
+ () =>
+ StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.background },
+ header: {
+ paddingHorizontal: layout.horizontalPadding,
+ paddingTop: 12,
+ paddingBottom: 10,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: withOpacity(colors.border, 0.9),
+ alignItems: "center",
+ },
+ headerInner: { width: "100%", maxWidth: layout.centeredContentWidth, gap: 12 },
+ headerRow: { flexDirection: "row", alignItems: "center", gap: 12 },
+ iconButton: {
+ width: 44,
+ height: 44,
+ borderRadius: radius.full,
+ backgroundColor: colors.card,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ headerCopy: { flex: 1, minWidth: 0 },
+ eyebrow: {
+ fontSize: fontSize.xs,
+ fontWeight: fontWeight.semibold,
+ color: colors.mutedForeground,
+ textTransform: "uppercase",
+ letterSpacing: 0.7,
+ },
+ title: {
+ marginTop: 2,
+ fontSize: fontSize.xl,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ searchRow: {
+ minHeight: 46,
+ paddingHorizontal: 12,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.92),
+ backgroundColor: colors.card,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 9,
+ },
+ searchInput: {
+ flex: 1,
+ minWidth: 0,
+ padding: 0,
+ fontSize: fontSize.base,
+ color: colors.foreground,
+ },
+ searchButton: {
+ minWidth: 44,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ scrollContent: {
+ width: "100%",
+ maxWidth: layout.centeredContentWidth,
+ alignSelf: "center",
+ paddingHorizontal: layout.horizontalPadding,
+ paddingTop: 16,
+ paddingBottom: 130,
+ gap: 16,
+ },
+ listRow: { marginBottom: 10 },
+ feedIntro: { paddingHorizontal: 2 },
+ feedTitle: {
+ fontSize: fontSize.lg,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ feedSubtitle: {
+ marginTop: 4,
+ fontSize: fontSize.sm,
+ lineHeight: 21,
+ color: colors.mutedForeground,
+ },
+ errorBox: {
+ padding: 14,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.destructive, 0.22),
+ backgroundColor: withOpacity(colors.destructive, 0.08),
+ gap: 10,
+ },
+ errorText: { fontSize: fontSize.sm, lineHeight: 20, color: colors.foreground },
+ errorActions: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
+ smallButton: {
+ minHeight: 44,
+ paddingHorizontal: 14,
+ borderRadius: radius.full,
+ backgroundColor: colors.card,
+ alignItems: "center",
+ justifyContent: "center",
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.9),
+ },
+ smallButtonText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.foreground,
+ },
+ section: { gap: 9 },
+ sectionTitle: {
+ fontSize: fontSize.xs,
+ fontWeight: fontWeight.semibold,
+ color: colors.mutedForeground,
+ textTransform: "uppercase",
+ letterSpacing: 0.8,
+ paddingHorizontal: 2,
+ },
+ linkCard: {
+ minHeight: 54,
+ paddingHorizontal: 14,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.9),
+ backgroundColor: colors.card,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ },
+ linkText: {
+ flex: 1,
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.foreground,
+ },
+ publication: {
+ borderRadius: radius.xxl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.92),
+ backgroundColor: colors.card,
+ overflow: "hidden",
+ },
+ publicationMain: { minHeight: 94, padding: 14, flexDirection: "row", gap: 13 },
+ cover: {
+ width: 56,
+ height: 78,
+ borderRadius: radius.md,
+ backgroundColor: withOpacity(colors.primary, 0.08),
+ overflow: "hidden",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ coverImage: { position: "absolute", top: 0, left: 0, width: 56, height: 78 },
+ publicationCopy: { flex: 1, minWidth: 0 },
+ publicationTitle: {
+ fontSize: fontSize.base,
+ lineHeight: 21,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ publicationAuthor: { marginTop: 4, fontSize: fontSize.sm, color: colors.mutedForeground },
+ publicationMeta: { marginTop: 7, fontSize: fontSize.xs, color: colors.mutedForeground },
+ details: {
+ paddingHorizontal: 14,
+ paddingBottom: 14,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: withOpacity(colors.border, 0.82),
+ gap: 12,
+ },
+ description: {
+ paddingTop: 12,
+ fontSize: fontSize.sm,
+ lineHeight: 21,
+ color: colors.foreground,
+ },
+ subjectRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
+ subject: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: radius.full,
+ backgroundColor: colors.muted,
+ },
+ subjectText: { fontSize: fontSize.xs, color: colors.mutedForeground },
+ downloadButton: {
+ minHeight: 46,
+ borderRadius: radius.xl,
+ backgroundColor: colors.primary,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ },
+ downloadText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.semibold,
+ color: colors.primaryForeground,
+ },
+ unsupported: { fontSize: fontSize.sm, lineHeight: 20, color: colors.mutedForeground },
+ pagination: { flexDirection: "row", gap: 10 },
+ pageButton: {
+ flex: 1,
+ minHeight: 46,
+ borderRadius: radius.xl,
+ backgroundColor: colors.card,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.9),
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ },
+ pageText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.foreground,
+ },
+ centerState: {
+ minHeight: 360,
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 28,
+ },
+ centerTitle: {
+ marginTop: 14,
+ fontSize: fontSize.lg,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ centerText: {
+ marginTop: 7,
+ fontSize: fontSize.sm,
+ lineHeight: 21,
+ textAlign: "center",
+ color: colors.mutedForeground,
+ },
+ downloadPanel: {
+ position: "absolute",
+ left: layout.horizontalPadding,
+ right: layout.horizontalPadding,
+ bottom: 16,
+ alignItems: "center",
+ },
+ downloadInner: {
+ width: "100%",
+ maxWidth: layout.centeredContentWidth,
+ padding: 14,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.92),
+ backgroundColor: colors.card,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.14,
+ shadowRadius: 18,
+ elevation: 7,
+ gap: 9,
+ },
+ downloadRow: { flexDirection: "row", alignItems: "center", gap: 10 },
+ downloadCopy: { flex: 1, minWidth: 0 },
+ downloadTitle: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ downloadMeta: { marginTop: 2, fontSize: fontSize.xs, color: colors.mutedForeground },
+ progressTrack: {
+ height: 4,
+ borderRadius: radius.full,
+ backgroundColor: colors.muted,
+ overflow: "hidden",
+ },
+ progressFill: {
+ height: "100%",
+ borderRadius: radius.full,
+ backgroundColor: colors.primary,
+ },
+ overlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" },
+ picker: {
+ maxHeight: "88%",
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ paddingBottom: Math.max(20, insets.bottom + 12),
+ borderTopLeftRadius: 26,
+ borderTopRightRadius: 26,
+ backgroundColor: colors.background,
+ gap: 12,
+ },
+ pickerHandle: {
+ alignSelf: "center",
+ width: 38,
+ height: 4,
+ borderRadius: radius.full,
+ backgroundColor: colors.border,
+ },
+ pickerTitle: {
+ fontSize: fontSize.xl,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ pickerSubtitle: { fontSize: fontSize.sm, color: colors.mutedForeground },
+ formatButton: {
+ minHeight: 52,
+ paddingHorizontal: 14,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.9),
+ backgroundColor: colors.card,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ },
+ formatText: {
+ flex: 1,
+ fontSize: fontSize.base,
+ fontWeight: fontWeight.medium,
+ color: colors.foreground,
+ textTransform: "uppercase",
+ },
+ formatList: { flexGrow: 0 },
+ formatListContent: { gap: 10 },
+ }),
+ [colors, insets.bottom, layout.centeredContentWidth, layout.horizontalPadding],
+ );
+
+ const renderPublication = (publication: OpdsPublication, keyPrefix = "publication") => {
+ const key = `${keyPrefix}:${publication.id ?? publication.title}`;
+ const expanded = expandedPublication === key;
+ const formats = listSupportedAcquisitions(publication);
+ const description = plainDescription(publication.description);
+ return (
+
+ setExpandedPublication(expanded ? undefined : key)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded }}
+ accessibilityLabel={t("library.opds.publicationDetails", {
+ title: publication.title,
+ })}
+ >
+
+
+
+
+
+ {publication.title}
+
+ {publication.authors.join(", ") || t("library.opds.unknownAuthor")}
+
+
+ {formats.length > 0
+ ? formats.map((item) => item.format.toUpperCase()).join(" · ")
+ : t("library.opds.noCompatibleFormat")}
+
+
+
+
+ {expanded ? (
+
+ {description ? {description} : null}
+ {publication.subjects.length > 0 ? (
+
+ {publication.subjects.slice(0, 8).map((subject) => (
+
+ {subject}
+
+ ))}
+
+ ) : null}
+ {formats.length > 0 ? (
+ chooseDownload(publication)}
+ disabled={
+ state.download.status === "downloading" || state.download.status === "importing"
+ }
+ accessibilityRole="button"
+ accessibilityState={{
+ disabled:
+ state.download.status === "downloading" ||
+ state.download.status === "importing",
+ }}
+ accessibilityLabel={t("library.opds.downloadTitle", {
+ title: publication.title,
+ })}
+ >
+
+
+ {formats.length > 1
+ ? t("library.opds.chooseFormat")
+ : t("library.opds.downloadAndImport")}
+
+
+ ) : (
+ {t("library.opds.unsupportedExplanation")}
+ )}
+
+ ) : null}
+
+ );
+ };
+
+ const contentError = state.content.status === "error" ? state.content.error : undefined;
+ const initialLoading =
+ state.content.status === "idle" || (state.content.status === "loading" && !feed);
+ const feedRows = useMemo(() => (feed ? createOpdsFeedRows(feed) : []), [feed]);
+ const downloadAccessibility = getOpdsDownloadAccessibility(state.download);
+
+ const renderFeedRow = ({ item }: { item: OpdsFeedRow }) => {
+ if (item.kind === "intro") {
+ return (
+
+ {item.feed.title}
+ {item.feed.subtitle ? {item.feed.subtitle} : null}
+
+ );
+ }
+ if (item.kind === "section") {
+ const title =
+ item.title === "collections"
+ ? t("library.opds.collections")
+ : item.title === "books"
+ ? t("library.opds.books")
+ : item.title;
+ return {title};
+ }
+ if (item.kind === "link") {
+ return (
+ openUrl(item.url, "push")}
+ accessibilityRole="button"
+ accessibilityLabel={item.title}
+ >
+ {item.icon ? : null}
+ {item.title}
+
+
+ );
+ }
+ if (item.kind === "publication") {
+ return {renderPublication(item.publication, item.keyPrefix)};
+ }
+ if (item.kind === "empty") {
+ return (
+
+
+ {t("library.opds.empty")}
+ {t("library.opds.emptyHint")}
+
+ );
+ }
+ return (
+
+ {item.previousUrl ? (
+ openUrl(item.previousUrl as string, "push")}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: false }}
+ accessibilityLabel={t("library.opds.previous")}
+ >
+
+ {t("library.opds.previous")}
+
+ ) : null}
+ {item.nextUrl ? (
+ openUrl(item.nextUrl as string, "push")}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: false }}
+ accessibilityLabel={t("library.opds.next")}
+ >
+ {t("library.opds.next")}
+
+
+ ) : null}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {catalogName || t("library.opds.catalog")}
+
+
+ {feed?.title ?? t("library.opds.loading")}
+
+
+
+ {state.content.status === "ready" && state.content.refreshing ? (
+
+ ) : (
+
+ )}
+
+
+ {canSearchOpds(state) ? (
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+ {initialLoading ? (
+
+
+ {t("library.opds.loading")}
+ {t("library.opds.loadingHint")}
+
+ ) : !feed && contentError ? (
+
+
+ {t("library.opds.loadFailed")}
+ {errorMessage(contentError)}
+
+
+ {t("library.opds.retry")}
+
+ {shouldEditOpdsCredentials(state) ? (
+ navigation.navigate("OpdsCatalogs", { editCatalogId: catalogId })}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: false }}
+ accessibilityLabel={t("library.opds.editCredentials")}
+ >
+ {t("library.opds.editCredentials")}
+
+ ) : null}
+
+
+ ) : (
+ item.key}
+ renderItem={renderFeedRow}
+ contentContainerStyle={s.scrollContent}
+ showsVerticalScrollIndicator={false}
+ initialNumToRender={8}
+ maxToRenderPerBatch={8}
+ windowSize={7}
+ removeClippedSubviews
+ ListHeaderComponent={
+ contentError ? (
+
+ {errorMessage(contentError)}
+
+
+ {t("library.opds.retry")}
+
+ {shouldEditOpdsCredentials(state) ? (
+
+ navigation.navigate("OpdsCatalogs", { editCatalogId: catalogId })
+ }
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.editCredentials")}
+ >
+ {t("library.opds.editCredentials")}
+
+ ) : null}
+
+
+ ) : null
+ }
+ />
+ )}
+
+ {state.download.status !== "idle" ? (
+
+
+
+ {state.download.status === "downloading" || state.download.status === "importing" ? (
+
+ ) : state.download.status === "success" ? (
+
+ ) : (
+
+ )}
+
+
+ {state.download.publicationTitle}
+
+
+ {state.download.status === "downloading"
+ ? state.download.total > 0
+ ? t("library.opds.downloadingProgress", {
+ percent: Math.round((state.download.loaded / state.download.total) * 100),
+ })
+ : t("library.opds.downloading")
+ : state.download.status === "importing"
+ ? t("library.opds.importing")
+ : state.download.status === "success"
+ ? state.download.importedCount > 0
+ ? t("library.opds.imported")
+ : t("library.opds.alreadyImported")
+ : errorMessage(state.download.error)}
+
+
+ {state.download.status === "downloading" ? (
+
+ {t("library.opds.cancel")}
+
+ ) : state.download.status === "error" && lastDownload.current ? (
+
+ {t("library.opds.retry")}
+
+ ) : state.download.status === "success" ? (
+ dispatch({ type: "downloadReset" })}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: false }}
+ accessibilityLabel={t("library.opds.done")}
+ >
+ {t("library.opds.done")}
+
+ ) : null}
+
+ {state.download.status === "downloading" || state.download.status === "importing" ? (
+ 0
+ ? downloadAccessibility.value
+ : {
+ text:
+ state.download.status === "importing"
+ ? t("library.opds.importing")
+ : t("library.opds.downloading"),
+ }
+ }
+ >
+ 0 ? Math.min(100, (state.download.loaded / state.download.total) * 100) : 8}%`,
+ },
+ ]}
+ />
+
+ ) : null}
+
+
+ ) : null}
+
+ setFormatChoice(undefined)}
+ >
+ setFormatChoice(undefined)}>
+ event.stopPropagation()}
+ accessibilityViewIsModal
+ >
+
+
+ {t("library.opds.chooseFormat")}
+
+
+ {formatChoice?.publication.title}
+
+ `${acquisition.format}:${acquisition.url}`}
+ initialNumToRender={8}
+ renderItem={({ item: acquisition }) => (
+ {
+ const publication = formatChoice?.publication;
+ setFormatChoice(undefined);
+ if (publication) void runDownload(publication, acquisition);
+ }}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: false }}
+ accessibilityLabel={t("library.opds.downloadFormat", {
+ format: acquisition.format.toUpperCase(),
+ })}
+ >
+
+ {acquisition.format}
+
+
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx b/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx
new file mode 100644
index 000000000..583903c12
--- /dev/null
+++ b/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx
@@ -0,0 +1,468 @@
+import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
+import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme";
+import {
+ type OpdsCatalog,
+ type OpdsCatalogAuth,
+ type OpdsCatalogStore,
+ canPreserveOpdsCatalogPassword,
+ classifyOpdsUrl,
+} from "@readany/core";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Alert,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { createOpdsFormSaveOwner } from "./opds-form-save-owner";
+
+interface OpdsCatalogFormSheetProps {
+ visible: boolean;
+ catalog?: OpdsCatalog;
+ store: OpdsCatalogStore;
+ onClose: () => void;
+ onSaved: () => void;
+ onBackgroundSaved?: () => void;
+}
+
+export function OpdsCatalogFormSheet({
+ visible,
+ catalog,
+ store,
+ onClose,
+ onSaved,
+ onBackgroundSaved,
+}: OpdsCatalogFormSheetProps) {
+ const { t } = useTranslation();
+ const colors = useColors();
+ const insets = useSafeAreaInsets();
+ const layout = useResponsiveLayout();
+ const [name, setName] = useState("");
+ const [url, setUrl] = useState("");
+ const [auth, setAuth] = useState("anonymous");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [enabled, setEnabled] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [renderedOpenGeneration, setRenderedOpenGeneration] = useState(0);
+ const [error, setError] = useState();
+ const saveOwner = useRef(createOpdsFormSaveOwner());
+ const wasVisible = useRef(false);
+
+ useEffect(() => {
+ const opening = visible && !wasVisible.current;
+ wasVisible.current = visible;
+ if (!visible) {
+ saveOwner.current.close();
+ setPassword("");
+ return;
+ }
+ if (!opening) return;
+ const generation = saveOwner.current.open();
+ setRenderedOpenGeneration(generation);
+ setName(catalog?.name ?? "");
+ setUrl(catalog?.url ?? "");
+ setAuth(catalog?.auth ?? "anonymous");
+ setUsername(catalog?.username ?? "");
+ setPassword("");
+ setEnabled(catalog?.enabled ?? true);
+ setError(undefined);
+ }, [catalog, visible]);
+
+ const hasPassword = (catalog?.passwordStorage ?? "none") !== "none";
+ const preservesPassword = Boolean(
+ catalog &&
+ canPreserveOpdsCatalogPassword(catalog, {
+ url: url.trim(),
+ auth,
+ username: username.trim(),
+ }),
+ );
+ const canSubmit =
+ name.trim().length > 0 &&
+ url.trim().length > 0 &&
+ (auth === "anonymous" ||
+ (username.trim().length > 0 && (password.length > 0 || preservesPassword))) &&
+ !submitting;
+ const savingCurrentOpen = saveOwner.current.isSavingCurrent(renderedOpenGeneration);
+
+ const s = useMemo(
+ () =>
+ StyleSheet.create({
+ overlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" },
+ keyboardWrap: { width: "100%", justifyContent: "flex-end" },
+ sheet: {
+ alignSelf: "center",
+ width: "100%",
+ maxWidth: layout.isTablet ? 620 : undefined,
+ maxHeight: "94%",
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ paddingBottom: Math.max(insets.bottom, 16) + 12,
+ borderTopLeftRadius: 26,
+ borderTopRightRadius: 26,
+ backgroundColor: colors.background,
+ gap: 14,
+ },
+ handle: {
+ alignSelf: "center",
+ width: 38,
+ height: 4,
+ borderRadius: radius.full,
+ backgroundColor: withOpacity(colors.border, 0.95),
+ },
+ title: { fontSize: fontSize.xl, fontWeight: fontWeight.semibold, color: colors.foreground },
+ subtitle: {
+ marginTop: 4,
+ fontSize: fontSize.sm,
+ lineHeight: 20,
+ color: colors.mutedForeground,
+ },
+ scroll: { flexGrow: 0 },
+ content: { gap: 14, paddingBottom: 4 },
+ field: { gap: 7 },
+ label: { fontSize: fontSize.sm, fontWeight: fontWeight.medium, color: colors.foreground },
+ input: {
+ minHeight: 48,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.92),
+ backgroundColor: colors.card,
+ paddingHorizontal: 14,
+ fontSize: fontSize.base,
+ color: colors.foreground,
+ },
+ segmented: {
+ minHeight: 48,
+ padding: 4,
+ borderRadius: radius.xl,
+ backgroundColor: colors.muted,
+ flexDirection: "row",
+ gap: 4,
+ },
+ segment: {
+ flex: 1,
+ minHeight: 40,
+ borderRadius: radius.lg,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ segmentActive: { backgroundColor: colors.card },
+ segmentText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.mutedForeground,
+ },
+ segmentTextActive: { color: colors.foreground },
+ helper: { fontSize: fontSize.xs, lineHeight: 18, color: colors.mutedForeground },
+ switchRow: {
+ minHeight: 56,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.92),
+ backgroundColor: colors.card,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ },
+ switchCopy: { flex: 1 },
+ switchTitle: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.foreground,
+ },
+ switchHint: { marginTop: 2, fontSize: fontSize.xs, color: colors.mutedForeground },
+ errorBox: {
+ padding: 12,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.destructive, 0.24),
+ backgroundColor: withOpacity(colors.destructive, 0.08),
+ },
+ errorText: { fontSize: fontSize.sm, lineHeight: 20, color: colors.destructive },
+ footer: { flexDirection: "row", gap: 10 },
+ button: {
+ minHeight: 48,
+ borderRadius: radius.xl,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ cancelButton: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.9),
+ backgroundColor: colors.card,
+ },
+ saveButton: {
+ flex: 1.2,
+ flexDirection: "row",
+ gap: 8,
+ backgroundColor: canSubmit ? colors.primary : withOpacity(colors.primary, 0.42),
+ },
+ cancelText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.foreground,
+ },
+ saveText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.semibold,
+ color: colors.primaryForeground,
+ },
+ }),
+ [canSubmit, colors, insets.bottom, layout.isTablet],
+ );
+
+ const persist = async () => {
+ if (!canSubmit) return;
+ const token = saveOwner.current.start(renderedOpenGeneration);
+ if (!token) return;
+ setSubmitting(true);
+ setError(undefined);
+ let succeeded = false;
+ try {
+ const input = {
+ name: name.trim(),
+ url: url.trim(),
+ enabled,
+ auth,
+ ...(auth === "basic"
+ ? { username: username.trim(), ...(password ? { password } : {}) }
+ : {}),
+ };
+ if (catalog) await store.updateCatalog(catalog.id, input);
+ else await store.addCatalog(input);
+ succeeded = true;
+ } catch {
+ // Ownership is resolved in finally so stale failures cannot touch a reopened form.
+ } finally {
+ const outcome = saveOwner.current.finish(token);
+ setSubmitting(saveOwner.current.hasActiveSave());
+ if (succeeded && outcome === "current") {
+ setPassword("");
+ onSaved();
+ } else if (succeeded && outcome === "stale") {
+ onBackgroundSaved?.();
+ } else if (!succeeded && outcome === "current") {
+ setError(t("library.opds.form.saveFailed"));
+ }
+ }
+ };
+
+ const handleSave = () => {
+ if (!canSubmit) return;
+ const classification = classifyOpdsUrl(url.trim());
+ if (!classification.allowed) {
+ const key =
+ classification.reason === "public-http"
+ ? "publicHttpBlocked"
+ : classification.reason === "credentials-not-allowed"
+ ? "credentialsInUrl"
+ : "invalidUrl";
+ setError(t(`library.opds.form.${key}`));
+ return;
+ }
+ if (!classification.requiresInsecureConfirmation) {
+ void persist();
+ return;
+ }
+ Alert.alert(t("library.opds.form.localHttpTitle"), t("library.opds.form.localHttpWarning"), [
+ { text: t("library.opds.cancel"), style: "cancel" },
+ {
+ text: t("library.opds.continue"),
+ onPress: () => void persist(),
+ },
+ ]);
+ };
+
+ return (
+ {
+ if (!savingCurrentOpen) onClose();
+ }}
+ >
+ {
+ if (!savingCurrentOpen) onClose();
+ }}
+ >
+
+ event.stopPropagation()}>
+
+
+
+ {catalog ? t("library.opds.form.editTitle") : t("library.opds.form.addTitle")}
+
+ {t("library.opds.form.subtitle")}
+
+
+
+ {t("library.opds.form.name")}
+
+
+
+ {t("library.opds.form.url")}
+
+
+
+ {t("library.opds.form.authentication")}
+
+ {(["anonymous", "basic"] as const).map((mode) => (
+ setAuth(mode)}
+ accessibilityRole="radio"
+ accessibilityState={{ checked: auth === mode }}
+ disabled={savingCurrentOpen}
+ >
+
+ {mode === "anonymous"
+ ? t("library.opds.form.anonymous")
+ : t("library.opds.form.basic")}
+
+
+ ))}
+
+
+ {auth === "basic" ? (
+ <>
+
+ {t("library.opds.form.username")}
+
+
+
+ {t("library.opds.form.password")}
+
+ {catalog ? (
+
+ {catalog.passwordStorage === "persistent"
+ ? t("library.opds.form.passwordStoredSecurely")
+ : catalog.passwordStorage === "session-only"
+ ? t("library.opds.form.passwordSessionOnly")
+ : t("library.opds.form.passwordMissing")}
+
+ ) : null}
+
+ >
+ ) : null}
+
+
+ {t("library.opds.form.enabled")}
+ {t("library.opds.form.enabledHint")}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {t("library.opds.cancel")}
+
+
+ {submitting ? (
+
+ ) : null}
+ {t("library.opds.save")}
+
+
+
+
+
+
+ );
+}
diff --git a/packages/app-expo/src/screens/library/OpdsCatalogsScreen.tsx b/packages/app-expo/src/screens/library/OpdsCatalogsScreen.tsx
new file mode 100644
index 000000000..cda3835ba
--- /dev/null
+++ b/packages/app-expo/src/screens/library/OpdsCatalogsScreen.tsx
@@ -0,0 +1,488 @@
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ EditIcon,
+ EyeOffIcon,
+ GlobeIcon,
+ PlusIcon,
+ RotateCcwIcon,
+ Trash2Icon,
+} from "@/components/ui/Icon";
+import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
+import type { RootStackParamList } from "@/navigation/RootNavigator";
+import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme";
+import type { NativeStackScreenProps } from "@react-navigation/native-stack";
+import type { OpdsCatalog } from "@readany/core";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ActivityIndicator,
+ Alert,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { OpdsCatalogFormSheet } from "./OpdsCatalogFormSheet";
+import { opdsMobileRuntime } from "./opds-mobile-runtime";
+import { createOpdsBrowserRouteParams } from "./opds-view-state";
+
+type Props = NativeStackScreenProps;
+
+export function OpdsCatalogsScreen({ navigation, route }: Props) {
+ const { t } = useTranslation();
+ const colors = useColors();
+ const layout = useResponsiveLayout();
+ const store = useMemo(() => opdsMobileRuntime.getCatalogStore(), []);
+ const [catalogs, setCatalogs] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState();
+ const [formOpen, setFormOpen] = useState(false);
+ const [editing, setEditing] = useState();
+ const [busyId, setBusyId] = useState();
+
+ const syncCatalogs = useCallback(
+ () => setCatalogs(store.listCatalogs({ includeHidden: true })),
+ [store],
+ );
+
+ useEffect(() => {
+ let active = true;
+ void opdsMobileRuntime
+ .ensureCatalogsLoaded()
+ .then(() => {
+ if (!active) return;
+ syncCatalogs();
+ setError(undefined);
+ })
+ .catch(() => {
+ if (active) {
+ setError(t("library.opds.catalogsLoadFailed"));
+ }
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [syncCatalogs, t]);
+
+ useEffect(() => {
+ const editCatalogId = route.params?.editCatalogId;
+ if (!editCatalogId || loading) return;
+ const catalog = store.getCatalog(editCatalogId);
+ if (catalog && !catalog.builtIn) {
+ setEditing(catalog);
+ setFormOpen(true);
+ navigation.setParams({ editCatalogId: undefined });
+ }
+ }, [loading, navigation, route.params?.editCatalogId, store]);
+
+ const visibleCatalogs = catalogs.filter((catalog) => !catalog.hidden);
+ const hiddenBuiltIns = catalogs.filter((catalog) => catalog.builtIn && catalog.hidden);
+
+ const s = useMemo(
+ () =>
+ StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.background },
+ header: {
+ paddingHorizontal: layout.horizontalPadding,
+ paddingTop: 12,
+ paddingBottom: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: withOpacity(colors.border, 0.9),
+ alignItems: "center",
+ },
+ headerInner: {
+ width: "100%",
+ maxWidth: layout.centeredContentWidth,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ iconButton: {
+ width: 44,
+ height: 44,
+ borderRadius: radius.full,
+ backgroundColor: colors.card,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ titleWrap: { flex: 1, minWidth: 0 },
+ title: { fontSize: fontSize.xl, fontWeight: fontWeight.semibold, color: colors.foreground },
+ subtitle: { marginTop: 2, fontSize: fontSize.sm, color: colors.mutedForeground },
+ scrollContent: {
+ width: "100%",
+ maxWidth: layout.centeredContentWidth,
+ alignSelf: "center",
+ paddingHorizontal: layout.horizontalPadding,
+ paddingTop: 18,
+ paddingBottom: 32,
+ gap: 18,
+ },
+ intro: {
+ padding: 16,
+ borderRadius: radius.xxl,
+ backgroundColor: withOpacity(colors.primary, 0.07),
+ borderWidth: 1,
+ borderColor: withOpacity(colors.primary, 0.12),
+ },
+ introEyebrow: {
+ fontSize: fontSize.xs,
+ fontWeight: fontWeight.semibold,
+ color: colors.primary,
+ textTransform: "uppercase",
+ letterSpacing: 0.8,
+ },
+ introText: {
+ marginTop: 6,
+ fontSize: fontSize.sm,
+ lineHeight: 21,
+ color: colors.foreground,
+ },
+ section: { gap: 10 },
+ sectionTitle: {
+ fontSize: fontSize.xs,
+ fontWeight: fontWeight.semibold,
+ color: colors.mutedForeground,
+ textTransform: "uppercase",
+ letterSpacing: 0.8,
+ paddingHorizontal: 2,
+ },
+ card: {
+ borderRadius: radius.xxl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.92),
+ backgroundColor: colors.card,
+ overflow: "hidden",
+ },
+ cardMain: {
+ minHeight: 76,
+ paddingHorizontal: 14,
+ paddingVertical: 13,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ catalogIcon: {
+ width: 42,
+ height: 42,
+ borderRadius: 15,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: withOpacity(colors.primary, 0.09),
+ },
+ catalogCopy: { flex: 1, minWidth: 0 },
+ nameRow: { flexDirection: "row", alignItems: "center", gap: 8 },
+ catalogName: {
+ flexShrink: 1,
+ fontSize: fontSize.base,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ badge: {
+ borderRadius: radius.full,
+ paddingHorizontal: 7,
+ paddingVertical: 3,
+ backgroundColor: colors.muted,
+ },
+ badgeText: { fontSize: 10, fontWeight: fontWeight.semibold, color: colors.mutedForeground },
+ catalogUrl: { marginTop: 4, fontSize: fontSize.xs, color: colors.mutedForeground },
+ status: { marginTop: 4, fontSize: fontSize.xs, color: colors.mutedForeground },
+ disabledCard: { opacity: 0.62 },
+ actions: {
+ minHeight: 48,
+ paddingHorizontal: 10,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: withOpacity(colors.border, 0.86),
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "flex-end",
+ gap: 4,
+ },
+ action: {
+ minWidth: 44,
+ minHeight: 44,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: radius.lg,
+ },
+ switchLabel: {
+ marginRight: "auto",
+ paddingLeft: 4,
+ fontSize: fontSize.xs,
+ color: colors.mutedForeground,
+ },
+ hiddenCard: {
+ minHeight: 58,
+ paddingHorizontal: 14,
+ borderRadius: radius.xl,
+ borderWidth: 1,
+ borderColor: withOpacity(colors.border, 0.8),
+ backgroundColor: withOpacity(colors.card, 0.72),
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ },
+ hiddenName: { flex: 1, fontSize: fontSize.sm, color: colors.foreground },
+ restoreText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ color: colors.primary,
+ },
+ state: { flex: 1, alignItems: "center", justifyContent: "center", padding: 28 },
+ stateTitle: {
+ marginTop: 14,
+ fontSize: fontSize.lg,
+ fontWeight: fontWeight.semibold,
+ color: colors.foreground,
+ },
+ stateText: {
+ marginTop: 6,
+ fontSize: fontSize.sm,
+ lineHeight: 21,
+ textAlign: "center",
+ color: colors.mutedForeground,
+ },
+ }),
+ [colors, layout.centeredContentWidth, layout.horizontalPadding],
+ );
+
+ const mutate = async (catalogId: string, operation: () => Promise) => {
+ setBusyId(catalogId);
+ setError(undefined);
+ try {
+ await operation();
+ syncCatalogs();
+ } catch {
+ setError(t("library.opds.catalogActionFailed"));
+ } finally {
+ setBusyId(undefined);
+ }
+ };
+
+ const confirmDelete = (catalog: OpdsCatalog) => {
+ Alert.alert(t("library.opds.deleteTitle"), t("library.opds.deleteDescription"), [
+ { text: t("library.opds.cancel"), style: "cancel" },
+ {
+ text: t("library.opds.delete"),
+ style: "destructive",
+ onPress: () => void mutate(catalog.id, () => store.removeCatalog(catalog.id)),
+ },
+ ]);
+ };
+
+ const authenticationLabel = (catalog: OpdsCatalog) => {
+ if (catalog.auth === "anonymous") {
+ return t("library.opds.authAnonymous");
+ }
+ if (catalog.passwordStorage === "persistent") {
+ return t("library.opds.authSecure");
+ }
+ if (catalog.passwordStorage === "session-only") {
+ return t("library.opds.authSession");
+ }
+ return t("library.opds.authMissing");
+ };
+
+ if (loading) {
+ return (
+
+
+
+ {t("library.opds.loadingCatalogs")}
+
+
+ );
+ }
+
+ return (
+
+
+
+ navigation.goBack()}
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.back")}
+ >
+
+
+
+ {t("library.opds.catalogsTitle")}
+ {t("library.opds.catalogsSubtitle")}
+
+ {
+ setEditing(undefined);
+ setFormOpen(true);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.form.addTitle")}
+ >
+
+
+
+
+
+
+
+ {t("library.opds.readerEyebrow")}
+ {t("library.opds.readerIntro")}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {t("library.opds.available")}
+ {visibleCatalogs.map((catalog) => {
+ const busy = busyId === catalog.id;
+ return (
+
+
+ navigation.navigate("OpdsBrowser", createOpdsBrowserRouteParams(catalog.id))
+ }
+ accessibilityRole="button"
+ accessibilityState={{ disabled: !catalog.enabled || busy }}
+ accessibilityLabel={t("library.opds.browseCatalog", {
+ name: catalog.name,
+ })}
+ >
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {catalog.name}
+
+ {catalog.builtIn ? (
+
+ {t("library.opds.builtIn")}
+
+ ) : null}
+
+
+ {catalog.url}
+
+ {authenticationLabel(catalog)}
+
+ {catalog.enabled ? (
+
+ ) : null}
+
+
+ {catalog.builtIn ? (
+ <>
+ {t("library.opds.builtInLocked")}
+ void mutate(catalog.id, () => store.hideBuiltIn(catalog.id))}
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.hideCatalog", {
+ name: catalog.name,
+ })}
+ >
+
+
+ >
+ ) : (
+ <>
+
+ {catalog.enabled ? t("library.opds.enabled") : t("library.opds.disabled")}
+
+
+ void mutate(catalog.id, () => store.setCatalogEnabled(catalog.id, value))
+ }
+ accessibilityLabel={t("library.opds.toggleCatalog", {
+ name: catalog.name,
+ })}
+ />
+ {
+ setEditing(catalog);
+ setFormOpen(true);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.editCatalog", {
+ name: catalog.name,
+ })}
+ >
+
+
+ confirmDelete(catalog)}
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.deleteCatalog", {
+ name: catalog.name,
+ })}
+ >
+
+
+ >
+ )}
+
+
+ );
+ })}
+
+
+ {hiddenBuiltIns.length > 0 ? (
+
+ {t("library.opds.hiddenPresets")}
+ {hiddenBuiltIns.map((catalog) => (
+
+
+ {catalog.name}
+ void mutate(catalog.id, () => store.restoreBuiltIn(catalog.id))}
+ accessibilityRole="button"
+ accessibilityLabel={t("library.opds.restoreCatalog", {
+ name: catalog.name,
+ })}
+ >
+ {t("library.opds.restore")}
+
+
+ ))}
+
+ ) : null}
+
+
+ setFormOpen(false)}
+ onSaved={() => {
+ setFormOpen(false);
+ setEditing(undefined);
+ syncCatalogs();
+ }}
+ onBackgroundSaved={syncCatalogs}
+ />
+
+ );
+}
diff --git a/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx b/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx
index fb4e84758..e4f39ebe1 100644
--- a/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx
+++ b/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx
@@ -3,6 +3,7 @@ import {
ChevronRightIcon,
CloudIcon,
GlobeIcon,
+ LibraryIcon,
} from "@/components/ui/Icon";
import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme";
@@ -35,6 +36,7 @@ interface WebDavImportSourceSheetProps {
onPickLocal: () => void;
onPickSavedWebDav: () => void;
onPickTemporaryWebDav: () => void;
+ onPickOpds: () => void;
}
export function WebDavImportSourceSheet({
@@ -47,6 +49,7 @@ export function WebDavImportSourceSheet({
onPickLocal,
onPickSavedWebDav,
onPickTemporaryWebDav,
+ onPickOpds,
}: WebDavImportSourceSheetProps) {
const { t } = useTranslation();
const colors = useColors();
@@ -65,11 +68,10 @@ export function WebDavImportSourceSheet({
Math.max(screenPadding, preferredLeft),
layout.width - popoverWidth - screenPadding,
);
- const showBelow =
- activeAnchor.y + activeAnchor.height + 12 + 230 < layout.height - screenPadding;
+ const showBelow = activeAnchor.y + activeAnchor.height + 12 + 284 < layout.height - screenPadding;
const popoverTop = showBelow
? activeAnchor.y + activeAnchor.height + 10
- : Math.max(screenPadding, activeAnchor.y - 230);
+ : Math.max(screenPadding, activeAnchor.y - 284);
const s = useMemo(
() =>
@@ -97,7 +99,7 @@ export function WebDavImportSourceSheet({
elevation: 8,
},
options: {
- maxHeight: 240,
+ maxHeight: 294,
},
optionCard: {
paddingHorizontal: 14,
@@ -131,7 +133,7 @@ export function WebDavImportSourceSheet({
marginHorizontal: 14,
},
}),
- [colors, layout.height, popoverLeft, popoverTop, popoverWidth],
+ [colors, popoverLeft, popoverTop, popoverWidth],
);
return (
@@ -143,10 +145,7 @@ export function WebDavImportSourceSheet({
onDismiss={onDismiss}
>
- event.stopPropagation()}
- >
+ event.stopPropagation()}>
+
+
+
+
+
+
+
+ {t("library.opds.catalogsTitle")}
+
+
+
diff --git a/packages/app-expo/src/screens/library/opds-back-controller.test.ts b/packages/app-expo/src/screens/library/opds-back-controller.test.ts
new file mode 100644
index 000000000..93f53a3cd
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-back-controller.test.ts
@@ -0,0 +1,123 @@
+import type { OpdsFeed } from "@readany/core";
+import { describe, expect, it, vi } from "vitest";
+import { createOpdsBackController } from "./opds-back-controller";
+import { type OpdsViewState, createInitialOpdsViewState, opdsViewReducer } from "./opds-view-state";
+
+const feed = (title: string): OpdsFeed => ({
+ title,
+ navigation: [],
+ publications: [],
+ groups: [],
+ facets: [],
+});
+
+function readyWithHistory(): OpdsViewState {
+ let state = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "root",
+ mode: "replace",
+ });
+ state = opdsViewReducer(state, { type: "loadSucceeded", requestId: 1, feed: feed("Root") });
+ state = opdsViewReducer(state, {
+ type: "loadStarted",
+ requestId: 2,
+ url: "child",
+ mode: "push",
+ });
+ return opdsViewReducer(state, {
+ type: "loadSucceeded",
+ requestId: 2,
+ feed: feed("Child"),
+ });
+}
+
+function setup(initial: OpdsViewState) {
+ let state = initial;
+ const cancelRequest = vi.fn();
+ const startBack = vi.fn();
+ const exit = vi.fn();
+ const controller = createOpdsBackController({
+ getState: () => state,
+ cancelRequest,
+ dispatch: (action) => {
+ state = opdsViewReducer(state, action);
+ },
+ startBack,
+ exit,
+ });
+ return { controller, cancelRequest, startBack, exit, getState: () => state };
+}
+
+describe("OPDS native back controller", () => {
+ it("uses internal history for the header back action", () => {
+ const { controller, startBack, exit } = setup(readyWithHistory());
+
+ controller.handleHeaderBack();
+
+ expect(startBack).toHaveBeenCalledWith("root");
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it("prevents a native route pop and uses the same internal history", () => {
+ const { controller, startBack } = setup(readyWithHistory());
+ const event = { preventDefault: vi.fn() };
+
+ controller.handleBeforeRemove(event);
+
+ expect(event.preventDefault).toHaveBeenCalledOnce();
+ expect(startBack).toHaveBeenCalledWith("root");
+ });
+
+ it("cancels an in-flight push and restores the previous ready feed", () => {
+ const child = readyWithHistory();
+ const pushing = opdsViewReducer(child, {
+ type: "loadStarted",
+ requestId: 3,
+ url: "grandchild",
+ mode: "push",
+ });
+ const { controller, cancelRequest, getState } = setup(pushing);
+
+ controller.handleBeforeRemove({ preventDefault: vi.fn() });
+
+ expect(cancelRequest).toHaveBeenCalledOnce();
+ expect(getState().content).toMatchObject({ status: "ready", currentUrl: "child" });
+ });
+
+ it("restores the previous feed when native back follows a failed push", () => {
+ const child = readyWithHistory();
+ const pushing = opdsViewReducer(child, {
+ type: "loadStarted",
+ requestId: 3,
+ url: "grandchild",
+ mode: "push",
+ });
+ const failed = opdsViewReducer(pushing, {
+ type: "loadFailed",
+ requestId: 3,
+ error: "unreachable",
+ });
+ const { controller, getState, exit } = setup(failed);
+
+ controller.handleBeforeRemove({ preventDefault: vi.fn() });
+
+ expect(getState().content).toMatchObject({ status: "ready", currentUrl: "child" });
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it("allows the root route to pop", () => {
+ let root = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "root",
+ mode: "replace",
+ });
+ root = opdsViewReducer(root, { type: "loadSucceeded", requestId: 1, feed: feed("Root") });
+ const { controller, exit } = setup(root);
+
+ controller.handleHeaderBack();
+
+ expect(exit).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-back-controller.ts b/packages/app-expo/src/screens/library/opds-back-controller.ts
new file mode 100644
index 000000000..f6555e948
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-back-controller.ts
@@ -0,0 +1 @@
+export { createOpdsBackController } from "@readany/core";
diff --git a/packages/app-expo/src/screens/library/opds-cover-cache.test.ts b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts
new file mode 100644
index 000000000..9b1799ed8
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts
@@ -0,0 +1,152 @@
+import type { OpdsAssetResponse } from "@readany/core";
+import { describe, expect, it, vi } from "vitest";
+import { createOpdsCoverCache, readOpdsCover } from "./opds-cover-cache";
+
+function response(chunks: Uint8Array[], headers: Record = {}) {
+ const cancel = vi.fn(async () => undefined);
+ const body = new ReadableStream({
+ pull(controller) {
+ const chunk = chunks.shift();
+ if (chunk) controller.enqueue(chunk);
+ else controller.close();
+ },
+ cancel,
+ });
+ return {
+ body,
+ bodyUsed: false,
+ headers: new Headers({ "Content-Type": "image/jpeg", ...headers }),
+ ok: true,
+ redirected: false,
+ status: 200,
+ statusText: "OK",
+ type: "default",
+ url: "https://catalog.test/cover.jpg",
+ arrayBuffer: vi.fn(),
+ blob: vi.fn(),
+ json: vi.fn(),
+ text: vi.fn(),
+ cancel,
+ } as unknown as OpdsAssetResponse & { cancel: ReturnType };
+}
+
+describe("OPDS cover streaming and cache", () => {
+ it("cancels a chunked response as soon as the real byte ceiling is crossed", async () => {
+ const asset = response([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]);
+
+ await expect(readOpdsCover(asset, new AbortController().signal, 5)).rejects.toThrow(
+ "cover-too-large",
+ );
+ expect(asset.cancel).toHaveBeenCalledOnce();
+ expect(asset.arrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it("does not trust a dishonest short Content-Length", async () => {
+ const asset = response([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])], {
+ "Content-Length": "2",
+ });
+
+ await expect(readOpdsCover(asset, new AbortController().signal, 5)).rejects.toThrow(
+ "cover-too-large",
+ );
+ expect(asset.cancel).toHaveBeenCalledOnce();
+ });
+
+ it("aborts the exact cover transport", async () => {
+ const asset = response([new Uint8Array([1])]);
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(readOpdsCover(asset, controller.signal, 5)).rejects.toThrow("cancelled");
+ expect(asset.cancel).toHaveBeenCalledOnce();
+ });
+
+ it("deduplicates duplicate cover URLs into one shared fetch", async () => {
+ const load = vi.fn(async () => ({ uri: "data:image/jpeg;base64,AQ==", byteLength: 1 }));
+ const cache = createOpdsCoverCache({ load, maxEntries: 4, maxBytes: 100 });
+
+ const [first, second] = await Promise.all([
+ cache.acquire("https://catalog.test/cover.jpg"),
+ cache.acquire("https://catalog.test/cover.jpg"),
+ ]);
+
+ expect(load).toHaveBeenCalledOnce();
+ expect(first.uri).toBe(second.uri);
+ first.release();
+ second.release();
+ });
+
+ it("evicts the least-recently-used released entry within entry and byte bounds", async () => {
+ const load = vi.fn(async (url: string) => ({
+ uri: `data:image/jpeg;base64,${url}`,
+ byteLength: 6,
+ }));
+ const cache = createOpdsCoverCache({ load, maxEntries: 1, maxBytes: 8 });
+
+ (await cache.acquire("one")).release();
+ (await cache.acquire("two")).release();
+ expect(cache.snapshot()).toMatchObject({ entries: 1, sourceBytes: 6, urls: ["two"] });
+ (await cache.acquire("one")).release();
+ expect(load).toHaveBeenCalledTimes(3);
+ });
+
+ it("uses reference-counted cancellation for a shared in-flight fetch", async () => {
+ let loaderSignal: AbortSignal | undefined;
+ const load = vi.fn(
+ async (_url: string, signal: AbortSignal) =>
+ new Promise<{ uri: string; byteLength: number }>((_resolve, reject) => {
+ loaderSignal = signal;
+ signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true });
+ }),
+ );
+ const cache = createOpdsCoverCache({ load, maxEntries: 4, maxBytes: 100 });
+ const first = new AbortController();
+ const second = new AbortController();
+ const firstLease = cache.acquire("shared", first.signal);
+ const secondLease = cache.acquire("shared", second.signal);
+
+ first.abort();
+ expect(loaderSignal?.aborted).toBe(false);
+ second.abort();
+ await expect(Promise.allSettled([firstLease, secondLease])).resolves.toHaveLength(2);
+ expect(loaderSignal?.aborted).toBe(true);
+ });
+
+ it("clears cached and in-flight resources on feed change or unmount", async () => {
+ let loaderSignal: AbortSignal | undefined;
+ const cache = createOpdsCoverCache({
+ load: async (_url, signal) => {
+ loaderSignal = signal;
+ return new Promise(() => {});
+ },
+ maxEntries: 4,
+ maxBytes: 100,
+ });
+ const pending = cache.acquire("pending");
+
+ cache.clear();
+
+ await expect(pending).rejects.toThrow("cancelled");
+ expect(loaderSignal?.aborted).toBe(true);
+ expect(cache.snapshot()).toMatchObject({ entries: 0, sourceBytes: 0, urls: [] });
+ });
+
+ it("does not repopulate a cleared feed when a stale loader resolves late", async () => {
+ let resolveLoad!: (value: { uri: string; byteLength: number }) => void;
+ const cache = createOpdsCoverCache({
+ load: async () =>
+ new Promise((resolve) => {
+ resolveLoad = resolve;
+ }),
+ maxEntries: 4,
+ maxBytes: 100,
+ });
+ const stale = cache.acquire("stale");
+
+ cache.clear();
+ resolveLoad({ uri: "data:image/jpeg;base64,AQ==", byteLength: 1 });
+
+ await expect(stale).rejects.toThrow("cancelled");
+ expect(cache.snapshot()).toMatchObject({ entries: 0, sourceBytes: 0, urls: [] });
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-cover-cache.ts b/packages/app-expo/src/screens/library/opds-cover-cache.ts
new file mode 100644
index 000000000..b56cf5ef0
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-cover-cache.ts
@@ -0,0 +1,6 @@
+export {
+ createOpdsCoverCache,
+ readOpdsCover,
+ type OpdsCoverLease,
+ type OpdsCoverValue,
+} from "@readany/core";
diff --git a/packages/app-expo/src/screens/library/opds-download-controller.test.ts b/packages/app-expo/src/screens/library/opds-download-controller.test.ts
new file mode 100644
index 000000000..15df4f1b1
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-download-controller.test.ts
@@ -0,0 +1,147 @@
+import { OpdsError } from "@readany/core";
+import { describe, expect, it, vi } from "vitest";
+import {
+ createOpdsDownloadController,
+ getOpdsDownloadAccessibility,
+} from "./opds-download-controller";
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("OPDS screen download controller", () => {
+ it("owns cancellation before credential lookup and never starts a cancelled download", async () => {
+ const credentials = deferred();
+ const execute = vi.fn(async () => ({ importedCount: 1 }));
+ const events: unknown[] = [];
+ let credentialSignal: AbortSignal | undefined;
+ const controller = createOpdsDownloadController({
+ onEvent: (event) => events.push(event),
+ });
+
+ const operation = controller.start({
+ publicationTitle: "Book",
+ prepare: (signal) => {
+ credentialSignal = signal;
+ return credentials.promise;
+ },
+ execute,
+ });
+
+ expect(controller.cancel()).toBe(true);
+ expect(credentialSignal?.aborted).toBe(true);
+ credentials.resolve("secret");
+ await expect(operation).resolves.toBeUndefined();
+ expect(execute).not.toHaveBeenCalled();
+ expect(events.map((event) => (event as { type: string }).type)).toEqual([
+ "downloadStarted",
+ "downloadCancelled",
+ ]);
+ });
+
+ it("transitions synchronously to noncancellable importing at the commit point", async () => {
+ const imported = deferred<{ importedCount: number }>();
+ const events: unknown[] = [];
+ const controller = createOpdsDownloadController({
+ onEvent: (event) => events.push(event),
+ });
+
+ const operation = controller.start({
+ publicationTitle: "Book",
+ prepare: async () => "secret",
+ execute: async ({ onImportStart }) => {
+ onImportStart();
+ return imported.promise;
+ },
+ });
+
+ await vi.waitFor(() => expect(controller.getPhase()).toBe("importing"));
+ expect(controller.cancel()).toBe(false);
+ expect(events.at(-1)).toMatchObject({ type: "downloadImporting" });
+ imported.resolve({ importedCount: 1 });
+ await expect(operation).resolves.toEqual({ importedCount: 1 });
+ expect(events.at(-1)).toMatchObject({ type: "downloadSucceeded", importedCount: 1 });
+ });
+
+ it("surfaces a committed import failure even when cancel is tapped late", async () => {
+ const imported = deferred<{ importedCount: number }>();
+ const events: unknown[] = [];
+ const controller = createOpdsDownloadController({
+ onEvent: (event) => events.push(event),
+ });
+ const operation = controller.start({
+ publicationTitle: "Book",
+ prepare: async () => undefined,
+ execute: async ({ onImportStart }) => {
+ onImportStart();
+ return imported.promise;
+ },
+ });
+
+ await vi.waitFor(() => expect(controller.getPhase()).toBe("importing"));
+ expect(controller.cancel()).toBe(false);
+ imported.reject(new OpdsError("import-failed"));
+ await expect(operation).rejects.toMatchObject({ code: "import-failed" });
+ expect(events.at(-1)).toMatchObject({ type: "downloadFailed", error: "import-failed" });
+ });
+
+ it("rejects a second request while credential lookup is active", async () => {
+ const credentials = deferred();
+ const controller = createOpdsDownloadController({ onEvent: vi.fn() });
+ const first = controller.start({
+ publicationTitle: "First",
+ prepare: () => credentials.promise,
+ execute: async () => ({ importedCount: 1 }),
+ });
+
+ await expect(
+ controller.start({
+ publicationTitle: "Second",
+ prepare: async () => undefined,
+ execute: async () => ({ importedCount: 1 }),
+ }),
+ ).rejects.toMatchObject({ code: "download-in-progress" });
+
+ controller.cancel();
+ credentials.resolve();
+ await first;
+ });
+
+ it("describes determinate, indeterminate, importing, success, and error states accessibly", () => {
+ expect(
+ getOpdsDownloadAccessibility({
+ status: "downloading",
+ requestId: 1,
+ publicationTitle: "Book",
+ loaded: 25,
+ total: 100,
+ }),
+ ).toMatchObject({ role: "progressbar", value: { min: 0, max: 100, now: 25 } });
+ expect(
+ getOpdsDownloadAccessibility({
+ status: "downloading",
+ requestId: 1,
+ publicationTitle: "Book",
+ loaded: 25,
+ total: 0,
+ }),
+ ).toMatchObject({ role: "progressbar", value: { text: "downloading" } });
+ expect(
+ getOpdsDownloadAccessibility({ status: "importing", requestId: 1, publicationTitle: "Book" }),
+ ).toMatchObject({ role: "progressbar", value: { text: "importing" }, liveRegion: "polite" });
+ expect(
+ getOpdsDownloadAccessibility({
+ status: "error",
+ requestId: 1,
+ publicationTitle: "Book",
+ error: "import-failed",
+ }),
+ ).toMatchObject({ role: "alert", liveRegion: "assertive" });
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-download-controller.ts b/packages/app-expo/src/screens/library/opds-download-controller.ts
new file mode 100644
index 000000000..307f6ae68
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-download-controller.ts
@@ -0,0 +1,111 @@
+import { OpdsError, type OpdsErrorCode } from "@readany/core";
+import type { OpdsDownloadState, OpdsViewAction } from "./opds-view-state";
+
+type DownloadEvent = Extract;
+type DownloadPhase = "idle" | "preparing" | "downloading" | "importing";
+
+interface DownloadResult {
+ readonly importedCount: number;
+}
+
+interface DownloadControllerOptions {
+ onEvent(event: DownloadEvent): void;
+}
+
+interface DownloadStart {
+ readonly publicationTitle: string;
+ prepare(signal: AbortSignal): Promise;
+ execute(options: {
+ credentials: TCredentials;
+ signal: AbortSignal;
+ onProgress(loaded: number, total: number): void;
+ onImportStart(): void;
+ }): Promise;
+}
+
+function errorCode(error: unknown): OpdsErrorCode {
+ return error instanceof OpdsError ? error.code : "download-failed";
+}
+
+export function createOpdsDownloadController({ onEvent }: DownloadControllerOptions) {
+ let sequence = 0;
+ let active:
+ | { requestId: number; controller: AbortController; phase: Exclude }
+ | undefined;
+
+ const isCurrent = (requestId: number) =>
+ active?.requestId === requestId && !active.controller.signal.aborted;
+
+ return {
+ async start(operation: DownloadStart): Promise {
+ if (active) throw new OpdsError("download-in-progress");
+ const requestId = ++sequence;
+ const controller = new AbortController();
+ active = { requestId, controller, phase: "preparing" };
+ onEvent({ type: "downloadStarted", requestId, publicationTitle: operation.publicationTitle });
+
+ try {
+ const credentials = await operation.prepare(controller.signal);
+ if (!isCurrent(requestId)) return undefined;
+ active.phase = "downloading";
+ const result = await operation.execute({
+ credentials,
+ signal: controller.signal,
+ onProgress: (loaded, total) => {
+ if (!isCurrent(requestId) || active?.phase !== "downloading") return;
+ onEvent({ type: "downloadProgress", requestId, loaded, total });
+ },
+ onImportStart: () => {
+ if (!isCurrent(requestId) || active?.phase !== "downloading") {
+ throw new OpdsError("cancelled");
+ }
+ active.phase = "importing";
+ onEvent({ type: "downloadImporting", requestId });
+ },
+ });
+ if (!isCurrent(requestId)) return undefined;
+ onEvent({ type: "downloadSucceeded", requestId, importedCount: result.importedCount });
+ return result;
+ } catch (error) {
+ if (!isCurrent(requestId)) return undefined;
+ onEvent({ type: "downloadFailed", requestId, error: errorCode(error) });
+ throw error;
+ } finally {
+ if (active?.requestId === requestId) active = undefined;
+ }
+ },
+ cancel(): boolean {
+ if (!active || active.phase === "importing") return false;
+ const { requestId, controller } = active;
+ active = undefined;
+ controller.abort();
+ onEvent({ type: "downloadCancelled", requestId });
+ return true;
+ },
+ getPhase(): DownloadPhase {
+ return active?.phase ?? "idle";
+ },
+ };
+}
+
+export function getOpdsDownloadAccessibility(state: OpdsDownloadState): {
+ role?: "progressbar" | "status" | "alert";
+ value?: { min?: number; max?: number; now?: number; text?: string };
+ liveRegion?: "polite" | "assertive";
+} {
+ if (state.status === "downloading") {
+ return state.total > 0
+ ? {
+ role: "progressbar",
+ value: { min: 0, max: state.total, now: Math.min(state.loaded, state.total) },
+ liveRegion: "polite",
+ }
+ : { role: "progressbar", value: { text: "downloading" }, liveRegion: "polite" };
+ }
+ if (state.status === "importing") {
+ return { role: "progressbar", value: { text: "importing" }, liveRegion: "polite" };
+ }
+ if (state.status === "success") return { role: "status", liveRegion: "polite" };
+ if (state.status === "error") return { role: "alert", liveRegion: "assertive" };
+ return {};
+}
diff --git a/packages/app-expo/src/screens/library/opds-feed-rows.test.ts b/packages/app-expo/src/screens/library/opds-feed-rows.test.ts
new file mode 100644
index 000000000..7b9b8bbc9
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-feed-rows.test.ts
@@ -0,0 +1,29 @@
+import type { OpdsFeed } from "@readany/core";
+import { describe, expect, it } from "vitest";
+import { createOpdsFeedRows } from "./opds-feed-rows";
+
+describe("OPDS virtual feed rows", () => {
+ it("flattens a dense catalog into stable rows for lazy rendering", () => {
+ const feed: OpdsFeed = {
+ title: "Dense shelf",
+ navigation: [],
+ facets: [],
+ groups: [],
+ publications: Array.from({ length: 200 }, (_, index) => ({
+ id: `book-${index}`,
+ title: `Book ${index}`,
+ authors: [],
+ subjects: [],
+ images: [],
+ acquisitions: [],
+ readingOrder: [],
+ })),
+ };
+
+ const rows = createOpdsFeedRows(feed);
+
+ expect(rows).toHaveLength(202);
+ expect(rows.filter((row) => row.kind === "publication")).toHaveLength(200);
+ expect(new Set(rows.map((row) => row.key))).toHaveLength(rows.length);
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-feed-rows.ts b/packages/app-expo/src/screens/library/opds-feed-rows.ts
new file mode 100644
index 000000000..b5643560d
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-feed-rows.ts
@@ -0,0 +1,85 @@
+import type { OpdsFeed, OpdsPublication } from "@readany/core";
+
+export type OpdsFeedRow =
+ | { kind: "intro"; key: string; feed: OpdsFeed }
+ | { kind: "section"; key: string; title: string }
+ | { kind: "link"; key: string; title: string; url: string; icon: boolean }
+ | {
+ kind: "publication";
+ key: string;
+ publication: OpdsPublication;
+ keyPrefix: string;
+ }
+ | { kind: "empty"; key: string }
+ | { kind: "pagination"; key: string; previousUrl?: string; nextUrl?: string };
+
+export function createOpdsFeedRows(feed: OpdsFeed): OpdsFeedRow[] {
+ const rows: OpdsFeedRow[] = [{ kind: "intro", key: "intro", feed }];
+ if (feed.navigation.length > 0) {
+ rows.push({ kind: "section", key: "navigation-title", title: "collections" });
+ feed.navigation.forEach((item, index) =>
+ rows.push({
+ kind: "link",
+ key: `navigation:${index}:${item.url}`,
+ title: item.title,
+ url: item.url,
+ icon: true,
+ }),
+ );
+ }
+ feed.facets.forEach((facet, facetIndex) => {
+ rows.push({ kind: "section", key: `facet-title:${facetIndex}`, title: facet.title });
+ facet.links.forEach((link, linkIndex) =>
+ rows.push({
+ kind: "link",
+ key: `facet:${facetIndex}:${linkIndex}:${link.url}`,
+ title: link.title ?? link.url,
+ url: link.url,
+ icon: false,
+ }),
+ );
+ });
+ if (feed.publications.length > 0) {
+ rows.push({ kind: "section", key: "books-title", title: "books" });
+ feed.publications.forEach((publication, index) =>
+ rows.push({
+ kind: "publication",
+ key: `publication:${publication.id ?? index}:${publication.title}`,
+ publication,
+ keyPrefix: "publication",
+ }),
+ );
+ }
+ feed.groups.forEach((group, groupIndex) => {
+ rows.push({ kind: "section", key: `group-title:${groupIndex}`, title: group.title });
+ group.navigation.forEach((item, itemIndex) =>
+ rows.push({
+ kind: "link",
+ key: `group-link:${groupIndex}:${itemIndex}:${item.url}`,
+ title: item.title,
+ url: item.url,
+ icon: false,
+ }),
+ );
+ group.publications.forEach((publication, publicationIndex) =>
+ rows.push({
+ kind: "publication",
+ key: `group-publication:${groupIndex}:${publication.id ?? publicationIndex}`,
+ publication,
+ keyPrefix: `group-${groupIndex}`,
+ }),
+ );
+ });
+ if (feed.navigation.length === 0 && feed.publications.length === 0 && feed.groups.length === 0) {
+ rows.push({ kind: "empty", key: "empty" });
+ }
+ if (feed.previousUrl || feed.nextUrl) {
+ rows.push({
+ kind: "pagination",
+ key: "pagination",
+ ...(feed.previousUrl ? { previousUrl: feed.previousUrl } : {}),
+ ...(feed.nextUrl ? { nextUrl: feed.nextUrl } : {}),
+ });
+ }
+ return rows;
+}
diff --git a/packages/app-expo/src/screens/library/opds-form-save-owner.test.ts b/packages/app-expo/src/screens/library/opds-form-save-owner.test.ts
new file mode 100644
index 000000000..ab67d5cfb
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-form-save-owner.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { createOpdsFormSaveOwner } from "./opds-form-save-owner";
+
+describe("mobile OPDS form save ownership", () => {
+ it("serializes deferred add and update saves in one open generation", () => {
+ const owner = createOpdsFormSaveOwner();
+ const generation = owner.open();
+ const first = owner.start(generation);
+
+ expect(first).toBeDefined();
+ expect(owner.isSavingCurrent(generation)).toBe(true);
+ expect(owner.start(generation)).toBeUndefined();
+ expect(owner.finish(first as never)).toBe("current");
+
+ const second = owner.start(generation);
+ expect(second).toBeDefined();
+ expect(owner.finish(second as never)).toBe("current");
+ });
+
+ it("treats completion after forced close and reopen as background-only", () => {
+ const owner = createOpdsFormSaveOwner();
+ const firstGeneration = owner.open();
+ const first = owner.start(firstGeneration);
+ owner.close();
+ const secondGeneration = owner.open();
+
+ expect(owner.isSavingCurrent(secondGeneration)).toBe(false);
+ expect(owner.hasActiveSave()).toBe(true);
+ expect(owner.finish(first as never)).toBe("stale");
+ expect(owner.hasActiveSave()).toBe(false);
+ expect(owner.start(secondGeneration)).toBeDefined();
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-form-save-owner.ts b/packages/app-expo/src/screens/library/opds-form-save-owner.ts
new file mode 100644
index 000000000..c2745d82c
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-form-save-owner.ts
@@ -0,0 +1,38 @@
+export interface OpdsFormSaveToken {
+ readonly saveId: number;
+ readonly openGeneration: number;
+}
+
+export function createOpdsFormSaveOwner() {
+ let openGeneration = 0;
+ let saveId = 0;
+ let open = false;
+ let active: OpdsFormSaveToken | undefined;
+
+ return {
+ open(): number {
+ open = true;
+ openGeneration += 1;
+ return openGeneration;
+ },
+ close(): void {
+ open = false;
+ },
+ start(generation: number): OpdsFormSaveToken | undefined {
+ if (active || !open || generation !== openGeneration) return undefined;
+ active = { saveId: ++saveId, openGeneration: generation };
+ return active;
+ },
+ finish(token: OpdsFormSaveToken): "current" | "stale" | "ignored" {
+ if (active?.saveId !== token.saveId) return "ignored";
+ active = undefined;
+ return open && token.openGeneration === openGeneration ? "current" : "stale";
+ },
+ isSavingCurrent(generation: number): boolean {
+ return active?.openGeneration === generation && open && generation === openGeneration;
+ },
+ hasActiveSave(): boolean {
+ return active !== undefined;
+ },
+ };
+}
diff --git a/packages/app-expo/src/screens/library/opds-mobile-runtime.test.ts b/packages/app-expo/src/screens/library/opds-mobile-runtime.test.ts
new file mode 100644
index 000000000..b5f69d5f6
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-mobile-runtime.test.ts
@@ -0,0 +1,37 @@
+import type { IPlatformService } from "@readany/core/services";
+import { describe, expect, it, vi } from "vitest";
+import { createOpdsMobileRuntime } from "./opds-mobile-runtime";
+
+describe("mobile OPDS runtime", () => {
+ it("shares one catalog store so session-only credentials survive screen navigation", async () => {
+ let persisted: string | null = null;
+ const platform = {
+ kvGetItem: vi.fn(async () => persisted),
+ kvSetItem: vi.fn(async (_key: string, value: string) => {
+ persisted = value;
+ }),
+ } as unknown as IPlatformService;
+ const runtime = createOpdsMobileRuntime(() => platform);
+ await runtime.ensureCatalogsLoaded();
+ const firstScreenStore = runtime.getCatalogStore();
+
+ const catalog = await firstScreenStore.addCatalog({
+ name: "Private catalog",
+ url: "https://catalog.test/opds",
+ auth: "basic",
+ username: "reader",
+ password: "session-secret",
+ });
+
+ await runtime.ensureCatalogsLoaded();
+ const browserStore = runtime.getCatalogStore();
+ expect(browserStore).toBe(firstScreenStore);
+ expect(await browserStore.getCredentials(catalog.id)).toMatchObject({
+ username: "reader",
+ password: "session-secret",
+ catalogOrigin: "https://catalog.test",
+ });
+ expect(platform.kvGetItem).toHaveBeenCalledOnce();
+ expect(runtime.getClient()).toBe(runtime.getClient());
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-mobile-runtime.ts b/packages/app-expo/src/screens/library/opds-mobile-runtime.ts
new file mode 100644
index 000000000..b5a272ee2
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-mobile-runtime.ts
@@ -0,0 +1,5 @@
+import { createOpdsRuntime, getPlatformService } from "@readany/core";
+
+export const createOpdsMobileRuntime = createOpdsRuntime;
+
+export const opdsMobileRuntime = createOpdsMobileRuntime(getPlatformService);
diff --git a/packages/app-expo/src/screens/library/opds-view-state.test.ts b/packages/app-expo/src/screens/library/opds-view-state.test.ts
new file mode 100644
index 000000000..1e624bf66
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-view-state.test.ts
@@ -0,0 +1,303 @@
+import type { OpdsFeed } from "@readany/core";
+import { describe, expect, it } from "vitest";
+import {
+ canSearchOpds,
+ createInitialOpdsViewState,
+ createOpdsBrowserRouteParams,
+ getOpdsPagination,
+ opdsViewReducer,
+ selectOpdsFeed,
+ shouldEditOpdsCredentials,
+} from "./opds-view-state";
+
+function feed(overrides: Partial = {}): OpdsFeed {
+ return {
+ title: "Catalog",
+ navigation: [],
+ publications: [],
+ groups: [],
+ facets: [],
+ ...overrides,
+ };
+}
+
+function readyState() {
+ const loading = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "https://catalog.test/root",
+ mode: "replace",
+ });
+ return opdsViewReducer(loading, {
+ type: "loadSucceeded",
+ requestId: 1,
+ feed: feed(),
+ });
+}
+
+describe("OPDS mobile view state", () => {
+ it("starts idle with no visible feed or active download", () => {
+ const state = createInitialOpdsViewState();
+
+ expect(state.content).toEqual({ status: "idle" });
+ expect(state.download).toEqual({ status: "idle" });
+ expect(selectOpdsFeed(state)).toBeUndefined();
+ });
+
+ it("uses loading for the first catalog request", () => {
+ const state = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "https://catalog.test/root",
+ mode: "replace",
+ });
+
+ expect(state.content).toMatchObject({
+ status: "loading",
+ requestId: 1,
+ pending: { url: "https://catalog.test/root", mode: "replace" },
+ });
+ });
+
+ it("retains the ready feed while refresh is in flight", () => {
+ const ready = readyState();
+ const refreshing = opdsViewReducer(ready, {
+ type: "loadStarted",
+ requestId: 2,
+ url: "https://catalog.test/root",
+ mode: "refresh",
+ });
+
+ expect(refreshing.content.status).toBe("ready");
+ expect(refreshing.content).toMatchObject({ refreshing: true, requestId: 2 });
+ expect(selectOpdsFeed(refreshing)?.title).toBe("Catalog");
+ });
+
+ it("pushes feed navigation and returns through its history", () => {
+ const ready = readyState();
+ const loadingChild = opdsViewReducer(ready, {
+ type: "loadStarted",
+ requestId: 2,
+ url: "https://catalog.test/fiction",
+ mode: "push",
+ });
+ const child = opdsViewReducer(loadingChild, {
+ type: "loadSucceeded",
+ requestId: 2,
+ feed: feed({ title: "Fiction" }),
+ });
+
+ expect(child.content).toMatchObject({
+ status: "ready",
+ currentUrl: "https://catalog.test/fiction",
+ history: ["https://catalog.test/root"],
+ });
+
+ const loadingRoot = opdsViewReducer(child, {
+ type: "loadStarted",
+ requestId: 3,
+ url: "https://catalog.test/root",
+ mode: "back",
+ });
+ const root = opdsViewReducer(loadingRoot, {
+ type: "loadSucceeded",
+ requestId: 3,
+ feed: feed({ title: "Catalog again" }),
+ });
+
+ expect(root.content).toMatchObject({
+ status: "ready",
+ currentUrl: "https://catalog.test/root",
+ history: [],
+ });
+ });
+
+ it("shows search only when the feed advertises it", () => {
+ expect(canSearchOpds(readyState())).toBe(false);
+
+ const loading = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "https://catalog.test/root",
+ mode: "replace",
+ });
+ const searchable = opdsViewReducer(loading, {
+ type: "loadSucceeded",
+ requestId: 1,
+ feed: feed({
+ search: { kind: "template", urlTemplate: "https://catalog.test?q={searchTerms}" },
+ }),
+ });
+
+ expect(canSearchOpds(searchable)).toBe(true);
+ });
+
+ it("exposes only advertised previous and next pagination links", () => {
+ const loading = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "https://catalog.test/page/2",
+ mode: "replace",
+ });
+ const ready = opdsViewReducer(loading, {
+ type: "loadSucceeded",
+ requestId: 1,
+ feed: feed({
+ previousUrl: "https://catalog.test/page/1",
+ nextUrl: "https://catalog.test/page/3",
+ }),
+ });
+
+ expect(getOpdsPagination(ready)).toEqual({
+ previousUrl: "https://catalog.test/page/1",
+ nextUrl: "https://catalog.test/page/3",
+ });
+ });
+
+ it("turns authentication failures into an edit-credentials recovery", () => {
+ const failed = opdsViewReducer(readyState(), {
+ type: "loadStarted",
+ requestId: 2,
+ url: "https://catalog.test/private",
+ mode: "push",
+ });
+ const error = opdsViewReducer(failed, {
+ type: "loadFailed",
+ requestId: 2,
+ error: "unauthorized",
+ });
+
+ expect(error.content.status).toBe("error");
+ expect(shouldEditOpdsCredentials(error)).toBe(true);
+ });
+
+ it("retries the failed request with a new request id", () => {
+ const loading = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "https://catalog.test/root",
+ mode: "replace",
+ });
+ const failed = opdsViewReducer(loading, {
+ type: "loadFailed",
+ requestId: 1,
+ error: "unreachable",
+ });
+ const retrying = opdsViewReducer(failed, { type: "retryStarted", requestId: 2 });
+
+ expect(retrying.content).toMatchObject({
+ status: "loading",
+ requestId: 2,
+ pending: { url: "https://catalog.test/root", mode: "replace" },
+ });
+ });
+
+ it("tracks download progress and imported completion", () => {
+ let state = opdsViewReducer(readyState(), {
+ type: "downloadStarted",
+ requestId: 7,
+ publicationTitle: "A Book",
+ });
+ state = opdsViewReducer(state, {
+ type: "downloadProgress",
+ requestId: 7,
+ loaded: 40,
+ total: 100,
+ });
+ expect(state.download).toMatchObject({ status: "downloading", loaded: 40, total: 100 });
+
+ state = opdsViewReducer(state, { type: "downloadImporting", requestId: 7 });
+ expect(state.download).toMatchObject({ status: "importing", publicationTitle: "A Book" });
+ expect(opdsViewReducer(state, { type: "downloadCancelled", requestId: 7 }).download).toEqual(
+ state.download,
+ );
+
+ state = opdsViewReducer(state, {
+ type: "downloadSucceeded",
+ requestId: 7,
+ importedCount: 1,
+ });
+ expect(state.download).toEqual({
+ status: "success",
+ requestId: 7,
+ publicationTitle: "A Book",
+ importedCount: 1,
+ });
+ });
+
+ it("resets cancellation and ignores late progress from the cancelled request", () => {
+ const downloading = opdsViewReducer(readyState(), {
+ type: "downloadStarted",
+ requestId: 7,
+ publicationTitle: "A Book",
+ });
+ const cancelled = opdsViewReducer(downloading, {
+ type: "downloadCancelled",
+ requestId: 7,
+ });
+ const staleProgress = opdsViewReducer(cancelled, {
+ type: "downloadProgress",
+ requestId: 7,
+ loaded: 100,
+ total: 100,
+ });
+
+ expect(staleProgress.download).toEqual({ status: "idle" });
+ });
+
+ it("ignores stale feed responses after a newer request starts", () => {
+ const first = opdsViewReducer(createInitialOpdsViewState(), {
+ type: "loadStarted",
+ requestId: 1,
+ url: "https://catalog.test/old",
+ mode: "replace",
+ });
+ const latest = opdsViewReducer(first, {
+ type: "loadStarted",
+ requestId: 2,
+ url: "https://catalog.test/latest",
+ mode: "replace",
+ });
+ const stale = opdsViewReducer(latest, {
+ type: "loadSucceeded",
+ requestId: 1,
+ feed: feed({ title: "Old response" }),
+ });
+
+ expect(stale).toBe(latest);
+ });
+
+ it("restores the previous ready feed when an in-flight or failed push is cancelled", () => {
+ const ready = readyState();
+ const pushing = opdsViewReducer(ready, {
+ type: "loadStarted",
+ requestId: 2,
+ url: "https://catalog.test/child",
+ mode: "push",
+ });
+ const restoredInFlight = opdsViewReducer(pushing, { type: "loadCancelled", requestId: 2 });
+ expect(restoredInFlight.content).toMatchObject({
+ status: "ready",
+ currentUrl: "https://catalog.test/root",
+ });
+
+ const failed = opdsViewReducer(pushing, {
+ type: "loadFailed",
+ requestId: 2,
+ error: "unreachable",
+ });
+ const restoredFailed = opdsViewReducer(failed, { type: "loadCancelled", requestId: 2 });
+ expect(restoredFailed.content).toMatchObject({
+ status: "ready",
+ currentUrl: "https://catalog.test/root",
+ });
+ });
+
+ it("creates serializable route params containing only the catalog id", () => {
+ const params = createOpdsBrowserRouteParams("catalog-id");
+
+ expect(params).toEqual({ catalogId: "catalog-id" });
+ expect(JSON.parse(JSON.stringify(params))).toEqual(params);
+ expect(JSON.stringify(params)).not.toContain("password");
+ });
+});
diff --git a/packages/app-expo/src/screens/library/opds-view-state.ts b/packages/app-expo/src/screens/library/opds-view-state.ts
new file mode 100644
index 000000000..f63853e0c
--- /dev/null
+++ b/packages/app-expo/src/screens/library/opds-view-state.ts
@@ -0,0 +1,18 @@
+export {
+ canSearchOpds,
+ createInitialOpdsViewState,
+ createOpdsBrowserRouteParams,
+ getOpdsPagination,
+ opdsViewReducer,
+ selectOpdsFeed,
+ shouldEditOpdsCredentials,
+} from "@readany/core";
+export type {
+ OpdsBrowserRouteParams,
+ OpdsContentState,
+ OpdsDownloadState,
+ OpdsLoadMode,
+ OpdsPendingRequest,
+ OpdsViewAction,
+ OpdsViewState,
+} from "@readany/core";
diff --git a/packages/app-expo/src/screens/library/useOpdsDownload.test.ts b/packages/app-expo/src/screens/library/useOpdsDownload.test.ts
new file mode 100644
index 000000000..971d55677
--- /dev/null
+++ b/packages/app-expo/src/screens/library/useOpdsDownload.test.ts
@@ -0,0 +1,299 @@
+import type {
+ ImportBooksResult,
+ OpdsAcquisition,
+ OpdsAssetResponse,
+ OpdsPublication,
+} from "@readany/core";
+import { describe, expect, it, vi } from "vitest";
+import type { MobileImportFile } from "../../stores/library-store";
+vi.mock("../../stores/library-store", () => ({ useLibraryStore: vi.fn() }));
+import { createOpdsDownloadAdapter, createOpdsDownloadUnmountGuard } from "./useOpdsDownload";
+
+const selected: OpdsAcquisition = {
+ rel: ["http://opds-spec.org/acquisition"],
+ url: "https://catalog.test/book.epub",
+ type: "application/epub+zip",
+ format: "epub",
+};
+
+const publication: OpdsPublication = {
+ title: "../../Catalog Book",
+ authors: ["Catalog Author"],
+ publisher: "Catalog Press",
+ language: "en",
+ identifier: "9781234567897",
+ published: "2025",
+ description: "Catalog description",
+ subjects: ["Subject A", "Subject B"],
+ images: [],
+ acquisitions: [selected],
+ readingOrder: [],
+};
+
+function asset(bytes = new Uint8Array([1, 2, 3])): OpdsAssetResponse {
+ const response = new Response(bytes, {
+ headers: { "Content-Length": String(bytes.byteLength) },
+ });
+ return Object.assign(response, {
+ cancel: vi.fn(async (_reason?: unknown) => undefined),
+ }) as unknown as OpdsAssetResponse;
+}
+
+function dependencies(overrides: Record = {}) {
+ const platform = {
+ writeFile: vi.fn(async (_path: string, _data: Uint8Array) => undefined),
+ deleteFile: vi.fn(async (_path: string) => undefined),
+ mkdir: vi.fn(async (_path: string) => undefined),
+ joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
+ };
+ const importBooks = vi.fn(
+ async (_files: MobileImportFile[], _options?: { transactional?: boolean }) =>
+ ({
+ imported: [{ id: "book-id" }],
+ skippedDuplicates: [],
+ failures: [],
+ }) as unknown as ImportBooksResult,
+ );
+ return {
+ platform,
+ client: {
+ fetchAsset: vi.fn(
+ async (_url: string, _origin: string, _credentials?: unknown, _signal?: AbortSignal) =>
+ asset(),
+ ),
+ },
+ importBooks,
+ getTempDirectory: vi.fn(async () => "file:///cache"),
+ createId: () => "fixed-id",
+ ...overrides,
+ };
+}
+
+describe("mobile OPDS download adapter", () => {
+ it("cancels on unmount and suppresses later hook state updates", () => {
+ const cancel = vi.fn();
+ const update = vi.fn();
+ const lifecycle = createOpdsDownloadUnmountGuard(cancel);
+
+ lifecycle.runIfMounted(update);
+ lifecycle.dispose();
+ lifecycle.runIfMounted(update);
+ lifecycle.dispose();
+
+ expect(update).toHaveBeenCalledOnce();
+ expect(cancel).toHaveBeenCalledOnce();
+ });
+
+ it("imports through the actual mobile file signature and cleans the temp file once", async () => {
+ const deps = dependencies();
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ const result = await run({
+ publication,
+ acquisition: selected,
+ catalogOrigin: "https://catalog.test",
+ });
+
+ expect(deps.importBooks).toHaveBeenCalledOnce();
+ expect(deps.importBooks.mock.calls[0]?.[1]).toEqual({ transactional: true });
+ const [[files]] = deps.importBooks.mock.calls;
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatchObject({
+ name: "Catalog Book.epub",
+ metadata: {
+ title: "../../Catalog Book",
+ author: "Catalog Author",
+ publisher: "Catalog Press",
+ subjects: ["Subject A", "Subject B"],
+ },
+ });
+ expect(files[0].metadata).not.toHaveProperty("tags");
+ expect(files[0].uri).toMatch(/^file:\/\/\/cache\/readany-opds-import\/opds-/);
+ expect(files[0].uri).toMatch(/\.epub$/);
+ expect(deps.platform.deleteFile).toHaveBeenCalledExactlyOnceWith(files[0].uri);
+ expect(result.cleanupFailed).toBe(false);
+ });
+
+ it("uses the React Native-safe ID path without a createId test override", async () => {
+ const originalCrypto = globalThis.crypto;
+ vi.stubGlobal("crypto", {
+ getRandomValues(bytes: Uint8Array) {
+ bytes.fill(7);
+ return bytes;
+ },
+ });
+ const deps = dependencies();
+ const { createId: _createId, ...productionDeps } = deps;
+ const run = createOpdsDownloadAdapter(productionDeps as never);
+
+ await expect(
+ run({ publication, catalogOrigin: "https://catalog.test" }),
+ ).resolves.toBeDefined();
+ expect(deps.platform.writeFile.mock.calls[0]?.[0]).toContain(
+ "07070707-0707-4707-8707-070707070707",
+ );
+ vi.stubGlobal("crypto", originalCrypto);
+ });
+
+ it.each(["getTempDirectory", "joinPath", "mkdir"] as const)(
+ "maps %s setup failures without trying cleanup before a path exists",
+ async (operation) => {
+ const deps = dependencies();
+ if (operation === "getTempDirectory") {
+ deps.getTempDirectory.mockRejectedValueOnce(new Error("C:/private/temp detail"));
+ } else {
+ deps.platform[operation].mockRejectedValueOnce(new Error("C:/private/temp detail"));
+ }
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ const error = await run({ publication, catalogOrigin: "https://catalog.test" }).catch(
+ (value: unknown) => value,
+ );
+
+ expect(error).toMatchObject({ code: "download-failed" });
+ expect(String(error)).not.toContain("private");
+ expect(deps.platform.deleteFile).not.toHaveBeenCalled();
+ },
+ );
+
+ it("maps store failures to import-failed and preserves that error when cleanup also fails", async () => {
+ const onCleanupError = vi.fn();
+ const deps = dependencies({
+ importBooks: vi.fn(async () => ({
+ imported: [],
+ skippedDuplicates: [],
+ failures: [{ name: "book.epub", error: "database secret detail" }],
+ })),
+ onCleanupError,
+ });
+ deps.platform.deleteFile.mockRejectedValueOnce(new Error("cleanup detail"));
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ const error = await run({
+ publication,
+ catalogOrigin: "https://catalog.test",
+ }).catch((value: unknown) => value);
+
+ expect(error).toMatchObject({ code: "import-failed" });
+ expect(String(error)).not.toContain("database secret detail");
+ expect(deps.platform.deleteFile).toHaveBeenCalledOnce();
+ expect(onCleanupError).toHaveBeenCalledWith(expect.any(Error), error);
+ });
+
+ it("never lets a throwing cleanup reporter mask the primary error", async () => {
+ const deps = dependencies({
+ importBooks: vi.fn(async () => {
+ throw new Error("database detail");
+ }),
+ onCleanupError: vi.fn(() => {
+ throw new Error("reporter detail");
+ }),
+ });
+ deps.platform.deleteFile.mockRejectedValueOnce(new Error("cleanup detail"));
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ await expect(run({ publication, catalogOrigin: "https://catalog.test" })).rejects.toMatchObject(
+ {
+ code: "import-failed",
+ },
+ );
+ });
+
+ it("cleans once on cancellation before import", async () => {
+ const controller = new AbortController();
+ const deps = dependencies();
+ deps.platform.writeFile.mockImplementationOnce(async () => {
+ controller.abort();
+ });
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ await expect(
+ run({
+ publication,
+ catalogOrigin: "https://catalog.test",
+ signal: controller.signal,
+ }),
+ ).rejects.toMatchObject({ code: "cancelled" });
+ expect(deps.importBooks).not.toHaveBeenCalled();
+ expect(deps.platform.deleteFile).toHaveBeenCalledOnce();
+ });
+
+ it("finishes an import atomically when cancellation arrives after import begins", async () => {
+ const controller = new AbortController();
+ let finishImport!: () => void;
+ const importGate = new Promise((resolve) => {
+ finishImport = resolve;
+ });
+ const deps = dependencies({
+ importBooks: vi.fn(async () => {
+ controller.abort();
+ await importGate;
+ return { imported: [{ id: "book-id" }], skippedDuplicates: [], failures: [] };
+ }),
+ });
+ const run = createOpdsDownloadAdapter(deps as never);
+ const pending = run({
+ publication,
+ catalogOrigin: "https://catalog.test",
+ signal: controller.signal,
+ });
+ finishImport();
+
+ await expect(pending).resolves.toMatchObject({ cleanupFailed: false });
+ expect(deps.platform.deleteFile).toHaveBeenCalledOnce();
+ });
+
+ it("announces the noncancellable import boundary synchronously before touching the store", async () => {
+ const order: string[] = [];
+ const deps = dependencies({
+ importBooks: vi.fn(async () => {
+ order.push("import");
+ return { imported: [{ id: "book-id" }], skippedDuplicates: [], failures: [] };
+ }),
+ });
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ await run({
+ publication,
+ catalogOrigin: "https://catalog.test",
+ onImportStart: () => order.push("boundary"),
+ });
+
+ expect(order).toEqual(["boundary", "import"]);
+ });
+
+ it("reports a cleanup-only failure without turning a successful managed import into failure", async () => {
+ const onCleanupError = vi.fn();
+ const deps = dependencies({ onCleanupError });
+ deps.platform.deleteFile.mockRejectedValueOnce(new Error("cleanup detail"));
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ const result = await run({ publication, catalogOrigin: "https://catalog.test" });
+
+ expect(result.cleanupFailed).toBe(true);
+ expect(onCleanupError).toHaveBeenCalledWith(expect.any(Error), undefined);
+ });
+
+ it("uses collision-proof names for concurrent downloads and new names on retry", async () => {
+ const deps = dependencies();
+ const fetchAsset = deps.client.fetchAsset;
+ fetchAsset.mockRejectedValueOnce(new Error("offline"));
+ const run = createOpdsDownloadAdapter(deps as never);
+
+ await expect(run({ publication, catalogOrigin: "https://catalog.test" })).rejects.toMatchObject(
+ {
+ code: "download-failed",
+ },
+ );
+ await Promise.all([
+ run({ publication, catalogOrigin: "https://catalog.test" }),
+ run({ publication, catalogOrigin: "https://catalog.test" }),
+ ]);
+
+ const paths = deps.platform.writeFile.mock.calls.map(([path]) => path);
+ expect(new Set(paths).size).toBe(2);
+ const cleaned = deps.platform.deleteFile.mock.calls.map(([path]) => path);
+ expect(new Set(cleaned).size).toBe(3);
+ expect(deps.platform.deleteFile).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/packages/app-expo/src/screens/library/useOpdsDownload.ts b/packages/app-expo/src/screens/library/useOpdsDownload.ts
new file mode 100644
index 000000000..87d32f63a
--- /dev/null
+++ b/packages/app-expo/src/screens/library/useOpdsDownload.ts
@@ -0,0 +1,174 @@
+import {
+ type ImportBooksResult,
+ type OpdsAcquisition,
+ OpdsClient,
+ type OpdsCredentials,
+ type OpdsDownloadProgress,
+ OpdsError,
+ type OpdsPublication,
+ downloadOpdsAcquisition,
+ listSupportedAcquisitions,
+ toBookMeta,
+} from "@readany/core";
+import { type IPlatformService, getPlatformService } from "@readany/core/services";
+import { generateId } from "@readany/core/utils";
+import { useCallback } from "react";
+import { type MobileImportFile, useLibraryStore } from "../../stores/library-store";
+
+type OpdsDownloadPlatform = Pick<
+ IPlatformService,
+ "writeFile" | "deleteFile" | "mkdir" | "joinPath"
+>;
+
+export interface OpdsDownloadRequest {
+ publication: OpdsPublication;
+ acquisition?: OpdsAcquisition;
+ catalogOrigin: string;
+ credentials?: OpdsCredentials;
+ signal?: AbortSignal;
+ onProgress?: (progress: OpdsDownloadProgress) => void;
+ onImportStart?: () => void;
+}
+
+export interface OpdsImportDownloadResult {
+ importResult: ImportBooksResult;
+ cleanupFailed: boolean;
+}
+
+export interface OpdsDownloadAdapterDependencies {
+ platform: OpdsDownloadPlatform;
+ client: Pick;
+ importBooks(
+ files: MobileImportFile[],
+ options?: { transactional?: boolean },
+ ): Promise;
+ getTempDirectory(): Promise;
+ createId?(): string;
+ onCleanupError?(cleanupError: unknown, primaryError: unknown): void;
+}
+
+let temporaryFileSequence = 0;
+
+export function createOpdsDownloadUnmountGuard(cancel: () => void) {
+ let mounted = true;
+ let disposed = false;
+ return {
+ runIfMounted(update: () => void): void {
+ if (mounted) update();
+ },
+ dispose(): void {
+ if (disposed) return;
+ disposed = true;
+ mounted = false;
+ cancel();
+ },
+ };
+}
+
+function nextTemporaryName(format: string, createId?: () => string): string {
+ temporaryFileSequence += 1;
+ const id = createId?.() ?? generateId();
+ return `opds-${Date.now()}-${temporaryFileSequence}-${id}.${format}`;
+}
+
+function selectedFormat(request: OpdsDownloadRequest) {
+ const supported = listSupportedAcquisitions(request.publication);
+ if (!request.acquisition) {
+ if (supported.length === 1) return supported[0];
+ throw new OpdsError("unsupported-acquisition");
+ }
+ const selected = supported.find(
+ (choice) =>
+ choice.url === request.acquisition?.url &&
+ choice.type === request.acquisition.type &&
+ choice.rel.join("\u0000") === request.acquisition.rel.join("\u0000"),
+ );
+ if (!selected) throw new OpdsError("unsupported-acquisition");
+ return selected;
+}
+
+export function createOpdsDownloadAdapter(dependencies: OpdsDownloadAdapterDependencies) {
+ return async (request: OpdsDownloadRequest): Promise => {
+ const choice = selectedFormat(request);
+ let temporaryPath: string;
+ try {
+ const tempRoot = await dependencies.getTempDirectory();
+ const workspace = await dependencies.platform.joinPath(tempRoot, "readany-opds-import");
+ await dependencies.platform.mkdir(workspace);
+ temporaryPath = await dependencies.platform.joinPath(
+ workspace,
+ nextTemporaryName(choice.format, dependencies.createId),
+ );
+ } catch {
+ throw new OpdsError("download-failed");
+ }
+
+ let primaryError: unknown;
+ let importResult: ImportBooksResult | undefined;
+ let cleanupFailed = false;
+ try {
+ const downloaded = await downloadOpdsAcquisition({
+ ...request,
+ acquisition: request.acquisition,
+ client: dependencies.client,
+ platform: dependencies.platform,
+ destinationPath: temporaryPath,
+ });
+ if (request.signal?.aborted) throw new OpdsError("cancelled");
+ request.onImportStart?.();
+ try {
+ importResult = await dependencies.importBooks(
+ [
+ {
+ uri: temporaryPath,
+ name: downloaded.suggestedFileName,
+ metadata: toBookMeta(request.publication),
+ },
+ ],
+ { transactional: true },
+ );
+ } catch {
+ throw new OpdsError("import-failed");
+ }
+ if (importResult.failures.length > 0) throw new OpdsError("import-failed");
+ } catch (error) {
+ primaryError = error;
+ } finally {
+ try {
+ await dependencies.platform.deleteFile(temporaryPath);
+ } catch (cleanupError) {
+ cleanupFailed = true;
+ try {
+ dependencies.onCleanupError?.(cleanupError, primaryError);
+ } catch {
+ // Cleanup reporting is best effort and must never replace the operation result.
+ }
+ }
+ }
+
+ if (primaryError) throw primaryError;
+ if (!importResult) throw new OpdsError("import-failed");
+ return { importResult, cleanupFailed };
+ };
+}
+
+export function useOpdsDownload() {
+ const importBooks = useLibraryStore((state) => state.importBooks);
+ const download = useCallback(
+ async (request: OpdsDownloadRequest) => {
+ const platform = getPlatformService();
+ return createOpdsDownloadAdapter({
+ platform,
+ client: new OpdsClient(platform),
+ importBooks,
+ getTempDirectory: async () => (await import("expo-file-system")).Paths.cache.uri,
+ onCleanupError: () => {
+ console.warn("[OPDS] Temporary download cleanup failed.");
+ },
+ })(request);
+ },
+ [importBooks],
+ );
+
+ return { download };
+}
diff --git a/packages/app-expo/src/stores/library-store.opds.test.ts b/packages/app-expo/src/stores/library-store.opds.test.ts
new file mode 100644
index 000000000..e34c803df
--- /dev/null
+++ b/packages/app-expo/src/stores/library-store.opds.test.ts
@@ -0,0 +1,261 @@
+import type { Book } from "@readany/core/types";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ const existingPaths = new Set();
+ const platform = {
+ getAppDataDir: vi.fn(async () => "/app"),
+ joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
+ mkdir: vi.fn(async () => undefined),
+ readFile: vi.fn(async () => new Uint8Array([1, 2, 3])),
+ writeFile: vi.fn(async () => undefined),
+ deleteFile: vi.fn(async (_path: string) => undefined),
+ exists: vi.fn(async (path: string) => existingPaths.has(path)),
+ };
+ const db = {
+ initDatabase: vi.fn(async () => undefined),
+ insertBook: vi.fn(async () => undefined),
+ updateBook: vi.fn(async () => undefined),
+ getDeletedBookByFileHash: vi.fn(async () => null as Book | null),
+ getDeletedBookByTitle: vi.fn(async () => null as Book | null),
+ };
+ return {
+ platform,
+ db,
+ getInfoAsync: vi.fn(async () => ({
+ exists: true,
+ isDirectory: false,
+ size: 3,
+ md5: "same-hash",
+ })),
+ saveCover: vi.fn(async (bookId: string) => `covers/${bookId}.jpg`),
+ copied: [] as string[],
+ existingPaths,
+ };
+});
+
+vi.mock("@/lib/book/cover-storage", () => ({
+ getCoverFileExtension: () => "jpg",
+ saveCoverBytesToAppData: mocks.saveCover,
+}));
+vi.mock("@/lib/book/imported-book-meta", () => ({
+ shouldPersistEmbeddedCover: () => true,
+ buildImportedBookMeta: ({
+ existing,
+ opds,
+ embedded,
+ fallbackTitle,
+ }: {
+ existing?: Partial;
+ opds?: Partial;
+ embedded?: Partial;
+ fallbackTitle: string;
+ }) => ({
+ ...embedded,
+ ...opds,
+ ...existing,
+ title: existing?.title ?? opds?.title ?? embedded?.title ?? fallbackTitle,
+ author: existing?.author ?? opds?.author ?? embedded?.author ?? "",
+ }),
+}));
+vi.mock("@/lib/book/metadata-extractor", () => ({
+ createRangeReadableFile: vi.fn(),
+ extractBookMetadata: vi.fn(async () => ({
+ title: "Embedded title",
+ author: "Embedded author",
+ coverBytes: new Uint8Array([9]),
+ coverMimeType: "image/jpeg",
+ })),
+ extractBookMetadataFromFile: vi.fn(),
+}));
+vi.mock("@/lib/rag/auto-vectorize-service", () => ({ queueBook: vi.fn() }));
+vi.mock("@readany/core/db/database", () => mocks.db);
+vi.mock("@readany/core/db/write-retry", () => ({
+ runWithDbRetry: (operation: () => Promise) => operation(),
+}));
+vi.mock("@readany/core/services", () => ({ getPlatformService: () => mocks.platform }));
+vi.mock("./persist", () => ({ debouncedSave: vi.fn(), loadFromFS: vi.fn() }));
+vi.mock("./vector-model-store", () => ({
+ useVectorModelStore: {
+ getState: () => ({
+ autoVectorizeOnImport: false,
+ vectorModelEnabled: false,
+ hasVectorCapability: () => false,
+ }),
+ },
+}));
+vi.mock("expo-file-system/legacy", () => ({ getInfoAsync: mocks.getInfoAsync }));
+vi.mock("expo-file-system", () => ({
+ File: class {
+ exists: boolean;
+ constructor(private readonly path: string) {
+ this.exists = mocks.existingPaths.has(path);
+ }
+ copy(destination: { path: string }) {
+ mocks.copied.push(destination.path);
+ }
+ delete() {}
+ },
+}));
+
+import { useLibraryStore } from "./library-store";
+
+function book(overrides: Partial = {}): Book {
+ return {
+ id: "existing-id",
+ filePath: "books/existing-id.epub",
+ format: "epub",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0.5,
+ isVectorized: false,
+ vectorizeProgress: 0,
+ tags: ["user-tag"],
+ fileHash: "same-hash",
+ syncStatus: "local",
+ addedAt: 1,
+ updatedAt: 1,
+ ...overrides,
+ };
+}
+
+describe("mobile transactional OPDS imports", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.copied.length = 0;
+ mocks.existingPaths.clear();
+ mocks.db.getDeletedBookByFileHash.mockResolvedValue(null);
+ mocks.db.insertBook.mockResolvedValue(undefined);
+ mocks.db.updateBook.mockResolvedValue(undefined);
+ useLibraryStore.setState({ books: [], isImporting: false });
+ });
+
+ it("uses the real mobile MD5 to skip an existing duplicate", async () => {
+ useLibraryStore.setState({ books: [book()] });
+
+ const result = await useLibraryStore
+ .getState()
+ .importBooks([{ uri: "file:///cache/book.epub", name: "Catalog.epub" }], {
+ transactional: true,
+ });
+
+ expect(mocks.getInfoAsync).toHaveBeenCalledWith("file:///cache/book.epub", { md5: true });
+ expect(result.skippedDuplicates).toHaveLength(1);
+ expect(result.imported).toHaveLength(0);
+ expect(mocks.copied).toHaveLength(0);
+ });
+
+ it("rolls back the managed book and cover when durable insertion fails", async () => {
+ mocks.db.insertBook.mockRejectedValueOnce(new Error("insert failed"));
+ mocks.getInfoAsync.mockResolvedValueOnce({
+ exists: true,
+ isDirectory: false,
+ size: 3,
+ md5: "new-hash",
+ });
+
+ const result = await useLibraryStore.getState().importBooks(
+ [
+ {
+ uri: "file:///cache/book.epub",
+ name: "Catalog.epub",
+ metadata: { title: "Catalog title", author: "Catalog author" },
+ },
+ ],
+ { transactional: true },
+ );
+
+ expect(result.failures).toHaveLength(1);
+ expect(result.imported).toHaveLength(0);
+ expect(useLibraryStore.getState().books).toHaveLength(0);
+ expect(mocks.platform.deleteFile).toHaveBeenCalledTimes(2);
+ expect(mocks.platform.deleteFile.mock.calls.map(([path]) => path)).toEqual(
+ expect.arrayContaining([
+ expect.stringMatching(/^\/app\/books\/.*\.epub$/),
+ expect.stringMatching(/^\/app\/covers\/.*\.jpg$/),
+ ]),
+ );
+ });
+
+ it("preserves existing managed files and state when a strict restore update fails", async () => {
+ const deleted = book({ deletedAt: 10 });
+ const priorBooks = [book({ id: "visible-id", fileHash: "visible-hash" })];
+ useLibraryStore.setState({ books: priorBooks });
+ mocks.existingPaths.add("/app/books/existing-id.epub");
+ mocks.existingPaths.add("/app/covers/existing-id.jpg");
+ mocks.db.getDeletedBookByFileHash.mockResolvedValueOnce(deleted);
+ mocks.db.updateBook.mockRejectedValueOnce(new Error("update failed"));
+
+ const result = await useLibraryStore.getState().importBooks(
+ [
+ {
+ uri: "file:///cache/book.epub",
+ name: "Catalog.epub",
+ metadata: { title: "Catalog title", author: "Catalog author" },
+ },
+ ],
+ { transactional: true },
+ );
+
+ expect(result.failures).toHaveLength(1);
+ expect(useLibraryStore.getState().books).toEqual(priorBooks);
+ expect(mocks.copied[0]).toMatch(/^\/app\/books\/existing-id-.+\.epub$/);
+ expect(mocks.saveCover.mock.calls[0]?.[0]).toMatch(/^existing-id-.+/);
+ expect(mocks.platform.deleteFile.mock.calls.map(([path]) => path)).toEqual([
+ mocks.copied[0],
+ expect.stringMatching(/^\/app\/covers\/existing-id-.+\.jpg$/),
+ ]);
+ expect(mocks.platform.deleteFile).not.toHaveBeenCalledWith("/app/books/existing-id.epub");
+ expect(mocks.platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/existing-id.jpg");
+ });
+
+ it("restores to new managed paths while retaining pre-existing files", async () => {
+ const deleted = book({ deletedAt: 10 });
+ mocks.existingPaths.add("/app/books/existing-id.epub");
+ mocks.existingPaths.add("/app/covers/existing-id.jpg");
+ mocks.db.getDeletedBookByFileHash.mockResolvedValueOnce(deleted);
+
+ const result = await useLibraryStore.getState().importBooks(
+ [
+ {
+ uri: "file:///cache/book.epub",
+ name: "Catalog.epub",
+ metadata: { title: "Catalog title", author: "Catalog author" },
+ },
+ ],
+ { transactional: true },
+ );
+
+ expect(result.imported).toHaveLength(1);
+ expect(mocks.db.updateBook).toHaveBeenCalledTimes(1);
+ expect(useLibraryStore.getState().books[0]).toMatchObject({
+ id: "existing-id",
+ filePath: expect.stringMatching(/^books\/existing-id-.+\.epub$/),
+ tags: ["user-tag"],
+ meta: {
+ title: "Saved title",
+ author: "Saved author",
+ coverUrl: expect.stringMatching(/^covers\/existing-id-.+\.jpg$/),
+ },
+ });
+ expect(mocks.platform.deleteFile).not.toHaveBeenCalled();
+ });
+
+ it("does not title-match a deleted book after a conclusive hash miss", async () => {
+ mocks.getInfoAsync.mockResolvedValueOnce({
+ exists: true,
+ isDirectory: false,
+ size: 3,
+ md5: "different-valid-hash",
+ });
+
+ const result = await useLibraryStore
+ .getState()
+ .importBooks([{ uri: "file:///cache/book.epub", name: "Saved title.epub" }], {
+ transactional: true,
+ });
+
+ expect(mocks.db.getDeletedBookByFileHash).toHaveBeenCalledWith("different-valid-hash");
+ expect(mocks.db.getDeletedBookByTitle).not.toHaveBeenCalled();
+ expect(result.imported[0]).not.toMatchObject({ id: "existing-id" });
+ });
+});
diff --git a/packages/app-expo/src/stores/library-store.ts b/packages/app-expo/src/stores/library-store.ts
index d9014c14c..ef302f693 100644
--- a/packages/app-expo/src/stores/library-store.ts
+++ b/packages/app-expo/src/stores/library-store.ts
@@ -1,4 +1,7 @@
+import { getCoverFileExtension, saveCoverBytesToAppData } from "@/lib/book/cover-storage";
+import { buildImportedBookMeta, shouldPersistEmbeddedCover } from "@/lib/book/imported-book-meta";
import {
+ type ExtractedMeta,
createRangeReadableFile,
extractBookMetadata,
extractBookMetadataFromFile,
@@ -13,7 +16,14 @@ import {
import * as db from "@readany/core/db/database";
import { runWithDbRetry } from "@readany/core/db/write-retry";
import { getPlatformService } from "@readany/core/services";
-import type { Book, BookGroup, LibraryFilter, SortField, SortOrder } from "@readany/core/types";
+import type {
+ Book,
+ BookGroup,
+ BookMeta,
+ LibraryFilter,
+ SortField,
+ SortOrder,
+} from "@readany/core/types";
import { generateId } from "@readany/core/utils";
import { create } from "zustand";
import { debouncedSave, loadFromFS } from "./persist";
@@ -50,6 +60,16 @@ export interface RemoveBookOptions {
preserveData?: boolean;
}
+export interface MobileImportFile {
+ uri: string;
+ name?: string;
+ metadata?: Partial;
+}
+
+export interface ImportBooksOptions {
+ transactional?: boolean;
+}
+
function keepActiveGroupId(activeGroupId: string, groups: BookGroup[]): string {
if (!activeGroupId) return "";
return groups.some((group) => group.id === activeGroupId) ? activeGroupId : "";
@@ -79,7 +99,10 @@ export interface LibraryState {
setViewMode: (mode: LibraryViewMode) => void;
setSortField: (field: SortField) => void;
setSortOrder: (order: SortOrder) => void;
- importBooks: (files: Array<{ uri: string; name?: string }>) => Promise;
+ importBooks: (
+ files: MobileImportFile[],
+ options?: ImportBooksOptions,
+ ) => Promise;
inspectDeletedBookCandidate: (
bookId: string,
file: { uri: string; name?: string },
@@ -132,15 +155,44 @@ async function ensureAppSubDir(subDir: string): Promise {
}
}
-async function saveCoverToAppData(bookId: string, coverBlob: Blob): Promise {
+async function getMobileManagedDestination(
+ directory: "books" | "covers",
+ bookId: string,
+ extension: string,
+ avoidExisting: boolean,
+): Promise<{ relativePath: string; absPath: string; storageId: string; created: boolean }> {
const platform = getPlatformService();
- await ensureAppSubDir("covers");
- const ext = coverBlob.type.includes("png") ? "png" : "jpg";
- const relativePath = `covers/${bookId}.${ext}`;
- const absPath = await resolveAppPath(relativePath);
- const arrayBuffer = await coverBlob.arrayBuffer();
- await platform.writeFile(absPath, new Uint8Array(arrayBuffer));
- return relativePath;
+ let storageId = bookId;
+ let relativePath = `${directory}/${storageId}.${extension}`;
+ let absPath = await resolveAppPath(relativePath);
+ let exists = await platform.exists(absPath);
+
+ while (avoidExisting && exists) {
+ storageId = `${bookId}-${generateId()}`;
+ relativePath = `${directory}/${storageId}.${extension}`;
+ absPath = await resolveAppPath(relativePath);
+ exists = await platform.exists(absPath);
+ }
+
+ return { relativePath, absPath, storageId, created: !exists };
+}
+
+async function saveImportedMobileCover(input: {
+ bookId: string;
+ bytes: Uint8Array;
+ mimeType?: string | null;
+ avoidExisting: boolean;
+ createdManagedPaths: Set;
+}): Promise {
+ const extension = getCoverFileExtension(input.bytes, input.mimeType);
+ const destination = await getMobileManagedDestination(
+ "covers",
+ input.bookId,
+ extension,
+ input.avoidExisting,
+ );
+ if (destination.created) input.createdManagedPaths.add(destination.absPath);
+ return saveCoverBytesToAppData(destination.storageId, input.bytes, input.mimeType);
}
function bytesToBase64(bytes: Uint8Array): string {
@@ -159,10 +211,10 @@ const MOBILE_IMPORT_METADATA_MAX_BYTES = 32 * 1024 * 1024;
async function getMobileFileStat(path: string): Promise<{ size: number; md5?: string }> {
const LegacyFileSystem = await import("expo-file-system/legacy");
- const info = await LegacyFileSystem.getInfoAsync(path);
+ const info = await LegacyFileSystem.getInfoAsync(path, { md5: true });
return {
size: info.exists && !info.isDirectory ? (info.size ?? 0) : 0,
- md5: undefined,
+ md5: info.exists && !info.isDirectory ? info.md5 : undefined,
};
}
@@ -302,11 +354,13 @@ async function copyBookToAppData(
ext: string,
srcPath: string,
sourceBytes?: Uint8Array,
-): Promise<{ relativePath: string; absPath: string }> {
+ avoidExisting = false,
+): Promise<{ relativePath: string; absPath: string; created: boolean }> {
const platform = getPlatformService();
await ensureAppSubDir("books");
- const relativePath = `books/${bookId}.${ext}`;
- const absPath = await resolveAppPath(relativePath);
+ const destination = await getMobileManagedDestination("books", bookId, ext, avoidExisting);
+ const { relativePath, absPath } = destination;
+ let { created } = destination;
if (sourceBytes) {
// If bytes are already in memory (e.g. from hash calculation), just write them
@@ -317,11 +371,12 @@ async function copyBookToAppData(
const srcFile = new ExpoFS.File(srcPath);
const destFile = new ExpoFS.File(absPath);
if (destFile.exists) {
+ created = false;
destFile.delete();
}
srcFile.copy(destFile);
}
- return { relativePath, absPath };
+ return { relativePath, absPath, created };
}
async function persistBookUpdate(bookId: string, updates: Partial): Promise {
@@ -397,12 +452,11 @@ async function restoreDeletedMobileBook(
...originalBook,
filePath: relativePath,
format: "epub",
- meta: {
- ...originalBook.meta,
- title: conversion.bookTitle || originalBook.meta.title || fileName.replace(/\.\w+$/i, ""),
- author: originalBook.meta.author || "",
- coverUrl: originalBook.meta.coverUrl,
- },
+ meta: buildImportedBookMeta({
+ existing: originalBook.meta,
+ embedded: { title: conversion.bookTitle },
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ }),
deletedAt: undefined,
fileHash,
syncStatus: "local",
@@ -441,12 +495,9 @@ async function restoreDeletedMobileBook(
await platform.writeFile(await resolveAppPath(relativePath), conversion.epubBytes);
let coverUrl = originalBook.meta.coverUrl;
- if (conversion.coverBytes && conversion.coverBytes.length > 0) {
+ if (!coverUrl?.trim() && conversion.coverBytes && conversion.coverBytes.length > 0) {
try {
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.jpg`;
- await platform.writeFile(await resolveAppPath(coverRelPath), conversion.coverBytes);
- coverUrl = coverRelPath;
+ coverUrl = await saveCoverBytesToAppData(bookId, conversion.coverBytes);
} catch (coverErr) {
console.warn(`[restoreDeletedMobileBook] UMD cover save failed: ${coverErr}`);
}
@@ -456,12 +507,11 @@ async function restoreDeletedMobileBook(
...originalBook,
filePath: relativePath,
format: "umd",
- meta: {
- ...originalBook.meta,
- title: conversion.bookTitle || originalBook.meta.title || fileName.replace(/\.\w+$/i, ""),
- author: conversion.author || originalBook.meta.author || "",
- coverUrl,
- },
+ meta: buildImportedBookMeta({
+ existing: originalBook.meta,
+ embedded: { title: conversion.bookTitle, author: conversion.author, coverUrl },
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ }),
deletedAt: undefined,
fileHash,
syncStatus: "local",
@@ -474,9 +524,8 @@ async function restoreDeletedMobileBook(
const { relativePath } = await copyBookToAppData(bookId, ext || "epub", filePath);
- let title = originalBook.meta.title || fileName.replace(/\.\w+$/i, "") || "Untitled";
- let author = originalBook.meta.author || "";
let coverUrl = originalBook.meta.coverUrl;
+ let embeddedMeta: (ExtractedMeta & { coverUrl?: string }) | undefined;
try {
const meta = await extractMobileImportMetadata({
@@ -485,17 +534,14 @@ async function restoreDeletedMobileBook(
fileName,
fileSize,
});
- if (meta.title) title = meta.title;
- if (meta.author) author = meta.author;
-
- if (meta.coverBytes && meta.coverBytes.length > 0) {
- const mimeType = meta.coverMimeType || "image/jpeg";
- const coverExt = mimeType.includes("png") ? "png" : "jpg";
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.${coverExt}`;
- await platform.writeFile(await resolveAppPath(coverRelPath), meta.coverBytes);
- coverUrl = coverRelPath;
+ if (!coverUrl?.trim() && meta.coverBytes && meta.coverBytes.length > 0) {
+ try {
+ coverUrl = await saveCoverBytesToAppData(bookId, meta.coverBytes, meta.coverMimeType);
+ } catch (coverErr) {
+ console.warn(`[restoreDeletedMobileBook] Cover save failed for ${fileName}:`, coverErr);
+ }
}
+ embeddedMeta = { ...meta, coverUrl };
} catch (metaErr) {
console.warn(`[restoreDeletedMobileBook] Metadata extraction failed for ${fileName}:`, metaErr);
}
@@ -504,12 +550,11 @@ async function restoreDeletedMobileBook(
...originalBook,
filePath: relativePath,
format,
- meta: {
- ...originalBook.meta,
- title,
- author,
- coverUrl,
- },
+ meta: buildImportedBookMeta({
+ existing: originalBook.meta,
+ embedded: embeddedMeta,
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ }),
deletedAt: undefined,
fileHash,
syncStatus: "local",
@@ -835,7 +880,7 @@ export const useLibraryStore = create((set, get) => ({
setSortField: (field) => set((state) => ({ filter: { ...state.filter, sortField: field } })),
setSortOrder: (order) => set((state) => ({ filter: { ...state.filter, sortOrder: order } })),
- importBooks: async (files) => {
+ importBooks: async (files, options = {}) => {
set({ isImporting: true });
const result = createEmptyImportBooksResult();
const duplicateIndex = createImportDuplicateIndex(get().books);
@@ -843,6 +888,7 @@ export const useLibraryStore = create((set, get) => ({
await db.initDatabase();
for (const fileInfo of files) {
const filePath = fileInfo.uri;
+ const createdManagedPaths = new Set();
const originalName = fileInfo.name
? decodeURIComponent(fileInfo.name)
: decodeURIComponent(filePath.split("/").pop() || "book");
@@ -882,6 +928,45 @@ export const useLibraryStore = create((set, get) => ({
})
: null;
const bookId = deletedMatch?.id ?? generateId();
+ const persistEmbeddedCover = shouldPersistEmbeddedCover(
+ deletedMatch?.meta,
+ fileInfo.metadata,
+ );
+ const persistImport = async (book: Book): Promise => {
+ const restoreUpdates = {
+ filePath: book.filePath,
+ format: book.format,
+ meta: book.meta,
+ deletedAt: undefined,
+ progress: book.progress,
+ currentCfi: book.currentCfi,
+ isVectorized: false,
+ vectorizeProgress: 0,
+ tags: book.tags,
+ fileHash: book.fileHash,
+ syncStatus: "local" as const,
+ lastOpenedAt: Date.now(),
+ };
+
+ if (options.transactional) {
+ if (deletedMatch) {
+ await db.updateBook(book.id, restoreUpdates);
+ } else {
+ await db.insertBook(book);
+ }
+ set((state) => ({ books: [...state.books, book] }));
+ debouncedSave("library-books", get().books);
+ return;
+ }
+
+ if (deletedMatch) {
+ set((state) => ({ books: [...state.books, book] }));
+ await db.updateBook(book.id, restoreUpdates);
+ debouncedSave("library-books", get().books);
+ } else {
+ await get().addBook(book);
+ }
+ };
console.log(
`[importBooks] Importing: name=${fileName}, format=${format}, uri=${filePath}`,
@@ -935,23 +1020,29 @@ export const useLibraryStore = create((set, get) => ({
// Write EPUB bytes directly to final app data location
await ensureAppSubDir("books");
- const relativePath = `books/${bookId}.epub`;
- const absPath = await resolveAppPath(relativePath);
+ const destination = await getMobileManagedDestination(
+ "books",
+ bookId,
+ "epub",
+ options.transactional === true,
+ );
+ const { relativePath, absPath } = destination;
+ if (destination.created) createdManagedPaths.add(absPath);
await platform.writeFile(absPath, conversion.epubBytes);
// TXT-converted EPUBs have no cover, and title is already known from converter.
// Skip metadata extraction entirely — saves a full EPUB re-parse.
- const title = conversion.bookTitle || fileName.replace(/\.\w+$/i, "") || "Untitled";
+ const completeMeta = buildImportedBookMeta({
+ existing: deletedMatch?.meta,
+ opds: fileInfo.metadata,
+ embedded: { title: conversion.bookTitle },
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ });
const book: Book = {
id: bookId,
filePath: relativePath,
format: "epub",
- meta: {
- ...(deletedMatch?.meta ?? {}),
- title,
- author: "",
- coverUrl: deletedMatch?.meta.coverUrl,
- },
+ meta: completeMeta,
groupId: deletedMatch?.groupId,
progress: deletedMatch?.progress ?? 0,
currentCfi: deletedMatch?.currentCfi,
@@ -965,31 +1056,12 @@ export const useLibraryStore = create((set, get) => ({
lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(),
};
- if (deletedMatch) {
- set((state) => ({ books: [...state.books, book] }));
- await db.updateBook(book.id, {
- filePath: book.filePath,
- format: book.format,
- meta: book.meta,
- deletedAt: undefined,
- progress: book.progress,
- currentCfi: book.currentCfi,
- isVectorized: false,
- vectorizeProgress: 0,
- tags: book.tags,
- fileHash: book.fileHash,
- syncStatus: "local",
- lastOpenedAt: Date.now(),
- });
- debouncedSave("library-books", get().books);
- } else {
- await get().addBook(book);
- }
+ await persistImport(book);
result.imported.push(book);
if (fileHash) {
duplicateIndex.byHash.set(fileHash, book);
}
- console.log(`[importBooks] TXT imported as EPUB: ${title}`);
+ console.log(`[importBooks] TXT imported as EPUB: ${completeMeta.title}`);
// Auto-vectorize if enabled. Keep failures isolated so a
// successful import doesn't get reported as a failed import.
@@ -1046,35 +1118,45 @@ export const useLibraryStore = create((set, get) => ({
const conversion = await converter.convertToBytes({ file: umdFile });
await ensureAppSubDir("books");
- const relativePath = `books/${bookId}.epub`;
- const absPath = await resolveAppPath(relativePath);
+ const destination = await getMobileManagedDestination(
+ "books",
+ bookId,
+ "epub",
+ options.transactional === true,
+ );
+ const { relativePath, absPath } = destination;
+ if (destination.created) createdManagedPaths.add(absPath);
await platform.writeFile(absPath, conversion.epubBytes);
- let coverUrl: string | undefined;
- if (conversion.coverBytes && conversion.coverBytes.length > 0) {
+ let coverUrl = deletedMatch?.meta.coverUrl;
+ if (
+ persistEmbeddedCover &&
+ conversion.coverBytes &&
+ conversion.coverBytes.length > 0
+ ) {
try {
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.jpg`;
- const coverAbsPath = await resolveAppPath(coverRelPath);
- await platform.writeFile(coverAbsPath, conversion.coverBytes);
- coverUrl = coverRelPath;
+ coverUrl = await saveImportedMobileCover({
+ bookId,
+ bytes: conversion.coverBytes,
+ avoidExisting: options.transactional === true,
+ createdManagedPaths,
+ });
} catch (coverErr) {
console.warn(`[importBooks] Failed to save UMD cover for ${fileName}:`, coverErr);
}
}
- const title = conversion.bookTitle || fileName.replace(/\.\w+$/i, "") || "Untitled";
- const author = conversion.author || "";
+ const completeMeta = buildImportedBookMeta({
+ existing: deletedMatch?.meta,
+ opds: fileInfo.metadata,
+ embedded: { title: conversion.bookTitle, author: conversion.author, coverUrl },
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ });
const book: Book = {
id: bookId,
filePath: relativePath,
format: "umd",
- meta: {
- ...(deletedMatch?.meta ?? {}),
- title,
- author,
- coverUrl: coverUrl || deletedMatch?.meta.coverUrl,
- },
+ meta: completeMeta,
groupId: deletedMatch?.groupId,
progress: deletedMatch?.progress ?? 0,
currentCfi: deletedMatch?.currentCfi,
@@ -1088,31 +1170,12 @@ export const useLibraryStore = create((set, get) => ({
lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(),
};
- if (deletedMatch) {
- set((state) => ({ books: [...state.books, book] }));
- await db.updateBook(book.id, {
- filePath: book.filePath,
- format: book.format,
- meta: book.meta,
- deletedAt: undefined,
- progress: book.progress,
- currentCfi: book.currentCfi,
- isVectorized: false,
- vectorizeProgress: 0,
- tags: book.tags,
- fileHash: book.fileHash,
- syncStatus: "local",
- lastOpenedAt: Date.now(),
- });
- debouncedSave("library-books", get().books);
- } else {
- await get().addBook(book);
- }
+ await persistImport(book);
result.imported.push(book);
if (fileHash) {
duplicateIndex.byHash.set(fileHash, book);
}
- console.log(`[importBooks] UMD imported as EPUB: ${title}`);
+ console.log(`[importBooks] UMD imported as EPUB: ${completeMeta.title}`);
try {
const vmState = useVectorModelStore.getState();
@@ -1138,13 +1201,20 @@ export const useLibraryStore = create((set, get) => ({
}
}
- const { relativePath } = await copyBookToAppData(bookId, ext || "epub", filePath);
+ const copiedBook = await copyBookToAppData(
+ bookId,
+ ext || "epub",
+ filePath,
+ undefined,
+ options.transactional === true,
+ );
+ const { relativePath } = copiedBook;
+ if (copiedBook.created) createdManagedPaths.add(copiedBook.absPath);
console.log(`[importBooks] File copied. relativePath: ${relativePath}`);
- // Extract metadata (title, author, cover) from book content
- let title = fileName.replace(/\.\w+$/i, "") || "Untitled";
- let author = "";
- let coverUrl: string | undefined;
+ // Extract metadata and cover from book content.
+ let coverUrl = deletedMatch?.meta.coverUrl;
+ let embeddedMeta: (ExtractedMeta & { coverUrl?: string }) | undefined;
try {
console.log(`[importBooks] Extracting metadata for format=${format}...`);
@@ -1157,43 +1227,41 @@ export const useLibraryStore = create((set, get) => ({
console.log(
`[importBooks] Metadata result: title="${meta.title}", author="${meta.author}", hasCover=${!!meta.coverBytes}, coverSize=${meta.coverBytes?.length ?? 0}`,
);
- if (meta.title) title = meta.title;
- if (meta.author) author = meta.author;
-
// Save cover image to app data
- if (meta.coverBytes && meta.coverBytes.length > 0) {
+ if (persistEmbeddedCover && meta.coverBytes && meta.coverBytes.length > 0) {
try {
- const mimeType = meta.coverMimeType || "image/jpeg";
- const coverExt = mimeType.includes("png") ? "png" : "jpg";
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.${coverExt}`;
- const coverAbsPath = await resolveAppPath(coverRelPath);
- console.log(`[importBooks] Saving cover to: ${coverAbsPath}`);
- const platform = getPlatformService();
- await platform.writeFile(coverAbsPath, meta.coverBytes);
- coverUrl = coverRelPath;
+ coverUrl = await saveImportedMobileCover({
+ bookId,
+ bytes: meta.coverBytes,
+ mimeType: meta.coverMimeType,
+ avoidExisting: options.transactional === true,
+ createdManagedPaths,
+ });
+ console.log(`[importBooks] Saving cover to: ${coverUrl}`);
console.log(`[importBooks] Cover saved. coverUrl=${coverUrl}`);
} catch (coverErr) {
console.warn(`[importBooks] Failed to save cover for ${fileName}:`, coverErr);
}
}
+ embeddedMeta = { ...meta, coverUrl };
} catch (metaErr) {
console.warn(`[importBooks] Metadata extraction failed for ${fileName}:`, metaErr);
}
+ const completeMeta = buildImportedBookMeta({
+ existing: deletedMatch?.meta,
+ opds: fileInfo.metadata,
+ embedded: embeddedMeta,
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ });
console.log(
- `[importBooks] Final book: title="${title}", author="${author}", coverUrl="${coverUrl}"`,
+ `[importBooks] Final book: title="${completeMeta.title}", author="${completeMeta.author}", coverUrl="${completeMeta.coverUrl}"`,
);
const book: Book = {
id: bookId,
filePath: relativePath,
format,
- meta: {
- ...(deletedMatch?.meta ?? {}),
- title,
- author,
- coverUrl: coverUrl || deletedMatch?.meta.coverUrl,
- },
+ meta: completeMeta,
groupId: deletedMatch?.groupId,
progress: deletedMatch?.progress ?? 0,
currentCfi: deletedMatch?.currentCfi,
@@ -1206,26 +1274,7 @@ export const useLibraryStore = create((set, get) => ({
updatedAt: Date.now(),
lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(),
};
- if (deletedMatch) {
- set((state) => ({ books: [...state.books, book] }));
- await db.updateBook(book.id, {
- filePath: book.filePath,
- format: book.format,
- meta: book.meta,
- deletedAt: undefined,
- progress: book.progress,
- currentCfi: book.currentCfi,
- isVectorized: false,
- vectorizeProgress: 0,
- tags: book.tags,
- fileHash: book.fileHash,
- syncStatus: "local",
- lastOpenedAt: Date.now(),
- });
- debouncedSave("library-books", get().books);
- } else {
- await get().addBook(book);
- }
+ await persistImport(book);
result.imported.push(book);
if (fileHash) {
duplicateIndex.byHash.set(fileHash, book);
@@ -1269,6 +1318,12 @@ export const useLibraryStore = create((set, get) => ({
);
}
} catch (err) {
+ if (options.transactional) {
+ const platform = getPlatformService();
+ for (const path of createdManagedPaths) {
+ await platform.deleteFile(path).catch(() => undefined);
+ }
+ }
console.error(`Failed to import ${fileInfo.uri}:`, err);
result.failures.push({
name: originalName,
diff --git a/packages/app/package.json b/packages/app/package.json
index 65791aa10..bbb66c381 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -77,6 +77,8 @@
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.10.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.4",
"@types/d3": "^7.4.3",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
@@ -84,6 +86,7 @@
"@types/react-dom": "^19.1.6",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.6.0",
+ "jsdom": "^30.0.1",
"tailwindcss": "^4.0.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
diff --git a/packages/app/src-tauri/Cargo.lock b/packages/app/src-tauri/Cargo.lock
index 40dd98cb1..e53c659d0 100644
--- a/packages/app/src-tauri/Cargo.lock
+++ b/packages/app/src-tauri/Cargo.lock
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -73,6 +84,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"dashmap",
+ "keyring",
"local-ip-address",
"rusqlite",
"serde",
@@ -375,6 +387,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.6.2"
@@ -512,6 +533,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.2.56"
@@ -573,6 +603,16 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -901,6 +941,35 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+[[package]]
+name = "dbus"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dbus-secret-service"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
+dependencies = [
+ "aes",
+ "block-padding",
+ "cbc",
+ "dbus",
+ "fastrand",
+ "hkdf",
+ "num",
+ "once_cell",
+ "sha2",
+ "zeroize",
+]
+
[[package]]
name = "der"
version = "0.7.10"
@@ -2213,6 +2282,16 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2342,6 +2421,23 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "byteorder",
+ "dbus-secret-service",
+ "linux-keyutils",
+ "log",
+ "secret-service",
+ "security-framework 2.11.1",
+ "security-framework 3.7.0",
+ "windows-sys 0.60.2",
+ "zeroize",
+]
+
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2399,6 +2495,15 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
[[package]]
name = "libloading"
version = "0.7.4"
@@ -2437,6 +2542,16 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "linux-keyutils"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
+dependencies = [
+ "bitflags 2.11.0",
+ "libc",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2680,6 +2795,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.11.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2696,6 +2824,30 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -2712,6 +2864,15 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -2738,6 +2899,17 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3977,7 +4149,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
- "security-framework",
+ "security-framework 3.7.0",
]
[[package]]
@@ -4005,7 +4177,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
- "security-framework",
+ "security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -4115,6 +4287,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "secret-service"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
+dependencies = [
+ "aes",
+ "cbc",
+ "futures-util",
+ "generic-array",
+ "hkdf",
+ "num",
+ "once_cell",
+ "rand 0.8.5",
+ "serde",
+ "sha2",
+ "zbus 4.4.0",
+]
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.11.0",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4732,6 +4936,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -5150,7 +5360,7 @@ dependencies = [
"thiserror 2.0.18",
"url",
"windows",
- "zbus",
+ "zbus 5.14.0",
]
[[package]]
@@ -5175,7 +5385,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
- "zbus",
+ "zbus 5.14.0",
]
[[package]]
@@ -6993,6 +7203,16 @@ dependencies = [
"rustix",
]
+[[package]]
+name = "xdg-home"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "yoke"
version = "0.8.1"
@@ -7016,6 +7236,38 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
+dependencies = [
+ "async-broadcast",
+ "async-process",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "hex",
+ "nix",
+ "ordered-stream",
+ "rand 0.8.5",
+ "serde",
+ "serde_repr",
+ "sha1",
+ "static_assertions",
+ "tracing",
+ "uds_windows",
+ "windows-sys 0.52.0",
+ "xdg-home",
+ "zbus_macros 4.4.0",
+ "zbus_names 3.0.0",
+ "zvariant 4.2.0",
+]
+
[[package]]
name = "zbus"
version = "5.14.0"
@@ -7046,9 +7298,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.14",
- "zbus_macros",
- "zbus_names",
- "zvariant",
+ "zbus_macros 5.14.0",
+ "zbus_names 4.3.1",
+ "zvariant 5.10.0",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
+dependencies = [
+ "proc-macro-crate 3.4.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils 2.1.0",
]
[[package]]
@@ -7061,9 +7326,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
- "zbus_names",
- "zvariant",
- "zvariant_utils",
+ "zbus_names 4.3.1",
+ "zvariant 5.10.0",
+ "zvariant_utils 3.3.0",
+]
+
+[[package]]
+name = "zbus_names"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
+dependencies = [
+ "serde",
+ "static_assertions",
+ "zvariant 4.2.0",
]
[[package]]
@@ -7074,7 +7350,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.14",
- "zvariant",
+ "zvariant 5.10.0",
]
[[package]]
@@ -7123,6 +7399,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
[[package]]
name = "zerotrie"
@@ -7175,6 +7465,19 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zvariant"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zvariant_derive 4.2.0",
+]
+
[[package]]
name = "zvariant"
version = "5.10.0"
@@ -7185,8 +7488,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 0.7.14",
- "zvariant_derive",
- "zvariant_utils",
+ "zvariant_derive 5.10.0",
+ "zvariant_utils 3.3.0",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
+dependencies = [
+ "proc-macro-crate 3.4.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils 2.1.0",
]
[[package]]
@@ -7199,7 +7515,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
- "zvariant_utils",
+ "zvariant_utils 3.3.0",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
diff --git a/packages/app/src-tauri/Cargo.toml b/packages/app/src-tauri/Cargo.toml
index 77691d175..5654a4e5b 100644
--- a/packages/app/src-tauri/Cargo.toml
+++ b/packages/app/src-tauri/Cargo.toml
@@ -37,4 +37,5 @@ tower-http = { version = "0.6.8", features = ["cors"] }
dashmap = "6.1.0"
uuid = { version = "1.22.0", features = ["v4"] }
base64 = "0.22.1"
+keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
tauri-plugin-window-state = "2.4.1"
diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs
index 382c6a415..df63026c3 100644
--- a/packages/app/src-tauri/src/lib.rs
+++ b/packages/app/src-tauri/src/lib.rs
@@ -1,5 +1,6 @@
mod db;
mod readany_cli;
+mod secrets;
mod storage;
mod sync;
mod vector;
@@ -45,6 +46,9 @@ pub fn run() {
vector::vector_reinit,
vector::vector_shutdown,
readany_cli::readany_cli_run,
+ secrets::secret_get,
+ secrets::secret_set,
+ secrets::secret_remove,
])
.setup(|app| {
let app_handle = app.handle().clone();
diff --git a/packages/app/src-tauri/src/secrets.rs b/packages/app/src-tauri/src/secrets.rs
new file mode 100644
index 000000000..c5943d742
--- /dev/null
+++ b/packages/app/src-tauri/src/secrets.rs
@@ -0,0 +1,140 @@
+const SERVICE: &str = "ReadAny";
+const ACCOUNT_PREFIX: &str = "opds.catalog.";
+const ACCOUNT_SUFFIX: &str = ".password";
+
+#[derive(Clone, Copy)]
+enum SecretOperation {
+ Get,
+ Set,
+ Remove,
+}
+
+fn redacted_error(operation: SecretOperation) -> String {
+ match operation {
+ SecretOperation::Get => "Failed to read secret",
+ SecretOperation::Set => "Failed to store secret",
+ SecretOperation::Remove => "Failed to remove secret",
+ }
+ .to_string()
+}
+
+fn credential_account(key: &str) -> Result {
+ let catalog_id = key
+ .strip_prefix(ACCOUNT_PREFIX)
+ .and_then(|value| value.strip_suffix(ACCOUNT_SUFFIX))
+ .ok_or_else(|| "Invalid secret key".to_string())?;
+ let id = uuid::Uuid::parse_str(catalog_id).map_err(|_| "Invalid secret key".to_string())?;
+ if id.get_version_num() != 4 || id.to_string() != catalog_id {
+ return Err("Invalid secret key".to_string());
+ }
+ Ok(format!("{ACCOUNT_PREFIX}{catalog_id}{ACCOUNT_SUFFIX}"))
+}
+
+#[tauri::command]
+pub fn secret_set(key: String, value: String) -> Result<(), String> {
+ let account = credential_account(&key)?;
+ let entry =
+ keyring::Entry::new(SERVICE, &account).map_err(|_| redacted_error(SecretOperation::Set))?;
+ entry
+ .set_password(&value)
+ .map_err(|_| redacted_error(SecretOperation::Set))
+}
+
+#[tauri::command]
+pub fn secret_get(key: String) -> Result
- {draftActionResult.ok && parseDraftCreateResult(draftActionResult)?.ok ? (
+ {draftActionResult.ok &&
+ parseDraftCreateResult(draftActionResult)?.ok ? (