/* Copyright (C) 2024 idlesauce This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, see . */ // this file also gets injected into extension sandbox // @ts-check class DirectoryListing { /** * @param {string} name * @param {string} mode * @param {number} [unixTimestamp] * @param {number} [sizeBytes] */ constructor(name, mode, unixTimestamp = 0, sizeBytes = 0) { this.name = name; this.mode = mode; this.mtime = new Date(unixTimestamp * 1000); this.size = sizeBytes; } static isDir(instance) { return (instance.mode === "d" || instance.mode === "m" || instance.mode === "smb-share") && !ignoredFileNames.includes(instance.name); } isDir() { return DirectoryListing.isDir(this); } isFile() { return this.mode === "-" && !ignoredFileNames.includes(this.name); } getIcon() { if (this.mode === "m" || this.mode === "d") { return DIR_ICON; } else if (this.mode === "smb-share") { return SMB_SHARE_ICON; } else { return FILE_ICON; } } getHumanReadableMode() { // https://github.com/ps5-payload-dev/sdk/blob/17b1d592d4aa095f6cc52e33c8a26fd6f898d65c/include/freebsd/sys/stat.h#L237 // if (this.mode === "m") { // return "Device mountpoint"; // } else if (this.mode === "d") { // return "Directory"; // } else if (this.mode === "b") { return "Block device"; } else if (this.mode === "c") { return "Char device"; } else if (this.mode === "l") { return "Symbolic link"; } else if (this.mode === "p") { return "Fifo or socket"; } else if (this.mode === "s") { return "Socket"; } else { return ""; // file, dir, device mount or unknown } } getHumanReadableSize() { if (this.size < 1024) { return this.size + " B"; } else if (this.size < 1024 * 1024) { return (this.size / 1024).toFixed(2) + " KB"; } else if (this.size < 1024 * 1024 * 1024) { return (this.size / (1024 * 1024)).toFixed(2) + " MB"; } else { return (this.size / (1024 * 1024 * 1024)).toFixed(2) + " GB"; } } } const HTTP_OK = 200; const HTTP_ERROR_BAD_REQUEST = 400; const HTTP_UNAUTHORIZED = 401; const HTTP_FORBIDDEN = 403; const HTTP_INTERNAL_SERVER_ERROR = 500; const SMB_PORT = 445; const SMB_PROTOCOL = "smb:"; const SMB_SCHEME_PREFIX = `${SMB_PROTOCOL}//`; /** * @template T * @typedef {Object} APIResponse * @property {number} status * @property {T} data */ // @ts-ignore const baseURL = window.location.origin == "null" ? "http://127.0.0.1:8080" : window.location.origin; const ignoredFileNames = [".", ".."]; class ApiClient { /** * @param {string} path * @param {(string | string[])?} [args] * @param {(string | string[])?} [env] * @param {string?} [cwd] * @param {boolean} [daemon] * @returns {Promise?>>} */ static async launchApp(path, args = null, env = null, cwd = null, daemon = false) { let params = new URLSearchParams({ "pipe": "1", "daemon": new Number(daemon).toString(), "path": path }); if (typeof args === "string") { // @ts-ignore params.append("args", args.replace(/ /g, "\\ ")); } else if (Array.isArray(args)) { // @ts-ignore params.append("args", args.map(arg => arg.replace(/ /g, "\\ ")).join(" ")); } if (env != null) { // @ts-ignore params.append("env", Object.entries(env).map(([key, val]) => `${key}=${val}`.replace(/ /g, "\\ ") ).join(" ")); } if (cwd != null) { params.append("cwd", cwd); } let uri = baseURL + "/hbldr?" + params.toString(); let response = await fetch(uri); if (!response.ok) { throw new Error("Failed to launch app, status code: " + response.status); // TODO: throw the error at the consumer return { status: response.status, data: null }; } return { status: response.status, data: response.body }; } /** * @param {string} path * @returns {Promise>} */ static async fsListInternalDir(path) { if (!path.endsWith("/")) { path += "/"; } let response = await fetch(baseURL + "/fs" + path + "?fmt=json"); if (!response.ok) { return { status: response.status, data: null }; } let data = await response.json(); data = data.filter(entry => !ignoredFileNames.includes(entry.name)); data.sort((x, y) => (DirectoryListing.isDir(x) == DirectoryListing.isDir(y)) ? x.name.localeCompare(y.name) : y.mode.localeCompare(x.mode)); const resultData = data.map(entry => new DirectoryListing(entry.name, entry.mode, entry.mtime, entry.size)); return { status: response.status, data: resultData }; } /** * @param {string} networkShareUrl * @returns {string} */ static getNetworkShareHttpProxyUrl(networkShareUrl) { const url = new URL(networkShareUrl); // for now only smb is supported if (url.protocol !== SMB_PROTOCOL) { throw new Error("Unsupported network share protocol: " + url.protocol); } const host = url.hostname; const user = url.username; const pass = url.password; const path = url.pathname; const protocolWithoutColon = url.protocol.replace(":", ""); let result = `${baseURL}/${protocolWithoutColon}${path}?addr=${host}`; if (user) { result += `&user=${user}`; } if (pass) { result += `&pass=${pass}`; } return result; } /** * @param {string} path * @returns {Promise>} */ static async fsListSmbDir(path) { if (!path.startsWith(SMB_SCHEME_PREFIX)) { return { status: HTTP_ERROR_BAD_REQUEST, data: null }; } if (!path.endsWith("/")) { path += "/"; } let url = ApiClient.getNetworkShareHttpProxyUrl(path); let response = await fetch(url); if (!response.ok) { return { status: response.status, data: null }; } let data = await response.json(); data = data.filter(entry => !ignoredFileNames.includes(entry.name)); data.sort((x, y) => (DirectoryListing.isDir(x) == DirectoryListing.isDir(y)) ? x.name.localeCompare(y.name) : y.mode.localeCompare(x.mode)); let resultData = data.map(entry => new DirectoryListing(entry.name, entry.mode, entry.mtime, entry.size)); return { status: response.status, data: resultData }; } /** * @param {string} path * @returns {Promise>} */ static async listSmbShares(path) { if (!path.startsWith(SMB_SCHEME_PREFIX)) { return { status: HTTP_ERROR_BAD_REQUEST, data: null }; } if (path.endsWith("/")) { path = path.substring(0, path.length - 1); } let url = ApiClient.getNetworkShareHttpProxyUrl(path); let response = await fetch(url); if (!response.ok) { return { status: response.status, data: null }; } let data = await response.json(); data = data.filter(entry => !entry.name.endsWith("$")); let resultData = data.map(entry => new DirectoryListing(entry.name, "smb-share")); return { status: response.status, data: resultData }; } /** * @param {string} path * @returns {Promise>} */ static async fsListDir(path) { if (path.startsWith(SMB_SCHEME_PREFIX)) { const url = new URL(path); if (url.pathname === "" || url.pathname === "/") { return await ApiClient.listSmbShares(path); } return await ApiClient.fsListSmbDir(path); } return await ApiClient.fsListInternalDir(path); } /** * * @param {string} path * @returns {Promise?>>} */ static async fsGetFileStream(path) { if (path.endsWith("/") || !path.startsWith("/")) { return { status: HTTP_ERROR_BAD_REQUEST, data: null }; } let response = await fetch(baseURL + "/fs" + path); if (!response.ok) { return { status: response.status, data: null }; } return { status: response.status, data: response.body }; } /** * * @param {string} path * @returns {Promise>} */ static async fsGetFileText(path) { if (path.endsWith("/") || !path.startsWith("/")) { return { status: HTTP_ERROR_BAD_REQUEST, data: null }; } let response = await fetch(baseURL + "/fs" + path); if (!response.ok) { return { status: response.status, data: null }; } let resultData = await response.text(); return { status: response.status, data: resultData }; } /** @typedef {Object} MdnsLocation * @property {string} domain * @property {string} protocol * @property {string} hostname * @property {string} address * @property {number} port */ /** * @returns {Promise} */ static async getMdnsDiscoveredLocations() { let response = await fetch(baseURL + "/mdns"); if (!response.ok) { return []; } return await response.json(); } static async getVersion() { try { const response = await fetch(baseURL + '/version'); if (response.ok) { return await response.json(); } } catch (error) { } return { api: 0, tag: '', date: '', time: '' }; } }