diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index c9c1fdbd..a56d544d 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -21,6 +21,7 @@ import { normalizeStagedResourcePart, remapStagedResourceMetadata, } from "./staged-resource.mjs"; +import { cacheStagedResourceBytes, listPendingStagedResources, listStagedResourceMetadata, readStagedResourceMetadata, recordStagedResourceAlias } from "./staged-resource-alias.mjs"; import { isMountedDiskImageVolume, isMountedInstallerPath, @@ -914,9 +915,15 @@ const registerResourceProtocol = () => { const directory = stagedResourceDirectory(); try { - const metadata = JSON.parse(await readFile(join(directory, `${stagedId}.json`), "utf8")); + const metadata = await readStagedResourceMetadata(directory, stagedId); const path = join(directory, `${stagedId}.bin`); - const { size } = await stat(path); + const size = await stat(path).then((details) => details.size).catch(() => null); + if (size === null && isSafeResourceId(metadata.resourceId)) { + const target = new URL(`edgeever-resource://resource/${encodeURIComponent(metadata.resourceId)}`); + target.search = new URL(request.url).search; + return handleResourceProtocolRequest(new Request(target, { headers: request.headers })); + } + if (size === null) throw new Error("Staged bytes are missing"); const stream = createReadStream(path); const headers = new Headers({ "Content-Type": metadata.type || "application/octet-stream", @@ -1735,12 +1742,24 @@ const startApplication = async () => { ipcMain.handle("desktop:list-staged-resources", async () => { const directory = stagedResourceDirectory(); try { await mkdir(directory, { recursive: true }); await restrictDirectory(directory); } catch {} - const names = await readdir(directory); - const result = []; - for (const name of names.filter((value) => value.endsWith(".json"))) { - try { result.push(JSON.parse(await readFile(join(directory, name), "utf8"))); } catch {} - } - return result; + return listPendingStagedResources(directory); + }); + ipcMain.handle("desktop:list-staged-resource-aliases", async (_event, memoId) => { + const metadata = await listStagedResourceMetadata(stagedResourceDirectory()); + return metadata.filter((item) => (!memoId || item.memoId === memoId) && isSafeResourceId(item.resourceId)) + .map((item) => ({ id: item.id, memoId: item.memoId, resourceId: item.resourceId })); + }); + ipcMain.handle("desktop:record-staged-resource-alias", async (_event, id, uploadedUrl) => { + const cacheDirectory = resourceCacheDirectory(); + const resourceId = await cacheStagedResourceBytes(stagedResourceDirectory(), cacheDirectory, id, uploadedUrl); + await Promise.all([ + restrictDirectory(cacheDirectory), + restrictFile(join(cacheDirectory, `${resourceId}.bin`)), + restrictFile(join(cacheDirectory, `${resourceId}.json`)), + ]); + const result = await recordStagedResourceAlias(stagedResourceDirectory(), id, uploadedUrl); + await restrictFile(join(stagedResourceDirectory(), `${id}.json`)); + return { id: result.id, resourceId: result.resourceId }; }); ipcMain.handle("desktop:remap-staged-resource-memo-ids", async (_event, mappings) => { if (!Array.isArray(mappings) || mappings.length === 0) return { updated: 0 }; @@ -1764,8 +1783,13 @@ const startApplication = async () => { ipcMain.handle("desktop:read-staged-resource", async (_event, id) => { if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); const directory = stagedResourceDirectory(); - const metadata = JSON.parse(await readFile(join(directory, `${id}.json`), "utf8")); - const bytes = await readFile(join(directory, `${id}.bin`)); + const metadata = await readStagedResourceMetadata(directory, id); + const bytes = await readFile(join(directory, `${id}.bin`)).catch(async (error) => { + if (!isSafeResourceId(metadata.resourceId)) throw error; + const response = await handleResourceProtocolRequest(new Request(`edgeever-resource://resource/${encodeURIComponent(metadata.resourceId)}`)); + if (!response.ok) throw new Error(`Resource request failed (${response.status})`); + return Buffer.from(await response.arrayBuffer()); + }); return { ...metadata, bytes: new Uint8Array(bytes) }; }); ipcMain.handle("desktop:read-staged-resource-part", async (_event, id, start, length) => { @@ -1808,8 +1832,9 @@ const startApplication = async () => { ipcMain.handle("desktop:remove-staged-resource", async (_event, id) => { if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); const directory = stagedResourceDirectory(); + const alias = await readStagedResourceMetadata(directory, id).catch(() => null); await Promise.all([ - unlink(join(directory, `${id}.json`)).catch(() => {}), + ...(isSafeResourceId(alias?.resourceId) ? [] : [unlink(join(directory, `${id}.json`)).catch(() => {})]), unlink(join(directory, `${id}.bin`)).catch(() => {}), unlink(join(directory, `${id}.pending.json`)).catch(() => {}), unlink(join(directory, `${id}.pending.bin`)).catch(() => {}), diff --git a/apps/desktop/src/main/staged-resource-alias.mjs b/apps/desktop/src/main/staged-resource-alias.mjs new file mode 100644 index 00000000..d875e635 --- /dev/null +++ b/apps/desktop/src/main/staged-resource-alias.mjs @@ -0,0 +1,76 @@ +import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { isSafeResourceId } from "./resource-url.mjs"; + +export const uploadedResourceIdFromUrl = (value) => { + try { + const url = new URL(value, "https://edgeever.invalid"); + const match = /^\/api\/v1\/resources\/([^/]+)\/blob$/.exec(url.pathname); + const id = match ? decodeURIComponent(match[1]) : null; + return isSafeResourceId(id) ? id : null; + } catch { + return null; + } +}; + +export const readStagedResourceMetadata = async (directory, id) => { + if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); + const metadata = JSON.parse(await readFile(join(directory, `${id}.json`), "utf8")); + if (metadata.id !== id) throw new Error("Staged resource metadata mismatch"); + return metadata; +}; + +export const recordStagedResourceAlias = async (directory, id, uploadedUrl) => { + const resourceId = uploadedResourceIdFromUrl(uploadedUrl); + if (!resourceId) throw new Error("Invalid uploaded resource URL"); + const metadata = await readStagedResourceMetadata(directory, id); + if (metadata.resourceId && metadata.resourceId !== resourceId) { + throw new Error("Staged resource already maps to another resource"); + } + const next = { ...metadata, resourceId }; + const temporary = join(directory, `${id}.alias-pending.json`); + try { + await writeFile(temporary, JSON.stringify(next), { mode: 0o600 }); + await rename(temporary, join(directory, `${id}.json`)); + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}); + throw error; + } + return next; +}; + +export const cacheStagedResourceBytes = async (stagedDirectory, cacheDirectory, id, uploadedUrl) => { + const resourceId = uploadedResourceIdFromUrl(uploadedUrl); + if (!resourceId) throw new Error("Invalid uploaded resource URL"); + const metadata = await readStagedResourceMetadata(stagedDirectory, id); + await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + const pendingPath = join(cacheDirectory, `${resourceId}.${randomUUID()}.pending`); + try { + await copyFile(join(stagedDirectory, `${id}.bin`), pendingPath); + await rename(pendingPath, join(cacheDirectory, `${resourceId}.bin`)); + await writeFile(join(cacheDirectory, `${resourceId}.json`), JSON.stringify({ contentType: metadata.type }), { mode: 0o600 }); + } catch (error) { + await rm(pendingPath, { force: true }).catch(() => {}); + throw error; + } + return resourceId; +}; + +export const listStagedResourceMetadata = async (directory) => { + const names = await readdir(directory).catch(() => []); + const result = []; + for (const name of names.filter((value) => value.endsWith(".json") && !value.endsWith(".pending.json") && !value.endsWith(".alias-pending.json"))) { + const id = name.slice(0, -5); + try { result.push(await readStagedResourceMetadata(directory, id)); } catch {} + } + return result; +}; + +export const listPendingStagedResources = async (directory) => { + const metadata = await listStagedResourceMetadata(directory); + const present = await Promise.all(metadata.map(async (item) => ( + await stat(join(directory, `${item.id}.bin`)).then(() => true).catch(() => false) + ))); + return metadata.filter((_, index) => present[index]); +}; diff --git a/apps/desktop/src/main/staged-resource-alias.test.mjs b/apps/desktop/src/main/staged-resource-alias.test.mjs new file mode 100644 index 00000000..6c97352b --- /dev/null +++ b/apps/desktop/src/main/staged-resource-alias.test.mjs @@ -0,0 +1,49 @@ +import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { + cacheStagedResourceBytes, + listPendingStagedResources, + listStagedResourceMetadata, + readStagedResourceMetadata, + recordStagedResourceAlias, + uploadedResourceIdFromUrl, +} from "./staged-resource-alias.mjs"; + +const roots = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test("upgrades an old staged file to a durable resource alias before deleting its bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-stage-alias-")); + roots.push(root); + const oldMetadata = { id: "stage_123", memoId: "memo_1", name: "photo.jpg", type: "image/jpeg", size: 4 }; + await writeFile(join(root, "stage_123.json"), JSON.stringify(oldMetadata)); + await writeFile(join(root, "stage_123.bin"), "jpeg"); + expect(await listPendingStagedResources(root)).toEqual([oldMetadata]); + + const cache = join(root, "cache"); + await cacheStagedResourceBytes(root, cache, "stage_123", "/api/v1/resources/res_uploaded/blob"); + const aliased = await recordStagedResourceAlias(root, "stage_123", "/api/v1/resources/res_uploaded/blob"); + expect(aliased).toEqual({ ...oldMetadata, resourceId: "res_uploaded" }); + expect(JSON.parse(await readFile(join(root, "stage_123.json"), "utf8"))).toEqual(aliased); + await unlink(join(root, "stage_123.bin")); + expect(await listPendingStagedResources(root)).toEqual([]); + expect(await listStagedResourceMetadata(root)).toEqual([aliased]); + expect((await readStagedResourceMetadata(root, "stage_123")).resourceId).toBe("res_uploaded"); + expect((await readFile(join(cache, "res_uploaded.bin"))).toString()).toBe("jpeg"); + expect(JSON.parse(await readFile(join(cache, "res_uploaded.json"), "utf8"))).toEqual({ contentType: "image/jpeg" }); +}); + +test("rejects an invalid or conflicting uploaded resource mapping", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-stage-alias-")); + roots.push(root); + await writeFile(join(root, "stage_123.json"), JSON.stringify({ id: "stage_123", memoId: "memo_1", name: "photo.jpg" })); + expect(uploadedResourceIdFromUrl("https://example.com/api/v1/resources/res_1/blob")).toBe("res_1"); + expect(uploadedResourceIdFromUrl("/api/v1/resources/../../secret/blob")).toBeNull(); + await expect(recordStagedResourceAlias(root, "stage_123", "https://example.com/not-a-resource")).rejects.toThrow(); + await recordStagedResourceAlias(root, "stage_123", "/api/v1/resources/res_1/blob"); + await expect(recordStagedResourceAlias(root, "stage_123", "/api/v1/resources/res_2/blob")).rejects.toThrow(); +}); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 9678911f..e8901d38 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -67,6 +67,8 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ completeStagedResource: (id) => ipcRenderer.invoke("desktop:stage-resource-complete", id), abortStagedResource: (id) => ipcRenderer.invoke("desktop:stage-resource-abort", id), listStagedResources: () => ipcRenderer.invoke("desktop:list-staged-resources"), + listStagedResourceAliases: (memoId) => ipcRenderer.invoke("desktop:list-staged-resource-aliases", memoId), + recordStagedResourceAlias: (id, uploadedUrl) => ipcRenderer.invoke("desktop:record-staged-resource-alias", id, uploadedUrl), remapStagedResourceMemoIds: (mappings) => ipcRenderer.invoke("desktop:remap-staged-resource-memo-ids", mappings), readStagedResource: (id) => ipcRenderer.invoke("desktop:read-staged-resource", id), readStagedResourcePart: (id, start, length) => ipcRenderer.invoke("desktop:read-staged-resource-part", id, start, length), diff --git a/apps/web/src/lib/desktop-repository.test.mjs b/apps/web/src/lib/desktop-repository.test.mjs index 72800660..8fdbb087 100644 --- a/apps/web/src/lib/desktop-repository.test.mjs +++ b/apps/web/src/lib/desktop-repository.test.mjs @@ -10,6 +10,9 @@ globalThis.window = { edgeeverDesktop: { isAvailable: true, listStagedResources: async () => [], + listStagedResourceAliases: async (memoId) => memoId === "memo_alias" + ? [{ id: "stage_exact", memoId, resourceId: "res_exact" }] + : [], sidecarRequest: async (method, params) => { lastRequest = { method, params }; if (method === "memo.get") { @@ -130,6 +133,38 @@ describe("desktop repository memo saves", () => { expect(lastRequest.params.contentMarkdown).not.toContain("edgeever-staged://"); expect(lastRequest.params.contentJson.content[0].attrs.src).toBe("/api/v1/resources/res_shot/blob"); }); + + test("uses the durable ID mapping when a stale editor saves an image", async () => { + await createDesktopRepository().updateMemo( + { id: "memo_alias", revision: 2, contentHash: "base-hash" }, + { + title: "Image", + contentJson: { type: "doc", content: [{ type: "image", attrs: { + alt: "unrelated-original-name.jpg", src: "edgeever-staged://stage_exact", + } }] }, + contentMarkdown: "![unrelated-original-name.jpg](edgeever-staged://stage_exact)", + tags: [], + }, + ); + expect(lastRequest.params.contentJson.content[0].attrs.src).toBe("/api/v1/resources/res_exact/blob"); + expect(lastRequest.params.contentMarkdown).toContain("/api/v1/resources/res_exact/blob"); + }); + + test("refuses to save a deleted temporary image with no durable mapping", async () => { + lastRequest = null; + await expect(createDesktopRepository().updateMemo( + { id: "memo_unknown", revision: 2, contentHash: "base-hash" }, + { + title: "Image", + contentJson: { type: "doc", content: [{ type: "image", attrs: { + alt: "missing.jpg", src: "edgeever-staged://stage_missing", + } }] }, + contentMarkdown: "![missing.jpg](edgeever-staged://stage_missing)", + tags: [], + }, + )).rejects.toThrow("missing staged resource"); + expect(lastRequest?.method).not.toBe("memo.update"); + }); }); describe("desktop repository memo reads", () => { diff --git a/apps/web/src/lib/desktop-repository.ts b/apps/web/src/lib/desktop-repository.ts index 05a4b90e..78909ba1 100644 --- a/apps/web/src/lib/desktop-repository.ts +++ b/apps/web/src/lib/desktop-repository.ts @@ -11,6 +11,8 @@ import type { EdgeEverRepository } from "@/lib/repository"; import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api"; import { createStagedResourceListItem, mapMarkdownResourceUrls, mapTiptapResourceUrls, toApiResourceUrl, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { + applyStagedResourceUrlRewrites, + collectStagedResourceReferences, contentReferencesStagedResourceUrl, repairMemoStagedResourceUrls, stagedResourceIdFromUrl, @@ -47,6 +49,22 @@ const resourcesForMemo = async (memoId: string) => { return listed.resources.filter((resource) => resource.memoId === memoId); }; +const rewriteUploadedAliases = async (memoId: string, memo: T): Promise => { + const aliases = await window.edgeeverDesktop?.listStagedResourceAliases?.(memoId) ?? []; + if (aliases.length === 0) return memo; + const rewrites = aliases.map((item) => ({ + placeholder: `edgeever-staged://${item.id}`, + url: `/api/v1/resources/${encodeURIComponent(item.resourceId)}/blob`, + })); + return { + ...memo, + contentJson: applyStagedResourceUrlRewrites(memo.contentJson, rewrites) as T["contentJson"], + contentMarkdown: typeof memo.contentMarkdown === "string" + ? applyStagedResourceUrlRewrites(memo.contentMarkdown, rewrites) as string + : memo.contentMarkdown, + }; +}; + const repairDisplayedMemo = async (memo: MemoDetail): Promise => { if ( !contentReferencesStagedResourceUrl(memo.contentJson) @@ -54,11 +72,11 @@ const repairDisplayedMemo = async (memo: MemoDetail): Promise => { ) { return toDisplayMemo(memo); } - const [resources, liveStagedIds] = await Promise.all([ - resourcesForMemo(memo.id), - liveStagedIdsForMemo(memo.id), - ]); - return toDisplayMemo(repairMemoStagedResourceUrls(memo, resources, liveStagedIds)); + const aliased = await rewriteUploadedAliases(memo.id, memo); + if (!contentReferencesStagedResourceUrl(aliased.contentJson) + && !contentReferencesStagedResourceUrl(aliased.contentMarkdown)) return toDisplayMemo(aliased); + const [resources, liveStagedIds] = await Promise.all([resourcesForMemo(memo.id), liveStagedIdsForMemo(memo.id)]); + return toDisplayMemo(repairMemoStagedResourceUrls(aliased, resources, liveStagedIds)); }; const listStagedResources = async (): Promise => { @@ -276,12 +294,21 @@ export const createDesktopRepository = (): EdgeEverRepository => ({ updateMemo: async (memo: MemoDetail, input) => { const needsStagedRepair = contentReferencesStagedResourceUrl(input.contentJson) || contentReferencesStagedResourceUrl(input.contentMarkdown); - const repairedInput = needsStagedRepair - ? repairMemoStagedResourceUrls( - { contentJson: input.contentJson, contentMarkdown: input.contentMarkdown }, - ...(await Promise.all([resourcesForMemo(memo.id), liveStagedIdsForMemo(memo.id)])), - ) + const aliasedInput = needsStagedRepair + ? await rewriteUploadedAliases(memo.id, { contentJson: input.contentJson, contentMarkdown: input.contentMarkdown }) : input; + const repairedInput = contentReferencesStagedResourceUrl(aliasedInput.contentJson) + || contentReferencesStagedResourceUrl(aliasedInput.contentMarkdown) + ? repairMemoStagedResourceUrls(aliasedInput, ...(await Promise.all([resourcesForMemo(memo.id), liveStagedIdsForMemo(memo.id)]))) + : aliasedInput; + if (contentReferencesStagedResourceUrl(repairedInput.contentJson) + || contentReferencesStagedResourceUrl(repairedInput.contentMarkdown)) { + const live = new Set((await window.edgeeverDesktop?.listStagedResources() ?? []).map((item) => item.id)); + const references = collectStagedResourceReferences(repairedInput); + if (references.length === 0 || references.some((reference) => !live.has(reference.stagedId))) { + throw new Error("Memo contains a missing staged resource"); + } + } const portableContentJson = mapTiptapResourceUrls(repairedInput.contentJson ?? input.contentJson, toApiResourceUrl); const contentMarkdown = repairedInput.contentMarkdown === undefined && input.contentMarkdown === undefined ? docToMarkdown(portableContentJson) diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index 2d204dfa..004a661e 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -128,15 +128,31 @@ const remapStagedResourceMemoIds = async (memoIdMappings: ReadonlyMap) => { - if (!isDesktopResourceRuntime() || (typeof navigator !== "undefined" && !navigator.onLine)) return { attempted: 0, synced: 0, failed: 0, rewrites: [] as StagedResourceRewrite[], stagedIds: [] as string[] }; - const staged = await window.edgeeverDesktop!.listStagedResources(); + if (!isDesktopResourceRuntime() || (typeof navigator !== "undefined" && !navigator.onLine)) return { attempted: 0, synced: 0, failed: 0, rewrites: [] as StagedResourceRewrite[], cleanupRewrites: [] as StagedResourceRewrite[], stagedIds: [] as string[] }; + const bridge = window.edgeeverDesktop!; + const [staged, aliases] = await Promise.all([ + bridge.listStagedResources(), + bridge.listStagedResourceAliases?.() ?? Promise.resolve([]), + ]); + const aliasesById = new Map(aliases.map((item) => [item.id, item])); const pending = await request("sync.outbox.list", { limit: 200 }); const pendingPayloads = pending.items.map((item) => item.payload); let synced = 0; let failed = 0; - const rewrites: StagedResourceRewrite[] = []; + const rewrites: StagedResourceRewrite[] = aliases.map((item) => ({ + memoId: item.memoId, + placeholder: `edgeever-staged://${item.id}`, + url: `/api/v1/resources/${encodeURIComponent(item.resourceId)}/blob`, + })); + const cleanupRewrites: StagedResourceRewrite[] = []; const stagedIds: string[] = []; for (const item of staged) { + const existingAlias = aliasesById.get(item.id); + if (existingAlias) { + cleanupRewrites.push(rewrites.find((rewrite) => rewrite.placeholder === `edgeever-staged://${item.id}`)!); + stagedIds.push(item.id); + continue; + } // An image can finish staging before the editor's debounced local save // has queued the memo update that references it. Uploading and deleting // the staged file in that gap leaves the memo with an unrecoverable @@ -154,7 +170,11 @@ const syncStagedResources = async (memoIdMappings: Map) => { ], { type: item.type }), }); await window.edgeeverDesktop!.sidecarRequest("resource.cache", { resource: uploaded.resource }); - rewrites.push({ memoId, placeholder: `edgeever-staged://${item.id}`, url: uploaded.resource.url }); + if (!bridge.recordStagedResourceAlias) throw new Error("Staged resource alias bridge is unavailable"); + await bridge.recordStagedResourceAlias(item.id, uploaded.resource.url); + const rewrite = { memoId, placeholder: `edgeever-staged://${item.id}`, url: uploaded.resource.url }; + rewrites.push(rewrite); + cleanupRewrites.push(rewrite); stagedIds.push(item.id); synced += 1; } catch { @@ -162,13 +182,13 @@ const syncStagedResources = async (memoIdMappings: Map) => { } } try { - await patchCreatedMemoResources(rewrites); + await patchCreatedMemoResources(cleanupRewrites); } catch { // Keep the staged files and rewritten outbox payloads recoverable. A later // sync can retry the remote patch without losing the local attachment. - failed += rewrites.length > 0 ? 1 : 0; + failed += cleanupRewrites.length > 0 ? 1 : 0; } - return { attempted: staged.length, synced, failed, rewrites, stagedIds }; + return { attempted: staged.length, synced, failed, rewrites, cleanupRewrites, stagedIds }; }; const removeSyncedStagedResources = async (stagedIds: string[]) => { @@ -770,10 +790,10 @@ export const syncDesktopData = () => { mergeMemoIdMappings(memoIdMappings, outbox.memoIdMappings); mergeSyncedMemos(syncedMemos, outbox.syncedMemos); if (stagedResources.failed === 0 && outbox.failed === 0 && outbox.conflicted === 0 && creates.conflicted === 0) { - const stillReferenced = await stagedIdsReferencedByLocalMemos(stagedResources.rewrites); + const stillReferenced = await stagedIdsReferencedByLocalMemos(stagedResources.cleanupRewrites); if (stillReferenced.size > 0) { try { - await patchCreatedMemoResources(stagedResources.rewrites.filter((rewrite) => { + await patchCreatedMemoResources(stagedResources.cleanupRewrites.filter((rewrite) => { const stagedId = rewrite.placeholder.slice("edgeever-staged://".length); return stillReferenced.has(stagedId); })); @@ -782,9 +802,9 @@ export const syncDesktopData = () => { } } const remainingReferenced = stillReferenced.size > 0 - ? await stagedIdsReferencedByLocalMemos(stagedResources.rewrites) + ? await stagedIdsReferencedByLocalMemos(stagedResources.cleanupRewrites) : stillReferenced; - const rendererReferenced = await stagedIdsReferencedByRenderer(stagedResources.rewrites); + const rendererReferenced = await stagedIdsReferencedByRenderer(stagedResources.cleanupRewrites); await removeSyncedStagedResources(stagedResources.stagedIds.filter((stagedId) => ( !remainingReferenced.has(stagedId) && !rendererReferenced.has(stagedId) ))); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 37ad462e..2da96b11 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -72,6 +72,8 @@ interface EdgeEverDesktopBridge { completeStagedResource(id: string): Promise<{ id: string }>; abortStagedResource(id: string): Promise; listStagedResources(): Promise>; + listStagedResourceAliases?(memoId?: string): Promise>; + recordStagedResourceAlias?(id: string, uploadedUrl: string): Promise<{ id: string; resourceId: string }>; remapStagedResourceMemoIds?(mappings: Array<[string, string]>): Promise<{ updated: number }>; readStagedResource(id: string): Promise<{ name: string; type: string; bytes: Uint8Array }>; readStagedResourcePart(id: string, start: number, length: number): Promise; diff --git a/package.json b/package.json index 1ac98c47..6a2dcb7d 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", - "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", + "test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar", "verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs", "verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration", "verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs", diff --git a/scripts/verify-desktop-protocol-e2e.mjs b/scripts/verify-desktop-protocol-e2e.mjs index 660cf4b3..61d74fa9 100644 --- a/scripts/verify-desktop-protocol-e2e.mjs +++ b/scripts/verify-desktop-protocol-e2e.mjs @@ -284,6 +284,34 @@ try { throw new Error(`Cached resource protocol failed: ${JSON.stringify(cachedResource)}`); } + const aliasedResource = await evaluate(`(async () => { + const bridge = window.edgeeverDesktop; + await bridge.recordStagedResourceAlias(${JSON.stringify(staged.id)}, "/api/v1/resources/${cachedResourceId}/blob"); + await bridge.removeStagedResource(${JSON.stringify(staged.id)}); + const [pending, aliases, response, bridged] = await Promise.all([ + bridge.listStagedResources(), + bridge.listStagedResourceAliases("protocol-e2e-memo"), + fetch("edgeever-staged://${staged.id}"), + bridge.readStagedResource(${JSON.stringify(staged.id)}), + ]); + return { + pending: pending.some((item) => item.id === ${JSON.stringify(staged.id)}), + alias: aliases.find((item) => item.id === ${JSON.stringify(staged.id)})?.resourceId, + status: response.status, + text: await response.text(), + bridgeText: new TextDecoder().decode(bridged.bytes), + }; + })()`); + if ( + aliasedResource.pending + || aliasedResource.alias !== cachedResourceId + || aliasedResource.status !== 200 + || aliasedResource.text !== fixtureText + || aliasedResource.bridgeText !== fixtureText + ) { + throw new Error(`Uploaded staged resource alias failed: ${JSON.stringify(aliasedResource)}`); + } + await cdp.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: join(testDirectory, "downloads"), @@ -302,12 +330,11 @@ try { await waitForDownloadedFile(); if (!(await readFile(downloadedPath)).equals(fixtureBytes)) throw new Error("Downloaded resource bytes changed"); - await evaluate(`window.edgeeverDesktop.removeStagedResource(${JSON.stringify(staged.id)})`); console.log(JSON.stringify({ ok: true, executablePath, origin: appProtocol.origin, - checks: ["app-assets", "file-input", "staged-resource", "cached-resource-range", "download"], + checks: ["app-assets", "file-input", "staged-resource", "cached-resource-range", "staged-resource-alias", "download"], })); } catch (error) { const details = processOutput.length > 0 ? `\n\nProcess output:\n${processOutput.join("")}` : "";