diff --git a/packages/fig/src/thumbnail.ts b/packages/fig/src/thumbnail.ts index c181d7654..ac1e77562 100644 --- a/packages/fig/src/thumbnail.ts +++ b/packages/fig/src/thumbnail.ts @@ -102,8 +102,14 @@ async function readEntryPayload( : null } if (entry.method !== 8) return null - const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) }) - return output.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null + const output = (() => { + try { + return inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) }) + } catch { + return null + } + })() + return output?.byteLength === entry.outputSize && hasPNGSignature(output) ? output : null } /** diff --git a/packages/vue/src/document/workspace/previews.ts b/packages/vue/src/document/workspace/previews.ts index 2810ef0bf..67b45c171 100644 --- a/packages/vue/src/document/workspace/previews.ts +++ b/packages/vue/src/document/workspace/previews.ts @@ -86,7 +86,7 @@ export function createDocumentPreviews( while (active.size < concurrency) { const id = previewQueue.shift() if (!id) break - queued.delete(id) + if (!queued.delete(id)) continue if (active.has(id) || previewUrls.value[id]) continue active.add(id) void runLoad(id, previewGenerations.get(id) ?? 0) @@ -108,7 +108,11 @@ export function createDocumentPreviews( for (const id of trackedIds) { if (previous.get(id) === current.get(id)) continue removeURL(id) - if (!current.has(id) || active.has(id) || queued.has(id)) continue + if (!current.has(id)) { + queued.delete(id) + continue + } + if (active.has(id) || queued.has(id)) continue queued.add(id) previewQueue.push(id) } @@ -125,6 +129,7 @@ export function createDocumentPreviews( for (const id of ids) removeURL(id) previewQueue.length = 0 queued.clear() + previewErrors.value = {} } function observePreview(element: Element | null, id: string): () => void { diff --git a/packages/vue/src/document/workspace/use.ts b/packages/vue/src/document/workspace/use.ts index 92f02c055..26df816cc 100644 --- a/packages/vue/src/document/workspace/use.ts +++ b/packages/vue/src/document/workspace/use.ts @@ -96,7 +96,7 @@ export function useDocumentWorkspace( } }, options.refreshInterval, - { immediate: false } + { immediate: true, immediateCallback: false } ) } diff --git a/src/app/storage/workspace/events.ts b/src/app/storage/workspace/events.ts index e6d112999..57e7b1e83 100644 --- a/src/app/storage/workspace/events.ts +++ b/src/app/storage/workspace/events.ts @@ -11,7 +11,13 @@ type StorageWorkspaceListener = (event: StorageWorkspaceEvent) => void const listeners = new Set() export function emitStorageWorkspaceEvent(event: StorageWorkspaceEvent): void { - for (const listener of listeners) listener(event) + for (const listener of listeners) { + try { + listener(event) + } catch (error) { + console.error('[Storage] Workspace event listener failed:', error) + } + } } export function onStorageWorkspaceEvent(listener: StorageWorkspaceListener): () => void { diff --git a/src/app/storage/workspace/source.ts b/src/app/storage/workspace/source.ts index d93a50ee0..a89e390bf 100644 --- a/src/app/storage/workspace/source.ts +++ b/src/app/storage/workspace/source.ts @@ -47,6 +47,7 @@ export function createStorageWorkspaceSource( updatedAt: metadata.updatedAt, metadataAuthoritative: true })) + if (activeStorageProviderID.value !== providerID) return [] onSnapshot({ documents, configured }) return documents } @@ -65,6 +66,7 @@ export function createStorageWorkspaceSource( lastSyncError: null }) } + if (activeStorageProviderID.value !== providerID) return [] onSnapshot({ documents: reconciliation.documents, configured }) return reconciliation.documents }, diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts index e40596715..ff527920c 100644 --- a/tests/engine/io/fig/thumbnail-range.test.ts +++ b/tests/engine/io/fig/thumbnail-range.test.ts @@ -41,6 +41,32 @@ describe('fig ranged thumbnail extraction', () => { expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull() }) + test('returns null when deflated thumbnail data is corrupt', async () => { + const png = new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ...Array.from({ length: 64 }, () => 1) + ]) + const bytes = zipSync({ 'thumbnail.png': png }) + const name = new TextEncoder().encode('thumbnail.png') + const nameOffset = bytes.findIndex((byte, index) => + name.every((nameByte, nameIndex) => bytes[index + nameIndex] === nameByte) + ) + const headerOffset = nameOffset - 30 + const header = new DataView(bytes.buffer, bytes.byteOffset + headerOffset, 30) + expect(header.getUint16(8, true)).toBe(8) + const dataOffset = nameOffset + name.byteLength + header.getUint16(28, true) + bytes.fill(0xff, dataOffset, dataOffset + header.getUint32(18, true)) + + expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull() + }) + test('rejects thumbnails above configured output limits', async () => { const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig')) const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), {