diff --git a/app/scripts/widget-tests/main.swift b/app/scripts/widget-tests/main.swift index 79d1721..c46d7d9 100644 --- a/app/scripts/widget-tests/main.swift +++ b/app/scripts/widget-tests/main.swift @@ -29,3 +29,21 @@ let importedPage = Data(#"{"items":[{"document":"goodreads.101","title":"Importe let page = try JSONDecoder().decode(WidgetBooksPage.self, from: importedPage) check(page.items.first?.document == "goodreads.101" && page.items.first?.percentage == nil && page.next == "100:next", "history-only books decode with nullable progress and paging") print("Widget metrics: 11 checks passed") + +// A provider refresh must notice a cover-only app publication, even with identical JSON. +let snapshotDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) +try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: snapshotDir) } +let jsonFile = snapshotDir.appendingPathComponent("widget.json") +let coverFile = snapshotDir.appendingPathComponent("cover.png") +try legacy.write(to: jsonFile) +let missingCover = WidgetSnapshot.read(in: snapshotDir)! +check(missingCover.cover == nil, "refresh starts without a cover") +try Data("restored by app".utf8).write(to: coverFile) +let recoveredCover = WidgetSnapshot.read(in: snapshotDir)! +check(recoveredCover.json == missingCover.json, "app publishes identical JSON") +check(recoveredCover != missingCover, "cover-only publication supersedes failed provider download") +check(WidgetSnapshot.read(in: snapshotDir) == recoveredCover, "unchanged bytes permit refresh") +try Data("corrected by app".utf8).write(to: coverFile) +check(WidgetSnapshot.read(in: snapshotDir) != recoveredCover, "replacement cover supersedes provider download") +print("Widget snapshot: 5 checks passed") diff --git a/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/ReadingWidget.kt b/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/ReadingWidget.kt index f0f41ab..77c8362 100644 --- a/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/ReadingWidget.kt +++ b/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/ReadingWidget.kt @@ -86,7 +86,15 @@ open class ReadingWidget : AppWidgetProvider() { fun saveFromApp(context: Context, json: String, coverBase64: String) { synchronized(lock) { val file = File(context.filesDir, COVER) - if (coverBase64.isEmpty()) file.delete() else file.writeBytes(Base64.decode(coverBase64, Base64.DEFAULT)) + val incoming = JSONObject(json) + val previous = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("data", null)?.let { JSONObject(it) } + val session = incoming.optJSONObject("session") + val oldSession = previous?.optJSONObject("session") + val sameCover = previous != null && session != null && oldSession != null && + listOf("server", "username").all { !session.isNull(it) && session.optString(it) == oldSession.optString(it) } && + listOf("book_document", "cover_url").all { !incoming.isNull(it) && incoming.optString(it) == previous.optString(it) } + if (coverBase64.isNotEmpty()) file.writeBytes(Base64.decode(coverBase64, Base64.DEFAULT)) + else if (!sameCover) file.delete() context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("data", json).apply() } refresh(context) @@ -106,9 +114,13 @@ open class ReadingWidget : AppWidgetProvider() { } private fun fetch(context: Context): Boolean { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val original = synchronized(lock) { prefs.getString("data", null) } ?: return false - var data = JSONObject(original) + val original = synchronized(lock) { + val json = prefs.getString("data", null) ?: return false + json to runCatching { File(context.filesDir, COVER).readBytes() }.getOrNull() + } + var data = JSONObject(original.first) val session = data.optJSONObject("session") ?: return false + val hasCover = original.second != null var changedCover = false var cover: ByteArray? = null try { @@ -140,6 +152,7 @@ open class ReadingWidget : AppWidgetProvider() { allBooks(session)?.let { items -> loaded++ val current = (0 until items.length()).map { items.getJSONObject(it) }.firstOrNull { it.optString("status") == "reading" && title(it) != null } + val oldDocument = data.optString("book_document") data.put("label", if (current == null) "CROSSPOINT SYNC" else "CONTINUE READING") .put("title", current?.let { title(it) } ?: "Nothing in progress") .put("author", current?.takeUnless { it.isNull("author") }?.optString("author") ?: "") @@ -147,9 +160,9 @@ open class ReadingWidget : AppWidgetProvider() { .put("book_document", current?.optString("document") ?: JSONObject.NULL) val url = current?.takeUnless { it.isNull("cover_url") }?.optString("cover_url") val old = data.takeUnless { it.isNull("cover_url") }?.optString("cover_url") - changedCover = url != old + changedCover = url != old || current?.optString("document") != oldDocument || !hasCover if (changedCover && url != null) cover = runCatching { coverPng(url) }.getOrNull() - if (!changedCover || cover != null || url == null) data.put("cover_url", url ?: JSONObject.NULL) + data.put("cover_url", url ?: JSONObject.NULL) } if (loaded == 0) return false if (loaded == 3) data.put("updated_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(java.util.Date())) @@ -158,7 +171,9 @@ open class ReadingWidget : AppWidgetProvider() { changedCover = true } synchronized(lock) { - if (prefs.getString("data", null) != original) return false + // Cover-only app publications also supersede this network refresh. + val currentCover = runCatching { File(context.filesDir, COVER).readBytes() }.getOrNull() + if (prefs.getString("data", null) != original.first || !java.util.Arrays.equals(currentCover, original.second)) return false if (changedCover) { val file = File(context.filesDir, COVER) if (cover == null) file.delete() else file.writeBytes(cover!!) diff --git a/app/src-tauri/gen/apple/CrossPointWidget/CrossPointWidget.swift b/app/src-tauri/gen/apple/CrossPointWidget/CrossPointWidget.swift index 8f0f58c..0472bc7 100644 --- a/app/src-tauri/gen/apple/CrossPointWidget/CrossPointWidget.swift +++ b/app/src-tauri/gen/apple/CrossPointWidget/CrossPointWidget.swift @@ -92,9 +92,10 @@ struct Provider: TimelineProvider { return try? JSONDecoder().decode(T.self, from: body) } private func refresh() async { - guard let original = locked({ try? Data(contentsOf: $0.appendingPathComponent("widget.json")) }) ?? nil, - var data = try? JSONDecoder().decode(ReadingData.self, from: original), let session = data.session else { return } + guard let original = locked({ WidgetSnapshot.read(in: $0) }) ?? nil, + var data = try? JSONDecoder().decode(ReadingData.self, from: original.json), let session = data.session else { return } let tz = -TimeZone.current.secondsFromGMT() / 60 + let hasCover = original.cover != nil var newCover: Data? var coverChanged = false do { @@ -128,15 +129,15 @@ struct Provider: TimelineProvider { data.title = current?.title ?? current?.filename ?? "Nothing in progress" data.author = current?.author ?? "" data.percent = current.map { min(100, max(0, Int((($0.percentage ?? 0) * 100).rounded()))) } ?? -1 + coverChanged = current?.document != data.book_document || current?.cover_url != data.cover_url || !hasCover data.book_document = current?.document - coverChanged = current?.cover_url != data.cover_url if coverChanged, let link = current?.cover_url, let url = URL(string: link), let (bytes, response) = try? await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 10)), (response as? HTTPURLResponse)?.statusCode == 200, let image = UIImage(data: bytes) { newCover = coverPNG(image) } // A failed cover remains retryable on the next refresh. - if !coverChanged || newCover != nil || current?.cover_url == nil { data.cover_url = current?.cover_url } + data.cover_url = current?.cover_url } if overview != nil && books != nil && goals != nil { data.updated_at = ISO8601DateFormatter().string(from: Date()) } } catch { @@ -145,8 +146,8 @@ struct Provider: TimelineProvider { } locked { dir in let file = dir.appendingPathComponent("widget.json") - // App changes, goal edits and logout all supersede this refresh. - guard (try? Data(contentsOf: file)) == original else { return } + // Include image bytes: the app can restore a cover with identical JSON. + guard WidgetSnapshot.read(in: dir) == original else { return } if coverChanged { let cover = dir.appendingPathComponent("cover.png") if let newCover { try? newCover.write(to: cover, options: .atomic) } diff --git a/app/src-tauri/gen/apple/CrossPointWidget/ReadingMetrics.swift b/app/src-tauri/gen/apple/CrossPointWidget/ReadingMetrics.swift index 1ce1ca9..783a96e 100644 --- a/app/src-tauri/gen/apple/CrossPointWidget/ReadingMetrics.swift +++ b/app/src-tauri/gen/apple/CrossPointWidget/ReadingMetrics.swift @@ -1,5 +1,17 @@ import Foundation +// Read and compare under the provider's snapshot lock. Cover-only app writes +// supersede in-flight network work just like a change to the JSON snapshot. +struct WidgetSnapshot: Equatable { + let json: Data + let cover: Data? + + static func read(in dir: URL) -> WidgetSnapshot? { + guard let json = try? Data(contentsOf: dir.appendingPathComponent("widget.json")) else { return nil } + return WidgetSnapshot(json: json, cover: try? Data(contentsOf: dir.appendingPathComponent("cover.png"))) + } +} + struct WidgetBook: Decodable { let document: String let title: String? diff --git a/app/src-tauri/src/widget.rs b/app/src-tauri/src/widget.rs index 2b0b02f..0b4bdc5 100644 --- a/app/src-tauri/src/widget.rs +++ b/app/src-tauri/src/widget.rs @@ -22,7 +22,13 @@ pub fn update_widget(json: String, cover: String) -> Result<(), String> { let guard = Lock(file); let cover_path = dir.join("cover.png"); if cover.is_empty() { - let _ = std::fs::remove_file(cover_path); + let incoming = serde_json::from_str::(&json).map_err(|e| e.to_string())?; + let previous = std::fs::read(dir.join("widget.json")).ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + // An offline app update may have no image bytes. Keep the saved cover + // only when its account, document and exact URL still identify this book. + let same_cover = previous.as_ref().is_some_and(|old| matching_cover(&incoming, old)); + if !same_cover { let _ = std::fs::remove_file(cover_path); } } else { let temp = dir.join("cover.tmp"); std::fs::write(&temp, STANDARD.decode(cover).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; @@ -73,3 +79,44 @@ pub fn update_shortcuts(app: tauri::AppHandle, json: Option) -> Result<( let _ = (app, json); Ok(()) } + +// Kept independent of the iOS filesystem so identity boundaries are testable. +#[cfg(any(target_os = "ios", test))] +fn matching_cover(incoming: &serde_json::Value, previous: &serde_json::Value) -> bool { + // Credentials can rotate without changing the account that owns the image. + ["server", "username"].iter().all(|key| + incoming["session"][*key].is_string() && incoming["session"][*key] == previous["session"][*key]) && + ["book_document", "cover_url"].iter().all(|key| + !incoming[*key].is_null() && incoming[*key] == previous[*key]) +} + +#[cfg(test)] +mod cover_tests { + use super::matching_cover; + use serde_json::json; + + #[test] + fn retains_only_the_exact_account_book_and_cover() { + let saved = json!({"session": {"server": "https://sync.test", "username": "reader", "key": "secret"}, + "book_document": "book-a", "cover_url": "https://covers.test/a.jpg", "percent": 40}); + let mut updated = saved.clone(); + updated["percent"] = json!(50); + assert!(matching_cover(&updated, &saved)); + updated["session"]["key"] = json!("rotated-secret"); + assert!(matching_cover(&updated, &saved), "password rotation keeps the same cover"); + for (key, value) in [("book_document", json!("book-b")), ("cover_url", json!("https://covers.test/fixed.jpg")), + ("session", json!({"server": "https://sync.test", "username": "other", "key": "secret"})), + ("session", json!({"server": "https://other.test", "username": "reader", "key": "secret"}))] { + let mut changed = updated.clone(); + changed[key] = value; + assert!(!matching_cover(&changed, &saved), "changed {key}"); + } + for key in ["session", "book_document", "cover_url"] { + let mut cleared = updated.clone(); + cleared.as_object_mut().unwrap().remove(key); + assert!(!matching_cover(&cleared, &saved), "missing {key}"); + assert!(!matching_cover(&saved, &cleared), "legacy missing {key}"); + } + assert!(!matching_cover(&json!({}), &saved)); + } +} diff --git a/app/src/Cover.test.jsx b/app/src/Cover.test.jsx index 786c24f..4685a02 100644 --- a/app/src/Cover.test.jsx +++ b/app/src/Cover.test.jsx @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -vi.mock('./api.js', () => ({ isApp: false, api: { cover: vi.fn() } })) +vi.mock('./api.js', () => ({ isApp: true, api: { cover: vi.fn() } })) +vi.mock('./coverCache.js', () => ({ coverBlob: vi.fn().mockResolvedValue(new Blob()) })) +const { coverBlob } = await import('./coverCache.js') const { api } = await import('./api.js') const { Cover } = await import('./ui.jsx') @@ -17,6 +19,13 @@ afterEach(() => { }) describe('Cover', () => { + it('warms the widget cache with the displayed URL, including a corrected cover', () => { + const { rerender } = render() + expect(coverBlob).toHaveBeenLastCalledWith(chamber.cover_url) + rerender() + expect(coverBlob).toHaveBeenLastCalledWith(quest.cover_url) + }) + it('follows a new book in the same slot (offline first paint, then the fresh list)', () => { const { container, rerender } = render() expect(src(container)).toBe(chamber.cover_url) diff --git a/app/src/api.js b/app/src/api.js index bad1894..75ff3ec 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -115,7 +115,7 @@ function clearOffline(session) { async function call(session, path, init = {}, keepOffline = true, { offlineFallback = true, timestampSaved = false, store = true, cachePath = path } = {}) { const version = sessionVersion const reading = readingVersion - const readingGet = (!init.method || init.method === 'GET') && /^\/api\/v1\/(clippings|progress)([/?]|$)/.test(path) + const readingGet = (!init.method || init.method === 'GET') && (/^\/api\/v1\/(clippings|progress)([/?]|$)/.test(path) || /^\/api\/v1\/documents\/[^/]+\/cover$/.test(path)) const key = `${OFFLINE}${session.username}@${session.server}${cachePath}` const isGet = keepOffline && (!init.method || init.method === 'GET') let res @@ -298,6 +298,8 @@ function invalidateClippingCaches(s, documents) { const PROGRESS = '/api/v1/progress?limit=500' function patchCachedBook(session, document, field, result) { + // A title/author correction or explicit cover clear also invalidates lookups. + if (field === 'info') localStorage.removeItem(`${OFFLINE}${session.username}@${session.server}/api/v1/documents/${document}/cover`) const key = `${OFFLINE}${session.username}@${session.server}${PROGRESS}` const progress = read(key) if (!progress?.items.some((b) => b.document === document)) return @@ -329,6 +331,7 @@ function patchCachedBook(session, document, field, result) { } function removeCachedBook(session, document) { + localStorage.removeItem(`${OFFLINE}${session.username}@${session.server}/api/v1/documents/${document}/cover`) const prefix = `${OFFLINE}${session.username}@${session.server}` const key = `${prefix}${PROGRESS}` const progress = read(key) @@ -539,6 +542,7 @@ async function changeReadingGoal(s, year, init) { } export const cached = { + cover: (s, doc) => saved(s, `/api/v1/documents/${doc}/cover`)?.url ?? null, readingGoals: (s) => saved(s, GOALS_CACHE) ?? saved(s, goalsPath()), books: (s) => { const r = saved(s, PROGRESS) diff --git a/app/src/api.test.js b/app/src/api.test.js index 51aa73e..5e6dfac 100644 --- a/app/src/api.test.js +++ b/app/src/api.test.js @@ -369,3 +369,19 @@ it('uploads history as multipart data and only clears cached data after a succes await api.importHistory(s, file, 'UTC', {}) expect(localStorage.getItem(key)).toBeNull() }) + +it('invalidates a looked-up cover on metadata edits and rejects a pre-edit lookup', async () => { + const s = session('reader') + fetchMock.mockResolvedValueOnce(response({ url: 'https://covers.test/wrong.jpg' })) + await api.cover(s, 'book') + expect(cached.cover(s, 'book')).toBe('https://covers.test/wrong.jpg') + let finish + fetchMock.mockReturnValueOnce(new Promise(resolve => { finish = resolve })) + const pending = api.cover(s, 'book') + fetchMock.mockResolvedValueOnce(response({ document: 'book', cover_url: null })) + await api.setInfo(s, 'book', { cover_url: null }) + expect(cached.cover(s, 'book')).toBeNull() + finish(response({ url: 'https://covers.test/wrong.jpg' })) + await expect(pending).rejects.toMatchObject({ stale: true }) + expect(cached.cover(s, 'book')).toBeNull() +}) diff --git a/app/src/coverCache.js b/app/src/coverCache.js new file mode 100644 index 0000000..63e1156 --- /dev/null +++ b/app/src/coverCache.js @@ -0,0 +1,30 @@ +import { http } from './api.js' + +// Shared by library covers and widget publishing. Exact URLs keep corrected +// covers separate; never fall back to the last image downloaded for another book. +export async function coverBlob(url) { + let cache + try { + cache = await caches.open('crosspoint-covers-v1') + const saved = await cache.match(url) + if (saved) return await saved.blob() + } catch { /* Storage may be unavailable in a WebView. */ } + // The app's requests use the WebView cache, unlike Tauri's HTTP client. + let blob + const validate = async bytes => { + const image = await createImageBitmap(bytes) + image.close() + return bytes + } + try { + const response = await fetch(url, { cache: 'force-cache' }) + if (response.ok) blob = await validate(await response.blob()) + } catch { /* CORS, offline, or a cached non-image response. */ } + if (!blob) { + const response = await http(url) + if (!response.ok) throw new Error('Cover unavailable') + blob = await validate(await response.blob()) + } + try { await cache?.put(url, new Response(blob)) } catch { /* Best effort. */ } + return blob +} diff --git a/app/src/coverCache.test.js b/app/src/coverCache.test.js new file mode 100644 index 0000000..5af84af --- /dev/null +++ b/app/src/coverCache.test.js @@ -0,0 +1,49 @@ +// @vitest-environment node +import { Blob } from 'node:buffer' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +vi.mock('./api.js', () => ({ http: vi.fn() })) +const { http } = await import('./api.js') +const { coverBlob } = await import('./coverCache.js') +let saved +beforeEach(() => { + saved = new Map() + vi.stubGlobal('caches', { open: vi.fn(async () => ({ match: async url => saved.get(url), put: async (url, response) => saved.set(url, response) })) }) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + http.mockReset() + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ close() {} })) +}) +afterEach(() => vi.unstubAllGlobals()) +it('reuses an app-cached cover offline and never substitutes another URL', async () => { + const blob = new Blob(['cover A']) + http.mockResolvedValueOnce(new Response(blob)) + expect(await (await coverBlob('https://covers.test/a')).text()).toBe('cover A') + http.mockRejectedValue(new Error('offline')) + expect(await (await coverBlob('https://covers.test/a')).text()).toBe('cover A') + await expect(coverBlob('https://covers.test/b')).rejects.toThrow('offline') + expect(http).toHaveBeenCalledTimes(2) +}) +it('can import a cover from the WebView HTTP cache', async () => { + fetch.mockResolvedValue(new Response(new Blob(['webview cover']))) + expect(await (await coverBlob('https://covers.test/c')).text()).toBe('webview cover') + expect(fetch).toHaveBeenCalledWith('https://covers.test/c', { cache: 'force-cache' }) + expect(http).not.toHaveBeenCalled() +}) +it('still downloads when persistent cache storage is unavailable', async () => { + caches.open.mockRejectedValue(new Error('storage unavailable')) + http.mockResolvedValue(new Response(new Blob(['cover']))) + expect(await (await coverBlob('https://covers.test/d')).text()).toBe('cover') +}) +it('does not cache unsuccessful HTTP responses', async () => { + http.mockResolvedValue(new Response('', { status: 404 })) + await expect(coverBlob('https://covers.test/missing')).rejects.toThrow() + expect(saved.size).toBe(0) +}) + +it('does not poison the cache with a successful response that is not an image', async () => { + http.mockResolvedValueOnce(new Response('captive portal')) + createImageBitmap.mockRejectedValueOnce(new Error('invalid image')) + await expect(coverBlob('https://covers.test/retry')).rejects.toThrow('invalid image') + expect(saved.size).toBe(0) + http.mockResolvedValueOnce(new Response('image bytes')) + expect(await (await coverBlob('https://covers.test/retry')).text()).toBe('image bytes') +}) diff --git a/app/src/ui.jsx b/app/src/ui.jsx index 1950425..dc98687 100644 --- a/app/src/ui.jsx +++ b/app/src/ui.jsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useRef, useState } from 'react' import { BookOpen, CircleAlert, CircleCheck, CircleX, Pause, Share, Share2, X } from 'lucide-react' import { api, isApp } from './api.js' +import { coverBlob } from './coverCache.js' import { useSheet } from './tabbar.js' // Pins the page while any sheet is open: focusing a field in a sheet would otherwise scroll the page behind it. @@ -214,6 +215,12 @@ export function Cover({ session, book, small = false, tiny = false, className = }, [session, book.document, book.cover_url, book.title, near]) const url = book.cover_url || (looked?.document === book.document ? looked.url : null) + // Populate the shared cache only for visible native-app covers. The widget + // can then use these bytes even though it downloads through a different client. + useEffect(() => { + if (isApp && near && url) coverBlob(url).catch(() => {}) + }, [near, url]) + const frame = `relative aspect-[2/3] shrink-0 overflow-hidden rounded-md shadow-sm ring-1 ring-stone-950/10 ${className}` if (url && url !== broken) { return setBroken(url)} className={`${frame} object-cover`} /> diff --git a/app/src/widget.js b/app/src/widget.js index 59691e5..90954f7 100644 --- a/app/src/widget.js +++ b/app/src/widget.js @@ -1,6 +1,7 @@ import { decodeHistory, hasReadingTime, localDay, longestReadingStreak, readingStreak } from './readingHistory.js' import { invoke } from '@tauri-apps/api/core' -import { http, isApp } from './api.js' +import { cached, isApp } from './api.js' +import { coverBlob } from './coverCache.js' // Feeds the home screen widget: Android through MainActivity's CrossPointWidget // bridge, iOS through the Rust update_widget command (App Group container). @@ -20,9 +21,7 @@ export function clearWidget() { async function coverBase64(url) { try { - const res = await http(url) - if (!res.ok) return '' - const img = await createImageBitmap(await res.blob()) + const img = await createImageBitmap(await coverBlob(url)) // Widget-sized PNG: bitmaps cross IPC to the launcher and must stay small. const canvas = document.createElement('canvas') canvas.width = 168 @@ -30,7 +29,9 @@ async function coverBase64(url) { const ctx = canvas.getContext('2d') const s = Math.max(168 / img.width, 252 / img.height) ctx.drawImage(img, (168 - img.width * s) / 2, (252 - img.height * s) / 2, img.width * s, img.height * s) - return canvas.toDataURL('image/png').split(',')[1] + const cover = canvas.toDataURL('image/png').split(',')[1] + img.close() + return cover } catch { return '' } @@ -72,11 +73,15 @@ export async function updateWidget({ session, books, summary, activity, goals }) // Stable through duplicate app renders; native providers use it for freshness. data.snapshot_day = today if (session) data.session = { server: session.server, username: session.username, key: session.key } - data.cover_url = current?.cover_url ?? null + data.cover_url = current?.cover_url ?? (session && current ? cached.cover(session, current.document) : null) ?? null const key = JSON.stringify(data) if (key === last) return last = key const currentVersion = ++version - const cover = current?.cover_url ? await coverBase64(current.cover_url) : '' - if (currentVersion === version) await publish(data, cover) + const cover = data.cover_url ? await coverBase64(data.cover_url) : '' + if (currentVersion === version) { + await publish(data, cover) + // A failed download must be retried even when reading data hasn't changed. + if (data.cover_url && !cover) last = '' + } } diff --git a/app/src/widget.test.js b/app/src/widget.test.js index 6eb30a9..49ac136 100644 --- a/app/src/widget.test.js +++ b/app/src/widget.test.js @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { localDay } from './readingHistory.js' -vi.mock('./api.js', () => ({ isApp: true, http: vi.fn() })) +vi.mock('./api.js', () => ({ isApp: true, http: vi.fn(), cached: { cover: vi.fn() } })) vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue() })) const { invoke } = await import('@tauri-apps/api/core') -const { http } = await import('./api.js') +const { http, cached } = await import('./api.js') const { clearWidget, updateWidget } = await import('./widget.js') const book = { document: 'shared', title: 'uxj4 book', status: 'reading', percentage: 0.4 } const update = (books) => updateWidget({ books, summary: null, activity: null }) @@ -14,8 +14,12 @@ const data = () => JSON.parse(invoke.mock.calls.at(-1)[1].json) beforeEach(async () => { await clearWidget() vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + vi.stubGlobal('caches', undefined) + vi.stubGlobal('createImageBitmap', vi.fn()) + cached.cover.mockReset() }) -afterEach(() => { delete window.CrossPointWidget }) +afterEach(() => { delete window.CrossPointWidget; vi.unstubAllGlobals() }) describe('widget account data', () => { it('publishes streaks without measured time and only an explicitly selected current-year goal', async () => { @@ -70,8 +74,11 @@ describe('widget account data', () => { it('ignores a cover download that completes after sign-out', async () => { let resolve - http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + let started + const requested = new Promise(r => { started = r }) + http.mockImplementationOnce(() => { started(); return new Promise(r => { resolve = r }) }) const pending = update([{ ...book, cover_url: 'https://covers.test/book.jpg' }]) + await requested await clearWidget() resolve({ ok: false }) await pending @@ -87,3 +94,45 @@ describe('widget account data', () => { expect(data().title).toBe('uxj4 book') }) }) + +describe('widget covers', () => { + it('uses the app lookup cache for the exact account and document when the library has no cover URL', async () => { + const session = { server: 'https://sync.test', username: 'reader', key: 'k' } + cached.cover.mockReturnValue('https://covers.test/looked-up.jpg') + http.mockRejectedValue(new Error('offline')) + await updateWidget({ session, books: [book] }) + expect(cached.cover).toHaveBeenCalledWith(session, book.document) + expect(data().cover_url).toBe('https://covers.test/looked-up.jpg') + expect(http).toHaveBeenCalledWith('https://covers.test/looked-up.jpg') + }) + + it('retries an unchanged snapshot after a failed cover download', async () => { + http.mockRejectedValue(new Error('offline')) + const books = [{ ...book, cover_url: 'https://covers.test/retry.jpg' }] + await update(books) + await update(books) + expect(http).toHaveBeenCalledTimes(2) + expect(invoke).toHaveBeenCalledTimes(2) + }) + + it('publishes the exact cached cover and keeps corrected URLs separate', async () => { + const blobs = new Map([ + ['https://covers.test/a.jpg', new Blob(['A'])], + ['https://covers.test/fixed.jpg', new Blob(['fixed'])], + ]) + vi.stubGlobal('caches', { open: async () => ({ match: async url => blobs.has(url) ? { blob: async () => blobs.get(url) } : undefined }) }) + vi.stubGlobal('createImageBitmap', vi.fn(async blob => ({ width: 168, height: 252, close() {}, bytes: await blob.text() }))) + let drawn + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: image => { drawn = image.bytes } }) + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockImplementation(() => `data:image/png;base64,${drawn}`) + await update([{ ...book, cover_url: 'https://covers.test/a.jpg' }]) + expect(invoke.mock.calls.at(-1)[1].cover).toBe('A') + await update([{ ...book, document: 'other', cover_url: 'https://covers.test/fixed.jpg' }]) + expect(invoke.mock.calls.at(-1)[1].cover).toBe('fixed') + http.mockRejectedValue(new Error('offline')) + await update([{ ...book, document: 'third', cover_url: 'https://covers.test/missing.jpg' }]) + expect(invoke.mock.calls.at(-1)[1].cover).toBe('') + expect(data().book_document).toBe('third') + vi.restoreAllMocks() + }) +})