fix: make staged desktop resource links durable
This commit is contained in:
1 parent
ad9fa6666a
commit
1c7ae270d3
10 files changed
+298
-35
No files matched your search
@@ -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(() => {}),
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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: "",
|
||||
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: "",
|
||||
tags: [],
|
||||
},
|
||||
)).rejects.toThrow("missing staged resource");
|
||||
expect(lastRequest?.method).not.toBe("memo.update");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop repository memo reads", () => {
|
||||
|
||||
@@ -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 <T extends { contentJson?: unknown; contentMarkdown?: string | null }>(memoId: string, memo: T): Promise<T> => {
|
||||
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<MemoDetail> => {
|
||||
if (
|
||||
!contentReferencesStagedResourceUrl(memo.contentJson)
|
||||
@@ -54,11 +72,11 @@ const repairDisplayedMemo = async (memo: MemoDetail): Promise<MemoDetail> => {
|
||||
) {
|
||||
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<ResourceListItem[]> => {
|
||||
@@ -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)
|
||||
|
||||
@@ -128,15 +128,31 @@ const remapStagedResourceMemoIds = async (memoIdMappings: ReadonlyMap<string, st
|
||||
};
|
||||
|
||||
const syncStagedResources = async (memoIdMappings: Map<string, string>) => {
|
||||
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<string, string>) => {
|
||||
], { 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<string, string>) => {
|
||||
}
|
||||
}
|
||||
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)
|
||||
)));
|
||||
|
||||
Vendored
+2
@@ -72,6 +72,8 @@ interface EdgeEverDesktopBridge {
|
||||
completeStagedResource(id: string): Promise<{ id: string }>;
|
||||
abortStagedResource(id: string): Promise<void>;
|
||||
listStagedResources(): Promise<Array<{ id: string; memoId: string; name: string; type: string; size: number }>>;
|
||||
listStagedResourceAliases?(memoId?: string): Promise<Array<{ id: string; memoId: string; resourceId: string }>>;
|
||||
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<ArrayBuffer>;
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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("")}` : "";
|
||||
|
||||
Reference in new issue
Block a user