bond-wm
Version:
An X Window Manager built on web technologies.
303 lines (264 loc) • 8.29 kB
text/typescript
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();
}