fix: make staged desktop resource links durable

This commit is contained in:
tianma-if committed 2026-09-23 18:52:31 +08:00
1 parent ad9fa6666a
commit 1c7ae270d3
10 files changed
+298 -35

No files matched your search

+36 -11
View File
@@ -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();
});
+2
View File
@@ -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: "![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", () => {
+37 -10
View File
@@ -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)
+31 -11
View File
@@ -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)
)));
+2
View File
@@ -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
View File
@@ -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",
+29 -2
View File
@@ -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("")}` : "";