UNPKG

bond-wm

Version:

An X Window Manager built on web technologies.

303 lines (264 loc) 8.29 kB
import { readFile, readdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { extname, isAbsolute, join } from "node:path"; import { XWMContext } from "./wm"; import { UserDirectoryKind, getXDGUserDirectory } from "./xdg"; import { DesktopEntry, DesktopEntryKind, DesktopEntryMap, setDesktopEntries, setEntries } from "@bond-wm/shared"; import { log, logError } from "./log"; const DesktopEntryObject = require("freedesktop-desktop-entry"); const FreedesktopIcons = require("freedesktop-icons") as FreedesktopIconsModule; interface DesktopEntryObjectShape { JSON: Record<string, DesktopEntryObjectGroup>; } interface DesktopEntryObjectGroup { entries: DesktopEntryObjectEntries; comment: string; precedingComment: string[]; } interface DesktopEntryObjectEntries { [name: string]: DesktopEntryObjectEntryProperties; } interface DesktopEntryObjectEntryProperties { value: string; comment: string; precedingComment: string[]; } interface FreedesktopIconsModule { ( icons: IconDescription | IconDescription[], themes?: string[] | string, exts?: string[] | string, fallbackPaths?: string[] | string ): Promise<string | null>; clearCache(): void; } interface IconDescription { name: string; /* filters each theme's icon directories, case-insensitive */ type?: "fixed" | "scalable" | "threshold"; context?: string; size?: number; scale?: number; } interface IDesktopEntryIconData { data: Buffer; mimeType: string; } export interface DesktopEntriesModule { launchDesktopEntry(entryName: string): void; getDesktopEntryIcon(entryName: string): Promise<IDesktopEntryIconData | null>; } const DesktopFileDirectories = [ "/usr/share/applications", "/usr/local/share/applications", "~/.local/share/applications", ]; export async function createDesktopEntriesModule({ store, wmServer }: XWMContext): Promise<DesktopEntriesModule> { const desktopFolder = await getXDGUserDirectory(UserDirectoryKind.Desktop); if (desktopFolder && existsSync(desktopFolder)) { const desktopEntries = await parseDesktopEntries(desktopFolder); store.dispatch(setDesktopEntries(desktopEntries)); } const allEntries = {}; for (const folder of DesktopFileDirectories) { if (!existsSync(folder)) { continue; } const addlEntries = await parseDesktopEntries(folder); if (addlEntries) { Object.assign(allEntries, addlEntries); } } store.dispatch(setEntries(allEntries)); function getEntryFromStore(entryName: string): DesktopEntry | null | undefined { const state = store.getState().desktop; return state?.desktopEntries[entryName] ?? state?.entries[entryName]; } const iconDataByName: Map<string, IDesktopEntryIconData | null> = new Map(); return { launchDesktopEntry(entryName: string): void { log("launchDesktopEntry", entryName); const entry = getEntryFromStore(entryName); if (entry) { switch (entry.kind) { case DesktopEntryKind.Application: { const exec = sanitizeExecString(entry.target); if (exec) { wmServer.launchProcess(exec); } } break; default: logError(`Unhandled desktop entry kind: ${entry.kind}`); break; } } }, async getDesktopEntryIcon(entryName: string): Promise<IDesktopEntryIconData | null> { const entry = getEntryFromStore(entryName); if (!entry || !entry.icon) { return null; } if (iconDataByName.has(entry.key)) { return iconDataByName.get(entry.key) ?? null; } const iconData = await parseDesktopEntryIcon(entry.icon); iconDataByName.set(entry.key, iconData); return iconData; }, }; } async function parseDesktopEntries(desktopFolder: string): Promise<DesktopEntryMap> { const entries: DesktopEntryMap = {}; const files = await readdir(desktopFolder, {}); for (const fileName of files) { if (!isDesktopFile(fileName)) { continue; } const filePath = join(desktopFolder, fileName); const object = new DesktopEntryObject(filePath) as DesktopEntryObjectShape; const desktopEntryGroup = object.JSON["Desktop Entry"]; if (!desktopEntryGroup || !desktopEntryGroup.entries) { continue; } const desktopEntryGroupEntries = desktopEntryGroup.entries; if ( isTrueValue(desktopEntryGroupEntries["NoDisplay"]?.value) || isTrueValue(desktopEntryGroupEntries["Hidden"]?.value) ) { continue; } const categoriesValue = desktopEntryGroupEntries["Categories"]?.value; const categories = ensureArray(categoriesValue, "Others"); const assignedCategory = categories[0] ?? "Others"; function ensureArray(value: unknown, defaultValue: string): string[] { if (Array.isArray(value)) { return value; } if (typeof value === "string") { return [value]; } return [defaultValue]; } const entry: DesktopEntry = { key: fileName, name: desktopEntryGroupEntries["Name"]?.value, kind: parseDesktopEntryKind(desktopEntryGroupEntries["Type"]?.value), icon: desktopEntryGroupEntries["Icon"]?.value, categories: [assignedCategory], }; if (!entry.name) { continue; } switch (entry.kind) { case DesktopEntryKind.Application: entry.target = desktopEntryGroupEntries["Exec"]?.value; entry.workingDirectory = desktopEntryGroupEntries["Path"]?.value; break; case DesktopEntryKind.Link: entry.target = desktopEntryGroupEntries["URL"]?.value; break; case DesktopEntryKind.Directory: continue; // Not supporting currently. } entries[fileName] = entry; } return entries; } function isDesktopFile(fileName: string): boolean { return fileName.endsWith(".desktop"); } function isTrueValue(value: string | null | undefined): boolean { return value === "true"; } function parseDesktopEntryKind(typeString: string): DesktopEntryKind { switch (typeString) { case "Link": return DesktopEntryKind.Link; case "Directory": return DesktopEntryKind.Directory; case "Application": default: return DesktopEntryKind.Application; } } const IconFallbackPaths = ["/usr/share/pixmaps", "/usr/share/icons"]; async function parseDesktopEntryIcon(iconString: string): Promise<IDesktopEntryIconData | null> { if (!iconString) { return null; } if (isAbsolute(iconString)) { return await readIconAsync(iconString); } // First probe for SVG alone. The API seems to not return SVG otherwise, preferring 256px png. const svgPath = await FreedesktopIcons( [ { name: iconString, type: "scalable", }, ], undefined, ["svg"] ); if (svgPath) { return await readIconAsync(svgPath); } const pngPath = await FreedesktopIcons( [ { name: iconString, size: 48, }, { name: iconString }, ], undefined, ["png"], IconFallbackPaths ); if (pngPath) { return await readIconAsync(pngPath); } return null; } async function readIconAsync(iconPath: string): Promise<IDesktopEntryIconData | null> { log("Reading icon path: " + iconPath); switch (extname(iconPath).toLowerCase()) { case ".png": { const fileBytes = await readFile(iconPath); return { data: fileBytes, mimeType: "image/png", }; // return "data:image/png;base64," + encodeArrayBufferToBase64(fileBytes); } break; case ".svg": { const fileBytes = await readFile(iconPath); return { data: fileBytes, mimeType: "image/svg+xml", }; // return "data:image/svg+xml;base64," + encodeArrayBufferToBase64(fileBytes); } break; } return null; } function sanitizeExecString(exec: string | null | undefined): string { if (typeof exec !== "string") { return ""; } return exec .replaceAll("%u", "") .replaceAll("%U", "") .replaceAll("%f", "") .replaceAll("%F", "") .replaceAll("%i", "") .replaceAll("%c", "") .replaceAll("%k", "") .trim(); }