import type { FSModule } from "browserfs/dist/node/core/FS"; import { monacoExtensions } from "components/apps/MonacoEditor/extensions"; import extensions from "components/system/Files/FileEntry/extensions"; import type { FileInfo } from "components/system/Files/FileEntry/useFileInfo"; import type { FileStat } from "components/system/Files/FileManager/functions"; import { get9pModifiedTime } from "contexts/fileSystem/functions"; import type { RootFileSystem } from "contexts/fileSystem/useAsyncFs"; import processDirectory from "contexts/process/directory"; import ini from "ini"; import { extname, join } from "path"; import { AUDIO_FILE_EXTENSIONS, BASE_2D_CONTEXT_OPTIONS, DYNAMIC_EXTENSION, FOLDER_BACK_ICON, FOLDER_FRONT_ICON, FOLDER_ICON, ICON_CACHE, ICON_CACHE_EXTENSION, ICON_GIF_FPS, ICON_GIF_SECONDS, IMAGE_FILE_EXTENSIONS, MOUNTED_FOLDER_ICON, MP3_MIME_TYPE, NEW_FOLDER_ICON, ONE_TIME_PASSIVE_EVENT, PHOTO_ICON, SHORTCUT_EXTENSION, SHORTCUT_ICON, SMALLEST_PNG_SIZE, SYSTEM_FILES, SYSTEM_PATHS, TIFF_IMAGE_FORMATS, UNKNOWN_ICON_PATH, VIDEO_FALLBACK_MIME_TYPE, VIDEO_FILE_EXTENSIONS, YT_ICON_CACHE, } from "utils/constants"; import { blobToBase64, bufferToUrl, decodeJxl, getGifJs, getHtmlToImage, imageToBufferUrl, imgDataToBuffer, isSafari, isYouTubeUrl, } from "utils/functions"; type InternetShortcut = { BaseURL: string; Comment: string; IconFile: string; Type: string; URL: string; }; type ShellClassInfo = { ShellClassInfo: { IconFile: string; }; }; type VideoElementWithSeek = HTMLVideoElement & { seekToNextFrame: () => Promise; }; export const getModifiedTime = (path: string, stats: FileStat): number => { const { atimeMs, ctimeMs, mtimeMs } = stats; return atimeMs === ctimeMs && ctimeMs === mtimeMs ? get9pModifiedTime(path) || mtimeMs : mtimeMs; }; export const getIconFromIni = ( fs: FSModule, directory: string ): Promise => new Promise((resolve) => { fs.readFile( join(directory, "desktop.ini"), (error, contents = Buffer.from("")) => { if (error) resolve(""); else { const { ShellClassInfo: { IconFile = "" }, } = ini.parse(contents.toString()) as ShellClassInfo; resolve(IconFile); } } ); }); export const getDefaultFileViewer = (extension: string): string => { if (AUDIO_FILE_EXTENSIONS.has(extension)) return "VideoPlayer"; if (VIDEO_FILE_EXTENSIONS.has(extension)) return "VideoPlayer"; if (IMAGE_FILE_EXTENSIONS.has(extension)) return "Photos"; if (monacoExtensions.has(extension)) return "MonacoEditor"; return ""; }; export const getIconByFileExtension = (extension: string): string => { const { icon: extensionIcon = "", process: [defaultProcess = ""] = [] } = extension in extensions ? extensions[extension] : {}; if (extensionIcon) return `/System/Icons/${extensionIcon}.webp`; return ( processDirectory[defaultProcess || getDefaultFileViewer(extension)]?.icon || UNKNOWN_ICON_PATH ); }; export const getProcessByFileExtension = (extension: string): string => { const [defaultProcess = ""] = extension in extensions ? extensions[extension].process : [getDefaultFileViewer(extension)]; return defaultProcess; }; export const getMimeType = (url: string): string => { switch (extname(url).toLowerCase()) { case ".jpg": case ".jpeg": return "image/jpeg"; case ".m3u8": return "application/x-mpegURL"; case ".m4v": case ".mkv": case ".mov": case ".mp4": return "video/mp4"; case ".oga": return "audio/ogg"; case ".ogg": case ".ogm": case ".ogv": return "video/ogg"; case ".png": return "image/png"; case ".wav": return "audio/wav"; case ".webm": return "video/webm"; default: return ""; } }; export const getShortcutInfo = (contents: Buffer): FileInfo => { const { InternetShortcut: { BaseURL: pid = "", Comment: comment = "", IconFile: icon = "", Type: type = "", URL: url = "", }, } = (ini.parse(contents.toString()) || {}) as { InternetShortcut: InternetShortcut; }; return { comment, icon: !icon && pid && pid !== "FileExplorer" ? processDirectory[pid]?.icon : icon, pid, type, url, }; }; export const createShortcut = (shortcut: Partial): string => ini .encode(shortcut, { section: "InternetShortcut", whitespace: false, }) .replace(/"/g, ""); export const makeExternalShortcut = (contents: Buffer): Buffer => { const { pid, url } = getShortcutInfo(contents); return Buffer.from( createShortcut({ URL: encodeURI( `${window.location.origin}${pid ? `/?app=${pid}` : ""}${ url ? `${pid ? "&" : "/?"}url=${url}` : "" }` ), }) ); }; const getIconsFromCache = (fs: FSModule, path: string): Promise => new Promise((resolveIcons) => { const iconCacheDirectory = join(ICON_CACHE, path); fs?.readdir( iconCacheDirectory, async (dirError, [firstIcon, ...otherIcons] = []) => { if (dirError) resolveIcons([]); else { resolveIcons( ( await Promise.all( [firstIcon, otherIcons[otherIcons.length - 1]] .filter(Boolean) .map( (cachedIcon): Promise => new Promise((resolveIcon) => { fs?.readFile( join(iconCacheDirectory, cachedIcon), (fileError, contents = Buffer.from("")) => { resolveIcon(fileError ? "" : bufferToUrl(contents)); } ); }) ) ) ).filter(Boolean) ); } } ); }); export const getInfoWithoutExtension = ( fs: FSModule, rootFs: RootFileSystem, path: string, isDirectory: boolean, useNewFolderIcon: boolean, callback: (value: FileInfo) => void ): void => { if (isDirectory) { const setFolderInfo = ( icon: string, subIcons?: string[], getIcon?: () => Promise ): void => callback({ getIcon, icon, pid: "FileExplorer", subIcons, url: path }); const getFolderIcon = (): string => { if (rootFs?.mntMap[path]?.getName() === "FileSystemAccess") { return MOUNTED_FOLDER_ICON; } if (useNewFolderIcon) return NEW_FOLDER_ICON; return FOLDER_ICON; }; const folderIcon = getFolderIcon(); setFolderInfo(folderIcon, [], async () => { const iconFromIni = await getIconFromIni(fs, path); if (iconFromIni) setFolderInfo(iconFromIni); else if (folderIcon === FOLDER_ICON) { const iconsFromCache = await getIconsFromCache(fs, path); if (iconsFromCache.length > 0) { setFolderInfo(FOLDER_BACK_ICON, [ ...iconsFromCache, FOLDER_FRONT_ICON, ]); } } }); } else { callback({ icon: UNKNOWN_ICON_PATH, pid: "", url: path }); } }; const getFirstAniImage = async ( imageBuffer: Buffer ): Promise => { const { parseAni } = await import("ani-cursor/dist/parser"); let firstImage: Uint8Array; try { ({ images: [firstImage], } = parseAni(imageBuffer)); return Buffer.from(firstImage); } catch { // Can't parse ani } return undefined; }; export const getInfoWithExtension = ( fs: FSModule, path: string, extension: string, callback: (value: FileInfo) => void ): void => { const subIcons: string[] = []; const getInfoByFileExtension = ( icon?: string, getIcon?: true | ((signal: AbortSignal) => void) ): void => callback({ getIcon, icon: icon || getIconByFileExtension(extension), pid: getProcessByFileExtension(extension), subIcons, url: path, }); switch (extension) { case SHORTCUT_EXTENSION: fs.readFile(path, (error, contents = Buffer.from("")) => { subIcons.push(SHORTCUT_ICON); if (error) { getInfoByFileExtension(); return; } const { comment, icon, pid, url } = getShortcutInfo(contents); const urlExt = extname(url).toLowerCase(); if (pid === "FileExplorer" && !icon) { const getIcon = (): void => { getIconFromIni(fs, url).then((iniIcon) => callback({ comment, icon: iniIcon || processDirectory[pid]?.icon, pid, subIcons, url, }) ); }; callback({ comment, getIcon, icon, pid, subIcons, url }); } else if (DYNAMIC_EXTENSION.has(urlExt)) { const cachedIconPath = join( ICON_CACHE, `${url}${ICON_CACHE_EXTENSION}` ); fs.lstat(cachedIconPath, (statError, cachedIconStats) => { if (!statError && cachedIconStats) { if (cachedIconStats.birthtimeMs === cachedIconStats.ctimeMs) { callback({ comment, icon: cachedIconPath, pid, subIcons, url, }); } else { fs.readFile(cachedIconPath, (_readError, cachedIconData) => callback({ comment, icon: bufferToUrl(cachedIconData as Buffer), pid, subIcons, url, }) ); } } else { getInfoWithExtension(fs, url, urlExt, (fileInfo) => { const { icon: urlIcon = icon, getIcon, subIcons: fileSubIcons = [], } = fileInfo; if (fileSubIcons.length > 0) { subIcons.push( ...fileSubIcons.filter( (subIcon) => !subIcons.includes(subIcon) ) ); } callback({ comment, getIcon, icon: urlIcon, pid, subIcons, url, }); }); } }); } else if (isYouTubeUrl(url)) { const ytId = new URL(url).pathname.replace("/", ""); const cachedIconPath = join( YT_ICON_CACHE, `${ytId}${ICON_CACHE_EXTENSION}` ); const baseFileInfo = { comment, pid, url, }; const isDefaultIcon = icon === processDirectory.VideoPlayer.icon; const videoSubIcons = [processDirectory.VideoPlayer.icon]; callback({ ...baseFileInfo, getIcon: isDefaultIcon ? () => fs.exists(cachedIconPath, (cachedIconExists) => callback({ ...baseFileInfo, icon: cachedIconExists ? cachedIconPath : `https://i.ytimg.com/vi/${ytId}/mqdefault.jpg`, subIcons: videoSubIcons, }) ) : undefined, icon: icon || processDirectory.VideoPlayer.icon, subIcons: icon && !isDefaultIcon ? videoSubIcons : undefined, }); } else { callback({ comment, icon: icon || UNKNOWN_ICON_PATH, pid, subIcons, url, }); } }); break; case ".ani": getInfoByFileExtension(PHOTO_ICON, (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { const firstImage = await getFirstAniImage(contents); if (firstImage && !signal.aborted) { getInfoByFileExtension(imageToBufferUrl(path, firstImage)); } } }) ); break; case ".exe": getInfoByFileExtension("/System/Icons/executable.webp", (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { const { extractExeIcon } = await import( "components/system/Files/FileEntry/exeIcons" ); const exeIcon = await extractExeIcon(contents); if (exeIcon && !signal.aborted) { getInfoByFileExtension(bufferToUrl(exeIcon)); } } }) ); break; case ".mp3": getInfoByFileExtension( `/System/Icons/${extensions[".mp3"].icon as string}.webp`, (signal) => fs.readFile(path, (error, contents = Buffer.from("")) => { if (!error && !signal.aborted) { import("music-metadata-browser").then( ({ parseBuffer, selectCover }) => { if (signal.aborted) return; parseBuffer( contents, { mimeType: MP3_MIME_TYPE, size: contents.length, }, { skipPostHeaders: true } ).then(({ common: { picture } = {} }) => { if (signal.aborted) return; const { data: coverPicture } = selectCover(picture) || {}; if (coverPicture) { getInfoByFileExtension(bufferToUrl(coverPicture)); } }); } ); } }) ); break; case ".sav": getInfoByFileExtension(UNKNOWN_ICON_PATH, true); break; case ".jxl": getInfoByFileExtension(PHOTO_ICON, (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { getInfoByFileExtension( imageToBufferUrl(path, imgDataToBuffer(await decodeJxl(contents))) ); } }) ); break; case ".qoi": getInfoByFileExtension(PHOTO_ICON, (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { const { decodeQoi } = await import("components/apps/Photos/qoi"); const icon = decodeQoi(contents); if (icon && !signal.aborted) { getInfoByFileExtension(imageToBufferUrl(path, icon)); } } }) ); break; case ".whtml": getInfoByFileExtension("/System/Icons/tinymce.webp", (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { const htmlToImage = await getHtmlToImage(); const containerElement = document.createElement("div"); containerElement.style.height = "600px"; containerElement.style.width = "600px"; containerElement.style.padding = "32px"; containerElement.style.backgroundColor = "#fff"; containerElement.style.zIndex = "-1"; containerElement.innerHTML = contents.toString(); document.body.append(containerElement); let documentImage: string | undefined; try { documentImage = await htmlToImage?.toPng(containerElement, { skipAutoScale: true, }); } catch { // Ignore failure to captrure } containerElement.remove(); if (documentImage && documentImage.length > SMALLEST_PNG_SIZE) { getInfoByFileExtension(documentImage); } } }) ); break; default: if (TIFF_IMAGE_FORMATS.has(extension)) { getInfoByFileExtension(PHOTO_ICON, (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { const firstImage = (await import("utif")).bufferToURI(contents); if (firstImage && !signal.aborted) { getInfoByFileExtension(firstImage); } } }) ); } else if (IMAGE_FILE_EXTENSIONS.has(extension)) { getInfoByFileExtension(PHOTO_ICON, (signal) => fs.readFile(path, (error, contents = Buffer.from("")) => { if (!error && contents.length > 0 && !signal.aborted) { const imageIcon = new Image(); imageIcon.addEventListener( "load", () => getInfoByFileExtension(imageIcon.src), { signal, ...ONE_TIME_PASSIVE_EVENT } ); imageIcon.addEventListener( "error", async () => { if (extension === ".cur") { const firstImage = await getFirstAniImage(contents); if (firstImage && !signal.aborted) { getInfoByFileExtension( imageToBufferUrl(path, firstImage) ); } } }, { signal, ...ONE_TIME_PASSIVE_EVENT } ); imageIcon.src = imageToBufferUrl(path, contents); } }) ); } else if (AUDIO_FILE_EXTENSIONS.has(extension)) { getInfoByFileExtension(processDirectory.VideoPlayer.icon); } else if (VIDEO_FILE_EXTENSIONS.has(extension)) { subIcons.push(processDirectory.VideoPlayer.icon); getInfoByFileExtension(processDirectory.VideoPlayer.icon, (signal) => fs.readFile(path, async (error, contents = Buffer.from("")) => { if (!error) { const video = document.createElement("video"); const canvas = document.createElement("canvas"); const gif = await getGifJs(); let framesRemaining = ICON_GIF_FPS * ICON_GIF_SECONDS; const getFrame = ( second: number, firstFrame: boolean ): Promise => new Promise((resolve) => { video.addEventListener( "canplaythrough", () => { const context = canvas.getContext("2d", { ...BASE_2D_CONTEXT_OPTIONS, willReadFrequently: true, }); if (!context || !canvas.width || !canvas.height) return; context.drawImage( video, 0, 0, canvas.width, canvas.height ); const imageData = context.getImageData( 0, 0, canvas.width, canvas.height ); gif.addFrame(imageData, { copy: true, delay: 100, }); framesRemaining -= 1; if (framesRemaining === 0) { gif .on("finished", (blob) => blobToBase64(blob).then(getInfoByFileExtension) ) .render(); } resolve(); }, { signal, ...ONE_TIME_PASSIVE_EVENT } ); video.currentTime = second; if ("seekToNextFrame" in video) { (video as VideoElementWithSeek) .seekToNextFrame?.() .catch(() => { // Ignore error during seekToNextFrame }); } else if (firstFrame) { video.load(); } }); video.addEventListener( "loadeddata", () => { canvas.height = video.videoHeight; canvas.width = video.videoWidth; const capturePoints = [ video.duration / 4, video.duration / 2, ]; const frameStep = 4 / ICON_GIF_FPS; const frameCount = framesRemaining / capturePoints.length; capturePoints.forEach(async (capturePoint, index) => { if (signal.aborted) return; for ( let frame = capturePoint; frame < capturePoint + frameCount * frameStep; frame += frameStep ) { if (signal.aborted) return; const firstFrame = index === 0; // eslint-disable-next-line no-await-in-loop await getFrame(frame, firstFrame); if (firstFrame && frame === capturePoint) { getInfoByFileExtension(canvas.toDataURL("image/jpeg")); } } }); }, { signal, ...ONE_TIME_PASSIVE_EVENT } ); video.src = bufferToUrl( contents, isSafari() ? getMimeType(path) || VIDEO_FALLBACK_MIME_TYPE : undefined ); } }) ); } else { getInfoByFileExtension(); } } }; export const filterSystemFiles = (directory: string) => (file: string): boolean => !SYSTEM_PATHS.has(join(directory, file)) && !SYSTEM_FILES.has(file); type WrapData = { lines: string[]; width: number; }; const canvasContexts = Object.create(null) as Record< string, CanvasRenderingContext2D >; const measureText = ( text: string, fontSize: string, fontFamily: string ): number => { const font = `${fontSize} ${fontFamily}`; if (!canvasContexts[font]) { const canvas = document.createElement("canvas"); const context = canvas.getContext( "2d", BASE_2D_CONTEXT_OPTIONS ) as CanvasRenderingContext2D; context.font = font; canvasContexts[font] = context; } const { actualBoundingBoxLeft, actualBoundingBoxRight } = canvasContexts[font].measureText(text); return Math.abs(actualBoundingBoxLeft) + Math.abs(actualBoundingBoxRight); }; export const getTextWrapData = ( text: string, fontSize: string, fontFamily: string, maxWidth?: number ): WrapData => { const lines = [""]; const totalWidth = measureText(text, fontSize, fontFamily); if (!maxWidth) return { lines: [text], width: totalWidth }; if (totalWidth > maxWidth) { const words = text.split(" "); [...text].forEach((character) => { const lineIndex = lines.length - 1; const lineText = `${lines[lineIndex]}${character}`; const lineWidth = measureText(lineText, fontSize, fontFamily); if (lineWidth > maxWidth) { const spacesInLine = lineText.split(" ").length - 1; const lineWithWords = words.splice(0, spacesInLine).join(" "); if ( lines.length === 1 && spacesInLine > 0 && lines[0] !== lineWithWords ) { lines[0] = lineText.slice(0, lineWithWords.length); lines.push(lineText.slice(lineWithWords.length)); } else { lines.push(character); } } else { lines[lineIndex] = lineText; } }); } return { lines, width: Math.min(maxWidth, totalWidth), }; };