Added config service

This commit is contained in:
Ezri Brimhall 2025-03-13 09:38:07 -06:00
parent 7218885cd7
commit c9a9091b48
Signed by: ezri
GPG Key ID: 058A78E5680C6F24
12 changed files with 401 additions and 70 deletions

35
app.ts
View File

@ -2,11 +2,42 @@ import { App } from "astal/gtk3";
import style from "./style.css";
import Bar from "./widget/Bar";
import "@/globals";
import { CompositorConnection, SwayConnection } from "@services/compositor";
import { OutputService } from "./services/outputs/service";
import { SwayConnection } from "@services/compositor";
import { OutputService } from "@/services/outputs/service";
import { ConfigService } from "@/services/config/service";
new SwayConnection();
new OutputService();
const confSrv = new ConfigService();
confSrv.connect("config-reloaded", () => {
console.log(confSrv.config);
console.log(
confSrv.doConfigMath(
String(confSrv.config.layouts?.battlestation.primary.position[0] ?? ""),
{
auxiliary: 50,
"left-wing": 250,
},
),
);
console.log(
confSrv.formatConfigString("{this.is.a.test.of.nested.parsing}", {
this: {
is: {
a: {
test: {
of: {
nested: {
parsing: "This was a successful test of nested parsing!",
},
},
},
},
},
},
}),
);
});
App.start({
css: style,

View File

@ -118,6 +118,7 @@ export class CompositorConnection extends GObject.Object {
* Set an output property without fully reconfiguring the output
*/
async setOutputProperty<Prop extends string & keyof OutputConfig>(
outputName: string,
property: Prop,
value: Exclude<OutputConfig[Prop], undefined>,
) {

View File

@ -276,7 +276,10 @@ export class SwayConnection extends CompositorConnection {
aspect_ratio: picture_aspect_ratio,
...mode,
})),
mode: {
mode:
output.current_mode === undefined
? null
: {
height: output.current_mode.height,
width: output.current_mode.width,
refresh: output.current_mode.refresh,

View File

@ -141,6 +141,10 @@ export interface OutputMode {
* The refresh rate of the mode in millihertz
*/
refresh: number;
/**
* The aspect ratio of the mode (if it stretches pixels)
*/
picture_aspect_ratio: string;
}
/**
@ -198,9 +202,9 @@ export interface OutputResponse {
*/
modes: OutputMode[];
/**
* The current mode of the output.
* The current mode of the output. Undefined if the output is disabled.
*/
current_mode: OutputMode;
current_mode?: OutputMode;
}
export enum NodeType {

View File

@ -171,7 +171,6 @@ export class VSWrapper<WrappedInterface extends Object> extends GObject.Object {
if (thisValue instanceof VSWrapper && typeof otherValue === "object") {
return thisValue.equals(otherValue);
}
console.log(`items differ: ${thisValue} !== ${otherValue}`);
return false;
}
@ -301,7 +300,7 @@ export interface OutputAdapter {
readonly model: string;
readonly serial: string;
readonly availableModes: OutputMode[];
readonly mode: OutputMode;
readonly mode: OutputMode | null;
}
@register({
@ -340,7 +339,7 @@ export class VSOutputAdapter
@property(Object)
declare readonly availableModes: VSOutputMode[];
@property(VSOutputMode)
declare readonly mode: VSOutputMode;
declare readonly mode: VSOutputMode | null;
}
/**

150
services/config/service.ts Normal file
View File

@ -0,0 +1,150 @@
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<ConfigFile> {
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<ConfigFile> {
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();
}
}

103
services/config/types.ts Normal file
View File

@ -0,0 +1,103 @@
/**
* Generic variable interface for user-defined variables referenced elsewhere
* in the configuration
*/
export interface Vars {
[key: string]: string | number | boolean | Vars;
}
export interface ConfigFile {
vars?: Vars;
import?: string[];
outputs?: Outputs.Outputs;
layouts?: Layouts.Layouts;
contexts?: Contexts.Contexts;
workspaces?: Workspaces.Workspaces;
}
export namespace Outputs {
export interface Outputs {
[name: string]: Output;
}
export interface Criteria {
name?: string | string[];
make?: string;
model?: string;
serial?: string | number;
hostname?: string | string[];
modes: Partial<Mode>[];
}
export interface Mode {
width: number;
height: number;
refresh: number;
picture_aspect_ratio: string;
}
export interface Options
extends Partial<{
resolution: string;
refresh: string;
"custom-mode": string;
scale: number;
"calibration-profile": string;
"adaptive-sync": boolean;
"allow-tearing": boolean;
}> {}
export interface Output {
criteria: Criteria;
windows: string[] | Record<string, Record<string, string>>;
options: Options;
}
}
export namespace Workspaces {
export interface Workspaces {
[id: string]: Workspace;
}
export interface Workspace {
name: string;
application?: string;
exec?: string;
args?: string[];
environ?: Record<string, string>;
unit?: string;
systemd?: boolean;
"log-output"?: boolean;
}
}
export namespace Layouts {
export interface Layouts {
[name: string]: Layout;
}
export interface Layout {
[screenName: string]: Screen;
}
export interface Screen {
required?: boolean;
score?: number;
position: [x: string | number, y: string | number];
outputs: string[];
options?: Outputs.Options;
}
}
export namespace Contexts {
export interface Contexts {
[name: string]: Context;
}
export interface Context {
vars?: Vars;
groups: {
[screenName: string]: string[];
};
}
}

View File

@ -81,12 +81,8 @@ export class DBusObject extends GObject.Object {
marshall_func_name in this &&
typeof this[marshall_func_name] === "function"
) {
console.log(`Calling ${marshall_func_name} to marshall value`);
return this[marshall_func_name]();
}
console.log(
`Did not find ${marshall_func_name}, falling back to default marshalling`,
);
switch (typeof value) {
case "string":
return GLib.Variant.new_string(value);
@ -106,9 +102,15 @@ export class DBusObject extends GObject.Object {
value.map((val) => val.objectPath),
);
}
return new GLib.Variant("av", value);
return new GLib.Variant(
"av",
value.map(this.marshallValue.bind(this)),
);
} else {
return new GLib.Variant("a{sv}", value);
console.warn(
"No marshall function found for object type, cannot implicitly marshall JS objects. Returning null.",
);
return null;
}
case "undefined":
return null;

View File

@ -18,6 +18,8 @@ import { CompositorConnection } from "@services/compositor";
export class VSMonitor extends DBusObject {
static _nextId: number = 1;
private valid: boolean = true;
/**
* The simplified name of the monitor, such as HDMI-A-1 or DP-3
*/
@ -45,7 +47,7 @@ export class VSMonitor extends DBusObject {
@property(String)
public readonly serial: string;
_mode: OutputMode;
_mode: OutputMode | null;
_active: boolean;
_powered: boolean;
_rect: Rect;
@ -57,28 +59,30 @@ export class VSMonitor extends DBusObject {
"dev.ezri.voidshell.Monitor",
`/dev/ezri/VoidShell/Monitor/_${VSMonitor._nextId++}`,
);
console.log("Creating monitor", adapter.name);
this.name = adapter.name;
this.availableModes = adapter.availableModes;
this.make = adapter.make;
this.model = adapter.model;
this.serial = adapter.serial;
this._mode = adapter.mode.simplify();
this._mode = adapter.mode?.simplify() ?? null;
this._active = adapter.active;
this._powered = adapter.powered;
this._rect = adapter.rect.simplify();
this._scale = adapter.scale;
this._transform = adapter.transform;
const subId = this.connect("destroyed", () => {
this.disconnect(subId);
this.dbusObj?.unexport();
});
}
public sync(adapter: VSOutputAdapter) {
if (!this.valid) {
console.error(
"Cannot sync a destroyed output! You have dangling objects!",
);
return;
}
// EDID data shouldn't ever change, so we'll ignore it.
const toEmit = new Set<string & keyof this>();
if (!adapter.mode.equals(this._mode)) {
if (!adapter.mode?.equals(this._mode)) {
toEmit.add("mode");
}
if (this._active !== adapter.active) {
@ -99,7 +103,7 @@ export class VSMonitor extends DBusObject {
if (this._transform !== adapter.transform) {
toEmit.add("transform");
}
this._mode = adapter.mode.simplify();
this._mode = adapter.mode?.simplify() ?? null;
this._active = adapter.active;
this._powered = adapter.powered;
this._rect = adapter.rect.simplify();
@ -126,10 +130,10 @@ export class VSMonitor extends DBusObject {
protected marshall_mode(): GLib.Variant {
return new GLib.Variant("(iiis)", [
this._mode.width,
this._mode.height,
this._mode.refresh,
this._mode.aspect_ratio,
this._mode?.width ?? 0,
this._mode?.height ?? 0,
this._mode?.refresh ?? 0,
this._mode?.aspect_ratio ?? "",
]);
}
@ -151,7 +155,10 @@ export class VSMonitor extends DBusObject {
* will not be immediately reflected.
*/
@property(VSOutputMode)
public get mode(): VSOutputMode {
public get mode(): VSOutputMode | null {
if (!this._mode) {
return null;
}
return VSOutputMode.wrap(this._mode);
}
@ -304,10 +311,16 @@ export class VSMonitor extends DBusObject {
);
}
public destroy() {
this.dbusObj?.unexport();
this.valid = false;
this.emit("destroyed");
}
/**
* Fired when the monitor is "destroyed" (disconnected). Invalidates the object.
* Fired when the monitor is "destroyed" (disconnected). Indicates the object is now invalid.
*/
@signal(VSMonitor)
@signal()
declare destroyed: () => void;
/**

View File

@ -1 +1,14 @@
import GObject, { property, register } from "astal/gobject";
import { VSMonitor } from "./monitor";
import { DBusObject } from "@services/dbus";
@register({
GTypeName: "VSOutput",
})
export class VSOutput extends DBusObject {
@property(String)
public readonly name: string;
@property(Object)
public readonly compatibleMonitors: VSMonitor[] = [];
}

View File

@ -22,39 +22,47 @@ export class OutputService extends GObject.Object {
@signal(VSMonitor)
declare monitorConnected: (monitor: VSMonitor) => void;
constructor() {
super();
CompositorConnection.instance.connect(
"output-change",
(_, event: VSOutputEvent) => {
private onOutputEvent(event: VSOutputEvent) {
console.log("processing output change event");
const idx = this.#monitors.findIndex(
(monitor) => monitor.name === event.output,
);
switch (event.type) {
case EventType.CREATE:
if (
this.#monitors.find(
(monitor) => monitor.name === event.output,
) === null
) {
this.#monitors.push(new VSMonitor(event.adapter!));
break;
}
case EventType.CHANGE:
const monitor = this.#monitors.find(
(monitor) => monitor.name === event.output,
if (idx === -1) {
console.log(`new monitor ${event.output} connected`);
const newMonitor = new VSMonitor(event.adapter!);
this.#monitors.push(newMonitor);
this.monitorConnected(newMonitor);
} else {
console.warn(
`Received create event for monitor ${event.output} we already know about. This is probably a bug!`,
);
monitor?.sync(event.adapter!);
}
break;
case EventType.CHANGE:
if (idx === -1) {
console.warn(
`Received change event for monitor ${event.output} we don't know about. This is probably a bug!`,
);
} else {
this.#monitors[idx].sync(event.adapter!);
}
break;
case EventType.DESTROY:
const idx = this.#monitors.findIndex(
(output) => output.name === event.output,
if (idx === -1) {
console.warn(
`Received destroy event for monitor ${event.output} we don't know about. This is probably a bug!`,
);
if (idx === -1) break;
this.#monitors[idx].destroyed();
} else {
this.#monitors[idx].destroy();
this.#monitors.splice(idx, 1);
break;
}
},
);
}
}
constructor() {
super();
CompositorConnection.instance
.getOutputs()
.then((compositorOutputs) => {
@ -62,6 +70,12 @@ export class OutputService extends GObject.Object {
this.#monitors.push(new VSMonitor(output)),
);
})
.catch(console.error);
.catch(console.error)
.then(() =>
CompositorConnection.instance.connect(
"output-change",
(_, event: VSOutputEvent) => this.onOutputEvent(event),
),
);
}
}

View File

@ -6,8 +6,6 @@ import { Tree } from "@/services/sway/workspaces";
const time = Variable("").poll(1000, "date");
export default function Bar(monitor: number) {
const tree = Tree.instance;
const currentContext = bind(tree, "currentContextName");
return (
<window
className="Bar"
@ -21,7 +19,7 @@ export default function Bar(monitor: number) {
application={App}
>
<centerbox>
<label label={currentContext} />
<box />
<box>
<SystemName />
</box>