diff --git a/Cargo.lock b/Cargo.lock index bdc1ec121..f6c57ef5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4662,6 +4662,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "zip", ] [[package]] diff --git a/web/Cargo.toml b/web/Cargo.toml index c8de1fb97..2e83437b0 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -59,6 +59,7 @@ gloo-net = { version = "0.5.0", default-features = false, features = ["websocke rfd = { version = "0.14.1", features = ["file-handle-inner"] } wasm-streams = "0.4.0" futures = { workspace = true } +zip = { version = "2.1.2", default-features = false, features = ["deflate"]} [dependencies.ruffle_core] path = "../core" diff --git a/web/package-lock.json b/web/package-lock.json index 20300a8ae..d36d809a6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4376,7 +4376,8 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true }, "node_modules/cosmiconfig": { "version": "9.0.0", @@ -7162,7 +7163,8 @@ "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -7231,7 +7233,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/ini": { "version": "1.3.8", @@ -7532,7 +7535,8 @@ "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true }, "node_modules/isexe": { "version": "2.0.0", @@ -8062,6 +8066,7 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", @@ -8073,6 +8078,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8086,12 +8092,14 @@ "node_modules/jszip/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true }, "node_modules/jszip/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8222,6 +8230,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, "dependencies": { "immediate": "~3.0.5" } @@ -9503,7 +9512,8 @@ "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true }, "node_modules/parent-module": { "version": "1.0.1", @@ -9919,7 +9929,8 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true }, "node_modules/progress": { "version": "2.0.3", @@ -11165,7 +11176,8 @@ "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -12500,7 +12512,8 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true }, "node_modules/utils-merge": { "version": "1.0.1", @@ -13581,7 +13594,6 @@ "version": "0.1.0", "license": "(MIT OR Apache-2.0)", "dependencies": { - "jszip": "^3.10.1", "wasm-feature-detect": "^1.6.1" }, "devDependencies": { diff --git a/web/packages/core/package.json b/web/packages/core/package.json index 835a86ce5..99da5f7f6 100644 --- a/web/packages/core/package.json +++ b/web/packages/core/package.json @@ -18,7 +18,6 @@ "checkTypes": "tsc --noemit" }, "dependencies": { - "jszip": "^3.10.1", "wasm-feature-detect": "^1.6.1" }, "devDependencies": { diff --git a/web/packages/core/src/load-ruffle.ts b/web/packages/core/src/load-ruffle.ts index b5cceefc0..2c5381b46 100644 --- a/web/packages/core/src/load-ruffle.ts +++ b/web/packages/core/src/load-ruffle.ts @@ -9,7 +9,7 @@ import { signExtensions, referenceTypes, } from "wasm-feature-detect"; -import type { RuffleInstanceBuilder } from "../dist/ruffle_web"; +import type { RuffleInstanceBuilder, ZipWriter } from "../dist/ruffle_web"; import { setPolyfillsOnLoad } from "./js-polyfills"; import { publicPath } from "./public-path"; import { BaseLoadOptions } from "./load-options"; @@ -35,7 +35,7 @@ type ProgressCallback = (bytesLoaded: number, bytesTotal: number) => void; async function fetchRuffle( config: BaseLoadOptions, progressCallback?: ProgressCallback, -): Promise { +): Promise<[typeof RuffleInstanceBuilder, typeof ZipWriter]> { // Apply some pure JavaScript polyfills to prevent conflicts with external // libraries, if needed. setPolyfillsOnLoad(); @@ -65,7 +65,11 @@ async function fetchRuffle( // Note: The argument passed to import() has to be a simple string literal, // otherwise some bundler will get confused and won't include the module? - const { default: init, RuffleInstanceBuilder } = await (extensionsSupported + const { + default: init, + RuffleInstanceBuilder, + ZipWriter, + } = await (extensionsSupported ? import("../dist/ruffle_web-wasm_extensions") : import("../dist/ruffle_web")); let response; @@ -113,10 +117,12 @@ async function fetchRuffle( await init(response); - return RuffleInstanceBuilder; + return [RuffleInstanceBuilder, ZipWriter]; } -let nativeConstructor: Promise | null = null; +let nativeConstructors: Promise< + [typeof RuffleInstanceBuilder, typeof ZipWriter] +> | null = null; /** * Obtain an instance of `Ruffle`. @@ -130,11 +136,11 @@ let nativeConstructor: Promise | null = null; export async function createRuffleBuilder( config: BaseLoadOptions, progressCallback?: ProgressCallback, -): Promise { - if (nativeConstructor === null) { - nativeConstructor = fetchRuffle(config, progressCallback); +): Promise<[RuffleInstanceBuilder, () => ZipWriter]> { + if (nativeConstructors === null) { + nativeConstructors = fetchRuffle(config, progressCallback); } - const constructor = await nativeConstructor; - return new constructor(); + const constructors = await nativeConstructors; + return [new constructors[0](), () => new constructors[1]()]; } diff --git a/web/packages/core/src/ruffle-player.ts b/web/packages/core/src/ruffle-player.ts index 865b7a9e5..b4e297ac5 100644 --- a/web/packages/core/src/ruffle-player.ts +++ b/web/packages/core/src/ruffle-player.ts @@ -1,4 +1,4 @@ -import type { RuffleHandle } from "../dist/ruffle_web"; +import type { RuffleHandle, ZipWriter } from "../dist/ruffle_web"; import { createRuffleBuilder } from "./load-ruffle"; import { applyStaticStyles, ruffleShadowTemplate } from "./shadow-template"; import { lookupElement } from "./register-element"; @@ -15,7 +15,6 @@ import type { MovieMetadata } from "./movie-metadata"; import { swfFileName } from "./swf-utils"; import { buildInfo } from "./build-info"; import { text, textAsParagraphs } from "./i18n"; -import JSZip from "jszip"; import { isExtension } from "./current-script"; import { configureBuilder } from "./internal/builder"; @@ -167,6 +166,7 @@ export class RufflePlayer extends HTMLElement { private swfUrl?: URL; private instance: RuffleHandle | null; + private newZipWriter: (() => ZipWriter) | null; private lastActivePlayingState: boolean; private _metadata: MovieMetadata | null; @@ -333,6 +333,7 @@ export class RufflePlayer extends HTMLElement { ); this.instance = null; + this.newZipWriter = null; this.onFSCommand = null; this._readyState = ReadyState.HaveNothing; @@ -673,7 +674,8 @@ export class RufflePlayer extends HTMLElement { 'The configuration option contextMenu no longer takes a boolean. Use "on", "off", or "rightClickOnly".', ); } - const builder = await createRuffleBuilder( + + const [builder, zipWriterClass] = await createRuffleBuilder( this.loadedConfig || {}, this.onRuffleDownloadProgress.bind(this), ).catch((e) => { @@ -714,6 +716,7 @@ export class RufflePlayer extends HTMLElement { this.panic(e); throw e; }); + this.newZipWriter = zipWriterClass; configureBuilder(builder, this.loadedConfig || {}); builder.setVolume(this.volumeSettings.get_volume()); @@ -1159,13 +1162,17 @@ export class RufflePlayer extends HTMLElement { event.pointerType === "touch" || event.pointerType === "pen"; } - private base64ToBlob(bytesBase64: string, mimeString: string): Blob { + private base64ToArray(bytesBase64: string): Uint8Array { const byteString = atob(bytesBase64); - const ab = new ArrayBuffer(byteString.length); - const ia = new Uint8Array(ab); + const ia = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } + return ia; + } + + private base64ToBlob(bytesBase64: string, mimeString: string): Blob { + const ab = this.base64ToArray(bytesBase64); const blob = new Blob([ab], { type: mimeString }); return blob; } @@ -1342,16 +1349,13 @@ export class RufflePlayer extends HTMLElement { * Gets the local save information as SOL files and downloads them as a single ZIP file. */ private async backupSaves(): Promise { - const zip = new JSZip(); + const zip = this.newZipWriter!(); const duplicateNames: string[] = []; Object.keys(localStorage).forEach((key) => { let solName = String(key.split("/").pop()); const solData = localStorage.getItem(key); if (solData && this.isB64SOL(solData)) { - const blob = this.base64ToBlob( - solData, - "application/octet-stream", - ); + const array = this.base64ToArray(solData); const duplicate = duplicateNames.filter( (value) => value === solName, ).length; @@ -1359,10 +1363,10 @@ export class RufflePlayer extends HTMLElement { if (duplicate > 0) { solName += ` (${duplicate + 1})`; } - zip.file(solName + ".sol", blob); + zip.addFile(solName + ".sol", array); } }); - const blob = await zip.generateAsync({ type: "blob" }); + const blob = new Blob([zip.save()]); this.saveFile(blob, "saves.zip"); } diff --git a/web/src/lib.rs b/web/src/lib.rs index 2b7210496..5f9bf1117 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -10,6 +10,7 @@ mod log_adapter; mod navigator; mod storage; mod ui; +mod zip; use crate::builder::RuffleInstanceBuilder; use external_interface::{external_to_js_value, js_to_external_value}; diff --git a/web/src/zip.rs b/web/src/zip.rs new file mode 100644 index 000000000..0ae4b934a --- /dev/null +++ b/web/src/zip.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::io::Write; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; +use zip::write::SimpleFileOptions; + +#[wasm_bindgen] +#[derive(Default)] +pub struct ZipWriter { + files: HashMap>, +} + +#[wasm_bindgen] +impl ZipWriter { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } + + #[wasm_bindgen(js_name = "addFile")] + pub fn add_file(&mut self, name: String, bytes: Vec) { + self.files.insert(name, bytes); + } + + pub fn save(&self) -> Result, JsValue> { + let mut buffer = Vec::new(); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buffer)); + for (name, content) in &self.files { + zip.start_file(name.to_string(), SimpleFileOptions::default()) + .map_err(|e| e.to_string())?; + zip.write_all(content).map_err(|e| e.to_string())?; + } + + zip.finish().map_err(|e| e.to_string())?; + Ok(buffer) + } +}