mirror of
https://github.com/MewoLab/AquaDX.git
synced 2026-02-11 08:17:26 +08:00
[+] Chuni Userbox with Assets
Co-authored-by: split / May <split@split.pet>
This commit is contained in:
314
AquaNet/src/libs/userbox/dds.ts
Normal file
314
AquaNet/src/libs/userbox/dds.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
|
||||
A simplified DDS parser with Chusan userbox in mind.
|
||||
There are some issues on Safari. I don't really care, to be honest.
|
||||
Authored by Raymond and May.
|
||||
|
||||
DDS header parsing based off of https://gist.github.com/brett19/13c83c2e5e38933757c2
|
||||
|
||||
*/
|
||||
|
||||
function makeFourCC(string: string) {
|
||||
return string.charCodeAt(0) +
|
||||
(string.charCodeAt(1) << 8) +
|
||||
(string.charCodeAt(2) << 16) +
|
||||
(string.charCodeAt(3) << 24);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Magic bytes for the DDS file format (see https://en.wikipedia.org/wiki/Magic_number_(programming))
|
||||
*/
|
||||
const DDS_MAGIC_BYTES = 0x20534444;
|
||||
|
||||
/*
|
||||
to get around the fact that TS's builtin Object.fromEntries() typing
|
||||
doesn't persist strict types and instead only uses broad types
|
||||
without creating a new function to get around it...
|
||||
sorry, this is a really ugly solution, but it's not my problem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description List of compression type markers used in DDS
|
||||
*/
|
||||
const DDS_COMPRESSION_TYPE_MARKERS = ["DXT1", "DXT3", "DXT5"] as const;
|
||||
|
||||
/**
|
||||
* @description Object mapping string versions of DDS compression type markers to their value in uint32s
|
||||
*/
|
||||
const DDS_COMPRESSION_TYPE_MARKERS_MAP = Object.fromEntries(
|
||||
DDS_COMPRESSION_TYPE_MARKERS
|
||||
.map(e => [e, makeFourCC(e)] as [typeof e, number])
|
||||
) as Record<typeof DDS_COMPRESSION_TYPE_MARKERS[number], number>
|
||||
|
||||
const DDS_DECOMPRESS_VERTEX_SHADER = `
|
||||
attribute vec2 aPosition;
|
||||
varying highp vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
vTextureCoord = ((aPosition * vec2(1.0, -1.0)) / 2.0 + 0.5);
|
||||
}`;
|
||||
const DDS_DECOMPRESS_FRAGMENT_SHADER = `
|
||||
varying highp vec2 vTextureCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTextureCoord);
|
||||
}`
|
||||
|
||||
export class DDS {
|
||||
constructor(db: IDBDatabase | undefined) {
|
||||
this.db = db
|
||||
|
||||
let gl = this.canvasGL.getContext("webgl");
|
||||
if (!gl) throw new Error("Failed to get WebGL rendering context") // TODO: make it switch to Classic userbox
|
||||
this.gl = gl;
|
||||
|
||||
let ctx = this.canvas2D.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to reach minimum system requirements") // TODO: make it switch to Classic userbox
|
||||
this.ctx = ctx;
|
||||
|
||||
let ext =
|
||||
gl.getExtension("WEBGL_compressed_texture_s3tc") ||
|
||||
gl.getExtension("MOZ_WEBGL_compressed_texture_s3tc") ||
|
||||
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");
|
||||
if (!ext) throw new Error("Browser is not supported."); // TODO: make it switch to Classic userbox
|
||||
this.ext = ext;
|
||||
|
||||
/* Initialize shaders */
|
||||
this.compileShaders();
|
||||
this.gl.useProgram(this.shader);
|
||||
|
||||
/* Setup position buffer */
|
||||
let attributeLocation = this.gl.getAttribLocation(this.shader ?? 0, "aPosition");
|
||||
let positionBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0]), this.gl.STATIC_DRAW);
|
||||
|
||||
this.gl.vertexAttribPointer(
|
||||
attributeLocation,
|
||||
2, this.gl.FLOAT,
|
||||
false, 0, 0
|
||||
);
|
||||
this.gl.enableVertexAttribArray(attributeLocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Loads a DDS file into the internal canvas object.
|
||||
* @param buffer Uint8Array to load DDS from.
|
||||
* @returns String if failed to load, void if success
|
||||
*/
|
||||
load(buffer: Uint8Array) {
|
||||
let header = this.loadHeader(buffer);
|
||||
if (!header) return;
|
||||
|
||||
let compressionMode: GLenum = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
|
||||
|
||||
if (header.pixelFormat.flags & 0x4) {
|
||||
switch (header.pixelFormat.type) {
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT1:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
|
||||
break;
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT3:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT3_EXT;
|
||||
break;
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT5:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT5_EXT;
|
||||
break;
|
||||
};
|
||||
} else return;
|
||||
|
||||
/* Initialize and configure the texture */
|
||||
let texture = this.gl.createTexture();
|
||||
this.gl.activeTexture(this.gl.TEXTURE0);
|
||||
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
|
||||
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
||||
|
||||
this.gl.compressedTexImage2D(
|
||||
this.gl.TEXTURE_2D,
|
||||
0,
|
||||
compressionMode,
|
||||
header.width,
|
||||
header.height,
|
||||
0,
|
||||
buffer.slice(128)
|
||||
);
|
||||
|
||||
this.gl.uniform1i(this.gl.getUniformLocation(this.shader || 0, "uTexture"), 0);
|
||||
|
||||
/* Prepare the canvas for drawing */
|
||||
this.canvasGL.width = header.width;
|
||||
this.canvasGL.height = header.height
|
||||
this.gl.viewport(0, 0, this.canvasGL.width, this.canvasGL.height);
|
||||
|
||||
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||
|
||||
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||
this.gl.deleteTexture(texture);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Export a Blob from the parsed DDS texture
|
||||
* @returns DDS texture in specified format
|
||||
* @param inFormat Mime type to export in
|
||||
*/
|
||||
getBlob(inFormat?: string): Promise<Blob | null> {
|
||||
return new Promise(res => this.canvasGL.toBlob(res, inFormat))
|
||||
}
|
||||
get2DBlob(inFormat?: string): Promise<Blob | null> {
|
||||
return new Promise(res => this.canvas2D.toBlob(res, inFormat))
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Helper function to load in a Blob
|
||||
* @input Blob to use
|
||||
*/
|
||||
async fromBlob(input: Blob) {
|
||||
this.load(new Uint8Array(await input.arrayBuffer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Read a DDS file header
|
||||
* @param buffer Uint8Array of the DDS file's contents
|
||||
*/
|
||||
loadHeader(buffer: Uint8Array) {
|
||||
if (this.getUint32(buffer, 0) !== DDS_MAGIC_BYTES) return;
|
||||
|
||||
return {
|
||||
size: this.getUint32(buffer, 4),
|
||||
flags: this.getUint32(buffer, 8),
|
||||
height: this.getUint32(buffer, 12),
|
||||
width: this.getUint32(buffer, 16),
|
||||
mipmaps: this.getUint32(buffer, 24),
|
||||
|
||||
/* TODO: figure out if we can cut any of this out (we totally can btw) */
|
||||
pixelFormat: {
|
||||
size: this.getUint32(buffer, 76),
|
||||
flags: this.getUint32(buffer, 80),
|
||||
type: this.getUint32(buffer, 84),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadFile(path: string) : Promise<boolean> {
|
||||
return new Promise(async r => {
|
||||
if (!this.db)
|
||||
return r(false);
|
||||
let transaction = this.db.transaction(["dds"], "readonly");
|
||||
let objectStore = transaction.objectStore("dds");
|
||||
let request = objectStore.get(path);
|
||||
request.onsuccess = async (e) => {
|
||||
if (request.result)
|
||||
if (request.result.blob) {
|
||||
await this.fromBlob(request.result.blob)
|
||||
return r(true);
|
||||
}
|
||||
r(false);
|
||||
}
|
||||
request.onerror = () => r(false);
|
||||
})
|
||||
};
|
||||
|
||||
async getFile(path: string, fallback?: string) : Promise<string> {
|
||||
if (this.urlCache[path])
|
||||
return this.urlCache[path]
|
||||
if (!await this.loadFile(path))
|
||||
if (fallback) {
|
||||
if (!await this.loadFile(fallback))
|
||||
return "";
|
||||
} else
|
||||
return "";
|
||||
let url = URL.createObjectURL(await this.getBlob("image/png") ?? new Blob([]));
|
||||
this.urlCache[path] = url;
|
||||
return url
|
||||
};
|
||||
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise<string> {
|
||||
if (!await this.loadFile(path))
|
||||
return "";
|
||||
this.canvas2D.width = w * (s ?? 1);
|
||||
this.canvas2D.height = h * (s ?? 1);
|
||||
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
|
||||
|
||||
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
|
||||
};
|
||||
async getFileScaled(path: string, s: number, fallback?: string): Promise<string> {
|
||||
if (this.urlCache[path])
|
||||
return this.urlCache[path]
|
||||
if (!await this.loadFile(path))
|
||||
if (fallback) {
|
||||
if (!await this.loadFile(fallback))
|
||||
return "";
|
||||
} else
|
||||
return "";
|
||||
this.canvas2D.width = this.canvasGL.width * (s ?? 1);
|
||||
this.canvas2D.height = this.canvasGL.height * (s ?? 1);
|
||||
this.ctx.drawImage(this.canvasGL, 0, 0, this.canvasGL.width, this.canvasGL.height, 0, 0, this.canvasGL.width * (s ?? 1), this.canvasGL.height * (s ?? 1));
|
||||
let url = URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
|
||||
this.urlCache[path] = url;
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a Uint32 from a Uint8Array at the specified offset
|
||||
* @param buffer Uint8Array to retrieve the Uint32 from
|
||||
* @param offset Offset at which to retrieve bytes
|
||||
*/
|
||||
getUint32(buffer: Uint8Array, offset: number) {
|
||||
return (buffer[offset + 0] << 0) +
|
||||
(buffer[offset + 1] << 8) +
|
||||
(buffer[offset + 2] << 16) +
|
||||
(buffer[offset + 3] << 24);
|
||||
};
|
||||
|
||||
private compileShaders() {
|
||||
let vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
|
||||
let fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
|
||||
|
||||
if (!vertexShader || !fragmentShader) return;
|
||||
|
||||
this.gl.shaderSource(vertexShader, DDS_DECOMPRESS_VERTEX_SHADER);
|
||||
this.gl.compileShader(vertexShader);
|
||||
|
||||
if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader)}`,
|
||||
);
|
||||
|
||||
this.gl.shaderSource(fragmentShader, DDS_DECOMPRESS_FRAGMENT_SHADER);
|
||||
this.gl.compileShader(fragmentShader);
|
||||
|
||||
if (!this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader)}`,
|
||||
);
|
||||
|
||||
let program = this.gl.createProgram();
|
||||
|
||||
if (!program) return;
|
||||
this.shader = program;
|
||||
|
||||
this.gl.attachShader(program, vertexShader);
|
||||
this.gl.attachShader(program, fragmentShader);
|
||||
this.gl.linkProgram(program);
|
||||
|
||||
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred linking the program: ${this.gl.getProgramInfoLog(program)}`,
|
||||
);
|
||||
};
|
||||
|
||||
canvas2D: HTMLCanvasElement = document.createElement("canvas");
|
||||
canvasGL: HTMLCanvasElement = document.createElement("canvas");
|
||||
urlCache: Record<string, string> = {};
|
||||
|
||||
ctx: CanvasRenderingContext2D;
|
||||
|
||||
gl: WebGLRenderingContext;
|
||||
ext: ReturnType<typeof this.gl.getExtension>;
|
||||
shader: WebGLShader | null = null;
|
||||
|
||||
db: IDBDatabase | undefined;
|
||||
};
|
||||
180
AquaNet/src/libs/userbox/userbox.ts
Normal file
180
AquaNet/src/libs/userbox/userbox.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
const isDirectory = (e: FileSystemEntry): e is FileSystemDirectoryEntry => e.isDirectory
|
||||
const isFile = (e: FileSystemEntry): e is FileSystemFileEntry => e.isFile
|
||||
|
||||
const getDirectory = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej()));
|
||||
const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej()));
|
||||
const getFiles = async (directory: FileSystemDirectoryEntry): Promise<Array<FileSystemEntry>> => {
|
||||
let reader = directory.createReader();
|
||||
let files: Array<FileSystemEntry> = [];
|
||||
let currentFiles: number = 1e9;
|
||||
while (currentFiles != 0) {
|
||||
let entries = await new Promise<Array<FileSystemEntry>>(r => reader.readEntries(r));
|
||||
files = files.concat(entries);
|
||||
currentFiles = entries.length;
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const validateDirectories = async (base: FileSystemDirectoryEntry, path: string): Promise<boolean> => {
|
||||
const pathTrail = path.split("/");
|
||||
let directory: FileSystemDirectoryEntry = base;
|
||||
for (let part of pathTrail) {
|
||||
let newDirectory = await getDirectory(directory, part).catch(_ => null);
|
||||
if (newDirectory && isDirectory(newDirectory)) {
|
||||
directory = newDirectory;
|
||||
} else
|
||||
return false;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
const getDirectoryFromPath = async (base: FileSystemDirectoryEntry, path: string): Promise<FileSystemDirectoryEntry | null> => {
|
||||
const pathTrail = path.split("/");
|
||||
let directory: FileSystemDirectoryEntry = base;
|
||||
for (let part of pathTrail) {
|
||||
let newDirectory = await getDirectory(directory, part).catch(_ => null);
|
||||
if (newDirectory && isDirectory(newDirectory)) {
|
||||
directory = newDirectory;
|
||||
} else
|
||||
return null;
|
||||
};
|
||||
return directory;
|
||||
}
|
||||
|
||||
export let ddsDB: IDBDatabase | undefined ;
|
||||
|
||||
/* Technically, processName should be in the translation file but I figured it was such a small thing that it didn't REALLY matter... */
|
||||
const DIRECTORY_PATHS = ([
|
||||
{
|
||||
folder: "ddsImage",
|
||||
processName: "Characters",
|
||||
path: "characterThumbnail",
|
||||
filter: (name: string) => name.substring(name.length - 6, name.length) == "02.dds",
|
||||
id: (name: string) => `0${name.substring(17, 21)}${name.substring(23, 24)}`
|
||||
},
|
||||
{
|
||||
folder: "namePlate",
|
||||
processName: "Nameplates",
|
||||
path: "nameplate",
|
||||
filter: () => true,
|
||||
id: (name: string) => name.substring(17, 25)
|
||||
},
|
||||
{
|
||||
folder: "avatarAccessory",
|
||||
processName: "Avatar Accessory Thumbnails",
|
||||
path: "avatarAccessoryThumbnail",
|
||||
filter: (name: string) => name.substring(14, 18) == "Icon",
|
||||
id: (name: string) => name.substring(19, 27)
|
||||
},
|
||||
{
|
||||
folder: "avatarAccessory",
|
||||
processName: "Avatar Accessories",
|
||||
path: "avatarAccessory",
|
||||
filter: (name: string) => name.substring(14, 17) == "Tex",
|
||||
id: (name: string) => name.substring(18, 26)
|
||||
},
|
||||
{
|
||||
folder: "texture",
|
||||
processName: "Surfboard Textures",
|
||||
useFileName: true,
|
||||
path: "surfboard",
|
||||
filter: (name: string) =>
|
||||
([
|
||||
"CHU_UI_Common_Avatar_body_00.dds",
|
||||
"CHU_UI_Common_Avatar_face_00.dds",
|
||||
"CHU_UI_title_rank_00_v10.dds"
|
||||
]).includes(name),
|
||||
id: (name: string) => name
|
||||
}
|
||||
] satisfies {folder: string, processName: string, path: string, useFileName?: boolean, filter: (name: string) => boolean, id: (name: string) => string}[] )
|
||||
|
||||
export const scanOptionFolder = async (optionFolder: FileSystemDirectoryEntry, progressUpdate: (progress: number, text: string) => void) => {
|
||||
let filesToProcess: Record<string, FileSystemFileEntry[]> = {};
|
||||
let directories = (await getFiles(optionFolder))
|
||||
.filter(directory => isDirectory(directory) && ((directory.name.substring(0, 1) == "A" && directory.name.length == 4) || directory.name == "surfboard"))
|
||||
|
||||
for (let directory of directories)
|
||||
if (isDirectory(directory)) {
|
||||
for (const directoryData of DIRECTORY_PATHS) {
|
||||
let folder = await getDirectoryFromPath(directory, directoryData.folder).catch(_ => null) ?? [];
|
||||
if (folder) {
|
||||
if (!filesToProcess[directoryData.path])
|
||||
filesToProcess[directoryData.path] = [];
|
||||
for (let dataFolderEntry of await getFiles(folder as FileSystemDirectoryEntry).catch(_ => null) ?? [])
|
||||
if (isDirectory(dataFolderEntry)) {
|
||||
for (let dataEntry of await getFiles(dataFolderEntry as FileSystemDirectoryEntry).catch(_ => null) ?? [])
|
||||
if (isFile(dataEntry) && directoryData.filter(dataEntry.name))
|
||||
filesToProcess[directoryData.path].push(dataEntry);
|
||||
} else if (isFile(dataFolderEntry) && directoryData.filter(dataFolderEntry.name))
|
||||
filesToProcess[directoryData.path].push(dataFolderEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = [];
|
||||
|
||||
for (const [folder, files] of Object.entries(filesToProcess)) {
|
||||
let reference = DIRECTORY_PATHS.find(r => r.path == folder);
|
||||
for (const [idx, file] of files.entries()) {
|
||||
progressUpdate((idx / files.length) * 100, `${t("userbox.new.setup.processing_file")} ${reference?.processName ?? "?"}...`)
|
||||
data.push({
|
||||
path: `${folder}:${reference?.id(file.name)}`, name: file.name, blob: await new Promise<File>(res => file.file(res))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
progressUpdate(100, `${t("userbox.new.setup.finalizing")}...`)
|
||||
|
||||
let transaction = ddsDB?.transaction(['dds'], 'readwrite', { durability: "strict" })
|
||||
if (!transaction) return; // TODO: bubble error up to user
|
||||
transaction.onerror = e => e.preventDefault()
|
||||
let objectStore = transaction.objectStore('dds');
|
||||
for (let object of data)
|
||||
objectStore.put(object)
|
||||
|
||||
// await transaction completion
|
||||
await new Promise(r => transaction.addEventListener("complete", r, {once: true}))
|
||||
};
|
||||
|
||||
export function initializeDb() : Promise<void> {
|
||||
return new Promise(r => {
|
||||
const dbRequest = indexedDB.open("userboxChusanDDS", 1)
|
||||
dbRequest.addEventListener("upgradeneeded", (event) => {
|
||||
if (!(event.target instanceof IDBOpenDBRequest)) return
|
||||
ddsDB = event.target.result;
|
||||
if (!ddsDB) return;
|
||||
|
||||
const store = ddsDB.createObjectStore('dds', { keyPath: 'path' });
|
||||
store.createIndex('path', 'path', { unique: true })
|
||||
store.createIndex('name', 'name', { unique: false })
|
||||
store.createIndex('blob', 'blob', { unique: false })
|
||||
r();
|
||||
});
|
||||
dbRequest.addEventListener("success", () => {
|
||||
ddsDB = dbRequest.result;
|
||||
r();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function userboxFileProcess(folder: FileSystemEntry, progressUpdate: (progress: number, progressString: string) => void): Promise<string | null> {
|
||||
if (!isDirectory(folder))
|
||||
return t("userbox.new.error.invalidFolder")
|
||||
if (!(await validateDirectories(folder, "bin/option")) || !(await validateDirectories(folder, "data/A000")))
|
||||
return t("userbox.new.error.invalidFolder");
|
||||
|
||||
initializeDb();
|
||||
const optionFolder = await getDirectoryFromPath(folder, "bin/option");
|
||||
if (optionFolder)
|
||||
await scanOptionFolder(optionFolder, progressUpdate);
|
||||
const dataFolder = await getDirectoryFromPath(folder, "data");
|
||||
if (dataFolder)
|
||||
await scanOptionFolder(dataFolder, progressUpdate);
|
||||
useLocalStorage("userboxNew", false).value = true;
|
||||
location.reload();
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user