Added config service
This commit is contained in:
parent
7218885cd7
commit
c9a9091b48
35
app.ts
35
app.ts
@ -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,
|
||||
|
||||
@ -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>,
|
||||
) {
|
||||
|
||||
@ -276,12 +276,15 @@ export class SwayConnection extends CompositorConnection {
|
||||
aspect_ratio: picture_aspect_ratio,
|
||||
...mode,
|
||||
})),
|
||||
mode: {
|
||||
height: output.current_mode.height,
|
||||
width: output.current_mode.width,
|
||||
refresh: output.current_mode.refresh,
|
||||
aspect_ratio: output.current_mode.picture_aspect_ratio,
|
||||
},
|
||||
mode:
|
||||
output.current_mode === undefined
|
||||
? null
|
||||
: {
|
||||
height: output.current_mode.height,
|
||||
width: output.current_mode.width,
|
||||
refresh: output.current_mode.refresh,
|
||||
aspect_ratio: output.current_mode.picture_aspect_ratio,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
150
services/config/service.ts
Normal 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
103
services/config/types.ts
Normal 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[];
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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[] = [];
|
||||
}
|
||||
|
||||
@ -22,39 +22,47 @@ export class OutputService extends GObject.Object {
|
||||
@signal(VSMonitor)
|
||||
declare monitorConnected: (monitor: VSMonitor) => void;
|
||||
|
||||
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 (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!`,
|
||||
);
|
||||
}
|
||||
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:
|
||||
if (idx === -1) {
|
||||
console.warn(
|
||||
`Received destroy event for monitor ${event.output} we don't know about. This is probably a bug!`,
|
||||
);
|
||||
} else {
|
||||
this.#monitors[idx].destroy();
|
||||
this.#monitors.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
CompositorConnection.instance.connect(
|
||||
"output-change",
|
||||
(_, event: VSOutputEvent) => {
|
||||
console.log("processing output change event");
|
||||
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,
|
||||
);
|
||||
monitor?.sync(event.adapter!);
|
||||
break;
|
||||
case EventType.DESTROY:
|
||||
const idx = this.#monitors.findIndex(
|
||||
(output) => output.name === event.output,
|
||||
);
|
||||
if (idx === -1) break;
|
||||
this.#monitors[idx].destroyed();
|
||||
this.#monitors.splice(idx, 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user