diff --git a/assets/apiClient.js b/assets/apiClient.js
index 9ca5eb6..8b27f20 100644
--- a/assets/apiClient.js
+++ b/assets/apiClient.js
@@ -1,158 +1,158 @@
-/* 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
- */
- constructor(name, mode) {
- this.name = name;
- this.mode = mode;
- }
-
- isDir() {
- return this.mode === "d" && !ignoredFileNames.includes(this.name);
- }
-
- isFile() {
- return this.mode === "-" && !ignoredFileNames.includes(this.name);
- }
-}
-
-// @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]
- * @returns {Promise?>} only returns true, throws error if not success code
- */
- static async launchApp(path, args = null, env = null, cwd = null) {
- let params = new URLSearchParams({
- "pipe": "1",
- "path": path
- });
-
- if (typeof args === "string") {
- // @ts-ignore
- params.append("args", args.replaceAll(" ", "\\ "));
- } else if (Array.isArray(args)) {
- // @ts-ignore
- params.append("args", args.map(arg => arg.replaceAll(" ", "\\ ")).join(" "));
- }
-
- if (env != null) {
- // @ts-ignore
- params.append("env", Object.entries(env).map(([key, val]) =>
- `${key}=${val}`.replaceAll(" ", "\\ ")
- ).join(" "));
- }
-
- if (cwd != null) {
- params.append("cwd", cwd);
- }
-
- let uri = baseURL + "/hbldr?" + params.toString();
-
- let response = await fetch(uri);
- if (response.status !== 200) {
- throw new Error("Failed to launch app, status code: " + response.status);
- }
-
- return response.body;
- }
-
- /**
- * @param {string} path
- * @returns {Promise}
- */
- static async fsListDir(path) {
- if (!path.endsWith("/")) {
- path += "/";
- }
-
- let response = await fetch(baseURL + "/fs" + path + "?fmt=json");
- if (!response.ok) {
- return null;
- }
- let data = await response.json();
- data.sort((x, y) => x.mode == y.mode ?
- x.name.localeCompare(y.name) :
- y.mode.localeCompare(x.mode));
-
- return data.filter(entry => !ignoredFileNames.includes(entry.name)).map(entry =>
- new DirectoryListing(entry.name, entry.mode));
- }
-
- /**
- *
- * @param {string} path
- * @returns {Promise?>}
- */
- static async fsGetFileStream(path) {
- if (path.endsWith("/") || !path.startsWith("/")) {
- return null;
- }
-
- let response = await fetch(baseURL + "/fs" + path);
- if (!response.ok) {
- return null;
- }
- return response.body;
- }
-
- /**
- *
- * @param {string} path
- * @returns {Promise}
- */
- static async fsGetFileText(path) {
- if (path.endsWith("/") || !path.startsWith("/")) {
- return null;
- }
-
- let response = await fetch(baseURL + "/fs" + path);
- if (!response.ok) {
- return null;
- }
- return await response.text();
- }
-
- 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: ''
- };
- }
-}
+/* 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
+ */
+ constructor(name, mode) {
+ this.name = name;
+ this.mode = mode;
+ }
+
+ isDir() {
+ return this.mode === "d" && !ignoredFileNames.includes(this.name);
+ }
+
+ isFile() {
+ return this.mode === "-" && !ignoredFileNames.includes(this.name);
+ }
+}
+
+// @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]
+ * @returns {Promise?>} only returns true, throws error if not success code
+ */
+ static async launchApp(path, args = null, env = null, cwd = null) {
+ let params = new URLSearchParams({
+ "pipe": "1",
+ "path": path
+ });
+
+ if (typeof args === "string") {
+ // @ts-ignore
+ params.append("args", args.replaceAll(" ", "\\ "));
+ } else if (Array.isArray(args)) {
+ // @ts-ignore
+ params.append("args", args.map(arg => arg.replaceAll(" ", "\\ ")).join(" "));
+ }
+
+ if (env != null) {
+ // @ts-ignore
+ params.append("env", Object.entries(env).map(([key, val]) =>
+ `${key}=${val}`.replaceAll(" ", "\\ ")
+ ).join(" "));
+ }
+
+ if (cwd != null) {
+ params.append("cwd", cwd);
+ }
+
+ let uri = baseURL + "/hbldr?" + params.toString();
+
+ let response = await fetch(uri);
+ if (response.status !== 200) {
+ throw new Error("Failed to launch app, status code: " + response.status);
+ }
+
+ return response.body;
+ }
+
+ /**
+ * @param {string} path
+ * @returns {Promise}
+ */
+ static async fsListDir(path) {
+ if (!path.endsWith("/")) {
+ path += "/";
+ }
+
+ let response = await fetch(baseURL + "/fs" + path + "?fmt=json");
+ if (!response.ok) {
+ return null;
+ }
+ let data = await response.json();
+ data.sort((x, y) => x.mode == y.mode ?
+ x.name.localeCompare(y.name) :
+ y.mode.localeCompare(x.mode));
+
+ return data.filter(entry => !ignoredFileNames.includes(entry.name)).map(entry =>
+ new DirectoryListing(entry.name, entry.mode));
+ }
+
+ /**
+ *
+ * @param {string} path
+ * @returns {Promise?>}
+ */
+ static async fsGetFileStream(path) {
+ if (path.endsWith("/") || !path.startsWith("/")) {
+ return null;
+ }
+
+ let response = await fetch(baseURL + "/fs" + path);
+ if (!response.ok) {
+ return null;
+ }
+ return response.body;
+ }
+
+ /**
+ *
+ * @param {string} path
+ * @returns {Promise}
+ */
+ static async fsGetFileText(path) {
+ if (path.endsWith("/") || !path.startsWith("/")) {
+ return null;
+ }
+
+ let response = await fetch(baseURL + "/fs" + path);
+ if (!response.ok) {
+ return null;
+ }
+ return await response.text();
+ }
+
+ 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: ''
+ };
+ }
+}
diff --git a/assets/carouselView.js b/assets/carouselView.js
index 338313f..d080219 100644
--- a/assets/carouselView.js
+++ b/assets/carouselView.js
@@ -1,399 +1,399 @@
-/* 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
-. */
-
-
-// @ts-check
-
-/**
- * @typedef {Object} CarouselItemSecondaryActions
- * @property {string} text
- * @property {function():(boolean|void|Promise|Promise)} onclick - return true to display you can exit now
- */
-
-/**
- * @typedef {Object} CarouselItem
- * @property {string} mainText
- * @property {(function():(boolean|void|Promise|Promise))?} onclick - return true to display you can exit now
- * @property {(function():Promise)} [asyncInfo]
- * @property {string} [secondaryText]
- * @property {string} [imgPath]
- * @property {CarouselItemSecondaryActions[]} [options]
- */
-
-
-/**
- * Shouldnt be interacted with directly, use router methods
- * @param {CarouselItem[]} items
- */
-async function renderMainContentCarousel(items, fadeout = true) {
- // reset content
- const content = /** @type {HTMLElement?} */ (document.getElementById("content"));
- if (!content) {
- // window.location.href = "/";
- debugger;
- return;
- }
-
- if (content.children.length > 0) {
- if (fadeout) {
- let elementsWithLoading = content.children[0].querySelectorAll(".loading");
- for (let loading of elementsWithLoading) {
- // specifically not removing the overlay to make it look less jarring
- loading.classList.remove("loading");
- }
-
- for (let i = 0; i < content.children.length; i++) {
- content.children[i].classList.add("fadeout");
- }
- await sleep(250);
- }
-
- content.innerHTML = "";
- }
-
- // create carousel div
- const carousel = document.createElement("div");
- carousel.classList.add("carousel");
- carousel.classList.add("fadein");
- content.appendChild(carousel);
-
- let asyncInfoPromisesList = [];
-
- // add entries
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- let ii = i;
-
- const entryElement = document.createElement("div");
- entryElement.classList.add("entry-wrapper");
-
- if (i == 0) {
- entryElement.classList.add("selected");
- }
-
- const entryElementMainButton = document.createElement("div");
- entryElementMainButton.classList.add("entry");
- entryElementMainButton.classList.add("entry-main");
-
- const entryElementImg = document.createElement("img");
- entryElementImg.classList.add("entry-img");
- entryElementImg.style.display = "none";
- entryElementImg.onerror = () => {
- entryElementImg.style.display = "none";
- }
- entryElementMainButton.appendChild(entryElementImg);
-
-
- const entryElementMainText = document.createElement("p");
- entryElementMainText.classList.add("entry-name");
- entryElementMainText.innerText = item.asyncInfo ? "" : item.mainText;
- entryElementMainButton.appendChild(entryElementMainText);
- entryElement.appendChild(entryElementMainButton);
-
- if (item.asyncInfo) {
- entryElementMainButton.classList.add("loading-overlay");
- entryElementMainButton.classList.add("loading");
- }
-
- carousel.appendChild(entryElement);
-
- asyncInfoPromisesList.push(async () => {
- let asyncInfo = null;
- if (item.asyncInfo) {
- asyncInfo = await item.asyncInfo();
- entryElementMainButton.classList.remove("loading-overlay");
- entryElementMainButton.classList.remove("loading");
- }
-
- if (asyncInfo && asyncInfo.result.mainText) {
- entryElementMainText.innerText = asyncInfo.result.mainText;
- }
-
- if ((asyncInfo && asyncInfo.result.secondaryText) || item.secondaryText) {
- const entryElementSecondaryText = document.createElement("p");
- entryElementSecondaryText.classList.add("entry-secondary");
- entryElementSecondaryText.classList.add("text-secondary");
- entryElementSecondaryText.innerText = ((asyncInfo && asyncInfo.result.secondaryText) ? asyncInfo.result.secondaryText : item.secondaryText) || "";
- entryElementMainButton.appendChild(entryElementSecondaryText);
- }
-
- if (asyncInfo && !asyncInfo.uuid) {
- // TODO: this is temporary maybe, idk if its better to show an error on the entry or remove the entry altogether
-
- // return if uuid is null which only happens on a timeout
- // so image and onclicks arent set
- return;
- }
-
- if ((asyncInfo && asyncInfo.result.imgPath) || item.imgPath) {
- entryElementImg.style.display = "block";
- let imgPath = ((asyncInfo && asyncInfo.result.imgPath) ? asyncInfo.result.imgPath : item.imgPath) || "";
- // workaround for svgs since the server doesnt return mime
- if (imgPath.trimEnd().toLowerCase().endsWith(".svg")) {
- try {
- let imgBytes = await (await fetch(imgPath)).arrayBuffer();
- let imgBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(imgBytes)));
- imgPath = "data:image/svg+xml;base64," + imgBase64;
- }
- catch (e) {
- // nothing
- }
- }
-
- entryElementImg.src = imgPath;
- }
-
- if ((asyncInfo && asyncInfo.result.options) || item.options) {
- const entryElementMoreOptionsButton = document.createElement("div");
- entryElementMoreOptionsButton.classList.add("entry");
- entryElementMoreOptionsButton.classList.add("entry-more-button");
- entryElementMoreOptionsButton.innerHTML = "•••";
-
- entryElementMoreOptionsButton.onclick = async () => {
- if (!entryElement.classList.contains("selected")) {
- smoothScrollToElementIndex(ii, true);
- return;
- }
- let options = null;
- if (asyncInfo && asyncInfo.result.options) {
- options = asyncInfo.result.options;
- }
- else if (item.options) {
- options = item.options;
- }
- // @ts-ignore
- await Globals.Router.handleModal(options);
- }
-
- entryElement.appendChild(entryElementMoreOptionsButton);
- }
-
-
- entryElementMainButton.onclick = async () => {
- if (!entryElement.classList.contains("selected")) {
- smoothScrollToElementIndex(ii, true);
- return;
- }
-
- entryElementMainButton.classList.add("loading-overlay");
- entryElementMainButton.classList.add("loading");
- await sleep(0); // TODO: Didnt test if its needed here, but i think if the asyncInfo.result.onclick isnt async we might need this to trigger a rerender, or ideally something better
- let onclickResult = null;
- if (asyncInfo && asyncInfo.result.onclick) {
- onclickResult = await asyncInfo.result.onclick();
- }
- else if (item.onclick) {
- onclickResult = await item.onclick();
- }
- else {
- // TODO: invalid item, remove
- }
-
- let res = onclickResult;
- let logStream = null;
-
- if (res && res.path) {
- logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd);
- res = logStream != null;
- }
-
- if (res == true) {
- entryElement.style.transform = "scale(2)";
- setTimeout(() => {
- entryElement.style.removeProperty("transform");
- }, 300);
- Globals.Router.handleLaunchedAppView(logStream);
- }
-
- entryElementMainButton.classList.remove("loading-overlay");
- entryElementMainButton.classList.remove("loading");
- }
-
- });
-
- }
-
- smoothScrollToElementIndex(0, false);
-
- // await all and reset cursor snap overlays every time a task completes
- await Promise.all(asyncInfoPromisesList.map(asyncInfoPromise => asyncInfoPromise().then(() => {
- if (document.getElementById("modal-content")) {
- return;
- }
- generateCursorSnapOverlays();
- })));
-}
-
-
-function removeAllCursorSnapOverlays() {
- const content = document.getElementById("content");
- if (!content) {
- return;
- }
- const overlays = content.getElementsByClassName("home-cursor-snap-overlay");
- while (overlays.length > 0) {
- overlays[0].remove();
- }
-}
-
-function generateCursorSnapOverlays(entryWrapperIndex = -1) {
- // create overlay for cursor snap
- // remove existing overlays
- removeAllCursorSnapOverlays();
- let content = document.getElementById("content");
- if (!content) {
- return;
- }
- if (entryWrapperIndex === -1) {
- // set to index of entry-wrapper with selected class
- let wrappers = content.getElementsByClassName("entry-wrapper");
- for (let i = 0; i < wrappers.length; i++) {
- if (wrappers[i].classList.contains("selected")) {
- entryWrapperIndex = i;
- break;
- }
- }
- }
-
- const entry = content.getElementsByClassName("entry-wrapper")[entryWrapperIndex];
-
- if (!entry) {
- return;
- }
-
- // for each entry in entry-wrapper make a cursor snap overlay
- for (let i = 0; i < entry.children.length; i++) {
- const entryChild = entry.children[i];
- const overlay = document.createElement("a");
- overlay.classList.add("home-cursor-snap-overlay");
- overlay.style.position = "fixed";
-
- // calculate the offset from the top of the viewport
-
- const entryChildRect = entryChild.getBoundingClientRect();
- let entryChildBottom = entryChildRect.top + entryChildRect.height;
- let entryChildHeight = entryChildRect.height;
- let topOffset = 0;
-
- // this method has not added the selected class yet so if theres a selected class on the wrapper
- // thats because the element was initialized with it, in that case the element is already 1.2x scaled
- // but otherwise the bounding box is the 1.0x size here bc the animation has not run yet
- // even if we add the selected class the bounding box scales with the animation so its useless until its finished
- if (!entry.classList.contains("selected")) {
- // we gotta calculate the transforms
- const transformScale = 1.2;
- const parentRect = entry.getBoundingClientRect();
- const parentCenter = parentRect.top + parentRect.height / 2;
- const entryTopRelativeToParentCenter = entryChildRect.top - parentCenter;
- const entryBottomRelativeToParentCenter = entryChildRect.bottom - parentCenter;
- const entryTransformedTop = parentCenter + (entryTopRelativeToParentCenter * transformScale);
- const entryTransformedBottom = parentCenter + (entryBottomRelativeToParentCenter * transformScale);
- const entryTransformedHeight = entryTransformedBottom - entryTransformedTop;
- entryChildHeight = entryTransformedHeight;
- entryChildBottom = entryTransformedBottom;
- }
-
- if (entryChild.classList.contains("entry")) {
- // main big button
- // snap cursor to the lower 3/8 of the button
- topOffset = entryChildBottom - entryChildHeight * (2 / 5);
- } else {
- // snap to center
- topOffset = entryChildBottom - (entryChildHeight / 2);
- }
-
- overlay.style.top = topOffset + "px";
- overlay.style.left = "0";
- overlay.style.right = "0";
- overlay.style.height = "1px";
- // allow clicks through
- overlay.style.pointerEvents = "none";
- // snap cursor
- overlay.tabIndex = 0;
- content.appendChild(overlay);
- }
-}
-
-
-/**
- * Only for home page carousel, also sets selected class and creates the cursor snap points
- */
-function smoothScrollToElementIndex(index, smooth = true) {
- const content = document.getElementById("content");
- if (!content) {
- return;
- }
- const carousel = content.getElementsByClassName("carousel")[0];
- if (!carousel){
- return;
- }
- const entries = carousel.getElementsByClassName("entry-wrapper");
-
- if (index < 0 || index >= entries.length) {
- return;
- }
-
- const entry = entries[index];
-
- generateCursorSnapOverlays(index);
-
- for (let t_entry of entries) {
- t_entry.classList.remove("selected");
- }
-
- entry.classList.add("selected");
-
- // webkit doesnt do smooth scrolling and smoothscroll.js cant center the item so we have to do it manually
- const itemRect = entry.getBoundingClientRect();
- const containerRect = content.getBoundingClientRect();
- const targetScrollLeft = itemRect.left - containerRect.left + content.scrollLeft - (containerRect.width - itemRect.width) / 2;
-
- if (targetScrollLeft < 1 && targetScrollLeft > -1) {
- return;
- }
-
- if (smooth) {
- carousel.scrollBy({ left: targetScrollLeft, behavior: "smooth" });
- carousel.classList.add("scrolling");
- if (Globals.removeScrollingClassFromCarouselTimeoutId) {
- clearTimeout(Globals.removeScrollingClassFromCarouselTimeoutId);
- }
- Globals.removeScrollingClassFromCarouselTimeoutId = setTimeout(() => {
- carousel.classList.remove("scrolling");
- }, 468); // 468 the constant length of the scrolling with smoothscroll.js
- } else {
- carousel.scrollLeft = targetScrollLeft;
- }
-}
-
-/**
- * Returns null if the current view isnt a carouselView
- * @returns { {selectedIndex: number?, totalEntries: number}? }
- */
-function getCurrentCarouselSelectionInfo() {
- const content = /** @type {HTMLElement} */ (document.getElementById("content"));
- const carousel = content.getElementsByClassName("carousel")[0];
- if (!carousel) {
- return null;
- }
-
- const entries = carousel.getElementsByClassName("entry-wrapper");
- let currentElementIndex = Array.from(entries).findIndex(entry => entry.classList.contains("selected"));
- return {
- selectedIndex: currentElementIndex,
- totalEntries: entries.length
- };
-}
+/* 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
+. */
+
+
+// @ts-check
+
+/**
+ * @typedef {Object} CarouselItemSecondaryActions
+ * @property {string} text
+ * @property {function():(boolean|void|Promise|Promise)} onclick - return true to display you can exit now
+ */
+
+/**
+ * @typedef {Object} CarouselItem
+ * @property {string} mainText
+ * @property {(function():(boolean|void|Promise|Promise))?} onclick - return true to display you can exit now
+ * @property {(function():Promise)} [asyncInfo]
+ * @property {string} [secondaryText]
+ * @property {string} [imgPath]
+ * @property {CarouselItemSecondaryActions[]} [options]
+ */
+
+
+/**
+ * Shouldnt be interacted with directly, use router methods
+ * @param {CarouselItem[]} items
+ */
+async function renderMainContentCarousel(items, fadeout = true) {
+ // reset content
+ const content = /** @type {HTMLElement?} */ (document.getElementById("content"));
+ if (!content) {
+ // window.location.href = "/";
+ debugger;
+ return;
+ }
+
+ if (content.children.length > 0) {
+ if (fadeout) {
+ let elementsWithLoading = content.children[0].querySelectorAll(".loading");
+ for (let loading of elementsWithLoading) {
+ // specifically not removing the overlay to make it look less jarring
+ loading.classList.remove("loading");
+ }
+
+ for (let i = 0; i < content.children.length; i++) {
+ content.children[i].classList.add("fadeout");
+ }
+ await sleep(250);
+ }
+
+ content.innerHTML = "";
+ }
+
+ // create carousel div
+ const carousel = document.createElement("div");
+ carousel.classList.add("carousel");
+ carousel.classList.add("fadein");
+ content.appendChild(carousel);
+
+ let asyncInfoPromisesList = [];
+
+ // add entries
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ let ii = i;
+
+ const entryElement = document.createElement("div");
+ entryElement.classList.add("entry-wrapper");
+
+ if (i == 0) {
+ entryElement.classList.add("selected");
+ }
+
+ const entryElementMainButton = document.createElement("div");
+ entryElementMainButton.classList.add("entry");
+ entryElementMainButton.classList.add("entry-main");
+
+ const entryElementImg = document.createElement("img");
+ entryElementImg.classList.add("entry-img");
+ entryElementImg.style.display = "none";
+ entryElementImg.onerror = () => {
+ entryElementImg.style.display = "none";
+ }
+ entryElementMainButton.appendChild(entryElementImg);
+
+
+ const entryElementMainText = document.createElement("p");
+ entryElementMainText.classList.add("entry-name");
+ entryElementMainText.innerText = item.asyncInfo ? "" : item.mainText;
+ entryElementMainButton.appendChild(entryElementMainText);
+ entryElement.appendChild(entryElementMainButton);
+
+ if (item.asyncInfo) {
+ entryElementMainButton.classList.add("loading-overlay");
+ entryElementMainButton.classList.add("loading");
+ }
+
+ carousel.appendChild(entryElement);
+
+ asyncInfoPromisesList.push(async () => {
+ let asyncInfo = null;
+ if (item.asyncInfo) {
+ asyncInfo = await item.asyncInfo();
+ entryElementMainButton.classList.remove("loading-overlay");
+ entryElementMainButton.classList.remove("loading");
+ }
+
+ if (asyncInfo && asyncInfo.result.mainText) {
+ entryElementMainText.innerText = asyncInfo.result.mainText;
+ }
+
+ if ((asyncInfo && asyncInfo.result.secondaryText) || item.secondaryText) {
+ const entryElementSecondaryText = document.createElement("p");
+ entryElementSecondaryText.classList.add("entry-secondary");
+ entryElementSecondaryText.classList.add("text-secondary");
+ entryElementSecondaryText.innerText = ((asyncInfo && asyncInfo.result.secondaryText) ? asyncInfo.result.secondaryText : item.secondaryText) || "";
+ entryElementMainButton.appendChild(entryElementSecondaryText);
+ }
+
+ if (asyncInfo && !asyncInfo.uuid) {
+ // TODO: this is temporary maybe, idk if its better to show an error on the entry or remove the entry altogether
+
+ // return if uuid is null which only happens on a timeout
+ // so image and onclicks arent set
+ return;
+ }
+
+ if ((asyncInfo && asyncInfo.result.imgPath) || item.imgPath) {
+ entryElementImg.style.display = "block";
+ let imgPath = ((asyncInfo && asyncInfo.result.imgPath) ? asyncInfo.result.imgPath : item.imgPath) || "";
+ // workaround for svgs since the server doesnt return mime
+ if (imgPath.trimEnd().toLowerCase().endsWith(".svg")) {
+ try {
+ let imgBytes = await (await fetch(imgPath)).arrayBuffer();
+ let imgBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(imgBytes)));
+ imgPath = "data:image/svg+xml;base64," + imgBase64;
+ }
+ catch (e) {
+ // nothing
+ }
+ }
+
+ entryElementImg.src = imgPath;
+ }
+
+ if ((asyncInfo && asyncInfo.result.options) || item.options) {
+ const entryElementMoreOptionsButton = document.createElement("div");
+ entryElementMoreOptionsButton.classList.add("entry");
+ entryElementMoreOptionsButton.classList.add("entry-more-button");
+ entryElementMoreOptionsButton.innerHTML = "•••";
+
+ entryElementMoreOptionsButton.onclick = async () => {
+ if (!entryElement.classList.contains("selected")) {
+ smoothScrollToElementIndex(ii, true);
+ return;
+ }
+ let options = null;
+ if (asyncInfo && asyncInfo.result.options) {
+ options = asyncInfo.result.options;
+ }
+ else if (item.options) {
+ options = item.options;
+ }
+ // @ts-ignore
+ await Globals.Router.handleModal(options);
+ }
+
+ entryElement.appendChild(entryElementMoreOptionsButton);
+ }
+
+
+ entryElementMainButton.onclick = async () => {
+ if (!entryElement.classList.contains("selected")) {
+ smoothScrollToElementIndex(ii, true);
+ return;
+ }
+
+ entryElementMainButton.classList.add("loading-overlay");
+ entryElementMainButton.classList.add("loading");
+ await sleep(0); // TODO: Didnt test if its needed here, but i think if the asyncInfo.result.onclick isnt async we might need this to trigger a rerender, or ideally something better
+ let onclickResult = null;
+ if (asyncInfo && asyncInfo.result.onclick) {
+ onclickResult = await asyncInfo.result.onclick();
+ }
+ else if (item.onclick) {
+ onclickResult = await item.onclick();
+ }
+ else {
+ // TODO: invalid item, remove
+ }
+
+ let res = onclickResult;
+ let logStream = null;
+
+ if (res && res.path) {
+ logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd);
+ res = logStream != null;
+ }
+
+ if (res == true) {
+ entryElement.style.transform = "scale(2)";
+ setTimeout(() => {
+ entryElement.style.removeProperty("transform");
+ }, 300);
+ Globals.Router.handleLaunchedAppView(logStream);
+ }
+
+ entryElementMainButton.classList.remove("loading-overlay");
+ entryElementMainButton.classList.remove("loading");
+ }
+
+ });
+
+ }
+
+ smoothScrollToElementIndex(0, false);
+
+ // await all and reset cursor snap overlays every time a task completes
+ await Promise.all(asyncInfoPromisesList.map(asyncInfoPromise => asyncInfoPromise().then(() => {
+ if (document.getElementById("modal-content")) {
+ return;
+ }
+ generateCursorSnapOverlays();
+ })));
+}
+
+
+function removeAllCursorSnapOverlays() {
+ const content = document.getElementById("content");
+ if (!content) {
+ return;
+ }
+ const overlays = content.getElementsByClassName("home-cursor-snap-overlay");
+ while (overlays.length > 0) {
+ overlays[0].remove();
+ }
+}
+
+function generateCursorSnapOverlays(entryWrapperIndex = -1) {
+ // create overlay for cursor snap
+ // remove existing overlays
+ removeAllCursorSnapOverlays();
+ let content = document.getElementById("content");
+ if (!content) {
+ return;
+ }
+ if (entryWrapperIndex === -1) {
+ // set to index of entry-wrapper with selected class
+ let wrappers = content.getElementsByClassName("entry-wrapper");
+ for (let i = 0; i < wrappers.length; i++) {
+ if (wrappers[i].classList.contains("selected")) {
+ entryWrapperIndex = i;
+ break;
+ }
+ }
+ }
+
+ const entry = content.getElementsByClassName("entry-wrapper")[entryWrapperIndex];
+
+ if (!entry) {
+ return;
+ }
+
+ // for each entry in entry-wrapper make a cursor snap overlay
+ for (let i = 0; i < entry.children.length; i++) {
+ const entryChild = entry.children[i];
+ const overlay = document.createElement("a");
+ overlay.classList.add("home-cursor-snap-overlay");
+ overlay.style.position = "fixed";
+
+ // calculate the offset from the top of the viewport
+
+ const entryChildRect = entryChild.getBoundingClientRect();
+ let entryChildBottom = entryChildRect.top + entryChildRect.height;
+ let entryChildHeight = entryChildRect.height;
+ let topOffset = 0;
+
+ // this method has not added the selected class yet so if theres a selected class on the wrapper
+ // thats because the element was initialized with it, in that case the element is already 1.2x scaled
+ // but otherwise the bounding box is the 1.0x size here bc the animation has not run yet
+ // even if we add the selected class the bounding box scales with the animation so its useless until its finished
+ if (!entry.classList.contains("selected")) {
+ // we gotta calculate the transforms
+ const transformScale = 1.2;
+ const parentRect = entry.getBoundingClientRect();
+ const parentCenter = parentRect.top + parentRect.height / 2;
+ const entryTopRelativeToParentCenter = entryChildRect.top - parentCenter;
+ const entryBottomRelativeToParentCenter = entryChildRect.bottom - parentCenter;
+ const entryTransformedTop = parentCenter + (entryTopRelativeToParentCenter * transformScale);
+ const entryTransformedBottom = parentCenter + (entryBottomRelativeToParentCenter * transformScale);
+ const entryTransformedHeight = entryTransformedBottom - entryTransformedTop;
+ entryChildHeight = entryTransformedHeight;
+ entryChildBottom = entryTransformedBottom;
+ }
+
+ if (entryChild.classList.contains("entry")) {
+ // main big button
+ // snap cursor to the lower 3/8 of the button
+ topOffset = entryChildBottom - entryChildHeight * (2 / 5);
+ } else {
+ // snap to center
+ topOffset = entryChildBottom - (entryChildHeight / 2);
+ }
+
+ overlay.style.top = topOffset + "px";
+ overlay.style.left = "0";
+ overlay.style.right = "0";
+ overlay.style.height = "1px";
+ // allow clicks through
+ overlay.style.pointerEvents = "none";
+ // snap cursor
+ overlay.tabIndex = 0;
+ content.appendChild(overlay);
+ }
+}
+
+
+/**
+ * Only for home page carousel, also sets selected class and creates the cursor snap points
+ */
+function smoothScrollToElementIndex(index, smooth = true) {
+ const content = document.getElementById("content");
+ if (!content) {
+ return;
+ }
+ const carousel = content.getElementsByClassName("carousel")[0];
+ if (!carousel){
+ return;
+ }
+ const entries = carousel.getElementsByClassName("entry-wrapper");
+
+ if (index < 0 || index >= entries.length) {
+ return;
+ }
+
+ const entry = entries[index];
+
+ generateCursorSnapOverlays(index);
+
+ for (let t_entry of entries) {
+ t_entry.classList.remove("selected");
+ }
+
+ entry.classList.add("selected");
+
+ // webkit doesnt do smooth scrolling and smoothscroll.js cant center the item so we have to do it manually
+ const itemRect = entry.getBoundingClientRect();
+ const containerRect = content.getBoundingClientRect();
+ const targetScrollLeft = itemRect.left - containerRect.left + content.scrollLeft - (containerRect.width - itemRect.width) / 2;
+
+ if (targetScrollLeft < 1 && targetScrollLeft > -1) {
+ return;
+ }
+
+ if (smooth) {
+ carousel.scrollBy({ left: targetScrollLeft, behavior: "smooth" });
+ carousel.classList.add("scrolling");
+ if (Globals.removeScrollingClassFromCarouselTimeoutId) {
+ clearTimeout(Globals.removeScrollingClassFromCarouselTimeoutId);
+ }
+ Globals.removeScrollingClassFromCarouselTimeoutId = setTimeout(() => {
+ carousel.classList.remove("scrolling");
+ }, 468); // 468 the constant length of the scrolling with smoothscroll.js
+ } else {
+ carousel.scrollLeft = targetScrollLeft;
+ }
+}
+
+/**
+ * Returns null if the current view isnt a carouselView
+ * @returns { {selectedIndex: number?, totalEntries: number}? }
+ */
+function getCurrentCarouselSelectionInfo() {
+ const content = /** @type {HTMLElement} */ (document.getElementById("content"));
+ const carousel = content.getElementsByClassName("carousel")[0];
+ if (!carousel) {
+ return null;
+ }
+
+ const entries = carousel.getElementsByClassName("entry-wrapper");
+ let currentElementIndex = Array.from(entries).findIndex(entry => entry.classList.contains("selected"));
+ return {
+ selectedIndex: currentElementIndex,
+ totalEntries: entries.length
+ };
+}
diff --git a/assets/extensionsHandler.js b/assets/extensionsHandler.js
index ae3ec3a..f4b4077 100644
--- a/assets/extensionsHandler.js
+++ b/assets/extensionsHandler.js
@@ -1,247 +1,247 @@
-/* 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
-. */
-
-
-// @ts-check
-
-const EXTENSION_SANDBOX_EXECUTE_ACTION = "EXTENSION_SANDBOX_EXECUTE_ACTION";
-
-const EXTENSION_SANDBOX_EXECUTE_RESPONSE = "EXTENSION_SANDBOX_EXECUTE_RESPONSE";
-// @ts-ignore (demo hb.js is in a diff context)
-const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL";
-// @ts-ignore (demo hb.js is in a diff context)
-const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER";
-
-const EXTENSION_MAIN_TIMEOUT_MS = 5000;
-
-/**
- * @typedef {Object} ExtensionInfo
- * @property {string} uuid - reference the sandbox iframe by this id
- * @property {CarouselItem} result
- */
-
-/**
- * @typedef {Object} ExtensionSandboxExecResult
- * @property {string} uuid
- * @property {boolean?} result
- */
-
-
-/**
- * @param {string} extensionPath
- * @returns {Promise} extensionId and result of main(), null if main() timed out, if null sandbox is deleted
- */
-async function loadJsExtension(extensionPath) {
- // load js as raw text
- let hbJs = await ApiClient.fsGetFileText(extensionPath);
- if (!hbJs) {
- // throw new Error("Failed to load extension: " + extensionPath);
- removeFromHomebrewList(extensionPath);
- await Globals.Router.handleHome();
- return null;
- }
-
- if (!Globals.hbApiJs) {
- let request2 = await fetch("homebrewApi.js");
- if (!request2.ok) {
- throw new Error("Failed to load extension api js");
- }
- Globals.hbApiJs = await request2.text();
- }
-
- if (!Globals.ApiClientJs) {
- let request3 = await fetch("apiClient.js");
- if (!request3.ok) {
- throw new Error("Failed to load api client js for extension");
- }
- Globals.ApiClientJs = await request3.text();
- }
-
- // create element to hold iframes if there isnt one already
- let sandbox = document.getElementById("js-extension-sandbox");
-
- if (!sandbox) {
- sandbox = document.createElement("div");
- sandbox.style.display = "none";
- sandbox.id = "js-extension-sandbox";
- document.body.appendChild(sandbox);
- }
-
- let extensionId = uuidv4();
-
- const iframeContent = `
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- const blob = new Blob([iframeContent], { type: "text/html" });
- const url = URL.createObjectURL(blob);
-
- const iframe = document.createElement("iframe");
- iframe.id = extensionId;
- // @ts-ignore
- iframe.sandbox = "allow-scripts allow-modals";
- iframe.style.display = "none";
-
- let result = new Promise((resolve, reject) => {
- let timeout = null;
- let handler = function(event) {
- if (event.data.extensionId === extensionId) {
- clearTimeout(timeout);
- window.removeEventListener("message", handler);
- resolve({ uuid: extensionId, result: remapFunctionIdsToSandboxedProxyCalls(JSON.parse(decodeURIComponent(event.data.result)),extensionId) });
- }
- }
-
- timeout = setTimeout(() => {
- window.removeEventListener("message", handler);
- sandbox.removeChild(iframe);
- resolve(null);
- }, EXTENSION_MAIN_TIMEOUT_MS);
-
- window.addEventListener("message", handler);
- });
-
- iframe.src = url;
-
- sandbox.appendChild(iframe);
-
- return result;
-}
-
-/**
- * @param {string} extensionId
- * @param {string} funcId
- * @returns {Promise?}
- */
-function executeInExtensionSandbox(extensionId, funcId) {
- const iframe = /** @type {HTMLIFrameElement} */ (document.getElementById(extensionId));
- if (!iframe || !iframe.contentWindow) {
- throw new Error("extension sandbox iframe not found");
- }
-
- let resultId = uuidv4();
-
- iframe.contentWindow.postMessage({ action: EXTENSION_SANDBOX_EXECUTE_ACTION, funcId: funcId, resultId: resultId }, "*");
-
- return new Promise((resolve, reject) => {
- window.addEventListener("message", function handler(event) {
- if (event.data.extensionId === extensionId && event.data.action === EXTENSION_SANDBOX_EXECUTE_RESPONSE && event.data.resultId === resultId) {
- window.removeEventListener("message", handler);
- let decodedResult = decodeURIComponent(event.data.result);
- if (decodedResult == "null" || decodedResult == "undefined") {
- resolve({ uuid: extensionId, result: null });
- return;
- }
- resolve({ uuid: extensionId, result: JSON.parse(decodedResult) });
- }
- });
- });
-}
-
-/** Reverse what remapFunctionsToFunctionIds of the hb api does */
-function remapFunctionIdsToSandboxedProxyCalls(obj, extensionId) {
- if (typeof obj !== "object" || obj === null) {
- return obj;
- }
-
- // if array, iterate over its elements recursively
- if (Array.isArray(obj)) {
- return obj.map(item => remapFunctionIdsToSandboxedProxyCalls(item, extensionId));
- }
-
- return Object.keys(obj).reduce((acc, key) => {
- if (key === "onclick") {
- acc[key] = async () => {
- let res = await executeInExtensionSandbox(extensionId, obj[key]);
- if (res) {
- return res.result;
- }
- return null;
- }
- } else if (typeof obj[key] === "object" && obj[key] !== null) {
- // Recursively serialize nested objects
- acc[key] = remapFunctionIdsToSandboxedProxyCalls(obj[key], extensionId);
- } else {
- acc[key] = obj[key];
- }
- return acc;
- }, {});
-}
-
-
-/**
- * Only call once at load
- */
-function registerExtensionMessagesListener() {
- window.addEventListener("message", async function handler(event) {
- // check if message has an extension uuid and extension is loaded
- if (!event.data.extensionId || !this.document.getElementById(event.data.extensionId)) {
- return;
- }
-
- if (event.data.action === EXTENSION_API_PARENT_FILE_BROWSER) {
- let sandbox = /** @type {HTMLIFrameElement?} */ (document.getElementById(event.data.extensionId));
- if (!sandbox || !sandbox.contentWindow) {
- return;
- }
- let result = await Globals.Router.pickFile(decodeURIComponent(event.data.initialPath), decodeURIComponent(event.data.title));
-
- sandbox.contentWindow.postMessage({ extensionId: event.data.extensionId, callback: event.data.callback, result: result }, "*");
- } else if (event.data.action === EXTENSION_API_PARENT_SHOW_CAROUSEL) {
- let temp_items = JSON.parse(decodeURIComponent(event.data.items));
- // these items should already be almost the same as what the carousel expects
- // the only difference is the the onclicks where replaced by function ids
- /**
- * @type {CarouselItem[]}
- */
- let items = remapFunctionIdsToSandboxedProxyCalls(temp_items, event.data.extensionId);
- await Globals.Router.handleCarousel(items);
- }
- });
-}
+/* 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
+. */
+
+
+// @ts-check
+
+const EXTENSION_SANDBOX_EXECUTE_ACTION = "EXTENSION_SANDBOX_EXECUTE_ACTION";
+
+const EXTENSION_SANDBOX_EXECUTE_RESPONSE = "EXTENSION_SANDBOX_EXECUTE_RESPONSE";
+// @ts-ignore (demo hb.js is in a diff context)
+const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL";
+// @ts-ignore (demo hb.js is in a diff context)
+const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER";
+
+const EXTENSION_MAIN_TIMEOUT_MS = 5000;
+
+/**
+ * @typedef {Object} ExtensionInfo
+ * @property {string} uuid - reference the sandbox iframe by this id
+ * @property {CarouselItem} result
+ */
+
+/**
+ * @typedef {Object} ExtensionSandboxExecResult
+ * @property {string} uuid
+ * @property {boolean?} result
+ */
+
+
+/**
+ * @param {string} extensionPath
+ * @returns {Promise} extensionId and result of main(), null if main() timed out, if null sandbox is deleted
+ */
+async function loadJsExtension(extensionPath) {
+ // load js as raw text
+ let hbJs = await ApiClient.fsGetFileText(extensionPath);
+ if (!hbJs) {
+ // throw new Error("Failed to load extension: " + extensionPath);
+ removeFromHomebrewList(extensionPath);
+ await Globals.Router.handleHome();
+ return null;
+ }
+
+ if (!Globals.hbApiJs) {
+ let request2 = await fetch("homebrewApi.js");
+ if (!request2.ok) {
+ throw new Error("Failed to load extension api js");
+ }
+ Globals.hbApiJs = await request2.text();
+ }
+
+ if (!Globals.ApiClientJs) {
+ let request3 = await fetch("apiClient.js");
+ if (!request3.ok) {
+ throw new Error("Failed to load api client js for extension");
+ }
+ Globals.ApiClientJs = await request3.text();
+ }
+
+ // create element to hold iframes if there isnt one already
+ let sandbox = document.getElementById("js-extension-sandbox");
+
+ if (!sandbox) {
+ sandbox = document.createElement("div");
+ sandbox.style.display = "none";
+ sandbox.id = "js-extension-sandbox";
+ document.body.appendChild(sandbox);
+ }
+
+ let extensionId = uuidv4();
+
+ const iframeContent = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const blob = new Blob([iframeContent], { type: "text/html" });
+ const url = URL.createObjectURL(blob);
+
+ const iframe = document.createElement("iframe");
+ iframe.id = extensionId;
+ // @ts-ignore
+ iframe.sandbox = "allow-scripts allow-modals";
+ iframe.style.display = "none";
+
+ let result = new Promise((resolve, reject) => {
+ let timeout = null;
+ let handler = function(event) {
+ if (event.data.extensionId === extensionId) {
+ clearTimeout(timeout);
+ window.removeEventListener("message", handler);
+ resolve({ uuid: extensionId, result: remapFunctionIdsToSandboxedProxyCalls(JSON.parse(decodeURIComponent(event.data.result)),extensionId) });
+ }
+ }
+
+ timeout = setTimeout(() => {
+ window.removeEventListener("message", handler);
+ sandbox.removeChild(iframe);
+ resolve(null);
+ }, EXTENSION_MAIN_TIMEOUT_MS);
+
+ window.addEventListener("message", handler);
+ });
+
+ iframe.src = url;
+
+ sandbox.appendChild(iframe);
+
+ return result;
+}
+
+/**
+ * @param {string} extensionId
+ * @param {string} funcId
+ * @returns {Promise?}
+ */
+function executeInExtensionSandbox(extensionId, funcId) {
+ const iframe = /** @type {HTMLIFrameElement} */ (document.getElementById(extensionId));
+ if (!iframe || !iframe.contentWindow) {
+ throw new Error("extension sandbox iframe not found");
+ }
+
+ let resultId = uuidv4();
+
+ iframe.contentWindow.postMessage({ action: EXTENSION_SANDBOX_EXECUTE_ACTION, funcId: funcId, resultId: resultId }, "*");
+
+ return new Promise((resolve, reject) => {
+ window.addEventListener("message", function handler(event) {
+ if (event.data.extensionId === extensionId && event.data.action === EXTENSION_SANDBOX_EXECUTE_RESPONSE && event.data.resultId === resultId) {
+ window.removeEventListener("message", handler);
+ let decodedResult = decodeURIComponent(event.data.result);
+ if (decodedResult == "null" || decodedResult == "undefined") {
+ resolve({ uuid: extensionId, result: null });
+ return;
+ }
+ resolve({ uuid: extensionId, result: JSON.parse(decodedResult) });
+ }
+ });
+ });
+}
+
+/** Reverse what remapFunctionsToFunctionIds of the hb api does */
+function remapFunctionIdsToSandboxedProxyCalls(obj, extensionId) {
+ if (typeof obj !== "object" || obj === null) {
+ return obj;
+ }
+
+ // if array, iterate over its elements recursively
+ if (Array.isArray(obj)) {
+ return obj.map(item => remapFunctionIdsToSandboxedProxyCalls(item, extensionId));
+ }
+
+ return Object.keys(obj).reduce((acc, key) => {
+ if (key === "onclick") {
+ acc[key] = async () => {
+ let res = await executeInExtensionSandbox(extensionId, obj[key]);
+ if (res) {
+ return res.result;
+ }
+ return null;
+ }
+ } else if (typeof obj[key] === "object" && obj[key] !== null) {
+ // Recursively serialize nested objects
+ acc[key] = remapFunctionIdsToSandboxedProxyCalls(obj[key], extensionId);
+ } else {
+ acc[key] = obj[key];
+ }
+ return acc;
+ }, {});
+}
+
+
+/**
+ * Only call once at load
+ */
+function registerExtensionMessagesListener() {
+ window.addEventListener("message", async function handler(event) {
+ // check if message has an extension uuid and extension is loaded
+ if (!event.data.extensionId || !this.document.getElementById(event.data.extensionId)) {
+ return;
+ }
+
+ if (event.data.action === EXTENSION_API_PARENT_FILE_BROWSER) {
+ let sandbox = /** @type {HTMLIFrameElement?} */ (document.getElementById(event.data.extensionId));
+ if (!sandbox || !sandbox.contentWindow) {
+ return;
+ }
+ let result = await Globals.Router.pickFile(decodeURIComponent(event.data.initialPath), decodeURIComponent(event.data.title));
+
+ sandbox.contentWindow.postMessage({ extensionId: event.data.extensionId, callback: event.data.callback, result: result }, "*");
+ } else if (event.data.action === EXTENSION_API_PARENT_SHOW_CAROUSEL) {
+ let temp_items = JSON.parse(decodeURIComponent(event.data.items));
+ // these items should already be almost the same as what the carousel expects
+ // the only difference is the the onclicks where replaced by function ids
+ /**
+ * @type {CarouselItem[]}
+ */
+ let items = remapFunctionIdsToSandboxedProxyCalls(temp_items, event.data.extensionId);
+ await Globals.Router.handleCarousel(items);
+ }
+ });
+}
diff --git a/assets/filePickerView.js b/assets/filePickerView.js
index 2afa7c2..2fce738 100644
--- a/assets/filePickerView.js
+++ b/assets/filePickerView.js
@@ -1,134 +1,134 @@
-/* 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
-. */
-
-
-// @ts-check
-
-/**
- * @param {string} path
- * @returns {Promise<{path: string, finished: boolean}>}
- */
-async function renderBrowsePage(path, fadein = false, title = 'Select file...') {
- /** @type {Promise<{path: string, finished: boolean}>[]} */
- let result = [];
-
- let data = null;
-
- if (!path.endsWith("/")) {
- path += "/";
- }
-
- data = await ApiClient.fsListDir(path);
-
- if (!data) {
- throw new Error("Failed to load directory: " + path);
- }
-
- // reset content
- const content = /** @type {HTMLElement} */ (document.getElementById("content"));
- content.innerHTML = "";
-
- // create list div
- const list = document.createElement("div");
- list.classList.add("list");
-
- let titleElement = document.createElement("div");
- titleElement.classList.add("list-title");
- titleElement.innerText = title;
- list.appendChild(titleElement);
-
- // add back button if needed
- if (path !== "/") {
- let entryElement = document.createElement("a");
- entryElement.classList.add("list-entry");
-
- entryElement.tabIndex = 0;
-
- result.push(new Promise((resolve, reject) => {
- entryElement.onclick = () => {
- let backPath = path.substring(0, path.lastIndexOf("/"));
- backPath = backPath.substring(0, backPath.lastIndexOf("/") + 1);
- resolve({ path: backPath, finished: false });
- }
- }));
-
-
- const backIcon = document.createElement("div");
- backIcon.classList.add("list-entry-icon");
- backIcon.classList.add("back-icon");
-
- entryElement.appendChild(backIcon);
-
- const nameElement = document.createElement("p");
- nameElement.classList.add("list-entry-name");
- nameElement.innerText = "..";
- entryElement.appendChild(nameElement);
-
- list.appendChild(entryElement);
- }
-
-
- // add entries
- data.forEach(entry => {
- let entryElement = document.createElement("a");
- entryElement.classList.add("list-entry");
- if (entry.isDir()) {
- entryElement.classList.add("list-entry-directory");
- // add dir icon
- const dirIcon = document.createElement("div");
- dirIcon.classList.add("list-entry-icon");
- dirIcon.classList.add("dir-icon");
-
- entryElement.appendChild(dirIcon);
- } else {
- entryElement.classList.add("list-entry-file");
- // add file icon
- const fileIcon = document.createElement("div");
- fileIcon.classList.add("list-entry-icon");
- fileIcon.classList.add("file-icon");
-
- entryElement.appendChild(fileIcon);
- }
-
- // make the cursor snap
- entryElement.tabIndex = 0;
-
- const nameElement = document.createElement("p");
- nameElement.classList.add("list-entry-name");
- nameElement.innerText = entry.name;
- entryElement.appendChild(nameElement);
-
- result.push(new Promise((resolve, reject) => {
- entryElement.onclick = () => {
- if (entry.isDir()) {
- resolve({ path: path + entry.name + "/", finished: false });
- } else {
- resolve({ path: path + entry.name, finished: true });
- }
- }
- }));
-
- list.appendChild(entryElement);
- });
-
- if (fadein) {
- content.classList.add("fadein");
- }
-
- content.appendChild(list);
-
- return Promise.race(result);
+/* 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
+. */
+
+
+// @ts-check
+
+/**
+ * @param {string} path
+ * @returns {Promise<{path: string, finished: boolean}>}
+ */
+async function renderBrowsePage(path, fadein = false, title = 'Select file...') {
+ /** @type {Promise<{path: string, finished: boolean}>[]} */
+ let result = [];
+
+ let data = null;
+
+ if (!path.endsWith("/")) {
+ path += "/";
+ }
+
+ data = await ApiClient.fsListDir(path);
+
+ if (!data) {
+ throw new Error("Failed to load directory: " + path);
+ }
+
+ // reset content
+ const content = /** @type {HTMLElement} */ (document.getElementById("content"));
+ content.innerHTML = "";
+
+ // create list div
+ const list = document.createElement("div");
+ list.classList.add("list");
+
+ let titleElement = document.createElement("div");
+ titleElement.classList.add("list-title");
+ titleElement.innerText = title;
+ list.appendChild(titleElement);
+
+ // add back button if needed
+ if (path !== "/") {
+ let entryElement = document.createElement("a");
+ entryElement.classList.add("list-entry");
+
+ entryElement.tabIndex = 0;
+
+ result.push(new Promise((resolve, reject) => {
+ entryElement.onclick = () => {
+ let backPath = path.substring(0, path.lastIndexOf("/"));
+ backPath = backPath.substring(0, backPath.lastIndexOf("/") + 1);
+ resolve({ path: backPath, finished: false });
+ }
+ }));
+
+
+ const backIcon = document.createElement("div");
+ backIcon.classList.add("list-entry-icon");
+ backIcon.classList.add("back-icon");
+
+ entryElement.appendChild(backIcon);
+
+ const nameElement = document.createElement("p");
+ nameElement.classList.add("list-entry-name");
+ nameElement.innerText = "..";
+ entryElement.appendChild(nameElement);
+
+ list.appendChild(entryElement);
+ }
+
+
+ // add entries
+ data.forEach(entry => {
+ let entryElement = document.createElement("a");
+ entryElement.classList.add("list-entry");
+ if (entry.isDir()) {
+ entryElement.classList.add("list-entry-directory");
+ // add dir icon
+ const dirIcon = document.createElement("div");
+ dirIcon.classList.add("list-entry-icon");
+ dirIcon.classList.add("dir-icon");
+
+ entryElement.appendChild(dirIcon);
+ } else {
+ entryElement.classList.add("list-entry-file");
+ // add file icon
+ const fileIcon = document.createElement("div");
+ fileIcon.classList.add("list-entry-icon");
+ fileIcon.classList.add("file-icon");
+
+ entryElement.appendChild(fileIcon);
+ }
+
+ // make the cursor snap
+ entryElement.tabIndex = 0;
+
+ const nameElement = document.createElement("p");
+ nameElement.classList.add("list-entry-name");
+ nameElement.innerText = entry.name;
+ entryElement.appendChild(nameElement);
+
+ result.push(new Promise((resolve, reject) => {
+ entryElement.onclick = () => {
+ if (entry.isDir()) {
+ resolve({ path: path + entry.name + "/", finished: false });
+ } else {
+ resolve({ path: path + entry.name, finished: true });
+ }
+ }
+ }));
+
+ list.appendChild(entryElement);
+ });
+
+ if (fadein) {
+ content.classList.add("fadein");
+ }
+
+ content.appendChild(list);
+
+ return Promise.race(result);
}
\ No newline at end of file
diff --git a/assets/global.js b/assets/global.js
index 5911887..ccc14bf 100644
--- a/assets/global.js
+++ b/assets/global.js
@@ -1,37 +1,37 @@
-/* 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
-. */
-
-
-// @ts-check
-
-/**
- * @typedef {Object} Globals
- * @property {Router} Router
- * @property {string?} hbApiJs
- * @property {string?} ApiClientJs
- * @property {number?} removeScrollingClassFromCarouselTimeoutId
- * @property {number} lastCarouselLeftRightKeyDownTimestamp
- */
-
-/** @type {Globals} */
-const Globals = {
- // @ts-ignore
- Router: null,
- hbApiJs: null,
- ApiClientJs: null,
- removeScrollingClassFromCarouselTimeoutId: null,
- lastCarouselLeftRightKeyDownTimestamp: 0,
-};
+/* 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
+. */
+
+
+// @ts-check
+
+/**
+ * @typedef {Object} Globals
+ * @property {Router} Router
+ * @property {string?} hbApiJs
+ * @property {string?} ApiClientJs
+ * @property {number?} removeScrollingClassFromCarouselTimeoutId
+ * @property {number} lastCarouselLeftRightKeyDownTimestamp
+ */
+
+/** @type {Globals} */
+const Globals = {
+ // @ts-ignore
+ Router: null,
+ hbApiJs: null,
+ ApiClientJs: null,
+ removeScrollingClassFromCarouselTimeoutId: null,
+ lastCarouselLeftRightKeyDownTimestamp: 0,
+};
diff --git a/assets/homebrewApi.js b/assets/homebrewApi.js
index 2103c85..fad8a49 100644
--- a/assets/homebrewApi.js
+++ b/assets/homebrewApi.js
@@ -1,81 +1,81 @@
-/* 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
-. */
-
-
-// the code here is what gets injected before the extension.js
-// these are things the extension can use to interact with the frontend
-
-const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL";
-const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER";
-
-// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
-function uuidv4() {
- return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
- (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
- );
-}
-
-function remapFunctionsToFunctionIds(obj) {
- if (typeof obj !== "object" || obj === null) {
- return obj;
- }
-
- // if array, iterate over its elements recursively
- if (Array.isArray(obj)) {
- return obj.map(item => remapFunctionsToFunctionIds(item));
- }
-
- return Object.keys(obj).reduce((acc, key) => {
- // return a uuid instead of the function and save the the function here in the sandbox so its state is saved and we can easily call it
- if (typeof obj[key] === "function") {
- let uuid = uuidv4();
- if (!window.Funcs) {
- window.Funcs = {};
- }
- window.Funcs[uuid] = obj[key];
- acc[key] = uuid;
- } else if (typeof obj[key] === "object" && obj[key] !== null) {
- // Recursively serialize nested objects
- acc[key] = remapFunctionsToFunctionIds(obj[key]);
- } else {
- acc[key] = obj[key];
- }
- return acc;
- }, {});
-}
-
-// public functions below, apiClient.js also will be injected
-
-function showCarousel(items) {
- window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_SHOW_CAROUSEL, items: encodeURIComponent(JSON.stringify(remapFunctionsToFunctionIds(items))) }, "*");
-}
-
-function pickFile(initialPath, title = 'Select file...') {
- const uuid = uuidv4();
- window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_FILE_BROWSER, callback: uuid, initialPath: encodeURIComponent(initialPath), title: encodeURIComponent(title)}, "*");
-
- // wait for response
- return new Promise((resolve, reject) => {
- window.addEventListener("message", function handler(event) {
- if (event.data.callback !== uuid) {
- return;
- }
-
- window.removeEventListener("message", handler);
- resolve(event.data.result);
- });
- });
-}
+/* 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
+. */
+
+
+// the code here is what gets injected before the extension.js
+// these are things the extension can use to interact with the frontend
+
+const EXTENSION_API_PARENT_SHOW_CAROUSEL = "EXTENSION_API_PARENT_SHOW_CAROUSEL";
+const EXTENSION_API_PARENT_FILE_BROWSER = "EXTENSION_API_PARENT_FILE_BROWSER";
+
+// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
+function uuidv4() {
+ return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
+ (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
+ );
+}
+
+function remapFunctionsToFunctionIds(obj) {
+ if (typeof obj !== "object" || obj === null) {
+ return obj;
+ }
+
+ // if array, iterate over its elements recursively
+ if (Array.isArray(obj)) {
+ return obj.map(item => remapFunctionsToFunctionIds(item));
+ }
+
+ return Object.keys(obj).reduce((acc, key) => {
+ // return a uuid instead of the function and save the the function here in the sandbox so its state is saved and we can easily call it
+ if (typeof obj[key] === "function") {
+ let uuid = uuidv4();
+ if (!window.Funcs) {
+ window.Funcs = {};
+ }
+ window.Funcs[uuid] = obj[key];
+ acc[key] = uuid;
+ } else if (typeof obj[key] === "object" && obj[key] !== null) {
+ // Recursively serialize nested objects
+ acc[key] = remapFunctionsToFunctionIds(obj[key]);
+ } else {
+ acc[key] = obj[key];
+ }
+ return acc;
+ }, {});
+}
+
+// public functions below, apiClient.js also will be injected
+
+function showCarousel(items) {
+ window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_SHOW_CAROUSEL, items: encodeURIComponent(JSON.stringify(remapFunctionsToFunctionIds(items))) }, "*");
+}
+
+function pickFile(initialPath, title = 'Select file...') {
+ const uuid = uuidv4();
+ window.parent.postMessage({ extensionId: window.extensionId, action: EXTENSION_API_PARENT_FILE_BROWSER, callback: uuid, initialPath: encodeURIComponent(initialPath), title: encodeURIComponent(title)}, "*");
+
+ // wait for response
+ return new Promise((resolve, reject) => {
+ window.addEventListener("message", function handler(event) {
+ if (event.data.callback !== uuid) {
+ return;
+ }
+
+ window.removeEventListener("message", handler);
+ resolve(event.data.result);
+ });
+ });
+}
diff --git a/assets/homebrewStore.js b/assets/homebrewStore.js
index 285c1bf..57c6a42 100644
--- a/assets/homebrewStore.js
+++ b/assets/homebrewStore.js
@@ -1,160 +1,160 @@
-/* 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
-. */
-
-
-// @ts-check
-const HOMEBREW_AUTO_SCAN_PATHS = ["/data/homebrew/"];
-// order of this matters, if both homebrew.js and eboot.elf exist, homebrew.js will be used
-const HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES = [ "homebrew.js", "eboot.elf" ];
-
-const LOCALSTORE_HOMEBREW_LIST_KEY = "LOCALSTORE_HOMEBREW_LIST";
-
-/**
- * @typedef {Object} HomebrewEntry
- * @property {string} path
- * @property {string} dir
- * @property {string} filename
- */
-
-/**
- * If item already exists it puts the item at the start
- * @param {string} path - Full absolute path with filename
- */
-function addToHomebrewStore(path) {
- let hbList = [];
- let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY);
- if (temp_hblistStr) {
- hbList = JSON.parse(temp_hblistStr);
- // if theres already an entry with the same path, remove it
- hbList = hbList.filter(entry => entry !== path);
- }
-
- // Add to start
- hbList.unshift(
- path
- );
-
- localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList));
-}
-
-/**
- * @param {string} path - Full absolute path with filename
- */
-function removeFromHomebrewList(path) {
- let hbList = [];
-
- let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY);
- if (!temp_hblistStr) {
- return;
- }
-
- hbList = JSON.parse(temp_hblistStr);
-
- // if theres already an entry with the same path, remove it
- hbList = hbList.filter(entry => entry !== path);
-
- localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList));
-}
-
-/**
- * @returns {HomebrewEntry[]}
- */
-function getHomebrewList() {
- let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY);
- if (!temp_hblistStr) {
- return [];
- }
-
- return JSON.parse(temp_hblistStr).reduce((acc, entry) => {
- acc.push({
- path: entry,
- dir: entry.substring(0, entry.lastIndexOf("/")),
- filename: entry.substring(entry.lastIndexOf("/") + 1)
- });
- return acc;
- }, []).sort((x, y) => {
- return x.dir.localeCompare(y.dir);
- });
-}
-
-// TODO: this is currently unused
-async function removeNonexistentHomebrews() {
- let hbs = getHomebrewList();
-
- // group by path to avoid making a request for every entry in case theres multiple in the same folder
- let pathGroups = {};
-
- hbs.forEach(entry => {
- if (!pathGroups[entry.dir]) {
- pathGroups[entry.dir] = [];
- }
- pathGroups[entry.dir].push(entry);
- });
-
- // loop though groups
- Object.keys(pathGroups).forEach(async (key) => {
- let json = await ApiClient.fsListDir(key);
-
- pathGroups[key].forEach(entry => {
- if (json === null) {
- removeFromHomebrewList(entry.path);
- return;
- }
-
- json.forEach(jsonDirent => {
- if (jsonDirent.name === entry.filename) {
- return;
- }
- });
-
- removeFromHomebrewList(entry.path);
- });
- });
-}
-
-async function scanHomebrews() {
- for (let path of HOMEBREW_AUTO_SCAN_PATHS) {
- let autoScanParentDirEntryList = await ApiClient.fsListDir(path);
- if (autoScanParentDirEntryList === null) {
- continue;
- }
-
- for (let entry of autoScanParentDirEntryList) {
- let hbDirEntries = await ApiClient.fsListDir(path + entry.name);
- if (hbDirEntries === null) {
- continue;
- }
-
- let foundExecutableName = null;
- for (let hbDirEntry of hbDirEntries) {
- for (let allowedName of HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES) {
- if (hbDirEntry.name.toLowerCase() === allowedName.toLowerCase()) {
- foundExecutableName = allowedName;
- break;
- }
- }
-
- if (foundExecutableName) {
- break;
- }
- }
-
- if (foundExecutableName) {
- addToHomebrewStore(path + entry.name + "/" + foundExecutableName);
- }
- }
- }
-}
+/* 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
+. */
+
+
+// @ts-check
+const HOMEBREW_AUTO_SCAN_PATHS = ["/data/homebrew/"];
+// order of this matters, if both homebrew.js and eboot.elf exist, homebrew.js will be used
+const HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES = [ "homebrew.js", "eboot.elf" ];
+
+const LOCALSTORE_HOMEBREW_LIST_KEY = "LOCALSTORE_HOMEBREW_LIST";
+
+/**
+ * @typedef {Object} HomebrewEntry
+ * @property {string} path
+ * @property {string} dir
+ * @property {string} filename
+ */
+
+/**
+ * If item already exists it puts the item at the start
+ * @param {string} path - Full absolute path with filename
+ */
+function addToHomebrewStore(path) {
+ let hbList = [];
+ let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY);
+ if (temp_hblistStr) {
+ hbList = JSON.parse(temp_hblistStr);
+ // if theres already an entry with the same path, remove it
+ hbList = hbList.filter(entry => entry !== path);
+ }
+
+ // Add to start
+ hbList.unshift(
+ path
+ );
+
+ localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList));
+}
+
+/**
+ * @param {string} path - Full absolute path with filename
+ */
+function removeFromHomebrewList(path) {
+ let hbList = [];
+
+ let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY);
+ if (!temp_hblistStr) {
+ return;
+ }
+
+ hbList = JSON.parse(temp_hblistStr);
+
+ // if theres already an entry with the same path, remove it
+ hbList = hbList.filter(entry => entry !== path);
+
+ localStorage.setItem(LOCALSTORE_HOMEBREW_LIST_KEY, JSON.stringify(hbList));
+}
+
+/**
+ * @returns {HomebrewEntry[]}
+ */
+function getHomebrewList() {
+ let temp_hblistStr = localStorage.getItem(LOCALSTORE_HOMEBREW_LIST_KEY);
+ if (!temp_hblistStr) {
+ return [];
+ }
+
+ return JSON.parse(temp_hblistStr).reduce((acc, entry) => {
+ acc.push({
+ path: entry,
+ dir: entry.substring(0, entry.lastIndexOf("/")),
+ filename: entry.substring(entry.lastIndexOf("/") + 1)
+ });
+ return acc;
+ }, []).sort((x, y) => {
+ return x.dir.localeCompare(y.dir);
+ });
+}
+
+// TODO: this is currently unused
+async function removeNonexistentHomebrews() {
+ let hbs = getHomebrewList();
+
+ // group by path to avoid making a request for every entry in case theres multiple in the same folder
+ let pathGroups = {};
+
+ hbs.forEach(entry => {
+ if (!pathGroups[entry.dir]) {
+ pathGroups[entry.dir] = [];
+ }
+ pathGroups[entry.dir].push(entry);
+ });
+
+ // loop though groups
+ Object.keys(pathGroups).forEach(async (key) => {
+ let json = await ApiClient.fsListDir(key);
+
+ pathGroups[key].forEach(entry => {
+ if (json === null) {
+ removeFromHomebrewList(entry.path);
+ return;
+ }
+
+ json.forEach(jsonDirent => {
+ if (jsonDirent.name === entry.filename) {
+ return;
+ }
+ });
+
+ removeFromHomebrewList(entry.path);
+ });
+ });
+}
+
+async function scanHomebrews() {
+ for (let path of HOMEBREW_AUTO_SCAN_PATHS) {
+ let autoScanParentDirEntryList = await ApiClient.fsListDir(path);
+ if (autoScanParentDirEntryList === null) {
+ continue;
+ }
+
+ for (let entry of autoScanParentDirEntryList) {
+ let hbDirEntries = await ApiClient.fsListDir(path + entry.name);
+ if (hbDirEntries === null) {
+ continue;
+ }
+
+ let foundExecutableName = null;
+ for (let hbDirEntry of hbDirEntries) {
+ for (let allowedName of HOMEBREW_AUTO_SCAN_ALLOWED_EXEC_NAMES) {
+ if (hbDirEntry.name.toLowerCase() === allowedName.toLowerCase()) {
+ foundExecutableName = allowedName;
+ break;
+ }
+ }
+
+ if (foundExecutableName) {
+ break;
+ }
+ }
+
+ if (foundExecutableName) {
+ addToHomebrewStore(path + entry.name + "/" + foundExecutableName);
+ }
+ }
+ }
+}
diff --git a/assets/index.html b/assets/index.html
index 3df2f28..96e6343 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -1,52 +1,52 @@
-
-
-
-
-
-
-
-
-
- Homebrew Launcher
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Homebrew Launcher
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/main.css b/assets/main.css
index b44e9cb..758a04a 100644
--- a/assets/main.css
+++ b/assets/main.css
@@ -1,452 +1,452 @@
-/* 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
-. */
-
-@keyframes fadein {
- 0% {
- opacity: 0;
- }
-
- 100% {
- opacity: 1;
- }
-}
-
-@keyframes fadeout {
- 0% {
- opacity: 1;
- }
-
- 100% {
- opacity: 0;
- }
-}
-
-@keyframes zoomin {
- 0% {
- transform: scale(0.8);
- }
-
- 100% {
- transform: scale(1);
- }
-}
-
-@keyframes spinner {
- to {
- transform: rotate(360deg);
- }
-}
-
-
-:root {
- font-family: "Arial", sans-serif;
- --text-color: #fff;
- --secondary-text-color: #bbb;
- color: var(--text-color);
- --background-color: #0b0d0f;
- --surface-color: #1f2225;
- --surface-color-hover: #3b4047;
- --modal-background-color: #181a1c;
- --file-picker-back-icon-color: var(--text-color);
- --file-picker-file-icon-color: var(--text-color);
- --file-picker-directory-icon-color: #f9e87c;
-}
-
-*:focus {
- outline: none;
-}
-
-body {
- background-color: var(--background-color);
- -webkit-touch-callout: none;
- -webkit-user-select: none;
- -khtml-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- font-size: 20px;
- margin: 0;
-}
-
-.header {
- text-align: center;
- margin-top: 2rem;
-}
-
-.header * {
- margin: 0;
- padding: 0;
-}
-
-div.separator {
- margin-top: 2rem;
- border-bottom: 1px solid var(--text-color);
-}
-
-div.carousel {
- padding: 40px;
- height: 100vh;
- display: flex;
- flex-direction: row;
- width: 100%;
- box-sizing: border-box;
- overflow-x: hidden;
- overflow-y: hidden;
- align-items: center;
-}
-
-/* add some padding to be able scroll the first/last item in the carousel to the center */
-div.carousel::after,
-div.carousel::before {
- position: relative;
- content: "";
- flex: none;
- width: 50vw;
- height: 1px;
-}
-
-div.carousel.scrolling div.entry-wrapper.selected div.entry-main {
- background-color: var(--surface-color-hover);
-}
-
-div.entry-wrapper.selected>div:hover {
- background-color: var(--surface-color-hover);
-}
-
-div.entry-wrapper.selected:not(:hover) div.entry-main {
- background-color: var(--surface-color-hover);
-}
-
-div.entry-wrapper:not(.selected) div.entry {
- background-color: var(--surface-color) !important;
-}
-
-
-div.entry-wrapper {
- flex: none;
- height: 55vh;
- width: 50vh;
- margin-right: 8vh;
- transition: transform 0.3s;
- box-sizing: border-box;
- position: relative;
-}
-
-div.entry-wrapper.selected {
- /* some js is tied to this for the cursor snapping to work so if you change this also change that */
- transform: scale(1.2);
-}
-
-div.entry {
- box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
- background-color: var(--surface-color);
- border-radius: 1vh;
- transition: background-color 0.3s;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- height: calc(100% - 50px);
- cursor: pointer;
- padding: 1rem;
- text-wrap: pretty;
- overflow-wrap: break-word;
- text-align: center;
- overflow: hidden;
- position: relative;
-}
-
-div.entry p {
- padding: 0;
- margin: 5px;
- max-width: 100%;
-}
-
-.entry img {
- display: block;
- max-width: 100%;
- max-height: 30vh;
- object-fit: contain;
-}
-
-div.entry-more-button {
- display: flex;
- justify-content: center;
- align-items: center;
- height: 7vh;
- text-align: center;
- margin-top: 0.5em;
- background-color: var(--surface-color);
- cursor: pointer;
- transition: background-color 0.3s;
-}
-
-.text-secondary {
- font-size: 0.85em;
- color: var(--secondary-text-color);
-}
-
-.entry-name {
- font-weight: 700;
-}
-
-div.list {
- margin: 0 10vh 0 10vh;
- display: flex;
- flex-direction: column;
- align-items: stretch;
-}
-
-div.list-title {
- padding: 0.5em;
- margin-top: 1em;
- font-weight: bold;
- font-size: larger;
- text-align: center;
-}
-
-a.list-entry {
- display: flex;
- flex-direction: row;
- align-items: center;
- padding: 0.5em;
- background-color: var(--surface-color);
- margin-top: 1em;
- border-radius: 6px;
- box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
- cursor: pointer;
- transition: background-color 0.3s;
-}
-
-a.list-entry:hover {
- background-color: var(--surface-color-hover);
-}
-
-div.list-entry-icon {
- min-height: 2rem;
- min-width: 2rem;
- margin-left: 1rem;
- margin-right: 1rem;
- mask-size: contain;
- -webkit-mask-size: contain;
-}
-
-.entry-main{
- position: relative;
-}
-
-.entry-img{
- margin-bottom: 10vh;
-}
-
-.entry-main img[style*="display: block"]~p:nth-child(2) {
- position: absolute;
- bottom: 7vh;
-}
-
-.entry-main img[style*="display: block"]~p:nth-child(3) {
- position: absolute;
- bottom: 3vh;
-}
-
-/* div.dir-icon {
- background-color: var(--file-picker-directory-icon-color);
- mask: url("googlefonts_folder.svg") no-repeat center;
- -webkit-mask: url("googlefonts_folder.svg") no-repeat center;
- mask-size:contain;
- -webkit-mask-size:contain;
-}
-
-div.file-icon {
- background-color: var(--file-picker-file-icon-color);
- mask: url("googlefonts_file.svg") no-repeat center;
- -webkit-mask: url("googlefonts_file.svg") no-repeat center;
- mask-size:contain;
- -webkit-mask-size:contain;
-}
-
-div.back-icon {
- background-color: var(--file-picker-back-icon-color);
- mask: url("googlefonts_arrow_back.svg") no-repeat center;
- -webkit-mask: url("googlefonts_arrow_back.svg") no-repeat center;
- mask-size:contain;
- -webkit-mask-size:contain;
-} */
-
-div.dir-icon {
- /* from google fonts */
- background-color: var(--file-picker-directory-icon-color);
- mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center;
- -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center;
- mask-size: contain;
- -webkit-mask-size: contain;
-}
-
-div.file-icon {
- /* from google fonts */
- background-color: var(--file-picker-file-icon-color);
- mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center;
- -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center;
- mask-size: contain;
- -webkit-mask-size: contain;
-}
-
-div.back-icon {
- /* from google fonts */
- background-color: var(--file-picker-back-icon-color);
- mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center;
- -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center;
- mask-size: contain;
- -webkit-mask-size: contain;
-}
-
-span.ps-triangle-icon {
- /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_T_button.svg */
- background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMiAyMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAuMDAyIDIwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMSIgeTE9IjAiIHgyPSIxMC4wMDEiIHkyPSIxNy45MDA4Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMC4wMDEiIGN5PSIxMCIgcj0iMTAuMDAxIi8+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8yXyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIxMC4wMDEiIHkxPSIwIiB4Mj0iMTAuMDAxIiB5Mj0iMjAuMDAwNSI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QTVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTk3IiBzdHlsZT0ic3RvcC1jb2xvcjojNTY1NzVBIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTUyNCIgc3R5bGU9InN0b3AtY29sb3I6IzNFM0U0MCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5ODkiIHN0eWxlPSJzdG9wLWNvbG9yOiMyQjJBMkMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NjQ3IiBzdHlsZT0ic3RvcC1jb2xvcjojMUUxQzFFIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuNjY1NyIgc3R5bGU9InN0b3AtY29sb3I6IzE2MTQxNiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxMjEzIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8cGF0aCBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgZD0iTTEwLjAwMiwxLjZjNC42MzEsMCw4LjQsMy43NjgsOC40LDguNGMwLDQuNjMyLTMuNzcsOC40LTguNCw4LjRDNS4zNjksMTguNCwxLjYsMTQuNjMyLDEuNiwxMAoJCQlDMS42LDUuMzY4LDUuMzY5LDEuNiwxMC4wMDIsMS42IE0xMC4wMDIsMEM0LjQ3OCwwLDAsNC40NzcsMCwxMHM0LjQ3OCwxMCwxMC4wMDIsMTBjNS41MjEsMCwxMC00LjQ3OCwxMC0xMFMxNS41MjMsMCwxMC4wMDIsMAoJCQlMMTAuMDAyLDB6Ii8+Cgk8L2c+Cgk8cGF0aCBmaWxsPSIjRURFQ0VCIiBkPSJNMTAuMDAxLDcuNTg3bDEuMjY5LDIuMTk3bDEuMjcsMi4xOThoLTIuNTM4SDcuNDY0bDEuMjY5LTIuMTk4TDEwLjAwMSw3LjU4NyBNMTAuMDAxLDUuMjQ4TDcuNzIsOS4yCgkJbC0yLjI4MiwzLjk1M2g0LjU2M2g0LjU2M0wxMi4yODIsOS4yTDEwLjAwMSw1LjI0OEwxMC4wMDEsNS4yNDh6Ii8+CjwvZz4KPC9zdmc+Cg==');
- display: inline-block;
- height: 2rem;
- width: 2rem;
- margin: 0.5rem;
-}
-
-span.ps-circle-icon {
- /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_C_button.svg */
- background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMSAyMC4wMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwMSAyMC4wMDEiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxnPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIxNy45MDE2Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMCIgY3k9IjEwIiByPSIxMCIvPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIyMC4wMDE1Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAxOTciIHN0eWxlPSJzdG9wLWNvbG9yOiM1NjU3NUEiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4xNTI0IiBzdHlsZT0ic3RvcC1jb2xvcjojM0UzRTQwIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMjk4OSIgc3R5bGU9InN0b3AtY29sb3I6IzJCMkEyQyIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2NDciIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFDMUUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjU3IiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNDE2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTAsMS42YzQuNjMzLDAsOC40LDMuNzY5LDguNCw4LjQwMWMwLDQuNjMxLTMuNzY4LDguNC04LjQsOC40Yy00LjYzMSwwLTguNC0zLjc3LTguNC04LjQKCQkJQzEuNiw1LjM2OCw1LjM2OSwxLjYsMTAsMS42IE0xMCwwQzQuNDc3LDAsMCw0LjQ3OCwwLDEwYzAsNS41MjMsNC40NzcsMTAuMDAxLDEwLDEwLjAwMVMyMCwxNS41MjQsMjAsMTBDMjAsNC40NzgsMTUuNTIzLDAsMTAsMAoJCQlMMTAsMHoiLz4KCTwvZz4KCTxwYXRoIGZpbGw9IiNFREVDRUIiIGQ9Ik0xMCw2LjgyMWMxLjc1NCwwLDMuMTc4LDEuNDI2LDMuMTc4LDMuMTc5YzAsMS43NTItMS40MjQsMy4xNzgtMy4xNzgsMy4xNzgKCQljLTEuNzUyLDAtMy4xNzgtMS40MjYtMy4xNzgtMy4xNzhDNi44MjIsOC4yNDcsOC4yNDgsNi44MjEsMTAsNi44MjEgTTEwLDUuNTc5Yy0yLjQ0MSwwLTQuNDIsMS45NzktNC40Miw0LjQyMQoJCWMwLDIuNDQxLDEuOTc5LDQuNDIsNC40Miw0LjQyczQuNDIyLTEuOTc5LDQuNDIyLTQuNDJDMTQuNDIyLDcuNTU5LDEyLjQ0MSw1LjU3OSwxMCw1LjU3OUwxMCw1LjU3OXoiLz4KPC9nPgo8L3N2Zz4K');
- display: inline-block;
- height: 2rem;
- width: 2rem;
- margin: 0.5rem;
-}
-
-span.ps-icon {
- /* https://commons.wikimedia.org/wiki/File:PlayStation_button_Home.svg */
- background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwNyAyMC4wMDYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwNyAyMC4wMDYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCQoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iOTYuMDAwNSIgeTE9IjM1NS45OTU2IiB4Mj0iMTE2LjAwNjgiIHkyPSIzNTUuOTk1NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwIDEgLTEgMCAzNjUuOTk5NSAtOTYuMDAwNSkiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPGNpcmNsZSBmaWxsPSJ1cmwoI1NWR0lEXzFfKSIgY3g9IjEwLjAwMyIgY3k9IjEwLjAwMyIgcj0iMTAuMDAzIi8+Cgk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMzQiIHkxPSIwIiB4Mj0iMTAuMDAzNCIgeTI9IjIwLjAwNjMiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjAyMzEiIHN0eWxlPSJzdG9wLWNvbG9yOiM1OTU5NUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjQ1MDciIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMzMzUiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjc5MTIiIHN0eWxlPSJzdG9wLWNvbG9yOiMxQzFDMUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPHBhdGggZmlsbD0idXJsKCNTVkdJRF8yXykiIGQ9Ik0xMC4wMDMsMS4yOTFjNC44MDQsMCw4LjcxMywzLjkwOSw4LjcxMyw4LjcxM3MtMy45MDksOC43MTItOC43MTMsOC43MTIKCQljLTQuODAzLDAtOC43MTItMy45MDktOC43MTItOC43MTJTNS4yLDEuMjkxLDEwLjAwMywxLjI5MSBNMTAuMDAzLDBDNC40NzksMCwwLDQuNDc5LDAsMTAuMDAzYzAsNS41MjQsNC40NzksMTAuMDAzLDEwLjAwMywxMC4wMDMKCQljNS41MjUsMCwxMC4wMDQtNC40NzksMTAuMDA0LTEwLjAwM0MyMC4wMDcsNC40NzksMTUuNTI4LDAsMTAuMDAzLDBMMTAuMDAzLDB6Ii8+CjwvZz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTEyLjUyNSwxMC43NjNjMC40MiwwLDAuNzcyLTAuMTcsMS4wMTgtMC40OTFjMC4yNjYtMC4zNDcsMC40MDYtMC44NjcsMC40MDYtMS41MDQKCWMwLTEuODQzLTAuNjI1LTIuNjQ4LTIuNTczLTMuMzE2Yy0wLjYxNy0wLjIwOS0yLjAwNS0wLjY0LTMuMDQ4LTAuODUxdjEwLjEyNmwyLjA1OCwwLjY1M1Y2Ljg0OGMwLTAuNTI4LDAuMjk2LTAuODU1LDAuNjczLTAuNzMKCWMwLjU2NCwwLjE1NiwwLjU2NCwwLjc5NSwwLjU2NCwxLjAzNnYzLjM5NEMxMS45NDMsMTAuNjksMTIuMjQ2LDEwLjc2MywxMi41MjUsMTAuNzYzeiIvPgo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTYuOTkzLDEyLjg3M2MwLjA4OS0wLjExMiwwLjExNy0wLjIyOCwwLjA4Mi0wLjM0M2MtMC4wNDMtMC4xNDEtMC4yMjctMC40MTMtMC45NjUtMC42NDcKCWMtMC45MTgtMC4zNDgtMi4xNzctMC40OTQtMy4yMDgtMC4zODNjLTAuOTk1LDAuMTEtMS43MTYsMC4zNjItMS43MjQsMC4zNjVsLTAuMDQ1LDAuMDE1djEuMDk2bDIuMzI1LTAuODE5CgljMC40NDYtMC4xNiwxLjEzNi0wLjE5NiwxLjUwOS0wLjA3NGMwLjI2OCwwLjA4OCwwLjMyNiwwLjIyOSwwLjMyNywwLjMzMmMwLjAwMSwwLjEyMy0wLjA3OSwwLjI5OC0wLjQ3LDAuNDM4bC0zLjY5MSwxLjMxNnYxLjA0NQoJbDQuOTUtMS43NzdDMTYuMDg5LDEzLjQzNSwxNi43NCwxMy4xOTIsMTYuOTkzLDEyLjg3M3oiLz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTcuNTcxLDEwLjkzbC0zLjE4LDEuMTNjLTAuMDExLDAuMDA0LTAuODAzLDAuMjY3LTEuMjYsMC42MDVjLTAuMTYyLDAuMTItMC4yMzUsMC4yNjgtMC4yMDgsMC40MTcKCWMwLjA1MSwwLjI3OCwwLjQxNSwwLjUzNywwLjk3MiwwLjY5NGMxLjE2NSwwLjM4NSwyLjM4NywwLjQ4MywzLjU2OCwwLjI4OWwwLjEwNy0wLjAxOHYtMC44ODRsLTAuOTY1LDAuMzUKCWMtMC40NDYsMC4xNi0xLjEzNywwLjE5Ni0xLjUxLDAuMDc0Yy0wLjI2OC0wLjA4OC0wLjMyNi0wLjIyOS0wLjMyNy0wLjMzMmMtMC4wMDItMC4xMjMsMC4wNzktMC4yOTgsMC40NzEtMC40MzlsMi4zMzItMC44MzVWMTAuOTMKCXoiLz4KPC9zdmc+Cg==');
- display: inline-block;
- height: 2rem;
- width: 2rem;
- margin: 0.5rem;
-}
-
-span.ps-r2-icon {
- /* https://commons.wikimedia.org/wiki/File:PlayStation_button_R2.svg */
- background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwIDE0Ljk0OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAgMTQuOTQ5IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTMuMzc5NCI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wNjI2IiBzdHlsZT0ic3RvcC1jb2xvcjojNTM1MzU2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMzAxOCIgc3R5bGU9InN0b3AtY29sb3I6IzM3MzczOSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjUzOTciIHN0eWxlPSJzdG9wLWNvbG9yOiMyNDI0MjUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43NzM5IiBzdHlsZT0ic3RvcC1jb2xvcjojMTgxODE4Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMV8pIiBkPSJNMjAsMTAuNzQ2YzAsNS42MDQtMjAsNS42MDQtMjAsMFYyLjU2M0MwLDEuMTQ3LDEuMTQ2LDAsMi41NjIsMGgxNC44NzYKCQkJQzE4Ljg1NCwwLDIwLDEuMTQ3LDIwLDIuNTYzVjEwLjc0NnoiLz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTQuOTQ5MiI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTU1IiBzdHlsZT0ic3RvcC1jb2xvcjojNTg1ODVCIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTQ4OCIgc3R5bGU9InN0b3AtY29sb3I6IzNGM0Y0MSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5NiIgc3R5bGU9InN0b3AtY29sb3I6IzJDMkMyRCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2MjQiIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFFMUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjQzIiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNjE3Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTcuNDM4LDEuNTEyYzAuNTgsMCwxLjA1MSwwLjQ3MSwxLjA1MSwxLjA1MXY4LjE4NGMwLDAuNTg2LTAuODAxLDEuMjM1LTIuMTQzLDEuNzM2CgkJCWMtMS42MjUsMC42MDctMy45MzgsMC45NTUtNi4zNDYsMC45NTVzLTQuNzIyLTAuMzQ4LTYuMzQ2LTAuOTU1Yy0xLjM0Mi0wLjUwMS0yLjE0My0xLjE1LTIuMTQzLTEuNzM2VjIuNTYzCgkJCWMwLTAuNTgsMC40NzEtMS4wNTEsMS4wNS0xLjA1MUgxNy40MzggTTE3LjQzOCwwSDIuNTYyQzEuMTQ2LDAsMCwxLjE0NywwLDIuNTYzdjguMTg0YzAsMi44MDIsNSw0LjIwMywxMCw0LjIwMwoJCQlzMTAtMS40MDEsMTAtNC4yMDNWMi41NjNDMjAsMS4xNDcsMTguODU0LDAsMTcuNDM4LDBMMTcuNDM4LDB6Ii8+Cgk8L2c+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTAuMDgyLDYuMDhjMC4yMDEtMC4zMjYsMC4zMDMtMC43MDUsMC4zMDMtMS4xMjZjMC0wLjMxNy0wLjA1Ny0wLjYxOC0wLjE3LTAuODk1CgkJCWMtMC4xMTMtMC4yNzgtMC4yNzktMC41MjEtMC40OTEtMC43MjJDOS41MTMsMy4xMzksOS4yNTksMi45OCw4Ljk2OCwyLjg2OEM4LjY3OCwyLjc1Nyw4LjM1NCwyLjcsOC4wMDIsMi43SDQuMTg4djcuNTE5aDEuODI5CgkJCVY3LjQyM2gxLjQwM2MwLjQ3MiwwLDAuNjcsMC4wOTMsMC43NTMsMC4xN2MwLjExNywwLjEwOSwwLjE3NywwLjMxMSwwLjE3NywwLjU5OXYyLjAyN2gxLjg0MVY4LjAwOAoJCQljMC0wLjQ1Ni0wLjA3NS0wLjc5Ni0wLjIyOS0xLjAzN0M5Ljg2OSw2LjgyNSw5Ljc0LDYuNzAyLDkuNTc1LDYuNjAzQzkuNzgxLDYuNDY5LDkuOTUxLDYuMjk0LDEwLjA4Miw2LjA4eiBNNi4wMTgsNC4zMTRoMS43MzYKCQkJYzAuMzE2LDAsMC41MzgsMC4wNjksMC42NjIsMC4yMDZDOC41NDUsNC42NjUsOC42MDksNC44NSw4LjYwOSw1LjA4M2MwLDAuMjI2LTAuMDY0LDAuNDA1LTAuMTk3LDAuNTUKCQkJQzguMjg0LDUuNzcxLDguMDQ1LDUuODQxLDcuNzAxLDUuODQxSDYuMDE4VjQuMzE0eiIvPgoJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMi43OTEsOC42MjZjMC4wNDMtMC4wOTksMC4xMTEtMC4yMDYsMC4yMDUtMC4zMjJjMC4xNzQtMC4yMiwwLjM5NS0wLjQyNywwLjY1LTAuNjE1CgkJCWMwLjM4MS0wLjI3MywwLjcxMS0wLjUyNiwwLjk3OS0wLjc1MWMwLjI3NS0wLjIzLDAuNTAxLTAuNDYyLDAuNjctMC42ODhjMC4xNzQtMC4yMywwLjMwMS0wLjQ3LDAuMzc5LTAuNzExCgkJCWMwLjA3Ny0wLjI0LDAuMTE2LTAuNTEsMC4xMTYtMC44MDFjMC0wLjM3Ni0wLjA2NS0wLjcxMi0wLjE5NC0wLjk5N2MtMC4xMy0wLjI4Ny0wLjMxNC0wLjUyOS0wLjU0OC0wLjcyCgkJCWMtMC4yMjktMC4xODgtMC41MDktMC4zMjktMC44My0wLjQyMmMtMC4zMTMtMC4wOTEtMC42NjItMC4xMzctMS4wMzYtMC4xMzdjLTAuNzksMC0xLjQxNCwwLjIzOS0xLjg1NCwwLjcxMQoJCQljLTAuNDM4LDAuNDY4LTAuNjU5LDEuMTIxLTAuNjU5LDEuOTQxdjAuMTg4aDEuNzIzVjUuMTE1YzAtMC40MiwwLjA3Ny0wLjcxNywwLjIyMy0wLjg2YzAuMTUxLTAuMTQ4LDAuMzU3LTAuMjIsMC42MzItMC4yMgoJCQljMC4xMiwwLDAuMjM2LDAuMDEzLDAuMzQ0LDAuMDM4YzAuMDk1LDAuMDIxLDAuMTc0LDAuMDYsMC4yNDIsMC4xMTNjMC4wNjQsMC4wNTMsMC4xMTUsMC4xMjMsMC4xNTMsMC4yMTUKCQkJYzAuMDQsMC4xLDAuMDYyLDAuMjI4LDAuMDYyLDAuMzhjMCwwLjEzLTAuMDIsMC4yNTEtMC4wNTUsMC4zNmMtMC4wMzcsMC4xMDctMC4wOTksMC4yMTktMC4xODYsMC4zMzEKCQkJYy0wLjA5MiwwLjExOC0wLjIxOSwwLjI0Ni0wLjM3NywwLjM4MWMtMC4xNjMsMC4xMzktMC4zNzQsMC4yOTgtMC42MjcsMC40NzVjLTAuNDY3LDAuMzM0LTAuODQ4LDAuNjUyLTEuMTM0LDAuOTQ2CgkJCWMtMC4yOSwwLjI5OC0wLjUxOSwwLjU5Ny0wLjY4MSwwLjg5Yy0wLjE2MywwLjI5Ni0wLjI3NCwwLjYwMS0wLjMzMiwwLjkwNmMtMC4wNTYsMC4yOTctMC4wODQsMC42Mi0wLjA4NCwwLjk2djAuMTg4aDUuMjM5VjguNjI2CgkJCUgxMi43OTF6Ii8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==');
- background-repeat: no-repeat;
- display: inline-block;
- height: 1.5rem;
- width: 2rem;
- margin: 0.5rem;
-}
-
-
-.launched-app-view {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
-}
-
-.launched-app-view div{
- display: flex;
- align-items: center;
-}
-
-.launched-app-view div.terminal {
- display: block;
- border: 0px solid var(--secondary-text-color);
- border-radius: 2px;
-}
-
-.launched-app-view div.terminal div {
- display: block;
-}
-
-.launched-app-view p {
- text-align: center;
-}
-
-.modal-overlay {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 100000;
- display: flex;
- justify-content: center;
- align-items: center;
- animation: fadein 0.3s ease-in-out;
- background-color: rgba(0, 0, 0, 0.7);
- -webkit-backdrop-filter: blur(5px);
- backdrop-filter: blur(5px);
-}
-
-.modal-content {
- background-color: var(--modal-background-color);
- padding: 20px;
- border-radius: 10px;
- animation: zoomin 0.3s ease;
- width: 70vh;
- height: 70vh;
-}
-
-.text-center {
- text-align: center;
- width: 100%;
-}
-
-.fadeout {
- animation: fadeout 0.25s ease !important;
-}
-
-.fadein {
- animation: fadein 0.25s ease;
-}
-
-.loading-overlay::before {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.5);
- border-radius: inherit;
- pointer-events: none;
- animation: fadein 0.3s ease;
-}
-
-/* https://stephanwagner.me/only-css-loading-spinner */
-.loading::after {
- content: "";
- position: absolute;
- top: 50%;
- left: 50%;
- width: 5vh;
- height: 5vh;
- margin-top: -2.5vh;
- margin-left: -2.5vh;
- border-radius: 50%;
- border-top: 2px solid var(--text-color);
- border-right: 2px solid transparent;
- animation: spinner .6s linear infinite, fadein 0.3s ease;
- pointer-events: none;
+/* 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
+. */
+
+@keyframes fadein {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes fadeout {
+ 0% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
+
+@keyframes zoomin {
+ 0% {
+ transform: scale(0.8);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes spinner {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+
+:root {
+ font-family: "Arial", sans-serif;
+ --text-color: #fff;
+ --secondary-text-color: #bbb;
+ color: var(--text-color);
+ --background-color: #0b0d0f;
+ --surface-color: #1f2225;
+ --surface-color-hover: #3b4047;
+ --modal-background-color: #181a1c;
+ --file-picker-back-icon-color: var(--text-color);
+ --file-picker-file-icon-color: var(--text-color);
+ --file-picker-directory-icon-color: #f9e87c;
+}
+
+*:focus {
+ outline: none;
+}
+
+body {
+ background-color: var(--background-color);
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ font-size: 20px;
+ margin: 0;
+}
+
+.header {
+ text-align: center;
+ margin-top: 2rem;
+}
+
+.header * {
+ margin: 0;
+ padding: 0;
+}
+
+div.separator {
+ margin-top: 2rem;
+ border-bottom: 1px solid var(--text-color);
+}
+
+div.carousel {
+ padding: 40px;
+ height: 100vh;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ box-sizing: border-box;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ align-items: center;
+}
+
+/* add some padding to be able scroll the first/last item in the carousel to the center */
+div.carousel::after,
+div.carousel::before {
+ position: relative;
+ content: "";
+ flex: none;
+ width: 50vw;
+ height: 1px;
+}
+
+div.carousel.scrolling div.entry-wrapper.selected div.entry-main {
+ background-color: var(--surface-color-hover);
+}
+
+div.entry-wrapper.selected>div:hover {
+ background-color: var(--surface-color-hover);
+}
+
+div.entry-wrapper.selected:not(:hover) div.entry-main {
+ background-color: var(--surface-color-hover);
+}
+
+div.entry-wrapper:not(.selected) div.entry {
+ background-color: var(--surface-color) !important;
+}
+
+
+div.entry-wrapper {
+ flex: none;
+ height: 55vh;
+ width: 50vh;
+ margin-right: 8vh;
+ transition: transform 0.3s;
+ box-sizing: border-box;
+ position: relative;
+}
+
+div.entry-wrapper.selected {
+ /* some js is tied to this for the cursor snapping to work so if you change this also change that */
+ transform: scale(1.2);
+}
+
+div.entry {
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+ background-color: var(--surface-color);
+ border-radius: 1vh;
+ transition: background-color 0.3s;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ height: calc(100% - 50px);
+ cursor: pointer;
+ padding: 1rem;
+ text-wrap: pretty;
+ overflow-wrap: break-word;
+ text-align: center;
+ overflow: hidden;
+ position: relative;
+}
+
+div.entry p {
+ padding: 0;
+ margin: 5px;
+ max-width: 100%;
+}
+
+.entry img {
+ display: block;
+ max-width: 100%;
+ max-height: 30vh;
+ object-fit: contain;
+}
+
+div.entry-more-button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 7vh;
+ text-align: center;
+ margin-top: 0.5em;
+ background-color: var(--surface-color);
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.text-secondary {
+ font-size: 0.85em;
+ color: var(--secondary-text-color);
+}
+
+.entry-name {
+ font-weight: 700;
+}
+
+div.list {
+ margin: 0 10vh 0 10vh;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+div.list-title {
+ padding: 0.5em;
+ margin-top: 1em;
+ font-weight: bold;
+ font-size: larger;
+ text-align: center;
+}
+
+a.list-entry {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 0.5em;
+ background-color: var(--surface-color);
+ margin-top: 1em;
+ border-radius: 6px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+a.list-entry:hover {
+ background-color: var(--surface-color-hover);
+}
+
+div.list-entry-icon {
+ min-height: 2rem;
+ min-width: 2rem;
+ margin-left: 1rem;
+ margin-right: 1rem;
+ mask-size: contain;
+ -webkit-mask-size: contain;
+}
+
+.entry-main{
+ position: relative;
+}
+
+.entry-img{
+ margin-bottom: 10vh;
+}
+
+.entry-main img[style*="display: block"]~p:nth-child(2) {
+ position: absolute;
+ bottom: 7vh;
+}
+
+.entry-main img[style*="display: block"]~p:nth-child(3) {
+ position: absolute;
+ bottom: 3vh;
+}
+
+/* div.dir-icon {
+ background-color: var(--file-picker-directory-icon-color);
+ mask: url("googlefonts_folder.svg") no-repeat center;
+ -webkit-mask: url("googlefonts_folder.svg") no-repeat center;
+ mask-size:contain;
+ -webkit-mask-size:contain;
+}
+
+div.file-icon {
+ background-color: var(--file-picker-file-icon-color);
+ mask: url("googlefonts_file.svg") no-repeat center;
+ -webkit-mask: url("googlefonts_file.svg") no-repeat center;
+ mask-size:contain;
+ -webkit-mask-size:contain;
+}
+
+div.back-icon {
+ background-color: var(--file-picker-back-icon-color);
+ mask: url("googlefonts_arrow_back.svg") no-repeat center;
+ -webkit-mask: url("googlefonts_arrow_back.svg") no-repeat center;
+ mask-size:contain;
+ -webkit-mask-size:contain;
+} */
+
+div.dir-icon {
+ /* from google fonts */
+ background-color: var(--file-picker-directory-icon-color);
+ mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center;
+ -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4IiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJNMTYwLTE2MHEtMzMgMC01Ni41LTIzLjVUODAtMjQwdi00ODBxMC0zMyAyMy41LTU2LjVUMTYwLTgwMGgyMDdxMTYgMCAzMC41IDZ0MjUuNSAxN2w1NyA1N2gzMjBxMzMgMCA1Ni41IDIzLjVUODgwLTY0MHY0MDBxMCAzMy0yMy41IDU2LjVUODAwLTE2MEgxNjBabTAtODBoNjQwdi00MDBINDQ3bC04MC04MEgxNjB2NDgwWm0wIDB2LTQ4MCA0ODBaIi8+PC9zdmc+') no-repeat center;
+ mask-size: contain;
+ -webkit-mask-size: contain;
+}
+
+div.file-icon {
+ /* from google fonts */
+ background-color: var(--file-picker-file-icon-color);
+ mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center;
+ -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjhweCIgdmlld0JveD0iMCAtOTYwIDk2MCA5NjAiIHdpZHRoPSIyOHB4Ij48cGF0aCBkPSJNMjQwLTgwcS0zMyAwLTU2LjUtMjMuNVQxNjAtMTYwdi02NDBxMC0zMyAyMy41LTU2LjVUMjQwLTg4MGgyODdxMTYgMCAzMC41IDZ0MjUuNSAxN2wxOTQgMTk0cTExIDExIDE3IDI1LjV0NiAzMC41djQ0N3EwIDMzLTIzLjUgNTYuNVQ3MjAtODBIMjQwWm0yODAtNTYwdi0xNjBIMjQwdjY0MGg0ODB2LTQ0MEg1NjBxLTE3IDAtMjguNS0xMS41VDUyMC02NDBaTTI0MC04MDB2MjAwLTIwMCA2NDAtNjQwWiIvPjwvc3ZnPg==') no-repeat center;
+ mask-size: contain;
+ -webkit-mask-size: contain;
+}
+
+div.back-icon {
+ /* from google fonts */
+ background-color: var(--file-picker-back-icon-color);
+ mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center;
+ -webkit-mask: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiBmaWxsPSIjZThlYWVkIj48cGF0aCBkPSJtMzEzLTQ0MCAxOTYgMTk2cTEyIDEyIDExLjUgMjhUNTA4LTE4OHEtMTIgMTEtMjggMTEuNVQ0NTItMTg4TDE4OC00NTJxLTYtNi04LjUtMTN0LTIuNS0xNXEwLTggMi41LTE1dDguNS0xM2wyNjQtMjY0cTExLTExIDI3LjUtMTF0MjguNSAxMXExMiAxMiAxMiAyOC41VDUwOC03MTVMMzEzLTUyMGg0NDdxMTcgMCAyOC41IDExLjVUODAwLTQ4MHEwIDE3LTExLjUgMjguNVQ3NjAtNDQwSDMxM1oiLz48L3N2Zz4=') no-repeat center;
+ mask-size: contain;
+ -webkit-mask-size: contain;
+}
+
+span.ps-triangle-icon {
+ /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_T_button.svg */
+ background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMiAyMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAuMDAyIDIwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMSIgeTE9IjAiIHgyPSIxMC4wMDEiIHkyPSIxNy45MDA4Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMC4wMDEiIGN5PSIxMCIgcj0iMTAuMDAxIi8+CgkJPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8yXyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIxMC4wMDEiIHkxPSIwIiB4Mj0iMTAuMDAxIiB5Mj0iMjAuMDAwNSI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QTVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTk3IiBzdHlsZT0ic3RvcC1jb2xvcjojNTY1NzVBIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTUyNCIgc3R5bGU9InN0b3AtY29sb3I6IzNFM0U0MCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5ODkiIHN0eWxlPSJzdG9wLWNvbG9yOiMyQjJBMkMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NjQ3IiBzdHlsZT0ic3RvcC1jb2xvcjojMUUxQzFFIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuNjY1NyIgc3R5bGU9InN0b3AtY29sb3I6IzE2MTQxNiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxMjEzIi8+CgkJPC9saW5lYXJHcmFkaWVudD4KCQk8cGF0aCBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgZD0iTTEwLjAwMiwxLjZjNC42MzEsMCw4LjQsMy43NjgsOC40LDguNGMwLDQuNjMyLTMuNzcsOC40LTguNCw4LjRDNS4zNjksMTguNCwxLjYsMTQuNjMyLDEuNiwxMAoJCQlDMS42LDUuMzY4LDUuMzY5LDEuNiwxMC4wMDIsMS42IE0xMC4wMDIsMEM0LjQ3OCwwLDAsNC40NzcsMCwxMHM0LjQ3OCwxMCwxMC4wMDIsMTBjNS41MjEsMCwxMC00LjQ3OCwxMC0xMFMxNS41MjMsMCwxMC4wMDIsMAoJCQlMMTAuMDAyLDB6Ii8+Cgk8L2c+Cgk8cGF0aCBmaWxsPSIjRURFQ0VCIiBkPSJNMTAuMDAxLDcuNTg3bDEuMjY5LDIuMTk3bDEuMjcsMi4xOThoLTIuNTM4SDcuNDY0bDEuMjY5LTIuMTk4TDEwLjAwMSw3LjU4NyBNMTAuMDAxLDUuMjQ4TDcuNzIsOS4yCgkJbC0yLjI4MiwzLjk1M2g0LjU2M2g0LjU2M0wxMi4yODIsOS4yTDEwLjAwMSw1LjI0OEwxMC4wMDEsNS4yNDh6Ii8+CjwvZz4KPC9zdmc+Cg==');
+ display: inline-block;
+ height: 2rem;
+ width: 2rem;
+ margin: 0.5rem;
+}
+
+span.ps-circle-icon {
+ /* https://commons.wikimedia.org/wiki/File:PlayStation_Portable_C_button.svg */
+ background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwMSAyMC4wMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwMSAyMC4wMDEiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxnPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIxNy45MDE2Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAzMjYiIHN0eWxlPSJzdG9wLWNvbG9yOiM1NzU4NUMiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC40NTYiIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMyMzQiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43OTMyIiBzdHlsZT0ic3RvcC1jb2xvcjojMUMxQjFDIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxjaXJjbGUgZmlsbD0idXJsKCNTVkdJRF8xXykiIGN4PSIxMCIgY3k9IjEwIiByPSIxMCIvPgoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIyMC4wMDE1Ij4KCQkJPHN0b3AgIG9mZnNldD0iMCIgc3R5bGU9InN0b3AtY29sb3I6IzVBNUI1RiIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjAxOTciIHN0eWxlPSJzdG9wLWNvbG9yOiM1NjU3NUEiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4xNTI0IiBzdHlsZT0ic3RvcC1jb2xvcjojM0UzRTQwIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMjk4OSIgc3R5bGU9InN0b3AtY29sb3I6IzJCMkEyQyIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2NDciIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFDMUUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjU3IiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNDE2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDEyMTMiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTAsMS42YzQuNjMzLDAsOC40LDMuNzY5LDguNCw4LjQwMWMwLDQuNjMxLTMuNzY4LDguNC04LjQsOC40Yy00LjYzMSwwLTguNC0zLjc3LTguNC04LjQKCQkJQzEuNiw1LjM2OCw1LjM2OSwxLjYsMTAsMS42IE0xMCwwQzQuNDc3LDAsMCw0LjQ3OCwwLDEwYzAsNS41MjMsNC40NzcsMTAuMDAxLDEwLDEwLjAwMVMyMCwxNS41MjQsMjAsMTBDMjAsNC40NzgsMTUuNTIzLDAsMTAsMAoJCQlMMTAsMHoiLz4KCTwvZz4KCTxwYXRoIGZpbGw9IiNFREVDRUIiIGQ9Ik0xMCw2LjgyMWMxLjc1NCwwLDMuMTc4LDEuNDI2LDMuMTc4LDMuMTc5YzAsMS43NTItMS40MjQsMy4xNzgtMy4xNzgsMy4xNzgKCQljLTEuNzUyLDAtMy4xNzgtMS40MjYtMy4xNzgtMy4xNzhDNi44MjIsOC4yNDcsOC4yNDgsNi44MjEsMTAsNi44MjEgTTEwLDUuNTc5Yy0yLjQ0MSwwLTQuNDIsMS45NzktNC40Miw0LjQyMQoJCWMwLDIuNDQxLDEuOTc5LDQuNDIsNC40Miw0LjQyczQuNDIyLTEuOTc5LDQuNDIyLTQuNDJDMTQuNDIyLDcuNTU5LDEyLjQ0MSw1LjU3OSwxMCw1LjU3OUwxMCw1LjU3OXoiLz4KPC9nPgo8L3N2Zz4K');
+ display: inline-block;
+ height: 2rem;
+ width: 2rem;
+ margin: 0.5rem;
+}
+
+span.ps-icon {
+ /* https://commons.wikimedia.org/wiki/File:PlayStation_button_Home.svg */
+ background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwLjAwNyAyMC4wMDYiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwLjAwNyAyMC4wMDYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCQoJCTxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMV8iIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iOTYuMDAwNSIgeTE9IjM1NS45OTU2IiB4Mj0iMTE2LjAwNjgiIHkyPSIzNTUuOTk1NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwIDEgLTEgMCAzNjUuOTk5NSAtOTYuMDAwNSkiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPGNpcmNsZSBmaWxsPSJ1cmwoI1NWR0lEXzFfKSIgY3g9IjEwLjAwMyIgY3k9IjEwLjAwMyIgcj0iMTAuMDAzIi8+Cgk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwLjAwMzQiIHkxPSIwIiB4Mj0iMTAuMDAzNCIgeTI9IjIwLjAwNjMiPgoJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjAyMzEiIHN0eWxlPSJzdG9wLWNvbG9yOiM1OTU5NUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjQ1MDciIHN0eWxlPSJzdG9wLWNvbG9yOiMzMzMzMzUiLz4KCQk8c3RvcCAgb2Zmc2V0PSIwLjc5MTIiIHN0eWxlPSJzdG9wLWNvbG9yOiMxQzFDMUQiLz4KCQk8c3RvcCAgb2Zmc2V0PSIxIiBzdHlsZT0ic3RvcC1jb2xvcjojMTQxNDE0Ii8+Cgk8L2xpbmVhckdyYWRpZW50PgoJPHBhdGggZmlsbD0idXJsKCNTVkdJRF8yXykiIGQ9Ik0xMC4wMDMsMS4yOTFjNC44MDQsMCw4LjcxMywzLjkwOSw4LjcxMyw4LjcxM3MtMy45MDksOC43MTItOC43MTMsOC43MTIKCQljLTQuODAzLDAtOC43MTItMy45MDktOC43MTItOC43MTJTNS4yLDEuMjkxLDEwLjAwMywxLjI5MSBNMTAuMDAzLDBDNC40NzksMCwwLDQuNDc5LDAsMTAuMDAzYzAsNS41MjQsNC40NzksMTAuMDAzLDEwLjAwMywxMC4wMDMKCQljNS41MjUsMCwxMC4wMDQtNC40NzksMTAuMDA0LTEwLjAwM0MyMC4wMDcsNC40NzksMTUuNTI4LDAsMTAuMDAzLDBMMTAuMDAzLDB6Ii8+CjwvZz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTEyLjUyNSwxMC43NjNjMC40MiwwLDAuNzcyLTAuMTcsMS4wMTgtMC40OTFjMC4yNjYtMC4zNDcsMC40MDYtMC44NjcsMC40MDYtMS41MDQKCWMwLTEuODQzLTAuNjI1LTIuNjQ4LTIuNTczLTMuMzE2Yy0wLjYxNy0wLjIwOS0yLjAwNS0wLjY0LTMuMDQ4LTAuODUxdjEwLjEyNmwyLjA1OCwwLjY1M1Y2Ljg0OGMwLTAuNTI4LDAuMjk2LTAuODU1LDAuNjczLTAuNzMKCWMwLjU2NCwwLjE1NiwwLjU2NCwwLjc5NSwwLjU2NCwxLjAzNnYzLjM5NEMxMS45NDMsMTAuNjksMTIuMjQ2LDEwLjc2MywxMi41MjUsMTAuNzYzeiIvPgo8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTYuOTkzLDEyLjg3M2MwLjA4OS0wLjExMiwwLjExNy0wLjIyOCwwLjA4Mi0wLjM0M2MtMC4wNDMtMC4xNDEtMC4yMjctMC40MTMtMC45NjUtMC42NDcKCWMtMC45MTgtMC4zNDgtMi4xNzctMC40OTQtMy4yMDgtMC4zODNjLTAuOTk1LDAuMTEtMS43MTYsMC4zNjItMS43MjQsMC4zNjVsLTAuMDQ1LDAuMDE1djEuMDk2bDIuMzI1LTAuODE5CgljMC40NDYtMC4xNiwxLjEzNi0wLjE5NiwxLjUwOS0wLjA3NGMwLjI2OCwwLjA4OCwwLjMyNiwwLjIyOSwwLjMyNywwLjMzMmMwLjAwMSwwLjEyMy0wLjA3OSwwLjI5OC0wLjQ3LDAuNDM4bC0zLjY5MSwxLjMxNnYxLjA0NQoJbDQuOTUtMS43NzdDMTYuMDg5LDEzLjQzNSwxNi43NCwxMy4xOTIsMTYuOTkzLDEyLjg3M3oiLz4KPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTcuNTcxLDEwLjkzbC0zLjE4LDEuMTNjLTAuMDExLDAuMDA0LTAuODAzLDAuMjY3LTEuMjYsMC42MDVjLTAuMTYyLDAuMTItMC4yMzUsMC4yNjgtMC4yMDgsMC40MTcKCWMwLjA1MSwwLjI3OCwwLjQxNSwwLjUzNywwLjk3MiwwLjY5NGMxLjE2NSwwLjM4NSwyLjM4NywwLjQ4MywzLjU2OCwwLjI4OWwwLjEwNy0wLjAxOHYtMC44ODRsLTAuOTY1LDAuMzUKCWMtMC40NDYsMC4xNi0xLjEzNywwLjE5Ni0xLjUxLDAuMDc0Yy0wLjI2OC0wLjA4OC0wLjMyNi0wLjIyOS0wLjMyNy0wLjMzMmMtMC4wMDItMC4xMjMsMC4wNzktMC4yOTgsMC40NzEtMC40MzlsMi4zMzItMC44MzVWMTAuOTMKCXoiLz4KPC9zdmc+Cg==');
+ display: inline-block;
+ height: 2rem;
+ width: 2rem;
+ margin: 0.5rem;
+}
+
+span.ps-r2-icon {
+ /* https://commons.wikimedia.org/wiki/File:PlayStation_button_R2.svg */
+ background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE2LjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwIDE0Ljk0OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjAgMTQuOTQ5IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGc+Cgk8Zz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTMuMzc5NCI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wNjI2IiBzdHlsZT0ic3RvcC1jb2xvcjojNTM1MzU2Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMzAxOCIgc3R5bGU9InN0b3AtY29sb3I6IzM3MzczOSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjUzOTciIHN0eWxlPSJzdG9wLWNvbG9yOiMyNDI0MjUiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC43NzM5IiBzdHlsZT0ic3RvcC1jb2xvcjojMTgxODE4Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMV8pIiBkPSJNMjAsMTAuNzQ2YzAsNS42MDQtMjAsNS42MDQtMjAsMFYyLjU2M0MwLDEuMTQ3LDEuMTQ2LDAsMi41NjIsMGgxNC44NzYKCQkJQzE4Ljg1NCwwLDIwLDEuMTQ3LDIwLDIuNTYzVjEwLjc0NnoiLz4KCQk8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzJfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjEwIiB5MT0iMCIgeDI9IjEwIiB5Mj0iMTQuOTQ5MiI+CgkJCTxzdG9wICBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1QjVCNUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC4wMTU1IiBzdHlsZT0ic3RvcC1jb2xvcjojNTg1ODVCIi8+CgkJCTxzdG9wICBvZmZzZXQ9IjAuMTQ4OCIgc3R5bGU9InN0b3AtY29sb3I6IzNGM0Y0MSIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjI5NiIgc3R5bGU9InN0b3AtY29sb3I6IzJDMkMyRCIvPgoJCQk8c3RvcCAgb2Zmc2V0PSIwLjQ2MjQiIHN0eWxlPSJzdG9wLWNvbG9yOiMxRTFFMUYiLz4KCQkJPHN0b3AgIG9mZnNldD0iMC42NjQzIiBzdHlsZT0ic3RvcC1jb2xvcjojMTYxNjE3Ii8+CgkJCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNDE0MTQiLz4KCQk8L2xpbmVhckdyYWRpZW50PgoJCTxwYXRoIGZpbGw9InVybCgjU1ZHSURfMl8pIiBkPSJNMTcuNDM4LDEuNTEyYzAuNTgsMCwxLjA1MSwwLjQ3MSwxLjA1MSwxLjA1MXY4LjE4NGMwLDAuNTg2LTAuODAxLDEuMjM1LTIuMTQzLDEuNzM2CgkJCWMtMS42MjUsMC42MDctMy45MzgsMC45NTUtNi4zNDYsMC45NTVzLTQuNzIyLTAuMzQ4LTYuMzQ2LTAuOTU1Yy0xLjM0Mi0wLjUwMS0yLjE0My0xLjE1LTIuMTQzLTEuNzM2VjIuNTYzCgkJCWMwLTAuNTgsMC40NzEtMS4wNTEsMS4wNS0xLjA1MUgxNy40MzggTTE3LjQzOCwwSDIuNTYyQzEuMTQ2LDAsMCwxLjE0NywwLDIuNTYzdjguMTg0YzAsMi44MDIsNSw0LjIwMywxMCw0LjIwMwoJCQlzMTAtMS40MDEsMTAtNC4yMDNWMi41NjNDMjAsMS4xNDcsMTguODU0LDAsMTcuNDM4LDBMMTcuNDM4LDB6Ii8+Cgk8L2c+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMTAuMDgyLDYuMDhjMC4yMDEtMC4zMjYsMC4zMDMtMC43MDUsMC4zMDMtMS4xMjZjMC0wLjMxNy0wLjA1Ny0wLjYxOC0wLjE3LTAuODk1CgkJCWMtMC4xMTMtMC4yNzgtMC4yNzktMC41MjEtMC40OTEtMC43MjJDOS41MTMsMy4xMzksOS4yNTksMi45OCw4Ljk2OCwyLjg2OEM4LjY3OCwyLjc1Nyw4LjM1NCwyLjcsOC4wMDIsMi43SDQuMTg4djcuNTE5aDEuODI5CgkJCVY3LjQyM2gxLjQwM2MwLjQ3MiwwLDAuNjcsMC4wOTMsMC43NTMsMC4xN2MwLjExNywwLjEwOSwwLjE3NywwLjMxMSwwLjE3NywwLjU5OXYyLjAyN2gxLjg0MVY4LjAwOAoJCQljMC0wLjQ1Ni0wLjA3NS0wLjc5Ni0wLjIyOS0xLjAzN0M5Ljg2OSw2LjgyNSw5Ljc0LDYuNzAyLDkuNTc1LDYuNjAzQzkuNzgxLDYuNDY5LDkuOTUxLDYuMjk0LDEwLjA4Miw2LjA4eiBNNi4wMTgsNC4zMTRoMS43MzYKCQkJYzAuMzE2LDAsMC41MzgsMC4wNjksMC42NjIsMC4yMDZDOC41NDUsNC42NjUsOC42MDksNC44NSw4LjYwOSw1LjA4M2MwLDAuMjI2LTAuMDY0LDAuNDA1LTAuMTk3LDAuNTUKCQkJQzguMjg0LDUuNzcxLDguMDQ1LDUuODQxLDcuNzAxLDUuODQxSDYuMDE4VjQuMzE0eiIvPgoJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMi43OTEsOC42MjZjMC4wNDMtMC4wOTksMC4xMTEtMC4yMDYsMC4yMDUtMC4zMjJjMC4xNzQtMC4yMiwwLjM5NS0wLjQyNywwLjY1LTAuNjE1CgkJCWMwLjM4MS0wLjI3MywwLjcxMS0wLjUyNiwwLjk3OS0wLjc1MWMwLjI3NS0wLjIzLDAuNTAxLTAuNDYyLDAuNjctMC42ODhjMC4xNzQtMC4yMywwLjMwMS0wLjQ3LDAuMzc5LTAuNzExCgkJCWMwLjA3Ny0wLjI0LDAuMTE2LTAuNTEsMC4xMTYtMC44MDFjMC0wLjM3Ni0wLjA2NS0wLjcxMi0wLjE5NC0wLjk5N2MtMC4xMy0wLjI4Ny0wLjMxNC0wLjUyOS0wLjU0OC0wLjcyCgkJCWMtMC4yMjktMC4xODgtMC41MDktMC4zMjktMC44My0wLjQyMmMtMC4zMTMtMC4wOTEtMC42NjItMC4xMzctMS4wMzYtMC4xMzdjLTAuNzksMC0xLjQxNCwwLjIzOS0xLjg1NCwwLjcxMQoJCQljLTAuNDM4LDAuNDY4LTAuNjU5LDEuMTIxLTAuNjU5LDEuOTQxdjAuMTg4aDEuNzIzVjUuMTE1YzAtMC40MiwwLjA3Ny0wLjcxNywwLjIyMy0wLjg2YzAuMTUxLTAuMTQ4LDAuMzU3LTAuMjIsMC42MzItMC4yMgoJCQljMC4xMiwwLDAuMjM2LDAuMDEzLDAuMzQ0LDAuMDM4YzAuMDk1LDAuMDIxLDAuMTc0LDAuMDYsMC4yNDIsMC4xMTNjMC4wNjQsMC4wNTMsMC4xMTUsMC4xMjMsMC4xNTMsMC4yMTUKCQkJYzAuMDQsMC4xLDAuMDYyLDAuMjI4LDAuMDYyLDAuMzhjMCwwLjEzLTAuMDIsMC4yNTEtMC4wNTUsMC4zNmMtMC4wMzcsMC4xMDctMC4wOTksMC4yMTktMC4xODYsMC4zMzEKCQkJYy0wLjA5MiwwLjExOC0wLjIxOSwwLjI0Ni0wLjM3NywwLjM4MWMtMC4xNjMsMC4xMzktMC4zNzQsMC4yOTgtMC42MjcsMC40NzVjLTAuNDY3LDAuMzM0LTAuODQ4LDAuNjUyLTEuMTM0LDAuOTQ2CgkJCWMtMC4yOSwwLjI5OC0wLjUxOSwwLjU5Ny0wLjY4MSwwLjg5Yy0wLjE2MywwLjI5Ni0wLjI3NCwwLjYwMS0wLjMzMiwwLjkwNmMtMC4wNTYsMC4yOTctMC4wODQsMC42Mi0wLjA4NCwwLjk2djAuMTg4aDUuMjM5VjguNjI2CgkJCUgxMi43OTF6Ii8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==');
+ background-repeat: no-repeat;
+ display: inline-block;
+ height: 1.5rem;
+ width: 2rem;
+ margin: 0.5rem;
+}
+
+
+.launched-app-view {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.launched-app-view div{
+ display: flex;
+ align-items: center;
+}
+
+.launched-app-view div.terminal {
+ display: block;
+ border: 0px solid var(--secondary-text-color);
+ border-radius: 2px;
+}
+
+.launched-app-view div.terminal div {
+ display: block;
+}
+
+.launched-app-view p {
+ text-align: center;
+}
+
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 100000;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ animation: fadein 0.3s ease-in-out;
+ background-color: rgba(0, 0, 0, 0.7);
+ -webkit-backdrop-filter: blur(5px);
+ backdrop-filter: blur(5px);
+}
+
+.modal-content {
+ background-color: var(--modal-background-color);
+ padding: 20px;
+ border-radius: 10px;
+ animation: zoomin 0.3s ease;
+ width: 70vh;
+ height: 70vh;
+}
+
+.text-center {
+ text-align: center;
+ width: 100%;
+}
+
+.fadeout {
+ animation: fadeout 0.25s ease !important;
+}
+
+.fadein {
+ animation: fadein 0.25s ease;
+}
+
+.loading-overlay::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: inherit;
+ pointer-events: none;
+ animation: fadein 0.3s ease;
+}
+
+/* https://stephanwagner.me/only-css-loading-spinner */
+.loading::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 5vh;
+ height: 5vh;
+ margin-top: -2.5vh;
+ margin-left: -2.5vh;
+ border-radius: 50%;
+ border-top: 2px solid var(--text-color);
+ border-right: 2px solid transparent;
+ animation: spinner .6s linear infinite, fadein 0.3s ease;
+ pointer-events: none;
}
\ No newline at end of file
diff --git a/assets/main.js b/assets/main.js
index 1c0866d..f6e6f95 100644
--- a/assets/main.js
+++ b/assets/main.js
@@ -1,296 +1,296 @@
-/* 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
-. */
-
-
-// @ts-check
-
-async function renderHomePage() {
- await scanHomebrews();
- let items = getHomebrewList();
-
- // remove previous extension sandboxes
- let oldsandbox = document.getElementById("js-extension-sandbox");
- if (oldsandbox) {
- oldsandbox.remove();
- }
-
- let carouselItems = items.reduce(
- /**
- * @param {Array} acc
- * @param {*} item
- * @returns {CarouselItem[]}
- */
- (acc, item) => {
- let newItem = {
- mainText: item.filename,
- secondaryText: item.dir,
- imgPath: baseURL + "/fs" + item.dir + "/sce_sys/icon0.png"
- }
- if (item.filename.endsWith(".js")) {
- newItem.asyncInfo = async () => {
- let temp = await loadJsExtension(item.path);
- if (!temp) {
- // TODO: Remove entry
- return { result: { mainText: "Error", secondaryText: "Extension main() timed out." } };
- }
-
- // let removeFromListOption = {
- // text: "Remove from list",
- // onclick: async () => {
- // removeFromHomebrewList(item.path);
- // await Globals.Router.handleHome();
- // }
- // };
-
- // if (temp.result.options) {
- // temp.result.options.push(removeFromListOption);
- // } else {
- // temp.result.options = [removeFromListOption];
- // }
-
- return temp;
- }
- } else {
- newItem.onclick = () => {
- return { path: item.path };
- }
-
- newItem.options = [
- {
- text: "Remove from list",
- onclick: async () => {
- removeFromHomebrewList(item.path);
- await Globals.Router.handleHome();
- }
- },
- {
- text: "Launch with args",
- onclick: () => {
- let args = window.prompt("Enter args");
- if (args) {
- return { path: item.path, args: args };
- }
- return null;
- }
- },
- {
- text: "Launch with file",
- onclick: async () => {
- let result = await Globals.Router.pickFile(item.path.substring(0, item.path.lastIndexOf("/")));
-
- if (result) {
- return { path: item.path, args: result };
- }
- }
- }
- ];
- }
-
- acc.push(newItem);
- return acc;
- }, []);
-
- // add "Add more..." button
- carouselItems.push({
- mainText: "+",
- secondaryText: "Add more...",
- onclick: async () => {
- let result = await Globals.Router.pickFile();
-
- if (result) {
- addToHomebrewStore(result);
- await Globals.Router.handleHome();
- }
- }
- })
-
- await renderMainContentCarousel(carouselItems);
-}
-
-/**
- * @param {ReadableStream?} [hbldrLogStream]
- */
-async function renderLaunchedAppView(hbldrLogStream = null) {
- const content = document.getElementById("content");
- if (!content) {
- return;
- }
- const wrapper = document.createElement("div");
- wrapper.classList.add("launched-app-view");
- content.appendChild(wrapper);
-
- const msgWrapper = document.createElement("div");
- wrapper.appendChild(msgWrapper);
-
- const msgIngress = document.createElement("span");
- msgIngress.innerText = "App is running in the background, press ";
- msgWrapper.appendChild(msgIngress);
-
- const btnCircle = document.createElement("span");
- btnCircle.classList.add("ps-circle-icon");
- msgWrapper.appendChild(btnCircle);
-
- const textCircle = document.createElement("span");
- textCircle.innerText = " to close this dialog, or ";
- msgWrapper.appendChild(textCircle);
-
- const btnTriangle = document.createElement("span");
- btnTriangle.classList.add("ps-triangle-icon");
- msgWrapper.appendChild(btnTriangle);
-
- const textTriangle = document.createElement("span");
- textTriangle.innerText = " to go back to the launcher.";
- msgWrapper.appendChild(textTriangle);
-
- if (hbldrLogStream) {
- const terminal = document.createElement("div");
- terminal.classList.add("terminal");
-
- terminal.style.display = "block";
- terminal.style.alignItems = "unset";
- terminal.style.justifyContent = "unset";
- terminal.style.borderWidth = "2px";
- terminal.style.padding = "5px";
- wrapper.appendChild(terminal);
-
- await (async () => {
- const term = new Terminal({
- convertEol: true,
- altClickMovesCursor: false,
- disableStdin: true,
- fontSize: 18,
- cols: 132,
- rows: 26
- });
-
- try {
- const reader = hbldrLogStream.getReader();
- const decoder = new TextDecoder();
-
- term.open(terminal);
- while (true) {
- const { done, value } = await reader.read();
- if (done) {
- break;
- }
- let decodedValue = decoder.decode(value);
- // alert(decodedValue);
- term.write(decodedValue);
- }
- } catch (error) {
-
- }
- })();
- }
-}
-
-window.onload = async function () {
- window.addEventListener("error", (event) => {
- alert(event.error);
- // window.location.href = "/";
- })
- window.addEventListener("unhandledrejection", (event) => {
- alert(event.reason);
- // window.location.href = "/";
- });
-
- const ver = await ApiClient.getVersion();
- if (ver.api > 0) {
- const verstr = ` ${ver.tag} (compiled at ${ver.date} ${ver.time})`;
- document.title += verstr;
- } else {
- document.title += ' (unknown version)';
- }
-
- registerExtensionMessagesListener();
-
- // for home page carousel
- document.addEventListener("keydown", (event) => {
- // L2
- if (event.keyCode === 118) {
- // reset hb list
- if (!confirm("Reset homebrew list?")) {
- return;
- }
- localStorage.removeItem(LOCALSTORE_HOMEBREW_LIST_KEY);
- window.location.href = "/";
- }
-
- // L1 - go to first element in carousel
- if (event.keyCode === 116) {
- smoothScrollToElementIndex(0, true); // doesnt do anything if the current view isnt a carouselView
- event.preventDefault();
- }
-
- // R1 - go to last element in carousel
- if (event.keyCode === 117) {
- let carouselInfo = getCurrentCarouselSelectionInfo();
- if (!carouselInfo) {
- // current view not a carousel
- return;
- }
-
- smoothScrollToElementIndex(carouselInfo.totalEntries - 1, true);
- event.preventDefault();
- }
-
- // carousel left-right dpad
- if (event.keyCode === 39 || event.keyCode === 37) {
- // return if modal is open
- if (document.getElementById("modal-overlay")) {
- return;
- }
-
- let carouselInfo = getCurrentCarouselSelectionInfo();
-
- if (!carouselInfo) {
- // current view not a carousel
- return;
- }
-
- let currentElementIndex = carouselInfo.selectedIndex;
- if (!currentElementIndex) {
- currentElementIndex = 0;
- }
-
- if (event.keyCode === 39) {
- // right
- // if the current element is the last element, go to the first element
- if (currentElementIndex === carouselInfo.totalEntries - 1 &&
- (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling
- currentElementIndex = 0;
- } else {
- currentElementIndex++;
- }
- } else if (event.keyCode === 37) {
- // left
- // if the current element is the first element, go to the last element
- if (currentElementIndex === 0 &&
- (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling
- currentElementIndex = carouselInfo.totalEntries - 1;
- } else {
- currentElementIndex--;
- }
- }
-
- Globals.lastCarouselLeftRightKeyDownTimestamp = Date.now();
- smoothScrollToElementIndex(currentElementIndex, true); // this checks if new index is out of bounds
- }
- });
- // this ctor renders the main page
- Globals.Router = new Router();
-};
-
+/* 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
+. */
+
+
+// @ts-check
+
+async function renderHomePage() {
+ await scanHomebrews();
+ let items = getHomebrewList();
+
+ // remove previous extension sandboxes
+ let oldsandbox = document.getElementById("js-extension-sandbox");
+ if (oldsandbox) {
+ oldsandbox.remove();
+ }
+
+ let carouselItems = items.reduce(
+ /**
+ * @param {Array} acc
+ * @param {*} item
+ * @returns {CarouselItem[]}
+ */
+ (acc, item) => {
+ let newItem = {
+ mainText: item.filename,
+ secondaryText: item.dir,
+ imgPath: baseURL + "/fs" + item.dir + "/sce_sys/icon0.png"
+ }
+ if (item.filename.endsWith(".js")) {
+ newItem.asyncInfo = async () => {
+ let temp = await loadJsExtension(item.path);
+ if (!temp) {
+ // TODO: Remove entry
+ return { result: { mainText: "Error", secondaryText: "Extension main() timed out." } };
+ }
+
+ // let removeFromListOption = {
+ // text: "Remove from list",
+ // onclick: async () => {
+ // removeFromHomebrewList(item.path);
+ // await Globals.Router.handleHome();
+ // }
+ // };
+
+ // if (temp.result.options) {
+ // temp.result.options.push(removeFromListOption);
+ // } else {
+ // temp.result.options = [removeFromListOption];
+ // }
+
+ return temp;
+ }
+ } else {
+ newItem.onclick = () => {
+ return { path: item.path };
+ }
+
+ newItem.options = [
+ {
+ text: "Remove from list",
+ onclick: async () => {
+ removeFromHomebrewList(item.path);
+ await Globals.Router.handleHome();
+ }
+ },
+ {
+ text: "Launch with args",
+ onclick: () => {
+ let args = window.prompt("Enter args");
+ if (args) {
+ return { path: item.path, args: args };
+ }
+ return null;
+ }
+ },
+ {
+ text: "Launch with file",
+ onclick: async () => {
+ let result = await Globals.Router.pickFile(item.path.substring(0, item.path.lastIndexOf("/")));
+
+ if (result) {
+ return { path: item.path, args: result };
+ }
+ }
+ }
+ ];
+ }
+
+ acc.push(newItem);
+ return acc;
+ }, []);
+
+ // add "Add more..." button
+ carouselItems.push({
+ mainText: "+",
+ secondaryText: "Add more...",
+ onclick: async () => {
+ let result = await Globals.Router.pickFile();
+
+ if (result) {
+ addToHomebrewStore(result);
+ await Globals.Router.handleHome();
+ }
+ }
+ })
+
+ await renderMainContentCarousel(carouselItems);
+}
+
+/**
+ * @param {ReadableStream?} [hbldrLogStream]
+ */
+async function renderLaunchedAppView(hbldrLogStream = null) {
+ const content = document.getElementById("content");
+ if (!content) {
+ return;
+ }
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("launched-app-view");
+ content.appendChild(wrapper);
+
+ const msgWrapper = document.createElement("div");
+ wrapper.appendChild(msgWrapper);
+
+ const msgIngress = document.createElement("span");
+ msgIngress.innerText = "App is running in the background, press ";
+ msgWrapper.appendChild(msgIngress);
+
+ const btnCircle = document.createElement("span");
+ btnCircle.classList.add("ps-circle-icon");
+ msgWrapper.appendChild(btnCircle);
+
+ const textCircle = document.createElement("span");
+ textCircle.innerText = " to close this dialog, or ";
+ msgWrapper.appendChild(textCircle);
+
+ const btnTriangle = document.createElement("span");
+ btnTriangle.classList.add("ps-triangle-icon");
+ msgWrapper.appendChild(btnTriangle);
+
+ const textTriangle = document.createElement("span");
+ textTriangle.innerText = " to go back to the launcher.";
+ msgWrapper.appendChild(textTriangle);
+
+ if (hbldrLogStream) {
+ const terminal = document.createElement("div");
+ terminal.classList.add("terminal");
+
+ terminal.style.display = "block";
+ terminal.style.alignItems = "unset";
+ terminal.style.justifyContent = "unset";
+ terminal.style.borderWidth = "2px";
+ terminal.style.padding = "5px";
+ wrapper.appendChild(terminal);
+
+ await (async () => {
+ const term = new Terminal({
+ convertEol: true,
+ altClickMovesCursor: false,
+ disableStdin: true,
+ fontSize: 18,
+ cols: 132,
+ rows: 26
+ });
+
+ try {
+ const reader = hbldrLogStream.getReader();
+ const decoder = new TextDecoder();
+
+ term.open(terminal);
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ let decodedValue = decoder.decode(value);
+ // alert(decodedValue);
+ term.write(decodedValue);
+ }
+ } catch (error) {
+
+ }
+ })();
+ }
+}
+
+window.onload = async function () {
+ window.addEventListener("error", (event) => {
+ alert(event.error);
+ // window.location.href = "/";
+ })
+ window.addEventListener("unhandledrejection", (event) => {
+ alert(event.reason);
+ // window.location.href = "/";
+ });
+
+ const ver = await ApiClient.getVersion();
+ if (ver.api > 0) {
+ const verstr = ` ${ver.tag} (compiled at ${ver.date} ${ver.time})`;
+ document.title += verstr;
+ } else {
+ document.title += ' (unknown version)';
+ }
+
+ registerExtensionMessagesListener();
+
+ // for home page carousel
+ document.addEventListener("keydown", (event) => {
+ // L2
+ if (event.keyCode === 118) {
+ // reset hb list
+ if (!confirm("Reset homebrew list?")) {
+ return;
+ }
+ localStorage.removeItem(LOCALSTORE_HOMEBREW_LIST_KEY);
+ window.location.href = "/";
+ }
+
+ // L1 - go to first element in carousel
+ if (event.keyCode === 116) {
+ smoothScrollToElementIndex(0, true); // doesnt do anything if the current view isnt a carouselView
+ event.preventDefault();
+ }
+
+ // R1 - go to last element in carousel
+ if (event.keyCode === 117) {
+ let carouselInfo = getCurrentCarouselSelectionInfo();
+ if (!carouselInfo) {
+ // current view not a carousel
+ return;
+ }
+
+ smoothScrollToElementIndex(carouselInfo.totalEntries - 1, true);
+ event.preventDefault();
+ }
+
+ // carousel left-right dpad
+ if (event.keyCode === 39 || event.keyCode === 37) {
+ // return if modal is open
+ if (document.getElementById("modal-overlay")) {
+ return;
+ }
+
+ let carouselInfo = getCurrentCarouselSelectionInfo();
+
+ if (!carouselInfo) {
+ // current view not a carousel
+ return;
+ }
+
+ let currentElementIndex = carouselInfo.selectedIndex;
+ if (!currentElementIndex) {
+ currentElementIndex = 0;
+ }
+
+ if (event.keyCode === 39) {
+ // right
+ // if the current element is the last element, go to the first element
+ if (currentElementIndex === carouselInfo.totalEntries - 1 &&
+ (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling
+ currentElementIndex = 0;
+ } else {
+ currentElementIndex++;
+ }
+ } else if (event.keyCode === 37) {
+ // left
+ // if the current element is the first element, go to the last element
+ if (currentElementIndex === 0 &&
+ (Date.now() - Globals.lastCarouselLeftRightKeyDownTimestamp) > 200) { // 200ms so holding down the key doesnt keep scrolling
+ currentElementIndex = carouselInfo.totalEntries - 1;
+ } else {
+ currentElementIndex--;
+ }
+ }
+
+ Globals.lastCarouselLeftRightKeyDownTimestamp = Date.now();
+ smoothScrollToElementIndex(currentElementIndex, true); // this checks if new index is out of bounds
+ }
+ });
+ // this ctor renders the main page
+ Globals.Router = new Router();
+};
+
diff --git a/assets/modalView.js b/assets/modalView.js
index c26637e..dd56385 100644
--- a/assets/modalView.js
+++ b/assets/modalView.js
@@ -1,105 +1,105 @@
-/* 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
-. */
-
-
-// @ts-check
-
-/**
- * @typedef {Object} ModalItem
- * @property {string} text
- * @property {function():any} onclick
- */
-
-/**
- * Shouldnt be interacted with directly, use router methods
- * @param {ModalItem[]} items
- */
-function renderModalOverlay(items) {
- const content = document.getElementById("content");
- if (!content) {
- throw new Error("content not found");
- }
- const oldOverlays = content.querySelectorAll("[data-modal-overlay]");
- for (let overlay of oldOverlays) {
- overlay.remove();
- }
-
- removeAllCursorSnapOverlays();
-
- const overlay = document.createElement("div");
- overlay.setAttribute("data-modal-overlay", "");
- overlay.classList.add("modal-overlay");
-
- const modalContent = document.createElement("div");
- modalContent.classList.add("modal-content");
-
- for (let item of items) {
- const entry = document.createElement("a");
- entry.classList.add("list-entry");
- entry.style.transition = "transform 0.3s ease";
- entry.style.position = "relative";
- const textElement = document.createElement("p");
- textElement.classList.add("text-center");
- textElement.innerText = item.text;
- entry.appendChild(textElement);
-
- // entry.onclick = item.onclick;
- entry.onclick = async () => {
-
- entry.classList.add("loading-overlay");
- entry.classList.add("loading");
- await sleep(0);
-
- let onclickResult = await item.onclick();
-
- let res = onclickResult;
- let logStream = null;
-
- if (res && res.path) {
- logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd);
- res = logStream != null;
- }
-
- if (res == true) {
- entry.style.transform = "scale(2)";
- setTimeout(() => {
- entry.style.removeProperty("transform");
- }, 300);
- Globals.Router.handleLaunchedAppView(logStream);
- }
-
- entry.classList.remove("loading-overlay");
- entry.classList.remove("loading");
- }
- entry.tabIndex = 1;
- modalContent.appendChild(entry);
- }
-
- overlay.appendChild(modalContent);
- content.appendChild(overlay);
-}
-
-function closeModal() {
- const content = document.getElementById("content");
- if (!content) {
- throw new Error("content not found");
- }
- const oldOverlays = content.querySelectorAll("[data-modal-overlay]");
- for (let overlay of oldOverlays) {
- overlay.remove();
- }
- generateCursorSnapOverlays();
-}
+/* 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
+. */
+
+
+// @ts-check
+
+/**
+ * @typedef {Object} ModalItem
+ * @property {string} text
+ * @property {function():any} onclick
+ */
+
+/**
+ * Shouldnt be interacted with directly, use router methods
+ * @param {ModalItem[]} items
+ */
+function renderModalOverlay(items) {
+ const content = document.getElementById("content");
+ if (!content) {
+ throw new Error("content not found");
+ }
+ const oldOverlays = content.querySelectorAll("[data-modal-overlay]");
+ for (let overlay of oldOverlays) {
+ overlay.remove();
+ }
+
+ removeAllCursorSnapOverlays();
+
+ const overlay = document.createElement("div");
+ overlay.setAttribute("data-modal-overlay", "");
+ overlay.classList.add("modal-overlay");
+
+ const modalContent = document.createElement("div");
+ modalContent.classList.add("modal-content");
+
+ for (let item of items) {
+ const entry = document.createElement("a");
+ entry.classList.add("list-entry");
+ entry.style.transition = "transform 0.3s ease";
+ entry.style.position = "relative";
+ const textElement = document.createElement("p");
+ textElement.classList.add("text-center");
+ textElement.innerText = item.text;
+ entry.appendChild(textElement);
+
+ // entry.onclick = item.onclick;
+ entry.onclick = async () => {
+
+ entry.classList.add("loading-overlay");
+ entry.classList.add("loading");
+ await sleep(0);
+
+ let onclickResult = await item.onclick();
+
+ let res = onclickResult;
+ let logStream = null;
+
+ if (res && res.path) {
+ logStream = await ApiClient.launchApp(res.path, res.args, res.env, res.cwd);
+ res = logStream != null;
+ }
+
+ if (res == true) {
+ entry.style.transform = "scale(2)";
+ setTimeout(() => {
+ entry.style.removeProperty("transform");
+ }, 300);
+ Globals.Router.handleLaunchedAppView(logStream);
+ }
+
+ entry.classList.remove("loading-overlay");
+ entry.classList.remove("loading");
+ }
+ entry.tabIndex = 1;
+ modalContent.appendChild(entry);
+ }
+
+ overlay.appendChild(modalContent);
+ content.appendChild(overlay);
+}
+
+function closeModal() {
+ const content = document.getElementById("content");
+ if (!content) {
+ throw new Error("content not found");
+ }
+ const oldOverlays = content.querySelectorAll("[data-modal-overlay]");
+ for (let overlay of oldOverlays) {
+ overlay.remove();
+ }
+ generateCursorSnapOverlays();
+}
diff --git a/assets/router.js b/assets/router.js
index 96a0fda..c9a5d24 100644
--- a/assets/router.js
+++ b/assets/router.js
@@ -1,383 +1,383 @@
-/* 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
-. */
-
-
-// @ts-check
-
-/**
- * these paths are only used for internally, navigating to these manually wont resolve them,
- * since most of these require non serializable parameters (function execution context)
- * @private
-*/
-const RouterRoutes = [
- {
- path: "/",
- keepInHistory: true,
- replaceSamePath: true,
- forceReplaceCurrentState: true
- },
- {
- path: "/modal",
- keepInHistory: false
- },
- {
- path: "/filePicker",
- keepInHistory: false
- },
- {
- path: "/carousel",
- keepInHistory: true
- },
- {
- path: "/launchedAppView",
- keepInHistory: true,
- replaceSamePath: true,
- forceReplaceCurrentState: true
- },
-];
-
-class Router {
- /**
- * @typedef {Object} State
- * @property {string} path
- * @property {string} dataRouterId
- */
-
- /**
- * @typedef {Object} Route
- * @property {string} path
- * @property {function():any} handler
- * @property {boolean} keepInHistory
- */
-
- /** @type {Semaphore} */
- navigationLock = new Semaphore(1);
-
- /** @type {number} */
- initialHistoryLength;
-
- /** @type {boolean} */
- disablePopHandler = false;
-
- constructor() {
- // save history length on first load (which in first load can only contain backward history)
- if (!sessionStorage.getItem("initialHistoryLength")) {
- sessionStorage.setItem("initialHistoryLength", window.history.length.toString());
- }
- this.initialHistoryLength = parseInt(/** @type {string} */(sessionStorage.getItem("initialHistoryLength")));
-
- // if the user reloads with history from old session, delete it
- // for it to be reliable first we need to get rid of the forwards history
- window.history.pushState({}, "", `#${uuidv4()}`);
-
- // the popstate after back fires twice if we do history.go(-) even though im removing the handler before it?
- // i guess removing the event listener is async?
- let handledInitialBack = false;
-
- // sadly history.back doesnt update instantly on webkit...
- window.addEventListener("popstate", function ctorPart2() {
- if (handledInitialBack) {
- window.removeEventListener("popstate", ctorPart2);
- return;
- }
- handledInitialBack = true;
-
- window.removeEventListener("popstate", ctorPart2);
-
- // we know there must be exactly one forward history, so subtract that
- if (this.initialHistoryLength != (window.history.length - 1)) {
- let goBackCount = -((window.history.length - 1) - this.initialHistoryLength);
- window.history.go(goBackCount);
- }
-
- window.addEventListener("popstate", this.handlePopState.bind(this));
- this.handleHome();
- }.bind(this));
-
- window.history.back();
- }
-
- async pushOrReplaceState(newPath) {
- await this.navigationLock.acquire();
-
- try {
- const oldPathItem = RouterRoutes.find((r) => r.path === this.getPath());
- const newPathItem = RouterRoutes.find((r) => r.path === newPath);
-
- let shouldReplace = oldPathItem ? !oldPathItem.keepInHistory : false;
- let shouldReplaceSamePath = oldPathItem ? oldPathItem.replaceSamePath : false;
- if (shouldReplaceSamePath && newPath === this.getPath()) {
- shouldReplace = true;
- }
-
- if (newPathItem && newPathItem.forceReplaceCurrentState) {
- shouldReplace = true;
- }
-
- /** @type {State} */
- const state = {
- path: newPath,
- dataRouterId: uuidv4()
- };
-
- // add hash if not root
- if (newPath !== "/") {
- newPath = `#${newPath}`;
- }
-
- const content = document.getElementById("content");
- if (content) {
- content.classList.add("fadeout");
- await sleep(250);
- }
-
- if (content && !shouldReplace) {
- // hide current content
- content.classList.remove("fadein");
- content.removeAttribute("id");
- content.style.display = "none";
- content.classList.remove("fadeout");
- } else if (content && shouldReplace) {
- // delete current content
- content.remove();
- }
-
- // create new content with data-router-id
- const newContent = document.createElement("div");
- newContent.id = "content"
- newContent.setAttribute("data-router-id", state.dataRouterId);
- newContent.setAttribute("data-router-path", encodeURIComponent(state.path));
- newContent.style.display = "block";
- document.body.appendChild(newContent);
-
-
- if (shouldReplace) {
- history.replaceState(state, "", newPath);
- } else {
- history.pushState(state, "", newPath);
- }
-
- closeModal();
- } finally {
- await this.navigationLock.release();
- }
-
- }
-
- async handlePopState(event) {
- if (this.disablePopHandler) {
- return;
- }
-
- await this.navigationLock.acquire();
-
- try {
- window.dispatchEvent(new CustomEvent("popstateHandlerEntered"));
- /** @type {State} */
- const state = event.state;
-
- if (!state) {
- await this.handleFallbackHome();
- return;
- }
-
- // delete current content div since we dont care about forwards history
- const currentContent = document.getElementById("content");
- if (currentContent) {
- currentContent.classList.add("fadeout");
- await sleep(250);
- // keep a copy of index
- if (currentContent.getAttribute("data-router-path") === encodeURIComponent("/")) {
- currentContent.removeAttribute("id");
- currentContent.style.display = "none";
- currentContent.classList.remove("fadeout");
- }
- else {
- currentContent.remove();
- }
- }
-
- // restore saved content div
- /** @type {HTMLElement?} */
- const restoredContent = document.querySelector(`[data-router-id="${state.dataRouterId}"]`);
- if (!restoredContent) {
- await this.handleFallbackHome();
- return;
- }
-
- restoredContent.id = "content";
- restoredContent.classList.add("fadein");
- setTimeout(() => {
- restoredContent.classList.remove("fadein");
- }, 250);
- restoredContent.style.display = "block";
-
- closeModal();
- } finally {
- await this.navigationLock.release();
- }
- }
-
- getNavigateAwayPromise() {
- return new Promise((resolve) => {
- function handler(event) {
- window.removeEventListener("popstate", handler);
- window.removeEventListener("hashchange", handler);
- resolve(null);
- }
-
- window.addEventListener("popstate", handler);
- window.addEventListener("hashchange", handler);
- });
- }
-
- async handleHome() {
- await this.pushOrReplaceState("/");
- await renderHomePage();
- }
-
- async handleFallbackHome() {
- let homeContent = /** @type {HTMLElement?} */ (document.querySelector(`[data-router-path="${encodeURIComponent("/")}"]`));
- // delete current content
- const currentContent = document.getElementById("content");
- if (currentContent && (!homeContent || homeContent.id !== "content")) {
- // currentContent.classList.add("fadeout");
- // await sleep(250);
- currentContent.remove();
- }
-
- let contentUuid = uuidv4();
-
- if (!homeContent) {
- homeContent = document.createElement("div");
- homeContent.id = "content";
- homeContent.setAttribute("data-router-path", encodeURIComponent("/"));
- homeContent.setAttribute("data-router-id", contentUuid);
- homeContent.style.display = "block";
- document.body.appendChild(homeContent);
-
- renderHomePage(); // fire and forget
- }
- else {
- homeContent.id = "content";
- homeContent.style.display = "block";
- contentUuid = /** @type {string} */ (homeContent.getAttribute("data-router-id"));
- }
-
- history.replaceState({ path: "/", dataRouterId: contentUuid }, "", `/`);
- }
-
- /**
- * @param {CarouselItem[]} items
- */
- async handleCarousel(items) {
- await this.pushOrReplaceState("/carousel");
- await renderMainContentCarousel(items, true);
- }
-
- /**
- * @param {ModalItem[]} items
- */
- async handleModal(items) {
- await this.pushOrReplaceState("/modal");
- renderModalOverlay(items);
- }
-
- /**
- * Replaces the initial page so you can exit with the back button
- * so you cant exit this, reload page to close this view
- * @param {ReadableStream?} [logStream]
- */
- async handleLaunchedAppView(logStream = null) {
- // TODO: theres a workaround for the back button to close the window even if there was history before this site
- // you can listen to keycode 27, and navigate back as far as possible there
-
- this.disablePopHandler = true;
- // remove forward history
- window.history.pushState({}, "", `#${uuidv4()}`);
-
- await new Promise(async (resolve) => {
- function handler(event) {
- window.removeEventListener("popstate", handler);
- resolve(null);
- }
- window.addEventListener("popstate", handler);
-
- window.history.back();
- });
-
- if (this.initialHistoryLength != (window.history.length - 1)) {
- let goBackCount = -((window.history.length - 1) - this.initialHistoryLength);
- window.history.go(goBackCount);
- }
-
- await this.pushOrReplaceState("/launchedAppView");
- await renderLaunchedAppView(logStream);
-
- // this.disablePopHandler = false;
- }
-
- /**
- * @param {string} initialPath
- * @returns {Promise}
- */
- async pickFile(initialPath = "/", title = "Select file...") {
- await this.pushOrReplaceState("/filePicker");
- let navigateAwayPromise = this.getNavigateAwayPromise();
-
- /** @type {{path: string, finished: boolean}?} */
- let result = await Promise.race([renderBrowsePage(initialPath, true, title), navigateAwayPromise]);
- while (result && !result.finished) {
- result = await Promise.race([renderBrowsePage(result.path, false, title), navigateAwayPromise]);
- }
-
- // result is only null if the user already navigated back
- if (result) {
- await this.back();
- }
-
- return result ? result.path : null;
- }
-
- getPath() {
- const hash = window.location.hash;
- if (!hash) {
- return "/";
- }
-
- return hash.slice(1);
- }
-
- async back() {
- // history.back doesnt update the history.length and window.location.href instantly
- // plus we await the popstate handler for the animations to finish
-
- await new Promise(async (resolve) => {
- function handler(event) {
- window.removeEventListener("popstateHandlerEntered", handler);
- // window.removeEventListener("hashchange", handler);
- resolve(null);
- }
- window.addEventListener("popstateHandlerEntered", handler);
- // window.addEventListener("hashchange", handler);
-
- window.history.back();
- });
-
- await this.navigationLock.awaitCurrentQueue();
- }
-
-}
+/* 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
+. */
+
+
+// @ts-check
+
+/**
+ * these paths are only used for internally, navigating to these manually wont resolve them,
+ * since most of these require non serializable parameters (function execution context)
+ * @private
+*/
+const RouterRoutes = [
+ {
+ path: "/",
+ keepInHistory: true,
+ replaceSamePath: true,
+ forceReplaceCurrentState: true
+ },
+ {
+ path: "/modal",
+ keepInHistory: false
+ },
+ {
+ path: "/filePicker",
+ keepInHistory: false
+ },
+ {
+ path: "/carousel",
+ keepInHistory: true
+ },
+ {
+ path: "/launchedAppView",
+ keepInHistory: true,
+ replaceSamePath: true,
+ forceReplaceCurrentState: true
+ },
+];
+
+class Router {
+ /**
+ * @typedef {Object} State
+ * @property {string} path
+ * @property {string} dataRouterId
+ */
+
+ /**
+ * @typedef {Object} Route
+ * @property {string} path
+ * @property {function():any} handler
+ * @property {boolean} keepInHistory
+ */
+
+ /** @type {Semaphore} */
+ navigationLock = new Semaphore(1);
+
+ /** @type {number} */
+ initialHistoryLength;
+
+ /** @type {boolean} */
+ disablePopHandler = false;
+
+ constructor() {
+ // save history length on first load (which in first load can only contain backward history)
+ if (!sessionStorage.getItem("initialHistoryLength")) {
+ sessionStorage.setItem("initialHistoryLength", window.history.length.toString());
+ }
+ this.initialHistoryLength = parseInt(/** @type {string} */(sessionStorage.getItem("initialHistoryLength")));
+
+ // if the user reloads with history from old session, delete it
+ // for it to be reliable first we need to get rid of the forwards history
+ window.history.pushState({}, "", `#${uuidv4()}`);
+
+ // the popstate after back fires twice if we do history.go(-) even though im removing the handler before it?
+ // i guess removing the event listener is async?
+ let handledInitialBack = false;
+
+ // sadly history.back doesnt update instantly on webkit...
+ window.addEventListener("popstate", function ctorPart2() {
+ if (handledInitialBack) {
+ window.removeEventListener("popstate", ctorPart2);
+ return;
+ }
+ handledInitialBack = true;
+
+ window.removeEventListener("popstate", ctorPart2);
+
+ // we know there must be exactly one forward history, so subtract that
+ if (this.initialHistoryLength != (window.history.length - 1)) {
+ let goBackCount = -((window.history.length - 1) - this.initialHistoryLength);
+ window.history.go(goBackCount);
+ }
+
+ window.addEventListener("popstate", this.handlePopState.bind(this));
+ this.handleHome();
+ }.bind(this));
+
+ window.history.back();
+ }
+
+ async pushOrReplaceState(newPath) {
+ await this.navigationLock.acquire();
+
+ try {
+ const oldPathItem = RouterRoutes.find((r) => r.path === this.getPath());
+ const newPathItem = RouterRoutes.find((r) => r.path === newPath);
+
+ let shouldReplace = oldPathItem ? !oldPathItem.keepInHistory : false;
+ let shouldReplaceSamePath = oldPathItem ? oldPathItem.replaceSamePath : false;
+ if (shouldReplaceSamePath && newPath === this.getPath()) {
+ shouldReplace = true;
+ }
+
+ if (newPathItem && newPathItem.forceReplaceCurrentState) {
+ shouldReplace = true;
+ }
+
+ /** @type {State} */
+ const state = {
+ path: newPath,
+ dataRouterId: uuidv4()
+ };
+
+ // add hash if not root
+ if (newPath !== "/") {
+ newPath = `#${newPath}`;
+ }
+
+ const content = document.getElementById("content");
+ if (content) {
+ content.classList.add("fadeout");
+ await sleep(250);
+ }
+
+ if (content && !shouldReplace) {
+ // hide current content
+ content.classList.remove("fadein");
+ content.removeAttribute("id");
+ content.style.display = "none";
+ content.classList.remove("fadeout");
+ } else if (content && shouldReplace) {
+ // delete current content
+ content.remove();
+ }
+
+ // create new content with data-router-id
+ const newContent = document.createElement("div");
+ newContent.id = "content"
+ newContent.setAttribute("data-router-id", state.dataRouterId);
+ newContent.setAttribute("data-router-path", encodeURIComponent(state.path));
+ newContent.style.display = "block";
+ document.body.appendChild(newContent);
+
+
+ if (shouldReplace) {
+ history.replaceState(state, "", newPath);
+ } else {
+ history.pushState(state, "", newPath);
+ }
+
+ closeModal();
+ } finally {
+ await this.navigationLock.release();
+ }
+
+ }
+
+ async handlePopState(event) {
+ if (this.disablePopHandler) {
+ return;
+ }
+
+ await this.navigationLock.acquire();
+
+ try {
+ window.dispatchEvent(new CustomEvent("popstateHandlerEntered"));
+ /** @type {State} */
+ const state = event.state;
+
+ if (!state) {
+ await this.handleFallbackHome();
+ return;
+ }
+
+ // delete current content div since we dont care about forwards history
+ const currentContent = document.getElementById("content");
+ if (currentContent) {
+ currentContent.classList.add("fadeout");
+ await sleep(250);
+ // keep a copy of index
+ if (currentContent.getAttribute("data-router-path") === encodeURIComponent("/")) {
+ currentContent.removeAttribute("id");
+ currentContent.style.display = "none";
+ currentContent.classList.remove("fadeout");
+ }
+ else {
+ currentContent.remove();
+ }
+ }
+
+ // restore saved content div
+ /** @type {HTMLElement?} */
+ const restoredContent = document.querySelector(`[data-router-id="${state.dataRouterId}"]`);
+ if (!restoredContent) {
+ await this.handleFallbackHome();
+ return;
+ }
+
+ restoredContent.id = "content";
+ restoredContent.classList.add("fadein");
+ setTimeout(() => {
+ restoredContent.classList.remove("fadein");
+ }, 250);
+ restoredContent.style.display = "block";
+
+ closeModal();
+ } finally {
+ await this.navigationLock.release();
+ }
+ }
+
+ getNavigateAwayPromise() {
+ return new Promise((resolve) => {
+ function handler(event) {
+ window.removeEventListener("popstate", handler);
+ window.removeEventListener("hashchange", handler);
+ resolve(null);
+ }
+
+ window.addEventListener("popstate", handler);
+ window.addEventListener("hashchange", handler);
+ });
+ }
+
+ async handleHome() {
+ await this.pushOrReplaceState("/");
+ await renderHomePage();
+ }
+
+ async handleFallbackHome() {
+ let homeContent = /** @type {HTMLElement?} */ (document.querySelector(`[data-router-path="${encodeURIComponent("/")}"]`));
+ // delete current content
+ const currentContent = document.getElementById("content");
+ if (currentContent && (!homeContent || homeContent.id !== "content")) {
+ // currentContent.classList.add("fadeout");
+ // await sleep(250);
+ currentContent.remove();
+ }
+
+ let contentUuid = uuidv4();
+
+ if (!homeContent) {
+ homeContent = document.createElement("div");
+ homeContent.id = "content";
+ homeContent.setAttribute("data-router-path", encodeURIComponent("/"));
+ homeContent.setAttribute("data-router-id", contentUuid);
+ homeContent.style.display = "block";
+ document.body.appendChild(homeContent);
+
+ renderHomePage(); // fire and forget
+ }
+ else {
+ homeContent.id = "content";
+ homeContent.style.display = "block";
+ contentUuid = /** @type {string} */ (homeContent.getAttribute("data-router-id"));
+ }
+
+ history.replaceState({ path: "/", dataRouterId: contentUuid }, "", `/`);
+ }
+
+ /**
+ * @param {CarouselItem[]} items
+ */
+ async handleCarousel(items) {
+ await this.pushOrReplaceState("/carousel");
+ await renderMainContentCarousel(items, true);
+ }
+
+ /**
+ * @param {ModalItem[]} items
+ */
+ async handleModal(items) {
+ await this.pushOrReplaceState("/modal");
+ renderModalOverlay(items);
+ }
+
+ /**
+ * Replaces the initial page so you can exit with the back button
+ * so you cant exit this, reload page to close this view
+ * @param {ReadableStream?} [logStream]
+ */
+ async handleLaunchedAppView(logStream = null) {
+ // TODO: theres a workaround for the back button to close the window even if there was history before this site
+ // you can listen to keycode 27, and navigate back as far as possible there
+
+ this.disablePopHandler = true;
+ // remove forward history
+ window.history.pushState({}, "", `#${uuidv4()}`);
+
+ await new Promise(async (resolve) => {
+ function handler(event) {
+ window.removeEventListener("popstate", handler);
+ resolve(null);
+ }
+ window.addEventListener("popstate", handler);
+
+ window.history.back();
+ });
+
+ if (this.initialHistoryLength != (window.history.length - 1)) {
+ let goBackCount = -((window.history.length - 1) - this.initialHistoryLength);
+ window.history.go(goBackCount);
+ }
+
+ await this.pushOrReplaceState("/launchedAppView");
+ await renderLaunchedAppView(logStream);
+
+ // this.disablePopHandler = false;
+ }
+
+ /**
+ * @param {string} initialPath
+ * @returns {Promise}
+ */
+ async pickFile(initialPath = "/", title = "Select file...") {
+ await this.pushOrReplaceState("/filePicker");
+ let navigateAwayPromise = this.getNavigateAwayPromise();
+
+ /** @type {{path: string, finished: boolean}?} */
+ let result = await Promise.race([renderBrowsePage(initialPath, true, title), navigateAwayPromise]);
+ while (result && !result.finished) {
+ result = await Promise.race([renderBrowsePage(result.path, false, title), navigateAwayPromise]);
+ }
+
+ // result is only null if the user already navigated back
+ if (result) {
+ await this.back();
+ }
+
+ return result ? result.path : null;
+ }
+
+ getPath() {
+ const hash = window.location.hash;
+ if (!hash) {
+ return "/";
+ }
+
+ return hash.slice(1);
+ }
+
+ async back() {
+ // history.back doesnt update the history.length and window.location.href instantly
+ // plus we await the popstate handler for the animations to finish
+
+ await new Promise(async (resolve) => {
+ function handler(event) {
+ window.removeEventListener("popstateHandlerEntered", handler);
+ // window.removeEventListener("hashchange", handler);
+ resolve(null);
+ }
+ window.addEventListener("popstateHandlerEntered", handler);
+ // window.addEventListener("hashchange", handler);
+
+ window.history.back();
+ });
+
+ await this.navigationLock.awaitCurrentQueue();
+ }
+
+}
diff --git a/assets/utils.js b/assets/utils.js
index 2f9490b..52d4748 100644
--- a/assets/utils.js
+++ b/assets/utils.js
@@ -1,81 +1,81 @@
-/* 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
-. */
-
-
-/**
- * throws exception if the server is down
- * @param {string} path
- * @returns {object?} null if not success
- */
-async function fetchJson(path) {
- let response = await fetch(baseURL + path);
- if (!response.ok) {
- return null;
- }
- let data = await response.json();
- return data;
-}
-
-// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
-function uuidv4() {
- return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
- (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
- );
-}
-
-async function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-class Semaphore {
- /**
- * @param {number} [maxConcurrency]
- */
- constructor(maxConcurrency = 1) {
- /** @type {{resolve: () => void, promise: Promise}[]} */
- this.queue = [];
- this.maxConcurrency = maxConcurrency;
- }
-
- acquire() {
- let resolver;
- const promise = new Promise((resolve) => {
- resolver = resolve;
- });
-
- this.queue.push({ resolve: resolver, promise: promise });
-
- if (this.queue.length <= this.maxConcurrency) {
- return Promise.resolve();
- }
-
- return promise;
- }
-
- release() {
- if (this.queue.length === 0) {
- return;
- }
-
- this.queue.shift().resolve();
- }
-
-
- awaitCurrentQueue() {
- const promises = this.queue.map(item => item.promise);
- return Promise.all(promises);
- }
+/* 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
+. */
+
+
+/**
+ * throws exception if the server is down
+ * @param {string} path
+ * @returns {object?} null if not success
+ */
+async function fetchJson(path) {
+ let response = await fetch(baseURL + path);
+ if (!response.ok) {
+ return null;
+ }
+ let data = await response.json();
+ return data;
+}
+
+// https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
+function uuidv4() {
+ return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
+ (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
+ );
+}
+
+async function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+class Semaphore {
+ /**
+ * @param {number} [maxConcurrency]
+ */
+ constructor(maxConcurrency = 1) {
+ /** @type {{resolve: () => void, promise: Promise}[]} */
+ this.queue = [];
+ this.maxConcurrency = maxConcurrency;
+ }
+
+ acquire() {
+ let resolver;
+ const promise = new Promise((resolve) => {
+ resolver = resolve;
+ });
+
+ this.queue.push({ resolve: resolver, promise: promise });
+
+ if (this.queue.length <= this.maxConcurrency) {
+ return Promise.resolve();
+ }
+
+ return promise;
+ }
+
+ release() {
+ if (this.queue.length === 0) {
+ return;
+ }
+
+ this.queue.shift().resolve();
+ }
+
+
+ awaitCurrentQueue() {
+ const promises = this.queue.map(item => item.promise);
+ return Promise.all(promises);
+ }
}
\ No newline at end of file
diff --git a/homebrew/DevilutionX/homebrew.js b/homebrew/DevilutionX/homebrew.js
index ec43ad4..f5d696e 100755
--- a/homebrew/DevilutionX/homebrew.js
+++ b/homebrew/DevilutionX/homebrew.js
@@ -1,60 +1,60 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const PAYLOAD = window.workingDir + '/devilutionx.elf';
-
- return {
- mainText: "DevilutionX",
- secondaryText: 'Diablo build for modern OSes',
- onclick: async () => {
- return {
- path: PAYLOAD,
- args: ''
- };
- },
- options: [
- {
- text: "Force Shareware mode",
- onclick: async () => {
- return {
- path: PAYLOAD,
- args: '--spawn'
- };
- }
- },
- {
- text: "Force Diablo mode",
- onclick: async () => {
- return {
- path: PAYLOAD,
- args: '--diablo'
- };
- }
- },
- {
- text: "Force Hellfire mode",
- onclick: async () => {
- return {
- path: PAYLOAD,
- args: '--hellfire'
- };
- }
- }
- ]
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const PAYLOAD = window.workingDir + '/devilutionx.elf';
+
+ return {
+ mainText: "DevilutionX",
+ secondaryText: 'Diablo build for modern OSes',
+ onclick: async () => {
+ return {
+ path: PAYLOAD,
+ args: ''
+ };
+ },
+ options: [
+ {
+ text: "Force Shareware mode",
+ onclick: async () => {
+ return {
+ path: PAYLOAD,
+ args: '--spawn'
+ };
+ }
+ },
+ {
+ text: "Force Diablo mode",
+ onclick: async () => {
+ return {
+ path: PAYLOAD,
+ args: '--diablo'
+ };
+ }
+ },
+ {
+ text: "Force Hellfire mode",
+ onclick: async () => {
+ return {
+ path: PAYLOAD,
+ args: '--hellfire'
+ };
+ }
+ }
+ ]
+ };
+}
diff --git a/homebrew/FBNeo/homebrew.js b/homebrew/FBNeo/homebrew.js
index ce08f9d..29d2704 100755
--- a/homebrew/FBNeo/homebrew.js
+++ b/homebrew/FBNeo/homebrew.js
@@ -1,34 +1,34 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const PAYLOAD = window.workingDir + '/fbneo.elf';
- const ARGS = ['-menu', '-integerscale', '-fullscreen', '-joy']
- const ENVVARS = {FBNEO_CONFIG_PATH: window.workingDir + '/config'};
-
- return {
- mainText: "FinalBurn Neo",
- secondaryText: 'An emulator for arcade games',
- onclick: async () => {
- return {
- path: PAYLOAD,
- args: ARGS,
- env: ENVVARS
- };
- }
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const PAYLOAD = window.workingDir + '/fbneo.elf';
+ const ARGS = ['-menu', '-integerscale', '-fullscreen', '-joy']
+ const ENVVARS = {FBNEO_CONFIG_PATH: window.workingDir + '/config'};
+
+ return {
+ mainText: "FinalBurn Neo",
+ secondaryText: 'An emulator for arcade games',
+ onclick: async () => {
+ return {
+ path: PAYLOAD,
+ args: ARGS,
+ env: ENVVARS
+ };
+ }
+ };
+}
diff --git a/homebrew/FFplay/homebrew.js b/homebrew/FFplay/homebrew.js
index db93da6..e81d715 100755
--- a/homebrew/FFplay/homebrew.js
+++ b/homebrew/FFplay/homebrew.js
@@ -1,36 +1,36 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const PAYLOAD = window.workingDir + '/ffplay.elf';
- const MEDIADIR = window.workingDir + '/media/';
-
- return {
- mainText: "FFplay",
- secondaryText: 'FFmpeg Media Player',
- onclick: async () => {
- const file = await pickFile(MEDIADIR);
- if(!file) {
- return;
- }
- return {
- path: PAYLOAD,
- args: ['-fs', file]
- };
- }
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const PAYLOAD = window.workingDir + '/ffplay.elf';
+ const MEDIADIR = window.workingDir + '/media/';
+
+ return {
+ mainText: "FFplay",
+ secondaryText: 'FFmpeg Media Player',
+ onclick: async () => {
+ const file = await pickFile(MEDIADIR);
+ if(!file) {
+ return;
+ }
+ return {
+ path: PAYLOAD,
+ args: ['-fs', file]
+ };
+ }
+ };
+}
diff --git a/homebrew/LakeSnes/homebrew.js b/homebrew/LakeSnes/homebrew.js
index d49e06f..498107c 100644
--- a/homebrew/LakeSnes/homebrew.js
+++ b/homebrew/LakeSnes/homebrew.js
@@ -1,48 +1,48 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const PAYLOAD = window.workingDir + '/lakesnes.elf';
- const ROMDIR = window.workingDir + '/roms/';
- const ROMTYPES = ['smc', 'sfc']
-
- async function getRomList() {
- let listing = await ApiClient.fsListDir(ROMDIR);
- return listing.filter(entry =>
- ROMTYPES.includes(entry.name.slice(-3))).map(entry => {
- const name = entry.name.slice(0, -4);
- return {
- mainText: name,
- imgPath: '/fs/' + ROMDIR + name + '.jpg',
- onclick: async() => {
- return {
- path: PAYLOAD,
- args: ROMDIR + entry.name
- }
- }
- };
- });
- }
- return {
- mainText: "LakeSnes",
- secondaryText: 'Super Nintendo Emulator',
- onclick: async () => {
- let items = await getRomList();
- showCarousel(items);
- }
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const PAYLOAD = window.workingDir + '/lakesnes.elf';
+ const ROMDIR = window.workingDir + '/roms/';
+ const ROMTYPES = ['smc', 'sfc']
+
+ async function getRomList() {
+ let listing = await ApiClient.fsListDir(ROMDIR);
+ return listing.filter(entry =>
+ ROMTYPES.includes(entry.name.slice(-3))).map(entry => {
+ const name = entry.name.slice(0, -4);
+ return {
+ mainText: name,
+ imgPath: '/fs/' + ROMDIR + name + '.jpg',
+ onclick: async() => {
+ return {
+ path: PAYLOAD,
+ args: ROMDIR + entry.name
+ }
+ }
+ };
+ });
+ }
+ return {
+ mainText: "LakeSnes",
+ secondaryText: 'Super Nintendo Emulator',
+ onclick: async () => {
+ let items = await getRomList();
+ showCarousel(items);
+ }
+ };
+}
diff --git a/homebrew/Mednafen/homebrew.js b/homebrew/Mednafen/homebrew.js
index c320042..af6c18b 100644
--- a/homebrew/Mednafen/homebrew.js
+++ b/homebrew/Mednafen/homebrew.js
@@ -1,134 +1,134 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const PAYLOAD = window.workingDir + '/mednafen.elf';
- const ENVVARS = {MEDNAFEN_HOME: window.workingDir};
-
- const ROMDIR = window.workingDir + '/roms/';
- const ROMTYPES = ['cue', 'dsk', 'gb', 'gba', 'gbc', 'gen', 'gg', 'lnx',
- 'm3u', 'nes', 'ngp', 'pce', 'psx', 'sfc', 'smc', 'sms',
- 'vb', 'ws', 'wsc', 'zip'];
-
- function getRomType(filename) {
- const dotIndex = filename.lastIndexOf('.');
- if (dotIndex === -1) {
- return '';
- }
- return filename.slice(dotIndex + 1);
- }
-
- function getRomName(filename) {
- const dotIndex = filename.lastIndexOf('.');
- if (dotIndex === -1) {
- return filename;
- }
-
- return filename.slice(0, dotIndex);
- }
-
- function getPlatformName(romType) {
- switch(romType) {
- case 'dsk': return 'Apple ][';
- case 'lnx': return 'Atari Lynx';
- case 'gb': return 'Game Boy';
- case 'gba': return 'Game Boy Advance';
- case 'gbc': return 'Game Boy Color';
- case 'gen': return 'Sega MegaDrive';
- case 'gg': return 'Sega Game Gear';
- case 'nes': return 'Nintendo Entertainment System';
- case 'ngp': return 'Neo Geo Pocket';
- case 'pce': return 'PC Engine';
- case 'psx': return 'Sony Playstation';
- case 'sfc':
- case 'smc': return 'Super Nintendo';
- case 'sms': return 'Sega Master System';
- case 'vb': return 'Virtual Boy';
- case 'ws': return 'WonderSwan';
- case 'wsc': return 'WonderSwan Color';
- default: return '';
- }
- }
-
- async function checkApiVersion() {
- try {
- const ver = await ApiClient.getVersion();
- console.log(ver);
- if (ver.api > 0) {
- return true;
- }
- } catch(error) {
- console.error(error);
- }
-
- alert('Incompatible web server');
- return false;
- }
-
- async function getRomList() {
- let listing = await ApiClient.fsListDir(ROMDIR);
- return listing.filter(entry =>
- ROMTYPES.includes(getRomType(entry.name))).map(entry => {
- const romType = getRomType(entry.name);
- const romName = getRomName(entry.name);
- const platformName = getPlatformName(romType);
-
- return {
- mainText: romName,
- secondaryText: platformName,
- imgPath: '/fs/' + ROMDIR + romName + '.jpg',
- onclick: async() => {
- return {
- path: PAYLOAD,
- args: ROMDIR + entry.name,
- env: ENVVARS
- }
- }
- };
- });
- }
-
- return {
- mainText: "Mednafen",
- secondaryText: 'Multi-system Emulator',
- onclick: async () => {
- if(await checkApiVersion()) {
- let items = await getRomList();
- showCarousel(items);
- }
- },
- options: [
- {
- text: "Browse ROM...",
- onclick: async () => {
- if(await checkApiVersion()) {
- const file = await pickFile(window.workingDir, "Select ROM...");
- if(!file) {
- return;
- }
- return {
- path: PAYLOAD,
- args: file,
- env: ENVVARS
- };
- }
- }
- }
- ]
- };
-}
-
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const PAYLOAD = window.workingDir + '/mednafen.elf';
+ const ENVVARS = {MEDNAFEN_HOME: window.workingDir};
+
+ const ROMDIR = window.workingDir + '/roms/';
+ const ROMTYPES = ['cue', 'dsk', 'gb', 'gba', 'gbc', 'gen', 'gg', 'lnx',
+ 'm3u', 'nes', 'ngp', 'pce', 'psx', 'sfc', 'smc', 'sms',
+ 'vb', 'ws', 'wsc', 'zip'];
+
+ function getRomType(filename) {
+ const dotIndex = filename.lastIndexOf('.');
+ if (dotIndex === -1) {
+ return '';
+ }
+ return filename.slice(dotIndex + 1);
+ }
+
+ function getRomName(filename) {
+ const dotIndex = filename.lastIndexOf('.');
+ if (dotIndex === -1) {
+ return filename;
+ }
+
+ return filename.slice(0, dotIndex);
+ }
+
+ function getPlatformName(romType) {
+ switch(romType) {
+ case 'dsk': return 'Apple ][';
+ case 'lnx': return 'Atari Lynx';
+ case 'gb': return 'Game Boy';
+ case 'gba': return 'Game Boy Advance';
+ case 'gbc': return 'Game Boy Color';
+ case 'gen': return 'Sega MegaDrive';
+ case 'gg': return 'Sega Game Gear';
+ case 'nes': return 'Nintendo Entertainment System';
+ case 'ngp': return 'Neo Geo Pocket';
+ case 'pce': return 'PC Engine';
+ case 'psx': return 'Sony Playstation';
+ case 'sfc':
+ case 'smc': return 'Super Nintendo';
+ case 'sms': return 'Sega Master System';
+ case 'vb': return 'Virtual Boy';
+ case 'ws': return 'WonderSwan';
+ case 'wsc': return 'WonderSwan Color';
+ default: return '';
+ }
+ }
+
+ async function checkApiVersion() {
+ try {
+ const ver = await ApiClient.getVersion();
+ console.log(ver);
+ if (ver.api > 0) {
+ return true;
+ }
+ } catch(error) {
+ console.error(error);
+ }
+
+ alert('Incompatible web server');
+ return false;
+ }
+
+ async function getRomList() {
+ let listing = await ApiClient.fsListDir(ROMDIR);
+ return listing.filter(entry =>
+ ROMTYPES.includes(getRomType(entry.name))).map(entry => {
+ const romType = getRomType(entry.name);
+ const romName = getRomName(entry.name);
+ const platformName = getPlatformName(romType);
+
+ return {
+ mainText: romName,
+ secondaryText: platformName,
+ imgPath: '/fs/' + ROMDIR + romName + '.jpg',
+ onclick: async() => {
+ return {
+ path: PAYLOAD,
+ args: ROMDIR + entry.name,
+ env: ENVVARS
+ }
+ }
+ };
+ });
+ }
+
+ return {
+ mainText: "Mednafen",
+ secondaryText: 'Multi-system Emulator',
+ onclick: async () => {
+ if(await checkApiVersion()) {
+ let items = await getRomList();
+ showCarousel(items);
+ }
+ },
+ options: [
+ {
+ text: "Browse ROM...",
+ onclick: async () => {
+ if(await checkApiVersion()) {
+ const file = await pickFile(window.workingDir, "Select ROM...");
+ if(!file) {
+ return;
+ }
+ return {
+ path: PAYLOAD,
+ args: file,
+ env: ENVVARS
+ };
+ }
+ }
+ }
+ ]
+ };
+}
+
diff --git a/homebrew/OffAct/homebrew.js b/homebrew/OffAct/homebrew.js
index d84aca3..33f4302 100644
--- a/homebrew/OffAct/homebrew.js
+++ b/homebrew/OffAct/homebrew.js
@@ -1,30 +1,30 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const PAYLOAD = window.workingDir + '/OffAct.elf';
-
- return {
- mainText: "OffAct",
- secondaryText: 'Offline account activation',
- onclick: async () => {
- return {
- path: PAYLOAD
- };
- }
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const PAYLOAD = window.workingDir + '/OffAct.elf';
+
+ return {
+ mainText: "OffAct",
+ secondaryText: 'Offline account activation',
+ onclick: async () => {
+ return {
+ path: PAYLOAD
+ };
+ }
+ };
+}
diff --git a/homebrew/SVTplay/homebrew.js b/homebrew/SVTplay/homebrew.js
index 5f318ca..3f7e3f8 100644
--- a/homebrew/SVTplay/homebrew.js
+++ b/homebrew/SVTplay/homebrew.js
@@ -1,78 +1,78 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const BASE_API_URL = "https://api.svt.se/";
- const BASE_IMG_URL = 'https://www.svtstatic.se/image/custom/400/';
- const PAYLOAD_PATH = window.workingDir + '/ffplay.elf';
-
- async function resolveStreamURL(channelId) {
- let response = await fetch(BASE_API_URL + 'video/' + channelId);
- if (!response.ok) {
- return '';
- }
- let data = await response.json();
- for (const vidref of data.videoReferences) {
- if(vidref.format == 'hls') {
- return ['-fs', vidref.redirect];
- }
- }
-
- return '';
- }
-
- async function getChannelList() {
- const params = new URLSearchParams({
- "ua": "svtplaywebb-play-render-produnction-client",
- "operationName": "ChannelsQuery",
- "variables": "{}",
- "extensions": JSON.stringify({
- "persistedQuery":{
- "sha256Hash":"21da6ea41fa3a9300a7b51071f9dc91317955a7dcddc800ddce58fc708e7c634",
- "version" : 1
- }
- })
- });
-
- let response = await fetch(BASE_API_URL + "/contento/graphql?" + params.toString());
- if (!response.ok) {
- return [];
- }
-
- let data = await response.json();
- return data.data.channels.channels.map((ch) => ({
- mainText: ch.name,
- secondaryText: ch.running.name,
- imgPath:BASE_IMG_URL + ch.running.image.id + '/' + ch.running.image.changed,
- onclick: async() => {
- return {
- path: PAYLOAD_PATH,
- args: await resolveStreamURL(ch.id)
- };
- }
- }));
- }
-
- return {
- mainText: "SVT Play",
- secondaryText: 'Live TV from Swedish public service',
- onclick: async () => {
- let items = await getChannelList();
- showCarousel(items);
- }
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const BASE_API_URL = "https://api.svt.se/";
+ const BASE_IMG_URL = 'https://www.svtstatic.se/image/custom/400/';
+ const PAYLOAD_PATH = window.workingDir + '/ffplay.elf';
+
+ async function resolveStreamURL(channelId) {
+ let response = await fetch(BASE_API_URL + 'video/' + channelId);
+ if (!response.ok) {
+ return '';
+ }
+ let data = await response.json();
+ for (const vidref of data.videoReferences) {
+ if(vidref.format == 'hls') {
+ return ['-fs', vidref.redirect];
+ }
+ }
+
+ return '';
+ }
+
+ async function getChannelList() {
+ const params = new URLSearchParams({
+ "ua": "svtplaywebb-play-render-produnction-client",
+ "operationName": "ChannelsQuery",
+ "variables": "{}",
+ "extensions": JSON.stringify({
+ "persistedQuery":{
+ "sha256Hash":"21da6ea41fa3a9300a7b51071f9dc91317955a7dcddc800ddce58fc708e7c634",
+ "version" : 1
+ }
+ })
+ });
+
+ let response = await fetch(BASE_API_URL + "/contento/graphql?" + params.toString());
+ if (!response.ok) {
+ return [];
+ }
+
+ let data = await response.json();
+ return data.data.channels.channels.map((ch) => ({
+ mainText: ch.name,
+ secondaryText: ch.running.name,
+ imgPath:BASE_IMG_URL + ch.running.image.id + '/' + ch.running.image.changed,
+ onclick: async() => {
+ return {
+ path: PAYLOAD_PATH,
+ args: await resolveStreamURL(ch.id)
+ };
+ }
+ }));
+ }
+
+ return {
+ mainText: "SVT Play",
+ secondaryText: 'Live TV from Swedish public service',
+ onclick: async () => {
+ let items = await getChannelList();
+ showCarousel(items);
+ }
+ };
+}
diff --git a/homebrew/SverigesRadio/homebrew.js b/homebrew/SverigesRadio/homebrew.js
index 5fab6f0..2347290 100644
--- a/homebrew/SverigesRadio/homebrew.js
+++ b/homebrew/SverigesRadio/homebrew.js
@@ -1,85 +1,85 @@
-/* Copyright (C) 2024 John Törnblom
-
-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
-. */
-
-
-async function main() {
- const BASE_API_URL = "http://api.sr.se/api/v2/";
- const PAYLOAD_PATH = window.workingDir + '/ffplay.elf';
-
- async function getChannelProgramme(chid) {
- const params = new URLSearchParams({
- "channelid": chid,
- "format": "json"
- });
-
- try {
- let response = await fetch(BASE_API_URL + "/scheduledepisodes/rightnow?" + params.toString());
- if (response.ok) {
- let data = await response.json();
- return data.channel.currentscheduledepisode;
- }
- } catch (error) {
- }
- }
-
- async function getChannelList() {
- const params = new URLSearchParams({
- "format": "json",
- "pagination": "false",
- "audioquality": "hi"
- });
- let response = await fetch(BASE_API_URL + "/channels?" + params.toString());
- if (!response.ok) {
- return [];
- }
-
- let data = await response.json();
-
- return await Promise.all(data.channels.map(async (ch) => {
- let img = ch.image;
- let title = '';
- let proginfo = await getChannelProgramme(ch.id);
-
- if(proginfo != undefined) {
- title = proginfo.title;
- if(proginfo.socialimage) {
- img = proginfo.socialimage;
- }
- }
-
- return {
- mainText: ch.name,
- secondaryText: title,
- imgPath: img,
- onclick: async () => {
- return {
- path: PAYLOAD_PATH,
- args: ch.liveaudio.url
- };
- }
- };
- }));
- }
-
- return {
- mainText: "Sveriges Radio",
- secondaryText: 'Live Radio from Swedish public service',
- onclick: async () => {
- let items = await getChannelList();
- showCarousel(items);
- }
- };
-}
+/* Copyright (C) 2024 John Törnblom
+
+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
+. */
+
+
+async function main() {
+ const BASE_API_URL = "http://api.sr.se/api/v2/";
+ const PAYLOAD_PATH = window.workingDir + '/ffplay.elf';
+
+ async function getChannelProgramme(chid) {
+ const params = new URLSearchParams({
+ "channelid": chid,
+ "format": "json"
+ });
+
+ try {
+ let response = await fetch(BASE_API_URL + "/scheduledepisodes/rightnow?" + params.toString());
+ if (response.ok) {
+ let data = await response.json();
+ return data.channel.currentscheduledepisode;
+ }
+ } catch (error) {
+ }
+ }
+
+ async function getChannelList() {
+ const params = new URLSearchParams({
+ "format": "json",
+ "pagination": "false",
+ "audioquality": "hi"
+ });
+ let response = await fetch(BASE_API_URL + "/channels?" + params.toString());
+ if (!response.ok) {
+ return [];
+ }
+
+ let data = await response.json();
+
+ return await Promise.all(data.channels.map(async (ch) => {
+ let img = ch.image;
+ let title = '';
+ let proginfo = await getChannelProgramme(ch.id);
+
+ if(proginfo != undefined) {
+ title = proginfo.title;
+ if(proginfo.socialimage) {
+ img = proginfo.socialimage;
+ }
+ }
+
+ return {
+ mainText: ch.name,
+ secondaryText: title,
+ imgPath: img,
+ onclick: async () => {
+ return {
+ path: PAYLOAD_PATH,
+ args: ch.liveaudio.url
+ };
+ }
+ };
+ }));
+ }
+
+ return {
+ mainText: "Sveriges Radio",
+ secondaryText: 'Live Radio from Swedish public service',
+ onclick: async () => {
+ let items = await getChannelList();
+ showCarousel(items);
+ }
+ };
+}
diff --git a/homebrew/demo/homebrew.js b/homebrew/demo/homebrew.js
index 5e8c2a8..e919081 100644
--- a/homebrew/demo/homebrew.js
+++ b/homebrew/demo/homebrew.js
@@ -1,60 +1,60 @@
-// must be called main
-// main must return in 5 seconds, other stuff like onclicks dont have such restrictions
-async function main() {
- let stateTest = "State is preserved";
- // must return an object like this
- return {
- // every field of this is optional
- mainText: "demo",
- secondaryText: window.workingDir, // injected, contains the path to the folder containing this js file
- imgPath: baseURL + "/fs/data/homebrew/demo/proxy-image.png", // the image in sce_sys/icon0.png wont show bc this overrides it
- onclick: async () => {
- // get rom infos here or you can create a function for it outside
-
- // one second delay to test the spinner
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // this is the format you need to pass to showCarousel
- let items = [
- {
- mainText: "The Final Insect (Taurus 1993)",
- imgPath: baseURL + "/fs/data/homebrew/demo/roms/1/img.jpg",
- onclick: async () => {
- await ApiClient.launchApp("/fs/data/homebrew/demo/demo.elf");
- return true; // return true to indicate stuff has launched and show you can exit now text
- },
- options: [
- {
- text: "Enter args",
- onclick: () => { alert(prompt("Enter args")); }
- }
- ]
- },
- {
- mainText: stateTest,
- secondaryText: "bottom text",
- onclick: () => {
- return false;
- }
- },
- {
- mainText: "Pick file...",
- onclick: async () => { alert("This alert is coming from the extension, path:" + await pickFile(window.workingDir)); } // pickfile arg = initial path, optional
- }
- ];
-
- showCarousel(items);
- },
- options: [
- {
- text: "Error test",
- onclick: () => { throw new Error("Testing"); }
- },
- {
- text: "Option 2",
- onclick: () => { alert("Option 2"); }
- }
- ]
-
- };
-}
+// must be called main
+// main must return in 5 seconds, other stuff like onclicks dont have such restrictions
+async function main() {
+ let stateTest = "State is preserved";
+ // must return an object like this
+ return {
+ // every field of this is optional
+ mainText: "demo",
+ secondaryText: window.workingDir, // injected, contains the path to the folder containing this js file
+ imgPath: baseURL + "/fs/data/homebrew/demo/proxy-image.png", // the image in sce_sys/icon0.png wont show bc this overrides it
+ onclick: async () => {
+ // get rom infos here or you can create a function for it outside
+
+ // one second delay to test the spinner
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // this is the format you need to pass to showCarousel
+ let items = [
+ {
+ mainText: "The Final Insect (Taurus 1993)",
+ imgPath: baseURL + "/fs/data/homebrew/demo/roms/1/img.jpg",
+ onclick: async () => {
+ await ApiClient.launchApp("/fs/data/homebrew/demo/demo.elf");
+ return true; // return true to indicate stuff has launched and show you can exit now text
+ },
+ options: [
+ {
+ text: "Enter args",
+ onclick: () => { alert(prompt("Enter args")); }
+ }
+ ]
+ },
+ {
+ mainText: stateTest,
+ secondaryText: "bottom text",
+ onclick: () => {
+ return false;
+ }
+ },
+ {
+ mainText: "Pick file...",
+ onclick: async () => { alert("This alert is coming from the extension, path:" + await pickFile(window.workingDir)); } // pickfile arg = initial path, optional
+ }
+ ];
+
+ showCarousel(items);
+ },
+ options: [
+ {
+ text: "Error test",
+ onclick: () => { throw new Error("Testing"); }
+ },
+ {
+ text: "Option 2",
+ onclick: () => { alert("Option 2"); }
+ }
+ ]
+
+ };
+}