// # DosFS // API for working with file system of dosbox import CacheNoop from "./js-dos-cache-noop"; import { DosModule } from "./js-dos-module"; import { Xhr } from "./js-dos-xhr"; // ### DosArchiveSource export interface DosArchiveSource { // source (archive) to download and extract via `extractAll` // **url** where archive is located url: string; // **mountPoint** mountPoint: string; // is a path to mount archive contents. There are two types of mountPoints: // // * path '/' which is a MEMFS that is live only in one ssesion. // It means that after restart all progress will be erased. // // * any other path (e.g. '/game'). This path will be stored across sessions in indexed db. It means // that progress will be there after browser restart. // // In other words, you can use path '/' to store temporal data, but others use to store // content that need to be persisten. // // **NOTE**: because content of folder is stored in indexed db original archive is downloaded // and extracted only once to avoid rewriting stored content! And you can't store different // content (from different archives) into one path. // **type** currently we support only zip archives type?: "zip"; } // ## DosFS export class DosFS { private dos: DosModule; private em: any; // typeof Module; private fs: any; private syncingPromise: Promise | null = null; private lastSyncTime = 0; constructor(dos: DosModule) { this.dos = dos; this.em = dos as any; this.fs = (dos as any).FS; // Sync fs to indexed db periodically this.dos.registerTickListener(() => { if (Date.now() - this.lastSyncTime < 5000) { return; } this.lastSyncTime = Date.now(); this.syncFs(); }); this.dos.registerPauseListener(() => this.syncFs()); this.dos.registerTerminateListener(() => this.syncFs()); } public chdir(path: string) { this.fs.chdir(path); } // ### extract public extract(url: string, mountPoint: string = "/", type: "zip" = "zip"): Promise { // simplified version of extractAll, works only for one archive. It calls extractAll inside. return this.extractAll([{ url, mountPoint, type }]); } // ### extractAll public extractAll(sources: DosArchiveSource[]): Promise { // tslint:disable-next-line // download given [`sources`](https://js-dos.com/6.22/docs/api/generate.html?page=js-dos-fs#dosfs-dosarchivesource) // and extract them to mountPoint's. // // this method will return `Promise`, that will be resolved // on success with empty object or rejected const extractArchiveInCwd = (url: string, path: string, type: "zip") => { return new Promise((resolve, reject) => { if (type !== "zip") { reject("Only ZIP archive is supported"); return; } new Xhr(url, { cache: new CacheNoop(), responseType: "arraybuffer", fail: (msg) => reject(msg), progress: (total, loaded) => { if (this.dos.onprogress !== undefined) { this.dos.onprogress("Downloading " + url, total, loaded); } }, success: (data: ArrayBuffer) => { this.chdir(path); const bytes = new Uint8Array(data); const buffer = this.em._malloc(bytes.length); this.em.HEAPU8.set(bytes, buffer); const retcode = this.em._extract_zip(buffer, bytes.length); this.em._free(buffer); if (retcode === 0) { this.writeOk(path); resolve(); } else { reject("Can't extract zip, retcode " + retcode + ", see more info in logs"); } }, }); }); }; const prepareMountFunction = (source: DosArchiveSource) => { const mountPoint = this.normalizePath(source.mountPoint); const type = source.type || "zip"; const isRoot = mountPoint === "/" || mountPoint.length === 0; const parts = mountPoint.split("/"); this.createPath(parts, 0, parts.length); const mountFn = () => { if (isRoot || !this.readOk(mountPoint)) { if (!isRoot) { this.dos.warn("Indexed db does not contains '" + mountPoint + "' rewriting..."); } return extractArchiveInCwd(source.url, mountPoint, type); } return Promise.resolve(); }; if (!isRoot) { this.fs.mount(this.fs.filesystems.IDBFS, {}, mountPoint); } return mountFn; }; return new Promise((resolve, reject) => { if (this.lastSyncTime > 0) { reject("Can't create persistent mount point, after syncing process starts"); return; } const mountFunctions: Array<() => Promise> = []; for (const source of sources) { mountFunctions.push(prepareMountFunction(source)); } this.fs.syncfs(true, (err: any) => { if (err) { this.dos.error("Can't restore FS from indexed db, cause: " + err); } const promises: Array> = []; for (const mountFn of mountFunctions) { promises.push(mountFn()); } Promise.all(promises) .then(() => { this.syncFs().then(resolve).catch(reject); }) .catch(reject); }); }); } // ### createFile public createFile(file: string, body: ArrayBuffer | Uint8Array | string) { // [synchronous] allow to create file in FS, you can pass absolute path. // All directories will be created // // body can be string or ArrayBuffer or Uint8Array if (body instanceof ArrayBuffer) { body = new Uint8Array(body); } // windows style path are also valid, but **drive letter is ignored** // if you pass only filename, then file will be writed in root "/" directory file = file.replace(new RegExp("^[a-zA-z]+:"), "").replace(new RegExp("\\\\", "g"), "/"); const parts = file.split("/"); if (parts.length === 0) { if (this.dos.onerror !== undefined) { this.dos.onerror("Can't create file '" + file + "', because it's not valid file path"); } return; } const filename = parts[parts.length - 1].trim(); if (filename.length === 0) { if (this.dos.onerror !== undefined) { this.dos.onerror("Can't create file '" + file + "', because file name is empty"); } return; } /* i < parts.length - 1, because last part is file name */ const path = this.createPath(parts, 0, parts.length - 1); this.fs.createDataFile(path, filename, body, true, true, true); } public writeFsToFile(filename: string, fsPattern: RegExp, mountName: string) { this.fs.syncfs(false, (err: any) => { if (!err) { window.indexedDB.open(mountName).onsuccess = (e: any) => { const db = e.target.result; const content: any = {}; db.transaction(["FILE_DATA"], "readonly") .objectStore("FILE_DATA") .openCursor().onsuccess = (e: any) => { const cursor = e.target.result; if (cursor) { if (!fsPattern || fsPattern.test(cursor.key)) { const value = cursor.value; value.contents = DosFS.toBase64(value.contents); const key = cursor.key; content[key] = value; } cursor.continue(); } else { DosFS.saveToFile( filename, JSON.stringify(content)); } }; }; } }); } public readFsFromFile(file: Blob) { if (!file) { return; } const reader = new FileReader(); reader.onload = (e: any) => { if (!e.target) { return; } const content: any = e.target.result; const entries = JSON.parse(content); for (const key of Object.keys(entries)) { const value = entries[key]; const contents = DosFS.fromBase64(value.contents); if (this.fs.analyzePath(key).exists) { this.fs.unlink(key); } this.createFile(key, contents); } }; reader.readAsText(file); } private static toBase64(bytes: Uint8Array): string { let binary = ""; const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[ i ]); } return window.btoa( binary ); } private static fromBase64(base64: string): Uint8Array { const binaryString = window.atob(base64); const len = binaryString.length; const bytes = new Uint8Array( len ); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } private static saveToFile(filename: string, data: string) { const blob = new Blob([data], {type: "text/csv"}); const elem = window.document.createElement("a"); elem.href = window.URL.createObjectURL(blob); elem.download = filename; document.body.appendChild(elem); elem.click(); document.body.removeChild(elem); } private createPath(parts: string[], begin: number, end: number) { let path = ""; for (let i = begin; i < end; ++i) { const part = parts[i].trim(); if (part.length === 0) { continue; } this.fs.createPath(path, part, true, true); path = path + "/" + part; } return path; } private syncFs() { if (this.syncingPromise) { return this.syncingPromise; } this.syncingPromise = new Promise((resolve, reject) => { // @ts-ignore the unusued local for startedAt not being read const startedAt = Date.now(); this.fs.syncfs(false, (err: any) => { if (err) { this.dos.error("Can't sync FS to indexed db, cause: " + err); reject(err); } this.syncingPromise = null; this.lastSyncTime = Date.now(); resolve(); }); }); return this.syncingPromise; } private normalizePath(path: string) { if (path.length === 0 || path[0] !== "/") { path = "/" + path; } if (path.length > 1 && path.endsWith("/")) { path = path.substr(0, path.length - 1); } return path; } private readOk(path: string) { try { const readed = this.fs.readFile(path + "/state.fs"); return readed[0] === 79 && readed[1] === 70; } catch { return false; } } private writeOk(path: string) { this.createFile(path + "/state.fs", new Uint8Array([79, 70])); // Ok } }