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
@@ -6,7 +6,6 @@ import type { SniffedValue } from "../components/core/Editor/sniffValue";
|
|||||||
import { isEncodedRun } from "../components/core/Editor/sniffValue";
|
import { isEncodedRun } from "../components/core/Editor/sniffValue";
|
||||||
import { copyToClipboard } from "./copy";
|
import { copyToClipboard } from "./copy";
|
||||||
import { fireAndForget } from "./fireAndForget";
|
import { fireAndForget } from "./fireAndForget";
|
||||||
import { rpc } from "./rpc";
|
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
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.
|
* 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.
|
* encode it again would walk megabytes twice for nothing.
|
||||||
*/
|
*/
|
||||||
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
|
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
|
||||||
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
|
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
|
||||||
const filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
|
const content =
|
||||||
if (filepath == null) {
|
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
|
return; // Cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
const data =
|
showToast({ message: `Saved to ${savedTo}` });
|
||||||
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}` });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { basename, resolveResource } from "@tauri-apps/api/path";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
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 { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||||
import { type as osType } from "@tauri-apps/plugin-os";
|
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 });
|
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 {
|
export function createTauriPlatform(): Platform {
|
||||||
const window = createWindow();
|
const window = createWindow();
|
||||||
|
|
||||||
@@ -136,7 +149,7 @@ export function createTauriPlatform(): Platform {
|
|||||||
dialog: {
|
dialog: {
|
||||||
// Overloaded on the interface; one implementation covers both shapes.
|
// Overloaded on the interface; one implementation covers both shapes.
|
||||||
open: ((options?: OpenDialogOptions) => open(options)) as Platform["dialog"]["open"],
|
open: ((options?: OpenDialogOptions) => open(options)) as Platform["dialog"]["open"],
|
||||||
save: (options) => save(options ?? {}),
|
save: (options) => saveDialog(options ?? {}),
|
||||||
},
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
@@ -145,6 +158,18 @@ export function createTauriPlatform(): Platform {
|
|||||||
url: (path) => convertFileSrc(path),
|
url: (path) => convertFileSrc(path),
|
||||||
basename: (path) => basename(path),
|
basename: (path) => basename(path),
|
||||||
resolveResource: (path) => resolveResource(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: {
|
blobs: {
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ export interface RpcStreamHandle<T> {
|
|||||||
unlisten: Unsubscribe;
|
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 {
|
export interface DialogFilter {
|
||||||
name: string;
|
name: string;
|
||||||
extensions: string[];
|
extensions: string[];
|
||||||
@@ -157,6 +168,25 @@ export interface PlatformFiles {
|
|||||||
|
|
||||||
/** Resolve a path bundled with the app itself, rather than one from the backend. */
|
/** Resolve a path bundled with the app itself, rather than one from the backend. */
|
||||||
resolveResource(path: string): Promise<string>;
|
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 {
|
import type {
|
||||||
|
DialogFilter,
|
||||||
DragDropEvent,
|
DragDropEvent,
|
||||||
OsType,
|
OsType,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -23,6 +24,7 @@ import type {
|
|||||||
PlatformWindow,
|
PlatformWindow,
|
||||||
RpcPayload,
|
RpcPayload,
|
||||||
RpcStreamHandle,
|
RpcStreamHandle,
|
||||||
|
SaveContent,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { commandSupport, runCommand } from "./commands";
|
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 {
|
function createWindow(db: WorkerConnection): PlatformWindow {
|
||||||
const noop = async () => {};
|
const noop = async () => {};
|
||||||
|
|
||||||
@@ -222,6 +309,8 @@ export function createWebPlatform(): Platform {
|
|||||||
url: (path) => path,
|
url: (path) => path,
|
||||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||||
resolveResource: async (path) => path,
|
resolveResource: async (path) => path,
|
||||||
|
|
||||||
|
save: saveBytes,
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user