import GObject, { register, signal } from "astal/gobject"; import { readFileAsync, monitorFile } from "astal/file"; import GLib from "gi://GLib"; import Gio from "gi://Gio"; import yaml from "yaml"; import { ConfigFile, Vars } from "./types"; @register({ GTypeName: "ConfigService", }) export class ConfigService extends GObject.Object { private get configPath(): string { const envVar = GLib.getenv("VOIDSHELL_CONFIG"); if (envVar !== null) { return envVar; } const defaultPath = `${GLib.getenv("XDG_CONFIG_HOME") ?? GLib.getenv("HOME") + "/.config"}/voidshell/config.yml`; return defaultPath; } private fileMonitors: [path: string, mon: Gio.FileMonitor][] = []; /** * Normalizes a path to be relative to the config directory, unless it is absolute. */ private normalizePath(path: string) { if (path.startsWith("/")) { return path; } if (path.startsWith("~")) { return `${GLib.getenv("HOME")}/${path.slice(2)}`; } const basedir = this.configPath.split("/").slice(0, -1).join("/"); return `${basedir}/${path}`; } /** * Recursively parse config files */ private async parseConfig(path: string): Promise { try { const contents = await readFileAsync(this.normalizePath(path)); const parsed = yaml.parse(contents) as ConfigFile; const importedFiles = (await parsed.import?.amap(this.parseConfig.bind(this))) ?? []; // Merge config files return importedFiles.reduce((config, imported) => { // Merge output definitions if (config.outputs !== undefined) { Object.entries(imported.outputs ?? {}).forEach(([output, def]) => { config.outputs![output] = { ...config.outputs?.[output], ...def }; }); } else { config.outputs = imported.outputs; } // Merge workspace definitions config.workspaces = { ...config.workspaces, ...imported.workspaces }; // Merge layout definitions config.layouts = { ...config.layouts, ...imported.layouts }; // Merge context definitions config.contexts = config.contexts ?? {}; Object.entries(imported.contexts ?? {}).forEach(([context, def]) => { if (context in config.contexts!) { config.contexts![context].vars = { ...config.contexts![context].vars, ...def.vars, }; config.contexts![context].groups = def.groups ?? config.contexts![context].groups; } else { config.contexts![context] = def; } }); // Merge variables config.vars = { ...config.vars, ...imported.vars }; return config; }, parsed); } catch (e) { console.warn(e); return {}; } } /** * Format a string, usually taken from the config, using the variables set in the config and extra variables passed in */ public formatConfigString(value: string, extraArgs?: Vars): string { const args = { ...this.config.vars, ...extraArgs }; return value.replace( /{([a-zA-Z_][a-zA-Z0-9_\-\.]*)}/g, (match, identifier: string) => { // @ts-expect-error const replacer = identifier.split(".").reduce((val, id) => { if (val === undefined) return undefined; if (typeof val !== "object") return undefined; if (id in val) return val[id]; return undefined; }, args); if (replacer === undefined) return match; if (typeof replacer === "object") return match; return replacer; }, ); } /** * Pass the given string to formatConfigString and then evaluate it as a mathematical expression. Only addition and subtraction are supported. */ public doConfigMath(value: string, extraArgs?: Vars): number { const str = this.formatConfigString(value, extraArgs); let total = 0; const matches = str.match(/[+\-]*(\.\d+|\d+(\.\d+)?)/g) ?? []; while (matches.length) { total += parseFloat(matches.shift() ?? "0"); } return total; } public get config(): Readonly { return this._config; } private _config: ConfigFile = {}; /** * Fully refresh the config file */ async refreshConfig() { this.fileMonitors.forEach(([_, mon]) => { mon.cancel(); }); this.fileMonitors = []; this._config = await this.parseConfig(this.configPath); this.configReloaded(); } @signal() declare configReloaded: () => void; constructor() { super(); this.refreshConfig(); } }