diff --git a/apps/yaak-client/lib/largeValue.ts b/apps/yaak-client/lib/largeValue.ts index ce613665..ecbf737e 100644 --- a/apps/yaak-client/lib/largeValue.ts +++ b/apps/yaak-client/lib/largeValue.ts @@ -6,7 +6,6 @@ import type { SniffedValue } from "../components/core/Editor/sniffValue"; import { isEncodedRun } from "../components/core/Editor/sniffValue"; import { copyToClipboard } from "./copy"; import { fireAndForget } from "./fireAndForget"; -import { rpc } from "./rpc"; import { showToast } from "./toast"; import { platform } from "@yaakapp-internal/platform"; @@ -212,35 +211,26 @@ export function copyImage(text: string, sniffed: SniffedValue, fallback: string) }); } -/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */ -function toBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i += 0x8000) { - binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); - } - return btoa(binary); -} - /** * Writes a collapsed value to a file the user picks. * - * A value that is already base64 goes straight to the backend as-is — decoding it here only to + * A value that is already base64 is handed over as base64. These are the values + * the editor collapsed for being large, so decoding one here only for a host to * encode it again would walk megabytes twice for nothing. */ export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) { const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin"); - const filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" }); - if (filepath == null) { + const content = + sniffed == null + ? new TextEncoder().encode(text) + : sniffed.encoding === "base64" + ? { base64: normalizeBase64(payloadOf(text, sniffed)) } + : decodeValue(text, sniffed); + + const savedTo = await platform.files.save(`${name}.${ext}`, content); + if (savedTo == null) { return; // Cancelled } - const data = - sniffed == null - ? toBase64(new TextEncoder().encode(text)) - : sniffed.encoding === "base64" - ? normalizeBase64(payloadOf(text, sniffed)) - : toBase64(decodeValue(text, sniffed)); - - await rpc("cmd_save_base64_to_binary", { filepath, data }); - showToast({ message: `Saved to ${filepath}` }); + showToast({ message: `Saved to ${savedTo}` }); } diff --git a/packages/platform/src/tauri/index.ts b/packages/platform/src/tauri/index.ts index b2483838..c4280394 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -4,7 +4,7 @@ import { emit as tauriEmit, listen as tauriListen } from "@tauri-apps/api/event" import { basename, resolveResource } from "@tauri-apps/api/path"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager"; -import { open, save } from "@tauri-apps/plugin-dialog"; +import { open, save as saveDialog } from "@tauri-apps/plugin-dialog"; import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs"; import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { type as osType } from "@tauri-apps/plugin-os"; @@ -119,6 +119,19 @@ function storedBodyPath(id: string): Promise { return rpc("cmd_http_response_body_path", { responseId: id }); } +/** + * Base64 for the trip over IPC, in chunks so a few megabytes don't blow the + * argument limit. The engine's write command speaks base64; only this host has + * to care. + */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + export function createTauriPlatform(): Platform { const window = createWindow(); @@ -136,7 +149,7 @@ export function createTauriPlatform(): Platform { dialog: { // Overloaded on the interface; one implementation covers both shapes. open: ((options?: OpenDialogOptions) => open(options)) as Platform["dialog"]["open"], - save: (options) => save(options ?? {}), + save: (options) => saveDialog(options ?? {}), }, files: { @@ -145,6 +158,18 @@ export function createTauriPlatform(): Platform { url: (path) => convertFileSrc(path), basename: (path) => basename(path), resolveResource: (path) => resolveResource(path), + + async save(suggestedName, content, filters) { + const path = await saveDialog({ defaultPath: suggestedName, filters }); + if (path == null) return null; + // Not the fs plugin: its ACL is read-only and scoped to the app's own + // directories, and a path the user just picked is neither. The engine + // writes it, which is where the desktop has always written it — and it + // speaks base64, so content that is already base64 goes straight over. + const data = content instanceof Uint8Array ? toBase64(content) : content.base64; + await rpc("cmd_save_base64_to_binary", { filepath: path, data }); + return path; + }, }, blobs: { diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index 4cf3da1a..52119da7 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -40,6 +40,17 @@ export interface RpcStreamHandle { unlisten: Unsubscribe; } +/** + * What to save: the bytes, or base64 of them for a caller that already has it + * that way. + * + * Both forms exist because both hosts want a different one, and a value that + * arrives base64 should not be decoded and re-encoded to get back where it + * started. Whichever a caller has is the one to pass; each host converts only + * when it has to. + */ +export type SaveContent = Uint8Array | { base64: string }; + export interface DialogFilter { name: string; extensions: string[]; @@ -157,6 +168,25 @@ export interface PlatformFiles { /** Resolve a path bundled with the app itself, rather than one from the backend. */ resolveResource(path: string): Promise; + + /** + * Put bytes somewhere the user chooses. Returns where they went — a path, a + * filename, whatever this host can say — or null if the user backed out. + * + * The caller supplies the bytes, which is the whole point: the alternative + * shape, where a dialog mints a path and a separate backend command writes to + * it, can only work on a host that has both a filesystem and a backend. A tab + * has neither, so every one of those call sites was a dead control. Whoever + * produces the bytes already knows what they are; the host only has to know + * where they go. + * + * `suggestedName` is what the save dialog opens with, extension included. + */ + save( + suggestedName: string, + content: SaveContent, + filters?: DialogFilter[], + ): Promise; } /** diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index 9f9c9d37..787a3a5b 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -16,6 +16,7 @@ */ import type { + DialogFilter, DragDropEvent, OsType, Platform, @@ -23,6 +24,7 @@ import type { PlatformWindow, RpcPayload, RpcStreamHandle, + SaveContent, Unsubscribe, } from "../types"; import { commandSupport, runCommand } from "./commands"; @@ -108,6 +110,91 @@ async function hostPluginCommand(cmd: string, payload?: RpcPayload): Promise< } } +/** + * The File System Access API, where the browser has it. Chromium does; Firefox + * and Safari do not, and fall through to a download below. + */ +interface SaveFilePicker { + (options: { + suggestedName?: string; + types?: { description: string; accept: Record }[]; + }): Promise<{ + name: string; + createWritable(): Promise<{ write(data: Uint8Array): Promise; close(): Promise }>; + }>; +} + +/** + * Saving, the only way a page can: a real save dialog where the browser offers + * one, and a download everywhere else. + * + * The return value is a name rather than a path because a name is all a tab + * ever learns. Callers show it back to the user and nothing more, which is why + * the interface promises "where they went" rather than a path. + */ +function toBytes(content: SaveContent): Uint8Array { + if (content instanceof Uint8Array) return content; + const binary = atob(content.base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +async function saveBytes( + suggestedName: string, + content: SaveContent, + filters?: DialogFilter[], +): Promise { + const picker = (window as unknown as { showSaveFilePicker?: SaveFilePicker }).showSaveFilePicker; + const bytes = toBytes(content); + + if (picker != null) { + let handle; + try { + handle = await picker({ + suggestedName, + types: filters?.map((f) => ({ + description: f.name, + // The API keys accepted extensions by MIME type. Nothing here knows + // the real one, and it only labels the dialog's filter, so the + // catch-all is honest enough. + accept: { "application/octet-stream": f.extensions.map((e) => `.${e}`) }, + })), + }); + } catch (err) { + // Only choosing a file is allowed to fall back. Backing out is not a + // failure and must not become a download nobody asked for; anything else + // here is a browser that won't show the dialog, which a download covers. + if ((err as { name?: string } | null)?.name === "AbortError") return null; + console.warn("Save dialog unavailable, falling back to a download", err); + } + + // Past the dialog, the user has named a destination. A write that fails + // there is a failed save, and saying so beats quietly putting the file + // somewhere else and reporting success. + if (handle != null) { + const writable = await handle.createWritable(); + await writable.write(bytes); + await writable.close(); + return handle.name; + } + } + + // No dialog: the file lands wherever the browser puts downloads, under the + // name we suggested. The user is not asked, so there is nothing to cancel. + const url = URL.createObjectURL(new Blob([bytes as BlobPart])); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = suggestedName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Revoked on a later turn: revoking while the browser is still reading the + // blob cancels the download in some of them. + setTimeout(() => URL.revokeObjectURL(url), 30_000); + return suggestedName; +} + function createWindow(db: WorkerConnection): PlatformWindow { const noop = async () => {}; @@ -222,6 +309,8 @@ export function createWebPlatform(): Platform { url: (path) => path, basename: async (path) => path.split(/[/\\]/).pop() ?? path, resolveResource: async (path) => path, + + save: saveBytes, }, /**