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";
|
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;
|
const invoke = getTauri()?.core?.invoke;
|
||||||
if (typeof invoke !== "function") {
|
if (typeof invoke !== "function") {
|
||||||
throw new Error("Tauri invoke() is unavailable in this environment.");
|
throw new Error("Tauri invoke() is unavailable in this environment.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await invoke(command)) as T;
|
return (await invoke(command, args)) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
@@ -2631,6 +2631,7 @@ dependencies = [
|
|||||||
name = "paseo"
|
name = "paseo"
|
||||||
version = "0.1.15"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.5.3", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
@@ -33,4 +34,3 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||||
@@ -53,6 +57,13 @@ struct LocalDaemonVersionResult {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AttachmentFileResult {
|
||||||
|
path: String,
|
||||||
|
byte_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_login_shell() -> String {
|
fn resolve_login_shell() -> String {
|
||||||
std::env::var("SHELL")
|
std::env::var("SHELL")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -209,6 +220,223 @@ async fn install_app_update(app: AppHandle) -> Result<AppUpdateInstallResult, St
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_attachment_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let app_data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("Failed to resolve app data directory: {error}"))?;
|
||||||
|
let attachment_dir = app_data_dir.join("paseo-desktop-attachments");
|
||||||
|
fs::create_dir_all(&attachment_dir)
|
||||||
|
.map_err(|error| format!("Failed to create attachment directory: {error}"))?;
|
||||||
|
Ok(attachment_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_extension(extension: Option<String>) -> String {
|
||||||
|
let raw = extension
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.trim_matches('.')
|
||||||
|
.to_string();
|
||||||
|
if raw.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(".{raw}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_attachment_id(attachment_id: &str) -> Result<(), String> {
|
||||||
|
if attachment_id.is_empty() {
|
||||||
|
return Err("Attachment ID cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if !attachment_id
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||||
|
{
|
||||||
|
return Err("Attachment ID contains invalid characters.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_existing_attachment_files(
|
||||||
|
attachment_dir: &Path,
|
||||||
|
attachment_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let id_prefix = format!("{attachment_id}.");
|
||||||
|
let entries = fs::read_dir(attachment_dir)
|
||||||
|
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name = file_name.to_string_lossy();
|
||||||
|
if file_name == attachment_id || file_name.starts_with(&id_prefix) {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|error| format!("Failed to remove prior attachment file: {error}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_attachment_path(attachment_dir: &Path, attachment_id: &str, extension: &str) -> PathBuf {
|
||||||
|
attachment_dir.join(format!("{attachment_id}{extension}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize_managed_attachment_path(
|
||||||
|
attachment_dir: &Path,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let candidate = PathBuf::from(path);
|
||||||
|
if !candidate.exists() {
|
||||||
|
return Err(format!("Attachment file not found at path: {path}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_candidate = fs::canonicalize(&candidate)
|
||||||
|
.map_err(|error| format!("Failed to resolve attachment path: {error}"))?;
|
||||||
|
let canonical_dir = fs::canonicalize(attachment_dir)
|
||||||
|
.map_err(|error| format!("Failed to resolve attachment directory: {error}"))?;
|
||||||
|
|
||||||
|
if !canonical_candidate.starts_with(&canonical_dir) {
|
||||||
|
return Err("Attachment path is outside managed attachment directory.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonical_candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn write_attachment_base64(
|
||||||
|
app: AppHandle,
|
||||||
|
attachment_id: String,
|
||||||
|
base64: String,
|
||||||
|
extension: Option<String>,
|
||||||
|
) -> Result<AttachmentFileResult, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
validate_attachment_id(&attachment_id)?;
|
||||||
|
let attachment_dir = resolve_attachment_dir(&app)?;
|
||||||
|
clear_existing_attachment_files(&attachment_dir, &attachment_id)?;
|
||||||
|
let normalized_extension = normalize_extension(extension);
|
||||||
|
let attachment_path =
|
||||||
|
build_attachment_path(&attachment_dir, &attachment_id, &normalized_extension);
|
||||||
|
let decoded_bytes = BASE64_STANDARD
|
||||||
|
.decode(base64.as_bytes())
|
||||||
|
.map_err(|error| format!("Failed to decode attachment base64: {error}"))?;
|
||||||
|
fs::write(&attachment_path, &decoded_bytes)
|
||||||
|
.map_err(|error| format!("Failed to write attachment file: {error}"))?;
|
||||||
|
|
||||||
|
Ok(AttachmentFileResult {
|
||||||
|
path: attachment_path.to_string_lossy().into_owned(),
|
||||||
|
byte_size: decoded_bytes.len() as u64,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Attachment write task failed: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn copy_attachment_file(
|
||||||
|
app: AppHandle,
|
||||||
|
attachment_id: String,
|
||||||
|
source_path: String,
|
||||||
|
extension: Option<String>,
|
||||||
|
) -> Result<AttachmentFileResult, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
validate_attachment_id(&attachment_id)?;
|
||||||
|
let source = PathBuf::from(source_path);
|
||||||
|
if !source.exists() {
|
||||||
|
return Err("Source attachment file does not exist.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_extension = source
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.map(|value| value.to_string());
|
||||||
|
let normalized_extension = normalize_extension(extension.or(source_extension));
|
||||||
|
let attachment_dir = resolve_attachment_dir(&app)?;
|
||||||
|
clear_existing_attachment_files(&attachment_dir, &attachment_id)?;
|
||||||
|
let destination_path =
|
||||||
|
build_attachment_path(&attachment_dir, &attachment_id, &normalized_extension);
|
||||||
|
let copied_bytes = fs::copy(&source, &destination_path)
|
||||||
|
.map_err(|error| format!("Failed to copy attachment file: {error}"))?;
|
||||||
|
|
||||||
|
Ok(AttachmentFileResult {
|
||||||
|
path: destination_path.to_string_lossy().into_owned(),
|
||||||
|
byte_size: copied_bytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Attachment copy task failed: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn read_file_base64(app: AppHandle, path: String) -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let attachment_dir = resolve_attachment_dir(&app)?;
|
||||||
|
let attachment_path = canonicalize_managed_attachment_path(&attachment_dir, &path)?;
|
||||||
|
let bytes = fs::read(&attachment_path)
|
||||||
|
.map_err(|error| format!("Failed to read attachment file: {error}"))?;
|
||||||
|
Ok(BASE64_STANDARD.encode(bytes))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Attachment read task failed: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_attachment_file(app: AppHandle, path: String) -> Result<bool, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let attachment_dir = resolve_attachment_dir(&app)?;
|
||||||
|
let attachment_path = match canonicalize_managed_attachment_path(&attachment_dir, &path) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
fs::remove_file(&attachment_path)
|
||||||
|
.map_err(|error| format!("Failed to delete attachment file: {error}"))?;
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Attachment delete task failed: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn garbage_collect_attachment_files(
|
||||||
|
app: AppHandle,
|
||||||
|
referenced_ids: Vec<String>,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let attachment_dir = resolve_attachment_dir(&app)?;
|
||||||
|
let referenced = referenced_ids.into_iter().collect::<HashSet<String>>();
|
||||||
|
let mut deleted_count = 0_u64;
|
||||||
|
|
||||||
|
let entries = fs::read_dir(&attachment_dir)
|
||||||
|
.map_err(|error| format!("Failed to scan attachment directory: {error}"))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name = file_name.to_string_lossy();
|
||||||
|
let id = file_name.split('.').next().unwrap_or_default();
|
||||||
|
if id.is_empty() || referenced.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|error| format!("Failed to delete stale attachment file: {error}"))?;
|
||||||
|
deleted_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(deleted_count)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Attachment GC task failed: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -221,7 +449,12 @@ pub fn run() {
|
|||||||
get_local_daemon_version,
|
get_local_daemon_version,
|
||||||
run_local_daemon_update,
|
run_local_daemon_update,
|
||||||
check_app_update,
|
check_app_update,
|
||||||
install_app_update
|
install_app_update,
|
||||||
|
write_attachment_base64,
|
||||||
|
copy_attachment_file,
|
||||||
|
read_file_base64,
|
||||||
|
delete_attachment_file,
|
||||||
|
garbage_collect_attachment_files
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
|
|||||||
Reference in New Issue
Block a user