Get fonts any time, then send them when your reader is connected.
{
if (!isApp) return
- let live = true, latest = 0
+ let live = true, latest = 0, retry, reported = false
+ const schedule = delay => {
+ clearTimeout(retry)
+ retry = setTimeout(load, delay)
+ }
const load = async () => {
+ clearTimeout(retry)
const request = ++latest
const revision = queueRevision.current
try {
const all = await listImports()
+ if (!live || request !== latest) return
+ if (revision !== queueRevision.current) { schedule(0); return }
+ reported = false
const entries = all.filter(isBook)
- if (live && request === latest) setImportCounts(all.reduce((counts, entry) => {
+ setImportCounts(all.reduce((counts, entry) => {
const kind = importKind(entry)
if (kind) counts[kind] = (counts[kind] ?? 0) + 1
return counts
}, {}))
- if (live && request === latest && revision === queueRevision.current) setFiles(cur => {
+ setFiles(cur => {
if (revision !== queueRevision.current) return cur
const byId = new Map(cur.map(item => [item.id, item]))
return entries.map(entry => {
@@ -466,12 +474,15 @@ export default function Send({ tab = '' }) {
})
})
} catch (error) {
- if (live) notify({ error: true, title: "Couldn't load queued files", detail: String(error) })
+ if (!live || request !== latest) return
+ if (!reported) notify({ error: true, title: "Couldn't load queued files", detail: String(error) })
+ reported = true
+ schedule(1000)
}
}
load()
window.addEventListener('crosspoint-imports-changed', load)
- return () => { live = false; window.removeEventListener('crosspoint-imports-changed', load) }
+ return () => { live = false; clearTimeout(retry); window.removeEventListener('crosspoint-imports-changed', load) }
}, [])
async function add(list) {
@@ -483,6 +494,7 @@ export default function Send({ tab = '' }) {
const entry = await addImport(file)
queueRevision.current += 1
setFiles(cur => cur.some(item => item.id === entry.id) ? cur : [...cur, { id: entry.id, file: entry, state: 'queued' }])
+ window.dispatchEvent(new Event('crosspoint-imports-changed'))
} catch (error) {
notify({ error: true, title: `Couldn't queue ${file.name}`, detail: String(error) })
}
@@ -494,6 +506,7 @@ export default function Send({ tab = '' }) {
await removeImport(item.id)
queueRevision.current += 1
setFiles(cur => cur.filter(f => f.id !== item.id))
+ window.dispatchEvent(new Event('crosspoint-imports-changed'))
} catch (error) {
notify({ error: true, title: "Couldn't remove queued file", detail: String(error) })
}
diff --git a/app/src/Send.queue.test.jsx b/app/src/Send.queue.test.jsx
index d69e18b3..f560e685 100644
--- a/app/src/Send.queue.test.jsx
+++ b/app/src/Send.queue.test.jsx
@@ -74,3 +74,22 @@ it('does not resurrect a removed file when an older list response arrives late',
await new Promise(done => setTimeout(done, 20))
expect(screen.queryByText('Shared.epub')).toBeNull()
})
+it('recovers from a failed queue refresh without another native change event',async()=>{
+ vi.spyOn(queue,'listImports').mockRejectedValueOnce(new Error('Temporary read failure'))
+ render(
)
+ await waitFor(()=>expect(screen.getAllByText('Shared.epub')).toHaveLength(2),{timeout:3000})
+})
+it('reloads a stale snapshot so another source incoming during removal is still shown',async()=>{
+ store.entries=store.entries.slice(0,1)
+ render(
)
+ await screen.findByText('Shared.epub')
+ let resolve
+ vi.spyOn(queue,'listImports').mockImplementationOnce(()=>new Promise(done=>{resolve=done}))
+ window.dispatchEvent(new Event('crosspoint-imports-changed'))
+ store.entries.push({id:'new',name:'Incoming.epub',size:4})
+ fireEvent.click(screen.getByRole('button',{name:'Remove Shared.epub'}))
+ await waitFor(()=>expect(screen.queryByText('Shared.epub')).toBeNull())
+ resolve([{id:'new',name:'Incoming.epub',size:4}])
+ await screen.findByText('Incoming.epub')
+ expect(screen.queryByText('Shared.epub')).toBeNull()
+})
diff --git a/app/src/Wallpaper.import.test.jsx b/app/src/Wallpaper.import.test.jsx
new file mode 100644
index 00000000..ae589bbe
--- /dev/null
+++ b/app/src/Wallpaper.import.test.jsx
@@ -0,0 +1,34 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+vi.mock('./api.js',()=>({isApp:true}))
+vi.mock('./catalogs.js',()=>({reader:async()=> 'http://reader',sendFile:vi.fn(async()=>{})}))
+vi.mock('./device.js',async original=>({...await original(),connect:async()=>({status:{}}),folders:async()=>[],loadDevicePrefs:()=>({host:'reader',folder:'/'} )}))
+vi.mock('./readers.js',async original=>{const real=await original();return {...real,refreshReaders:async()=>real.readers}})
+vi.mock('./wallpaper/community.js',()=>({CATEGORIES:[],canFilter:()=>false,communityPage:async()=>({items:[],totalPages:0}),fullImage:vi.fn()}))
+vi.mock('./wallpaper/render.js',async original=>({...await original(),autoLevels:()=>({}),renderWallpaper:(image,settings)=>({name:image.src,rotation:settings.rotation}),toBmp:result=>new Blob([result?.name??'STALE'])}))
+const store=vi.hoisted(()=>({entries:[{id:'old',name:'Old.png',size:1},{id:'new',name:'New.png',size:1}]}))
+vi.mock('./importQueue.js',()=>({listImports:async()=>store.entries.map(e=>({...e})),readImport:async entry=>new File(['image'],entry.name),removeImport:async id=>{store.entries=store.entries.filter(e=>e.id!==id)}}))
+import Wallpaper from './Wallpaper.jsx'
+import {sendFile} from './catalogs.js'
+afterEach(()=>{cleanup();vi.unstubAllGlobals();vi.restoreAllMocks()})
+it('sends the selected queued image before its preview animation frame and removes only that image',async()=>{
+ vi.stubGlobal('matchMedia',()=>({matches:false,addEventListener(){},removeEventListener(){}}))
+ vi.stubGlobal('requestAnimationFrame',()=>1)
+ vi.stubGlobal('cancelAnimationFrame',()=>{})
+ vi.stubGlobal('Image',class {
+ naturalWidth=100;naturalHeight=100
+ set src(value){this.value=value;queueMicrotask(()=>this.onload?.())}
+ get src(){return this.value}
+ })
+ vi.stubGlobal('URL',class extends URL { static createObjectURL(file){return file.name} static revokeObjectURL(){} })
+ vi.spyOn(window,'scrollTo').mockImplementation(()=>{})
+ render(
)
+ fireEvent.click((await screen.findAllByRole('button',{name:'Edit sleep screen'}))[1])
+ fireEvent.click(await screen.findByRole('button',{name:/Send to reader/}))
+ await waitFor(()=>expect(store.entries.map(e=>e.id)).toEqual(['old']))
+ const file=sendFile.mock.calls[0][0]
+ const text=await new Promise(resolve=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.readAsText(file)})
+ expect(text).toBe('New.png')
+ expect(file.type).toBe('image/bmp')
+})
diff --git a/app/src/Wallpaper.jsx b/app/src/Wallpaper.jsx
index 17e9fc11..7df3ea27 100644
--- a/app/src/Wallpaper.jsx
+++ b/app/src/Wallpaper.jsx
@@ -319,8 +319,8 @@ export default function Wallpaper() {
async function send() {
setStatus({ busy: true, text: 'Sending to reader…' })
const prefs = { ...loadDevicePrefs(), optimize: false, renameFromMetadata: false }
- const file = new File([toBmp(result.current)], fileName(), { type: 'image/bmp' })
try {
+ const file = new File([toBmp(renderWallpaper(img.el, s))], fileName(), { type: 'image/bmp' })
const upload = async () => {
const base = await reader(prefs)
let folder = '/.sleep'
@@ -366,7 +366,7 @@ export default function Wallpaper() {
{
if (pendingImport.current === entry.id) { imageRequest.current += 1; pendingImport.current = null }
setImg(current => current?.importId === entry.id ? { ...current, importId: null } : current)
- }} disabled={!!status?.busy} />
+ }} actionDisabled={!!status?.busy} removalDisabled={!!status?.busy} />
Turn a photo into a sleep screen, dithered to your reader's four shades of grey.
From d3290ce37f4da690b837571739c3406522c1459e Mon Sep 17 00:00:00 2001
From: jcapayne <7683622+jcapayne@users.noreply.github.com>
Date: Fri, 13 Nov 2026 19:25:21 -0500
Subject: [PATCH 4/4] fix: serialize iOS import buffering and publication
---
app/src-tauri/src/coordinated_import.rs | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/app/src-tauri/src/coordinated_import.rs b/app/src-tauri/src/coordinated_import.rs
index 11125c20..fd6849fb 100644
--- a/app/src-tauri/src/coordinated_import.rs
+++ b/app/src-tauri/src/coordinated_import.rs
@@ -3,6 +3,9 @@ use objc2::rc::Retained;
use objc2_foundation::{NSString, NSURL};
use std::path::Path;
+// All Open In workers share one slot, including workers from separate batches.
+static IMPORT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
pub struct ScopedSource {
url: Retained,
accessed: bool,
@@ -18,6 +21,10 @@ impl ScopedSource {
pub fn import_into(&self, root: &Path, name: &str) -> Result {
use objc2_foundation::{NSFileCoordinator, NSFileCoordinatorReadingOptions};
use std::{cell::RefCell, ptr::NonNull};
+ // Acquire before coordination/reading and retain through publication and
+ // buffer disposal. The worker continues owning its source grant while waiting.
+ // This mutex protects scheduling only, so a prior panic cannot corrupt state.
+ let _serial = IMPORT_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let outcome = RefCell::new(None);
let reader = block2::StackBlock::new(|readable: NonNull| {
// The coordinator may supply a different URL; read only its accessor URL
@@ -55,7 +62,7 @@ mod tests {
}
#[test]
- fn waits_for_a_coordinated_writer_and_publishes_only_complete_content() {
+ fn waits_for_a_coordinated_writer_and_serializes_other_imports() {
autoreleasepool(|_| {
let root = root();
let input = root.join("source.epub");
@@ -84,12 +91,27 @@ mod tests {
}));
let early = done_rx.recv_timeout(Duration::from_millis(150));
let before_publish = crate::import_store::list(&queue).unwrap();
+ // While the first import waits on its provider, a different source must
+ // not start buffering its contents or publish ahead of that import.
+ let second_path = root.join("second.txt");
+ fs::write(&second_path, b"second complete file").unwrap();
+ let second = ScopedSource::acquire(&url::Url::from_file_path(&second_path).unwrap()).unwrap();
+ let destination = queue.clone();
+ let (second_tx, second_rx) = mpsc::channel();
+ let second_reader = thread::spawn(move || autoreleasepool(|_| {
+ second_tx.send(second.import_into(&destination, "second.txt")).unwrap();
+ }));
+ let concurrent = second_rx.recv_timeout(Duration::from_millis(150));
release_tx.send(()).unwrap();
writer.join().unwrap();
reader.join().unwrap();
+ second_reader.join().unwrap();
+ let second_result = if let Ok(result) = concurrent.as_ref() { result.as_ref().unwrap().clone() } else { second_rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap() };
let result = if let Ok(result) = early.as_ref() { result.as_ref().unwrap().clone() } else { done_rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap() };
assert!(early.is_err(),"import completed while the provider still owned its write");
assert!(before_publish.is_empty(),"partial provider content was published");
+ assert!(concurrent.is_err(), "a second import buffered and published while the first was still coordinating");
+ assert_eq!(crate::import_store::read(&queue,&second_result.id).unwrap(),b"second complete file");
assert_eq!(crate::import_store::read(&queue,&result.id).unwrap(),b"complete provider content");
fs::remove_dir_all(root).unwrap();
});