desktop: make tauri attachments filesystem-only
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDesktopAttachmentStore } from "./desktop-attachment-store";
|
||||
|
||||
const {
|
||||
copyDesktopAttachmentFileMock,
|
||||
writeDesktopAttachmentBase64Mock,
|
||||
deleteDesktopAttachmentFileMock,
|
||||
garbageCollectDesktopAttachmentFilesMock,
|
||||
readDesktopFileBase64Mock,
|
||||
resolveDesktopPreviewUrlMock,
|
||||
releaseDesktopPreviewUrlMock,
|
||||
} = vi.hoisted(() => ({
|
||||
copyDesktopAttachmentFileMock: vi.fn(async () => ({
|
||||
path: "/managed/att_1.png",
|
||||
byteSize: 4,
|
||||
})),
|
||||
writeDesktopAttachmentBase64Mock: vi.fn(async () => ({
|
||||
path: "/managed/att_2.png",
|
||||
byteSize: 4,
|
||||
})),
|
||||
deleteDesktopAttachmentFileMock: vi.fn(async () => true),
|
||||
garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0),
|
||||
readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="),
|
||||
resolveDesktopPreviewUrlMock: vi.fn(async () => "blob:test"),
|
||||
releaseDesktopPreviewUrlMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./desktop-file-commands", () => ({
|
||||
copyDesktopAttachmentFile: copyDesktopAttachmentFileMock,
|
||||
writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock,
|
||||
deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock,
|
||||
garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock,
|
||||
}));
|
||||
|
||||
vi.mock("./desktop-preview-url", () => ({
|
||||
readDesktopFileBase64: readDesktopFileBase64Mock,
|
||||
resolveDesktopPreviewUrl: resolveDesktopPreviewUrlMock,
|
||||
releaseDesktopPreviewUrl: releaseDesktopPreviewUrlMock,
|
||||
}));
|
||||
|
||||
describe("desktop attachment store", () => {
|
||||
beforeEach(() => {
|
||||
copyDesktopAttachmentFileMock.mockClear();
|
||||
writeDesktopAttachmentBase64Mock.mockClear();
|
||||
deleteDesktopAttachmentFileMock.mockClear();
|
||||
garbageCollectDesktopAttachmentFilesMock.mockClear();
|
||||
readDesktopFileBase64Mock.mockClear();
|
||||
resolveDesktopPreviewUrlMock.mockClear();
|
||||
releaseDesktopPreviewUrlMock.mockClear();
|
||||
});
|
||||
|
||||
it("saves dropped file paths as desktop-file metadata", async () => {
|
||||
const store = createDesktopAttachmentStore();
|
||||
const attachment = await store.save({
|
||||
id: "att_1",
|
||||
mimeType: "image/png",
|
||||
source: {
|
||||
kind: "file_uri",
|
||||
uri: "file:///Users/test/Desktop/image.png",
|
||||
},
|
||||
});
|
||||
|
||||
expect(copyDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
||||
attachmentId: "att_1",
|
||||
sourcePath: "/Users/test/Desktop/image.png",
|
||||
extension: ".png",
|
||||
});
|
||||
expect(attachment.storageType).toBe("desktop-file");
|
||||
expect(attachment.storageKey).toBe("/managed/att_1.png");
|
||||
});
|
||||
|
||||
it("saves blob/data-url sources via desktop filesystem writes", async () => {
|
||||
const store = createDesktopAttachmentStore();
|
||||
await store.save({
|
||||
id: "att_2",
|
||||
source: {
|
||||
kind: "data_url",
|
||||
dataUrl: "data:image/png;base64,AAECAw==",
|
||||
},
|
||||
});
|
||||
|
||||
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
|
||||
attachmentId: "att_2",
|
||||
base64: "AAECAw==",
|
||||
extension: ".png",
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates encode/preview/delete/gc to desktop command path", async () => {
|
||||
const store = createDesktopAttachmentStore();
|
||||
const attachment = {
|
||||
id: "att_3",
|
||||
mimeType: "image/jpeg",
|
||||
storageType: "desktop-file" as const,
|
||||
storageKey: "/managed/att_3.jpg",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
await store.encodeBase64({ attachment });
|
||||
await store.resolvePreviewUrl({ attachment });
|
||||
await store.releasePreviewUrl?.({ attachment, url: "blob:test" });
|
||||
await store.delete({ attachment });
|
||||
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
||||
|
||||
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
|
||||
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
||||
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
||||
path: "/managed/att_3.jpg",
|
||||
});
|
||||
expect(garbageCollectDesktopAttachmentFilesMock).toHaveBeenCalledWith({
|
||||
referencedIds: ["att_3"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { AttachmentMetadata, AttachmentStore } from "@/attachments/types";
|
||||
import {
|
||||
blobToBase64,
|
||||
fileUriToPath,
|
||||
generateAttachmentId,
|
||||
getFileExtensionFromName,
|
||||
normalizeMimeType,
|
||||
parseDataUrl,
|
||||
} from "@/attachments/utils";
|
||||
import {
|
||||
copyDesktopAttachmentFile,
|
||||
deleteDesktopAttachmentFile,
|
||||
garbageCollectDesktopAttachmentFiles,
|
||||
writeDesktopAttachmentBase64,
|
||||
} from "@/desktop/attachments/desktop-file-commands";
|
||||
import {
|
||||
readDesktopFileBase64,
|
||||
releaseDesktopPreviewUrl,
|
||||
resolveDesktopPreviewUrl,
|
||||
} from "@/desktop/attachments/desktop-preview-url";
|
||||
|
||||
const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
"image/tiff": ".tiff",
|
||||
"image/bmp": ".bmp",
|
||||
"image/svg+xml": ".svg",
|
||||
};
|
||||
|
||||
function inferFileNameFromPath(path: string): string | null {
|
||||
const normalizedPath = path.replace(/\\/g, "/");
|
||||
const parts = normalizedPath.split("/");
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return lastPart && lastPart.length > 0 ? lastPart : null;
|
||||
}
|
||||
|
||||
function extensionForAttachment(input: {
|
||||
fileName?: string | null;
|
||||
sourcePath?: string | null;
|
||||
mimeType: string;
|
||||
}): string {
|
||||
const fromName = getFileExtensionFromName(input.fileName);
|
||||
if (fromName) {
|
||||
return fromName;
|
||||
}
|
||||
|
||||
const fromSourcePath = getFileExtensionFromName(input.sourcePath);
|
||||
if (fromSourcePath) {
|
||||
return fromSourcePath;
|
||||
}
|
||||
|
||||
return IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType] ?? ".img";
|
||||
}
|
||||
|
||||
function toDesktopMetadata(input: {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
storagePath: string;
|
||||
byteSize: number;
|
||||
fileName?: string | null;
|
||||
}): AttachmentMetadata {
|
||||
return {
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
storageType: "desktop-file",
|
||||
storageKey: input.storagePath,
|
||||
fileName: input.fileName ?? null,
|
||||
byteSize: input.byteSize,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDesktopAttachmentFromFileUri(input: {
|
||||
id: string;
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const sourcePath = fileUriToPath(input.uri);
|
||||
const fileName = input.fileName ?? inferFileNameFromPath(sourcePath);
|
||||
const mimeType = normalizeMimeType(input.mimeType);
|
||||
const extension = extensionForAttachment({
|
||||
fileName,
|
||||
sourcePath,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await copyDesktopAttachmentFile({
|
||||
attachmentId: input.id,
|
||||
sourcePath,
|
||||
extension,
|
||||
});
|
||||
|
||||
return toDesktopMetadata({
|
||||
id: input.id,
|
||||
mimeType,
|
||||
storagePath: result.path,
|
||||
byteSize: result.byteSize,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDesktopAttachmentFromBase64(input: {
|
||||
id: string;
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
fileName?: string | null;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const extension = extensionForAttachment({
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
});
|
||||
|
||||
const result = await writeDesktopAttachmentBase64({
|
||||
attachmentId: input.id,
|
||||
base64: input.base64,
|
||||
extension,
|
||||
});
|
||||
|
||||
return toDesktopMetadata({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
storagePath: result.path,
|
||||
byteSize: result.byteSize,
|
||||
fileName: input.fileName,
|
||||
});
|
||||
}
|
||||
|
||||
function assertDesktopAttachment(attachment: AttachmentMetadata): void {
|
||||
if (attachment.storageType !== "desktop-file") {
|
||||
throw new Error(
|
||||
`Unsupported desktop attachment storage type '${attachment.storageType}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDesktopAttachmentStore(): AttachmentStore {
|
||||
return {
|
||||
storageType: "desktop-file",
|
||||
|
||||
async save(input): Promise<AttachmentMetadata> {
|
||||
const id = input.id ?? generateAttachmentId();
|
||||
const fileName = input.fileName ?? null;
|
||||
|
||||
if (input.source.kind === "file_uri") {
|
||||
return await saveDesktopAttachmentFromFileUri({
|
||||
id,
|
||||
uri: input.source.uri,
|
||||
mimeType: input.mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.source.kind === "data_url") {
|
||||
const parsed = parseDataUrl(input.source.dataUrl);
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType);
|
||||
return await saveDesktopAttachmentFromBase64({
|
||||
id,
|
||||
base64: parsed.base64,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
|
||||
const base64 = await blobToBase64(input.source.blob);
|
||||
return await saveDesktopAttachmentFromBase64({
|
||||
id,
|
||||
base64,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
},
|
||||
|
||||
async encodeBase64({ attachment }): Promise<string> {
|
||||
assertDesktopAttachment(attachment);
|
||||
return await readDesktopFileBase64(attachment.storageKey);
|
||||
},
|
||||
|
||||
async resolvePreviewUrl({ attachment }): Promise<string> {
|
||||
assertDesktopAttachment(attachment);
|
||||
return await resolveDesktopPreviewUrl(attachment);
|
||||
},
|
||||
|
||||
async releasePreviewUrl({ attachment, url }): Promise<void> {
|
||||
assertDesktopAttachment(attachment);
|
||||
await releaseDesktopPreviewUrl({ url });
|
||||
},
|
||||
|
||||
async delete({ attachment }): Promise<void> {
|
||||
assertDesktopAttachment(attachment);
|
||||
await deleteDesktopAttachmentFile({ path: attachment.storageKey });
|
||||
},
|
||||
|
||||
async garbageCollect({ referencedIds }): Promise<void> {
|
||||
await garbageCollectDesktopAttachmentFiles({
|
||||
referencedIds: Array.from(referencedIds),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
|
||||
interface AttachmentFileResult {
|
||||
path: string;
|
||||
byteSize: number;
|
||||
}
|
||||
|
||||
export async function writeDesktopAttachmentBase64(input: {
|
||||
attachmentId: string;
|
||||
base64: string;
|
||||
extension?: string | null;
|
||||
}): Promise<AttachmentFileResult> {
|
||||
return await invokeDesktopCommand<AttachmentFileResult>(
|
||||
"write_attachment_base64",
|
||||
{
|
||||
attachmentId: input.attachmentId,
|
||||
base64: input.base64,
|
||||
extension: input.extension ?? null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyDesktopAttachmentFile(input: {
|
||||
attachmentId: string;
|
||||
sourcePath: string;
|
||||
extension?: string | null;
|
||||
}): Promise<AttachmentFileResult> {
|
||||
return await invokeDesktopCommand<AttachmentFileResult>("copy_attachment_file", {
|
||||
attachmentId: input.attachmentId,
|
||||
sourcePath: input.sourcePath,
|
||||
extension: input.extension ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDesktopAttachmentFile(input: {
|
||||
path: string;
|
||||
}): Promise<boolean> {
|
||||
return await invokeDesktopCommand<boolean>("delete_attachment_file", {
|
||||
path: input.path,
|
||||
});
|
||||
}
|
||||
|
||||
export async function garbageCollectDesktopAttachmentFiles(input: {
|
||||
referencedIds: readonly string[];
|
||||
}): Promise<number> {
|
||||
return await invokeDesktopCommand<number>("garbage_collect_attachment_files", {
|
||||
referencedIds: [...input.referencedIds],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { invokeDesktopCommandMock } = vi.hoisted(() => ({
|
||||
invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({
|
||||
invokeDesktopCommand: invokeDesktopCommandMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
__desktopPreviewUrlTestUtils,
|
||||
releaseDesktopPreviewUrl,
|
||||
resolveDesktopPreviewUrl,
|
||||
} from "./desktop-preview-url";
|
||||
|
||||
describe("desktop preview URLs", () => {
|
||||
const createObjectURL = vi.fn(() => "blob:desktop-preview-1");
|
||||
const revokeObjectURL = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("URL", {
|
||||
createObjectURL,
|
||||
revokeObjectURL,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
createObjectURL.mockClear();
|
||||
revokeObjectURL.mockClear();
|
||||
invokeDesktopCommandMock.mockClear();
|
||||
__desktopPreviewUrlTestUtils.clearActiveObjectUrls();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resolves renderer-safe blob URLs for desktop attachments", async () => {
|
||||
const url = await resolveDesktopPreviewUrl({
|
||||
id: "att-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "desktop-file",
|
||||
storageKey: "/tmp/att-1.png",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
expect(invokeDesktopCommandMock).toHaveBeenCalledWith("read_file_base64", {
|
||||
path: "/tmp/att-1.png",
|
||||
});
|
||||
expect(url).toBe("blob:desktop-preview-1");
|
||||
expect(url.startsWith("asset://")).toBe(false);
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases blob URLs via URL.revokeObjectURL", async () => {
|
||||
const url = await resolveDesktopPreviewUrl({
|
||||
id: "att-2",
|
||||
mimeType: "image/jpeg",
|
||||
storageType: "desktop-file",
|
||||
storageKey: "/tmp/att-2.jpg",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
await releaseDesktopPreviewUrl({ url });
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:desktop-preview-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { fileUriToPath } from "@/attachments/utils";
|
||||
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const activeObjectUrls = new Set<string>();
|
||||
|
||||
export async function readDesktopFileBase64(pathOrUri: string): Promise<string> {
|
||||
return await invokeDesktopCommand<string>("read_file_base64", {
|
||||
path: fileUriToPath(pathOrUri),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveDesktopPreviewUrl(
|
||||
attachment: AttachmentMetadata
|
||||
): Promise<string> {
|
||||
const base64 = await readDesktopFileBase64(attachment.storageKey);
|
||||
|
||||
if (
|
||||
typeof URL !== "undefined" &&
|
||||
typeof URL.createObjectURL === "function"
|
||||
) {
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
const blob = new Blob([buffer], { type: attachment.mimeType });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
activeObjectUrls.add(objectUrl);
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
return `data:${attachment.mimeType};base64,${base64}`;
|
||||
}
|
||||
|
||||
export async function releaseDesktopPreviewUrl(input: {
|
||||
url: string;
|
||||
}): Promise<void> {
|
||||
if (!activeObjectUrls.has(input.url)) {
|
||||
return;
|
||||
}
|
||||
activeObjectUrls.delete(input.url);
|
||||
|
||||
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(input.url);
|
||||
}
|
||||
}
|
||||
|
||||
export const __desktopPreviewUrlTestUtils = {
|
||||
base64ToUint8Array,
|
||||
clearActiveObjectUrls: () => {
|
||||
activeObjectUrls.clear();
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export async function invokeDesktopCommand<T>(command: string): Promise<T> {
|
||||
export async function invokeDesktopCommand<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const invoke = getTauri()?.core?.invoke;
|
||||
if (typeof invoke !== "function") {
|
||||
throw new Error("Tauri invoke() is unavailable in this environment.");
|
||||
}
|
||||
|
||||
return (await invoke(command)) as T;
|
||||
return (await invoke(command, args)) as T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user