mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-16 23:01:31 +02:00
Add a way for a host to save bytes (#678)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cef569ee15
commit
4859aba142
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user