Add a way for a host to save bytes (#678)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-15 14:01:47 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent cef569ee15
commit 4859aba142
4 changed files with 158 additions and 24 deletions
+27 -2
View File
@@ -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<string | null> {
return rpc<string | null>("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: {
+30
View File
@@ -40,6 +40,17 @@ export interface RpcStreamHandle<T> {
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<string>;
/**
* 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<string | null>;
}
/**
+89
View File
@@ -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<T>(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<string, string[]> }[];
}): Promise<{
name: string;
createWritable(): Promise<{ write(data: Uint8Array): Promise<void>; close(): Promise<void> }>;
}>;
}
/**
* 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<string | null> {
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,
},
/**