From c60db13197e489df191d59fb8eca2376518132d6 Mon Sep 17 00:00:00 2001 From: Aikiooo <78739437+Aikiooo@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:18:11 +0200 Subject: [PATCH] fix(browser): decode binary writes and keep download blobs alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser file service ignored the third `write` argument — the `isBinary` flag that `file.writeBinary()` sets alongside its base64-encoded payload. The native handlers decode that payload, so ignoring the flag stored the base64 text itself instead of the file's contents and every binary write came out corrupted. `saveToDevice` also revoked the blob url on the next tick. The url is handed to the download manager, or to an external app on Android, after `click()` returns, so the revoke could kill the blob mid-handoff and the download failed. --- src/platforms/browser/services/file.test.ts | 95 +++++++++++++++++++++ src/platforms/browser/services/file.ts | 33 +++++-- 2 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 src/platforms/browser/services/file.test.ts diff --git a/src/platforms/browser/services/file.test.ts b/src/platforms/browser/services/file.test.ts new file mode 100644 index 0000000..eff2abe --- /dev/null +++ b/src/platforms/browser/services/file.test.ts @@ -0,0 +1,95 @@ +import opfs from "platforms/browser/lib/opfs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import fileService from "./file"; + +vi.mock("platforms/browser/lib/opfs", () => ({ + default: { + init: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})); + +const writeFile = vi.mocked(opfs.writeFile); + +function callback() { + return { + success: vi.fn(), + error: vi.fn(), + } as unknown as Callback; +} + +function writtenBytes(): ArrayBuffer { + const [path, contents] = writeFile.mock.calls[0]; + expect(path).toBe("/files/report.apk"); + return contents as ArrayBuffer; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + writeFile.mockReset(); +}); + +describe("browser file write", () => { + it("decodes the payload when the write is flagged binary", async () => { + // file.writeBinary() sends bytes as base64 with isBinary set; storing + // that string as-is is how a binary download came out corrupted. + const result = callback(); + const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + + await fileService.write(result, [ + "/files/report.apk", + btoa("PK\u0003\u0004"), + true, + ]); + + expect(result.success).toHaveBeenCalledTimes(1); + expect(new Uint8Array(writtenBytes())).toEqual(bytes); + }); + + it("stores text payloads unchanged", async () => { + const result = callback(); + + await fileService.write(result, ["/files/report.apk", "plain text"]); + + expect(result.success).toHaveBeenCalledTimes(1); + expect(writeFile).toHaveBeenCalledWith("/files/report.apk", "plain text"); + }); + + it("reports a failed write to the callback", async () => { + const result = callback(); + writeFile.mockRejectedValueOnce(new Error("disk full")); + + await fileService.write(result, ["/files/report.apk", "plain text"]); + + expect(result.error).toHaveBeenCalledWith("disk full"); + }); +}); + +describe("browser saveToDevice", () => { + it("keeps the blob url alive past the download click", async () => { + vi.useFakeTimers(); + vi.mocked(opfs.readFile).mockResolvedValue(new ArrayBuffer(4)); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + URL.createObjectURL = vi.fn(() => "blob:download"); + URL.revokeObjectURL = vi.fn(); + + const result = callback(); + await fileService.saveToDevice(result, [ + "files/report.apk", + "report.apk", + "", + "", + ]); + + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + // The url is handed to the download manager after click() returns, so + // revoking it on the next tick kills the blob mid-handoff. + vi.advanceTimersByTime(1000); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(60_000); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:download"); + }); +}); diff --git a/src/platforms/browser/services/file.ts b/src/platforms/browser/services/file.ts index 0481870..1ce3bb4 100644 --- a/src/platforms/browser/services/file.ts +++ b/src/platforms/browser/services/file.ts @@ -12,13 +12,23 @@ export default { } }, /** - * Write data to a file + * Write data to a file. + * + * `isBinary` mirrors the native FileHandler: `file.writeBinary()` sends the + * bytes base64-encoded and sets this flag, and the native handlers decode + * it. Ignoring the flag stored that base64 text instead of the file's + * contents, so every binary write through this service was corrupted. * @param {any} callback - * @param {[string, ArrayBuffer]} param1 + * @param {[string, ArrayBuffer | string, boolean?]} param1 */ - async write(callback: Callback, [path, data]: [string, ArrayBuffer]) { + async write( + callback: Callback, + [path, data, isBinary]: [string, ArrayBuffer | string, boolean?], + ) { try { - await opfs.writeFile(path, data); + const contents = + isBinary && typeof data === "string" ? base64ToArrayBuffer(data) : data; + await opfs.writeFile(path, contents); callback.success(); } catch (error) { callback.error(errorMessage(error)); @@ -114,10 +124,14 @@ export default { }); document.body.appendChild(a); a.click(); + // The blob: URL is handed to the download manager (or an external app + // on Android) after `click()` returns, so revoking it on the next tick + // can kill it mid-handoff and the download fails. Leave it long enough + // for the handoff to complete. setTimeout(() => { document.body.removeChild(a); window.URL.revokeObjectURL(url); - }, 0); + }, 60_000); if (notificationTitle) { new Notification(notificationTitle, { body: notificationBody || "", @@ -273,6 +287,15 @@ function getContentType(filename: string) { } } +function base64ToArrayBuffer(content: string): ArrayBuffer { + const binary = atob(content); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }