Repository navigation
Fix offline widget book covers with shared caching #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
itsthisjustin
merged 2 commits into
crosspoint-reader:main
from
jcapayne:codex/widget-offline-covers
Nov 9, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
app/src-tauri/gen/apple/CrossPointWidget/ReadingMetrics.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the sync server is reachable but the cover host is not, this can set
coverChangedbecausecover.pngwas missing and then wait on the network; meanwhile the app can recover the image fromcrosspoint-covers-v1and publish the same JSON snapshot plus cover bytes. The final supersession guard compares onlywidget.json, so it still passes and thenewCover == nilbranch removes the cover the app just wrote. The Android!hasCoverpath has the same same-JSON race; recheck the cover under the final lock or track a cover generation before deleting it.Useful? React with 👍 / 👎.