"use strict"; /* * Copyright (c) 2013-2025 Vanessa Freudenberg * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ Object.extend(Squeak, "files", { fsck: function(whenDone, dir, files, stale, stats) { dir = dir || ""; stale = stale || {dirs: [], files: []}; stats = stats || {dirs: 0, files: 0, bytes: 0, deleted: 0}; if (!files) { // find existing files files = {}; // ... in localStorage Object.keys(Squeak.Settings).forEach(function(key) { var match = key.match(/squeak-file(\.lz)?:(.*)$/); if (match) {files[match[2]] = true}; }); // ... or in memory if (window.SqueakDBFake) Object.keys(SqueakDBFake.bigFiles).forEach(function(path) { files[path] = true; }); // ... or in IndexedDB (the normal case) if (typeof indexedDB !== "undefined") { return this.dbTransaction("readonly", "fsck cursor", function(fileStore) { var cursorReq = fileStore.openCursor(); cursorReq.onsuccess = function(e) { var cursor = e.target.result; if (cursor) { files[cursor.key] = cursor.value.byteLength; cursor.continue(); } else { // got all files Squeak.fsck(whenDone, dir, files, stale, stats); } } cursorReq.onerror = function(e) { console.error("fsck failed"); } }); } // otherwise fall through } // check directories var entries = Squeak.dirList(dir); for (var name in entries) { var path = dir + "/" + name, isDir = entries[name][3]; if (isDir) { stats.dirs++; var exists = "squeak:" + path in Squeak.Settings; if (exists) { Squeak.fsck(null, path, files, stale, stats); } else { stale.dirs.push(path); } } else { stats.files++; if (path in files) { files[path] = null; // mark as visited stats.bytes += entries[name][4]; } else { stale.files.push(path); } } } if (dir === "") { // we're back at the root, almost done console.log("squeak fsck: " + stats.dirs + " directories, " + stats.files + " files, " + (stats.bytes/1000000).toFixed(1) + " MBytes"); // check orphaned files var orphaned = [], total = 0; for (var path in files) { total++; var size = files[path]; if (size !== null) orphaned.push({ path: path, size: size }); // not marked visited } // recreate directory entries for orphaned files for (var i = 0; i < orphaned.length; i++) { var path = Squeak.splitFilePath(orphaned[i].path); var size = orphaned[i].size; console.log("squeak fsck: restoring " + path.fullname + " (" + size + " bytes)"); Squeak.dirCreate(path.dirname, true, "force"); var directory = Squeak.dirList(path.dirname); var now = Squeak.totalSeconds(); var entry = [/*name*/ path.basename, /*ctime*/ now, /*mtime*/ 0, /*dir*/ false, size]; directory[path.basename] = entry; Squeak.Settings["squeak:" + path.dirname] = JSON.stringify(directory); } for (var i = 0; i < stale.dirs.length; i++) { var dir = stale.dirs[i]; if (Squeak.Settings["squeak:" + dir]) continue; // now contains orphaned files console.log("squeak fsck: cleaning up directory " + dir); Squeak.dirDelete(dir); stats.dirs--; stats.deleted++; } for (var i = 0; i < stale.files.length; i++) { var path = stale.files[i]; if (path in files) continue; // was orphaned if (Squeak.Settings["squeak:" + path]) continue; // now is a directory console.log("squeak fsck: cleaning up file entry " + path); Squeak.fileDelete(path); stats.files--; stats.deleted++; } if (whenDone) whenDone(stats); } }, dbTransaction: function(mode, description, transactionFunc, completionFunc) { // File contents is stored in the IndexedDB named "squeak" in object store "files" // and directory entries in localStorage with prefix "squeak:" function fakeTransaction() { transactionFunc(Squeak.dbFake()); if (completionFunc) completionFunc(); } if (typeof indexedDB == "undefined") { return fakeTransaction(); } function startTransaction() { var trans = SqueakDB.transaction("files", mode), fileStore = trans.objectStore("files"); trans.oncomplete = function(e) { if (completionFunc) completionFunc(); } trans.onerror = function(e) { console.error("Transaction error during " + description, e); } trans.onabort = function(e) { console.error("Transaction error: aborting " + description, e); // fall back to local/memory storage transactionFunc(Squeak.dbFake()); if (completionFunc) completionFunc(); } transactionFunc(fileStore); }; // if database connection already opened, just do transaction if (window.SqueakDB) return startTransaction(); // otherwise, open SqueakDB first var openReq; try { // fails in restricted iframe openReq = indexedDB.open("squeak"); } catch (err) {} // UIWebView implements the interface but only returns null // https://stackoverflow.com/questions/27415998/indexeddb-open-returns-null-on-safari-ios-8-1-1-and-halts-execution-on-cordova if (!openReq) { return fakeTransaction(); } openReq.onsuccess = function(e) { if (Squeak.debugFiles) console.log("Opened files database."); window.SqueakDB = this.result; SqueakDB.onversionchange = function(e) { delete window.SqueakDB; this.close(); }; SqueakDB.onerror = function(e) { console.error("Error accessing database", e); }; startTransaction(); }; openReq.onupgradeneeded = function (e) { // run only first time, or when version changed if (Squeak.debugFiles) console.log("Creating files database"); var db = e.target.result; db.createObjectStore("files"); }; openReq.onerror = function(e) { console.error("Error opening files database", e); console.warn("Falling back to local storage"); fakeTransaction(); }; openReq.onblocked = function(e) { // If some other tab is loaded with the database, then it needs to be closed // before we can proceed upgrading the database. console.log("Database upgrade needed, but was blocked."); console.warn("Falling back to local storage"); fakeTransaction(); }; }, dbFake: function() { // indexedDB is not supported by this browser, fake it using localStorage // since localStorage space is severly limited, use LZString if loaded // see https://github.com/pieroxy/lz-string if (typeof SqueakDBFake == "undefined") { if (typeof indexedDB == "undefined") console.warn("IndexedDB not supported by this browser, using localStorage"); window.SqueakDBFake = { bigFiles: {}, bigFileThreshold: 100000, get: function(filename) { var buffer = SqueakDBFake.bigFiles[filename]; if (!buffer) { var string = Squeak.Settings["squeak-file:" + filename]; if (!string) { var compressed = Squeak.Settings["squeak-file.lz:" + filename]; if (compressed) { if (typeof LZString == "object") { string = LZString.decompressFromUTF16(compressed); } else { console.error("LZString not loaded: cannot decompress " + filename); } } } if (string) { var bytes = new Uint8Array(string.length); for (var i = 0; i < bytes.length; i++) bytes[i] = string.charCodeAt(i) & 0xFF; buffer = bytes.buffer; } } var req = {result: buffer}; setTimeout(function(){ if (req.onsuccess) req.onsuccess({target: req}); }, 0); return req; }, put: function(buffer, filename) { if (buffer.byteLength > SqueakDBFake.bigFileThreshold) { if (!SqueakDBFake.bigFiles[filename]) console.log("File " + filename + " (" + buffer.byteLength + " bytes) too large, storing in memory only"); SqueakDBFake.bigFiles[filename] = buffer; } else { var string = Squeak.bytesAsString(new Uint8Array(buffer)); if (typeof LZString == "object") { var compressed = LZString.compressToUTF16(string); Squeak.Settings["squeak-file.lz:" + filename] = compressed; delete Squeak.Settings["squeak-file:" + filename]; } else { Squeak.Settings["squeak-file:" + filename] = string; } } var req = {}; setTimeout(function(){if (req.onsuccess) req.onsuccess()}, 0); return req; }, delete: function(filename) { delete Squeak.Settings["squeak-file:" + filename]; delete Squeak.Settings["squeak-file.lz:" + filename]; delete SqueakDBFake.bigFiles[filename]; var req = {}; setTimeout(function(){if (req.onsuccess) req.onsuccess()}, 0); return req; }, openCursor: function() { var req = {}; setTimeout(function(){if (req.onsuccess) req.onsuccess({target: req})}, 0); return req; }, } } return SqueakDBFake; }, fileGet: function(filepath, thenDo, errorDo) { if (!errorDo) errorDo = function(err) { console.log(err) }; var path = this.splitFilePath(filepath); if (!path.basename) return errorDo("Invalid path: " + filepath); if (Squeak.debugFiles) { console.log("Reading " + path.fullname); var realThenDo = thenDo; thenDo = function(data) { console.log("Read " + data.byteLength + " bytes from " + path.fullname); realThenDo(data); } } // if we have been writing to memory, return that version if (window.SqueakDBFake && SqueakDBFake.bigFiles[path.fullname]) return thenDo(SqueakDBFake.bigFiles[path.fullname]); this.dbTransaction("readonly", "get " + filepath, function(fileStore) { var getReq = fileStore.get(path.fullname); getReq.onerror = function(e) { errorDo(e) }; getReq.onsuccess = function(e) { if (this.result !== undefined) return thenDo(this.result); // might be a template Squeak.fetchTemplateFile(path.fullname, function gotTemplate(template) {thenDo(template)}, function noTemplate() { // if no indexedDB then we have checked fake db already if (typeof indexedDB == "undefined") return errorDo("file not found: " + path.fullname); // fall back on fake db, may be file is there var fakeReq = Squeak.dbFake().get(path.fullname); fakeReq.onerror = function(e) { errorDo("file not found: " + path.fullname) }; fakeReq.onsuccess = function(e) { thenDo(this.result); } }); }; }); }, filePut: function(filepath, contents, optSuccess) { // store file, return dir entry if successful var path = this.splitFilePath(filepath); if (!path.basename) return null; var directory = this.dirList(path.dirname); if (!directory) return null; // get or create entry var entry = directory[path.basename], now = this.totalSeconds(); if (!entry) { // new file entry = [/*name*/ path.basename, /*ctime*/ now, /*mtime*/ 0, /*dir*/ false, /*size*/ 0]; directory[path.basename] = entry; } else if (entry[3]) // is a directory return null; if (Squeak.debugFiles) { console.log("Writing " + path.fullname + " (" + contents.byteLength + " bytes)"); if (contents.byteLength > 0 && filepath.endsWith(".log")) { console.log((new TextDecoder).decode(contents).replace(/\r/g, '\n')); } } // update directory entry entry[2] = now; // modification time entry[4] = contents.byteLength || contents.length || 0; Squeak.Settings["squeak:" + path.dirname] = JSON.stringify(directory); // put file contents (async) this.dbTransaction("readwrite", "put " + filepath, function(fileStore) { fileStore.put(contents, path.fullname); }, function transactionComplete() { if (optSuccess) optSuccess(); }); return entry; }, fileDelete: function(filepath, entryOnly) { var path = this.splitFilePath(filepath); if (!path.basename) return false; var directory = this.dirList(path.dirname); if (!directory) return false; var entry = directory[path.basename]; if (!entry || entry[3]) return false; // not found or is a directory // delete entry from directory delete directory[path.basename]; Squeak.Settings["squeak:" + path.dirname] = JSON.stringify(directory); if (Squeak.debugFiles) console.log("Deleting " + path.fullname); if (entryOnly) return true; // delete file contents (async) this.dbTransaction("readwrite", "delete " + filepath, function(fileStore) { fileStore.delete(path.fullname); }); return true; }, fileRename: function(from, to) { var oldpath = this.splitFilePath(from); if (!oldpath.basename) return false; var newpath = this.splitFilePath(to); if (!newpath.basename) return false; var olddir = this.dirList(oldpath.dirname); if (!olddir) return false; var entry = olddir[oldpath.basename]; if (!entry || entry[3]) return false; // not found or is a directory var samedir = oldpath.dirname == newpath.dirname; var newdir = samedir ? olddir : this.dirList(newpath.dirname); if (!newdir) return false; if (newdir[newpath.basename]) return false; // exists already if (Squeak.debugFiles) console.log("Renaming " + oldpath.fullname + " to " + newpath.fullname); delete olddir[oldpath.basename]; // delete old entry entry[0] = newpath.basename; // rename entry newdir[newpath.basename] = entry; // add new entry Squeak.Settings["squeak:" + newpath.dirname] = JSON.stringify(newdir); if (!samedir) Squeak.Settings["squeak:" + oldpath.dirname] = JSON.stringify(olddir); // move file contents (async) this.fileGet(oldpath.fullname, function success(contents) { this.dbTransaction("readwrite", "rename " + oldpath.fullname + " to " + newpath.fullname, function(fileStore) { fileStore.delete(oldpath.fullname); fileStore.put(contents, newpath.fullname); }); }.bind(this), function error(msg) { console.log("File rename failed: " + msg); }.bind(this)); return true; }, fileExists: function(filepath) { var path = this.splitFilePath(filepath); if (!path.basename) return false; var directory = this.dirList(path.dirname); if (!directory) return false; var entry = directory[path.basename]; if (!entry || entry[3]) return false; // not found or is a directory return true; }, dirCreate: function(dirpath, withParents, force) { var path = this.splitFilePath(dirpath); if (!path.basename) return false; if (withParents && !Squeak.Settings["squeak:" + path.dirname]) Squeak.dirCreate(path.dirname, true); var parent = this.dirList(path.dirname); if (!parent) return false; var existing = parent[path.basename]; if (existing) { if (!existing[3]) { // already exists and is not a directory if (!force) return false; existing[3] = true; // force it to be a directory Squeak.Settings["squeak:" + path.dirname] = JSON.stringify(parent); } if (Squeak.Settings["squeak:" + path.fullname]) return true; // already exists // directory exists but is not in localStorage, so create it // (this is not supposed to happen but deal with it anyways) } if (Squeak.debugFiles) console.log("Creating directory " + path.fullname); var now = this.totalSeconds(), entry = [/*name*/ path.basename, /*ctime*/ now, /*mtime*/ now, /*dir*/ true, /*size*/ 0]; parent[path.basename] = entry; Squeak.Settings["squeak:" + path.fullname] = JSON.stringify({}); Squeak.Settings["squeak:" + path.dirname] = JSON.stringify(parent); return true; }, dirDelete: function(dirpath) { var path = this.splitFilePath(dirpath); if (!path.basename) return false; var directory = this.dirList(path.dirname); if (!directory) return false; if (!directory[path.basename]) return false; var children = this.dirList(path.fullname); if (children) for (var child in children) return false; // not empty if (Squeak.debugFiles) console.log("Deleting directory " + path.fullname); // delete from parent delete directory[path.basename]; Squeak.Settings["squeak:" + path.dirname] = JSON.stringify(directory); // delete itself delete Squeak.Settings["squeak:" + path.fullname]; return true; }, dirList: function(dirpath, includeTemplates) { // return directory entries or null var path = this.splitFilePath(dirpath), localEntries = Squeak.Settings["squeak:" + path.fullname], template = includeTemplates && Squeak.Settings["squeak-template:" + path.fullname]; function addEntries(dir, entries) { for (var key in entries) { if (entries.hasOwnProperty(key)) { var entry = entries[key]; dir[entry[0]] = entry; } } } if (localEntries || template) { // local entries override templates var dir = {}; if (template) addEntries(dir, JSON.parse(template).entries); if (localEntries) addEntries(dir, JSON.parse(localEntries)); return dir; } if (path.fullname == "/") return {}; return null; }, splitFilePath: function(filepath) { if (filepath[0] !== '/') filepath = '/' + filepath; filepath = filepath.replace(/\/\//g, '/'); // replace double-slashes var matches = filepath.match(/(.*)\/(.*)/), dirname = matches[1] ? matches[1] : '/', basename = matches[2] ? matches[2] : null; return {fullname: filepath, dirname: dirname, basename: basename}; }, splitUrl: function(url, base) { var matches = url.match(/(.*\/)?(.*)/), uptoslash = matches[1] || '', filename = matches[2] || ''; if (!uptoslash.match(/^[a-z]+:\/\//)) { if (base && !base.match(/\/$/)) base += '/'; uptoslash = (base || '') + uptoslash; url = uptoslash + filename; } return {full: url, uptoslash: uptoslash, filename: filename}; }, flushFile: function(file) { if (file.modified) { var buffer = file.contents.buffer; if (buffer.byteLength !== file.size) { buffer = new ArrayBuffer(file.size); (new Uint8Array(buffer)).set(file.contents.subarray(0, file.size)); } Squeak.filePut(file.name, buffer); file.modified = false; } }, flushAllFiles: function() { if (typeof SqueakFiles == 'undefined') return; for (var name in SqueakFiles) this.flushFile(SqueakFiles[name]); }, closeAllFiles: function() { // close the files held open in memory Squeak.flushAllFiles(); delete window.SqueakFiles; }, fetchTemplateDir: function(path, url) { // Called on app startup. Fetch url/sqindex.json and // cache all subdirectory entries in Squeak.Settings. // File contents is only fetched on demand path = Squeak.splitFilePath(path).fullname; function ensureTemplateParent(template) { var path = Squeak.splitFilePath(template); if (path.dirname !== "/") ensureTemplateParent(path.dirname); var template = JSON.parse(Squeak.Settings["squeak-template:" + path.dirname] || '{"entries": {}}'); if (!template.entries[path.basename]) { var now = Squeak.totalSeconds(); template.entries[path.basename] = [path.basename, now, now, true, 0]; Squeak.Settings["squeak-template:" + path.dirname] = JSON.stringify(template); } } function checkSubTemplates(path, url) { var template = JSON.parse(Squeak.Settings["squeak-template:" + path]); for (var key in template.entries) { var entry = template.entries[key]; if (entry[3]) Squeak.fetchTemplateDir(path + "/" + entry[0], url + "/" + entry[0]); }; } if (Squeak.Settings["squeak-template:" + path]) { checkSubTemplates(path, url); } else { var index = url + "/sqindex.json"; var rq = new XMLHttpRequest(); rq.open('GET', index, true); rq.onload = function(e) { if (rq.status == 200) { console.log("adding template dir " + path); ensureTemplateParent(path); var entries = JSON.parse(rq.response), template = {url: url, entries: {}}; for (var key in entries) { var entry = entries[key]; template.entries[entry[0]] = entry; } Squeak.Settings["squeak-template:" + path] = JSON.stringify(template); checkSubTemplates(path, url); } else rq.onerror(rq.statusText); }; rq.onerror = function(e) { console.log("cannot load template index " + index); } rq.send(); } }, fetchTemplateFile: function(path, ifFound, ifNotFound) { path = Squeak.splitFilePath(path); var template = Squeak.Settings["squeak-template:" + path.dirname]; if (!template) return ifNotFound(); var url = JSON.parse(template).url; if (!url) return ifNotFound(); url += "/" + path.basename; var rq = new XMLHttpRequest(); rq.open("get", url, true); rq.responseType = "arraybuffer"; rq.timeout = 30000; rq.onreadystatechange = function() { if (this.readyState != this.DONE) return; if (this.status == 200) { var buffer = this.response; console.log("Got " + buffer.byteLength + " bytes from " + url); Squeak.dirCreate(path.dirname, true); Squeak.filePut(path.fullname, buffer); ifFound(buffer); } else { console.error("Download failed (" + this.status + ") " + url); ifNotFound(); } } console.log("Fetching " + url); rq.send(); }, });