Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/scripts/widget-tests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -140,16 +152,17 @@ 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") ?: "")
.put("percent", current?.let { ((it.optDouble("percentage", 0.0).takeIf { value -> value.isFinite() } ?: 0.0).coerceIn(0.0, 1.0) * 100).roundToInt() } ?: -1)
.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()))
Expand All @@ -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!!)
Expand Down
13 changes: 7 additions & 6 deletions app/src-tauri/gen/apple/CrossPointWidget/CrossPointWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve covers written during a provider retry

When the sync server is reachable but the cover host is not, this can set coverChanged because cover.png was missing and then wait on the network; meanwhile the app can recover the image from crosspoint-covers-v1 and publish the same JSON snapshot plus cover bytes. The final supersession guard compares only widget.json, so it still passes and the newCover == nil branch removes the cover the app just wrote. The Android !hasCover path has the same same-JSON race; recheck the cover under the final lock or track a cover generation before deleting it.

Useful? React with 👍 / 👎.

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 {
Expand All @@ -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) }
Expand Down
12 changes: 12 additions & 0 deletions app/src-tauri/gen/apple/CrossPointWidget/ReadingMetrics.swift
Original file line number Diff line number Diff line change
@@ -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?
Expand Down
49 changes: 48 additions & 1 deletion app/src-tauri/src/widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(&json).map_err(|e| e.to_string())?;
let previous = std::fs::read(dir.join("widget.json")).ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&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())?;
Expand Down Expand Up @@ -73,3 +79,44 @@ pub fn update_shortcuts(app: tauri::AppHandle, json: Option<String>) -> 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));
}
}
11 changes: 10 additions & 1 deletion app/src/Cover.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -17,6 +19,13 @@ afterEach(() => {
})

describe('Cover', () => {
it('warms the widget cache with the displayed URL, including a corrected cover', () => {
const { rerender } = render(<Cover session={session} book={chamber} />)
expect(coverBlob).toHaveBeenLastCalledWith(chamber.cover_url)
rerender(<Cover session={session} book={quest} />)
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(<Cover session={session} book={chamber} />)
expect(src(container)).toBe(chamber.cover_url)
Expand Down
6 changes: 5 additions & 1 deletion app/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions app/src/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
30 changes: 30 additions & 0 deletions app/src/coverCache.js
Original file line number Diff line number Diff line change
@@ -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 <img> 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
}
49 changes: 49 additions & 0 deletions app/src/coverCache.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
Loading
Loading