monitor interface works

This commit is contained in:
Ezri Brimhall 2025-02-28 22:09:12 -07:00
parent c023391702
commit 7218885cd7
Signed by: ezri
GPG Key ID: 058A78E5680C6F24
11 changed files with 990 additions and 57 deletions

7
app.ts
View File

@ -2,10 +2,11 @@ import { App } from "astal/gtk3";
import style from "./style.css"; import style from "./style.css";
import Bar from "./widget/Bar"; import Bar from "./widget/Bar";
import "@/globals"; import "@/globals";
import { Sway } from "@services/sway/ipc"; import { CompositorConnection, SwayConnection } from "@services/compositor";
import { Tree } from "@services/sway/workspaces"; import { OutputService } from "./services/outputs/service";
new Tree("/home/ezri/.config/sway/workspaces.json"); new SwayConnection();
new OutputService();
App.start({ App.start({
css: style, css: style,

View File

@ -0,0 +1,22 @@
<!DOCTYPE
node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="dev.ezri.voidshell.Monitor">
<!-- Compositor-supplied values -->
<property name="name" type="s" access="read" />
<!-- EDID values -->
<property name="availableModes" type="a(iiis)" access="read" />
<property name="make" type="s" access="read" />
<property name="model" type="s" access="read" />
<property name="serial" type="s" access="read" />
<!-- Configuration values -->
<property name="mode" type="(iiis)" access="readwrite" />
<property name="active" type="b" access="readwrite" />
<property name="powered" type="b" access="readwrite" />
<property name="position" type="(ii)" access="readwrite" />
<property name="scale" type="d" access="readwrite" />
<property name="rect" type="(iiii)" access="read" />
<property name="transform" type="s" access="readwrite" />
</interface>
</node>

View File

@ -0,0 +1,16 @@
<!DOCTYPE
node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="dev.ezri.voidshell.Output">
<property name="name" type="s" access="read" />
<property name="criteria" type="a{sv}" access="read" />
<property name="connected" type="b" access="read" />
<property name="matches" type="ao" access="read" />
<property name="active" type="o" access="read" />
<method name="changeMonitor">
<arg type="o" name="monitor" />
<arg type="b" direction="out" />
</method>
</interface>
</node>

View File

@ -1,6 +1,13 @@
import GObject, { register, property, signal } from "astal/gobject"; import GObject, { register, property, signal } from "astal/gobject";
import { ConnectionInstantiationError, NotImplementedError } from "./errors"; import { ConnectionInstantiationError, NotImplementedError } from "./errors";
import { OutputConfig, VSOutputAdapter, VSWorkspaceAdapter } from "./types"; import {
OutputAdapter,
OutputConfig,
VSOutputAdapter,
VSOutputEvent,
VSWorkspaceAdapter,
VSWorkspaceEvent,
} from "./types";
/** /**
* Mid-level abstract compositor connection providing raw data about the compositor to the rest of VoidShell. * Mid-level abstract compositor connection providing raw data about the compositor to the rest of VoidShell.
@ -91,6 +98,14 @@ export class CompositorConnection extends GObject.Object {
throw new NotImplementedError(); throw new NotImplementedError();
} }
/**
* Gets a list of all workspaces
* @returns a list of VSWorkspaceAdapter objects representing the workspaces presently known to the compositor
*/
async getWorkspaces(): Promise<VSWorkspaceAdapter[]> {
throw new NotImplementedError();
}
/** /**
* Disable an output * Disable an output
* @param outputName The name of the output to disable * @param outputName The name of the output to disable
@ -99,18 +114,28 @@ export class CompositorConnection extends GObject.Object {
throw new NotImplementedError(); throw new NotImplementedError();
} }
/**
* Set an output property without fully reconfiguring the output
*/
async setOutputProperty<Prop extends string & keyof OutputConfig>(
property: Prop,
value: Exclude<OutputConfig[Prop], undefined>,
) {
throw new NotImplementedError();
}
/** /**
* Emitted when a workspace's data changes * Emitted when a workspace's data changes
*/ */
@signal(VSWorkspaceAdapter) @signal(VSWorkspaceEvent)
declare workspaceChange: (event: VSWorkspaceAdapter) => void; declare workspaceChange: (event: VSWorkspaceEvent) => void;
/** /**
* Emitted when an output's data changes * Emitted when an output's data changes
* For example, it is enabled or disabled, or its mode changes * For example, it is enabled or disabled, or its mode changes
*/ */
@signal(VSOutputAdapter) @signal(VSOutputEvent)
declare outputChange: (event: VSOutputAdapter) => void; declare outputChange: (event: VSOutputEvent) => void;
/** /**
* Emitted when the binding mode changes * Emitted when the binding mode changes

View File

@ -15,7 +15,14 @@ import * as utils from "@/utils";
import { Mutex } from "@/synchronization"; import { Mutex } from "@/synchronization";
import { CompositorConnection } from "../connection"; import { CompositorConnection } from "../connection";
import { ActionError } from "../errors"; import { ActionError } from "../errors";
import { OutputConfig, VSOutputAdapter, VSRect } from "../types"; import {
EventType,
OutputConfig,
VSOutputAdapter,
VSOutputEvent,
VSWorkspaceAdapter,
VSWorkspaceEvent,
} from "../types";
class SwayCommandError class SwayCommandError
extends ActionError extends ActionError
@ -58,9 +65,121 @@ class SwayCommandError
export class SwayConnection extends CompositorConnection { export class SwayConnection extends CompositorConnection {
private ipc: SwayIPC; private ipc: SwayIPC;
private lastOutputs = new Map<string, VSOutputAdapter>();
private lastWorkspaces = new Map<string, VSWorkspaceAdapter>();
constructor() { constructor() {
super(); super();
this.ipc = SwayIPC.instance; this.ipc = SwayIPC.instance;
this.onWorkspaceEvent();
this.onOutputEvent();
this.ipc.connect("workspace", () => {
this.onWorkspaceEvent().catch(console.error);
});
this.ipc.connect("output", () => {
this.onOutputEvent().catch(console.error);
});
}
/**
* Call when we receive a workspace event from Sway
*/
private async onWorkspaceEvent() {
const workspaces = await this.getWorkspaces();
// Emit new events for all new outputs. No need to check set, this is the first emission.
workspaces
.filter((workspace) => !this.lastWorkspaces.has(workspace.id))
.forEach((workspace) => {
this.workspaceChange(
new VSWorkspaceEvent({
type: EventType.CREATE,
workspace: workspace.id,
adapter: workspace,
}),
);
});
// Emit changed events for all outputs that have changed. No need to check set, as this and previous loops are guaranteed disjoint.
workspaces
.filter((workspace) => this.lastWorkspaces.has(workspace.id))
.filter(
(workspace) => !workspace.equals(this.lastWorkspaces.get(workspace.id)),
)
.forEach((workspace) => {
this.workspaceChange(
new VSWorkspaceEvent({
type: EventType.CHANGE,
workspace: workspace.id,
adapter: workspace,
}),
);
});
// Emit destroy events for all outputs that do not now exist.
const workspaceNames = new Set(workspaces.map((workspace) => workspace.id));
Array.from(this.lastWorkspaces.values())
.filter((workspace) => !workspaceNames.has(workspace.id))
.forEach((workspace) => {
this.workspaceChange(
new VSWorkspaceEvent({
type: EventType.DESTROY,
workspace: workspace.id,
adapter: null,
}),
);
});
this.lastWorkspaces = new Map();
workspaces.forEach((workspace) =>
this.lastWorkspaces.set(workspace.id, workspace),
);
}
/**
* Call when we receive an output event from Sway
*
* NOTE: Currently, Sway does not provide any useful information with this event, so we need to get all outputs
* ourselves and determine what changed.
*/
private async onOutputEvent() {
const outputs = await this.getOutputs();
// Emit new events for all new outputs. No need to check set, this is the first emission.
outputs
.filter((output) => !this.lastOutputs.has(output.name))
.forEach((output) => {
this.outputChange(
new VSOutputEvent({
type: EventType.CREATE,
output: output.name,
adapter: output,
}),
);
});
// Emit changed events for all outputs that have changed. No need to check set, as this and previous loops are guaranteed disjoint.
outputs
.filter((output) => this.lastOutputs.has(output.name))
.filter((output) => !output.equals(this.lastOutputs.get(output.name)))
.forEach((output) => {
this.outputChange(
new VSOutputEvent({
type: EventType.CHANGE,
output: output.name,
adapter: output,
}),
);
});
// Emit destroy events for all outputs that do not now exist.
const outputNames = new Set(outputs.map((output) => output.name));
Array.from(this.lastOutputs.values())
.filter((output) => !outputNames.has(output.name))
.forEach((output) => {
this.outputChange(
new VSOutputEvent({
type: EventType.DESTROY,
output: output.name,
adapter: null,
}),
);
});
this.lastOutputs = new Map();
outputs.forEach((output) => this.lastOutputs.set(output.name, output));
} }
async focusWorkspace(workspaceId: string) { async focusWorkspace(workspaceId: string) {
@ -102,21 +221,89 @@ export class SwayConnection extends CompositorConnection {
return SwayCommandError.assertCommands(`output ${outputName} disable`); return SwayCommandError.assertCommands(`output ${outputName} disable`);
} }
async setOutputProperty<Prop extends string & keyof OutputConfig>(
output: string,
property: Prop,
value: Exclude<OutputConfig[Prop], undefined>,
) {
switch (property) {
case "modeline":
return SwayCommandError.assertCommands(
`output ${output} mode ${value}`,
);
case "scale":
return SwayCommandError.assertCommands(
`output ${output} scale ${value}`,
);
case "position":
return SwayCommandError.assertCommands(
`output ${output} position ${(value as number[])[0]} ${(value as number[])[1]}`,
);
case "active":
if (value) {
return SwayCommandError.assertCommands(`output ${output} enable`);
} else {
return SwayCommandError.assertCommands(`output ${output} disable`);
}
case "powered":
return SwayCommandError.assertCommands(
`output ${output} power ${value ? "on" : "off"}`,
);
case "transform":
return SwayCommandError.assertCommands(
`output ${output} transform ${value}`,
);
case "colorProfile":
return SwayCommandError.assertCommands(
`output ${output} color_profile ${value}`,
);
}
}
private parseSwayOutput(output: OutputResponse): VSOutputAdapter {
return new VSOutputAdapter({
name: output.name,
active: output.active,
powered: output.power,
rect: output.rect,
scale: output.scale,
transform: output.transform,
current_workspace: output.current_workspace,
make: output.make,
model: output.model,
serial: output.serial,
availableModes: output.modes.map(({ picture_aspect_ratio, ...mode }) => ({
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,
},
});
}
private parseSwayWorkspace(workspace: WorkspaceResponse): VSWorkspaceAdapter {
return new VSWorkspaceAdapter({
id: workspace.name,
output: workspace.output,
focused: workspace.focused,
visible: workspace.visible,
urgent: workspace.urgent,
});
}
async getOutputs(): Promise<VSOutputAdapter[]> { async getOutputs(): Promise<VSOutputAdapter[]> {
const result = await this.ipc.getOutputs(); return (await this.ipc.getOutputs()).map((swayOutput) =>
return result.map( this.parseSwayOutput(swayOutput),
(swayOutput) => );
new VSOutputAdapter({ }
name: swayOutput.name,
active: swayOutput.active, async getWorkspaces(): Promise<VSWorkspaceAdapter[]> {
rect: new VSRect(swayOutput.rect), return (await this.ipc.getWorkspaces()).map((swayWorkspace) =>
scale: swayOutput.scale, this.parseSwayWorkspace(swayWorkspace),
transform: swayOutput.transform,
current_workspace: swayOutput.current_workspace,
make: swayOutput.make,
model: swayOutput.model,
serial: swayOutput.serial,
}),
); );
} }
} }
@ -179,11 +366,13 @@ class SwayIPC extends GObject.Object {
} }
#connected = false; #connected = false;
#onConnected: Promise<void>;
/** /**
* Run a command on sway * Run a command on sway
*/ */
public async command(command: string): Promise<CommandResponse[]> { public async command(command: string): Promise<CommandResponse[]> {
await this.#onConnected;
return JSON.parse( return JSON.parse(
(await this.#sendRaw( (await this.#sendRaw(
this.#commandSocket, this.#commandSocket,
@ -197,6 +386,7 @@ class SwayIPC extends GObject.Object {
* Get the current sway workspaces * Get the current sway workspaces
*/ */
public async getWorkspaces(): Promise<WorkspaceResponse[]> { public async getWorkspaces(): Promise<WorkspaceResponse[]> {
await this.#onConnected;
return JSON.parse( return JSON.parse(
(await this.#sendRaw( (await this.#sendRaw(
this.#commandSocket, this.#commandSocket,
@ -209,6 +399,7 @@ class SwayIPC extends GObject.Object {
* Get the current sway outputs * Get the current sway outputs
*/ */
public async getOutputs(): Promise<OutputResponse[]> { public async getOutputs(): Promise<OutputResponse[]> {
await this.#onConnected;
return JSON.parse( return JSON.parse(
(await this.#sendRaw( (await this.#sendRaw(
this.#commandSocket, this.#commandSocket,
@ -221,6 +412,7 @@ class SwayIPC extends GObject.Object {
* Get the current sway tree * Get the current sway tree
*/ */
public async getTree(): Promise<TreeNode> { public async getTree(): Promise<TreeNode> {
await this.#onConnected;
return JSON.parse( return JSON.parse(
(await this.#sendRaw( (await this.#sendRaw(
this.#commandSocket, this.#commandSocket,
@ -231,6 +423,12 @@ class SwayIPC extends GObject.Object {
constructor() { constructor() {
super(); super();
this.#onConnected = new Promise((resolve) => {
const id = this.connect("connected", () => {
resolve();
this.disconnect(id);
});
});
this.#connect(); this.#connect();
} }

View File

@ -0,0 +1,2 @@
export { SwayConnection } from "./connections/sway";
export { CompositorConnection } from "./connection";

View File

@ -28,15 +28,151 @@ export const enum EventType {
DESTROY, DESTROY,
} }
type Constructor<T> = { new (...args: any[]): T }; export type Constructor<T> = { new (...args: any[]): T };
class WrappingError extends Error {
name = "WrappingError";
}
/**
* Generic wrapper for TypeScript interfaces that are used in GObject signals.
* While this can be used directly, it is recommended to subclass it for each
* interface to expose the properties as GObject properties.
*/
@register({ @register({
GTypeName: "VSWrapper", GTypeName: "VSWrapper",
}) })
export class VSWrapper<WrappedInterface extends Object> extends GObject.Object { export class VSWrapper<WrappedInterface extends Object> extends GObject.Object {
declare static readonly wrappers: {
[key: string]: typeof VSWrapper<any>;
};
declare private keys: string[];
private getWrapperConstructor<Key extends string & keyof WrappedInterface>(
key: Key,
): Constructor<VSWrapper<any>> {
if ("wrappers" in this.constructor) {
if (
typeof this.constructor.wrappers !== "object" ||
this.constructor.wrappers === null
) {
throw new WrappingError(
`Trying to wrap ${key}: ${this.constructor.name}.wrappers is not an object!`,
);
}
if (!(key in this.constructor.wrappers)) {
throw new WrappingError(
`Trying to wrap ${key}: ${this.constructor.name}.wrappers.${key} does not exist!`,
);
}
// @ts-ignore yeah, no good way to type this properly :/
return this.constructor.wrappers[key];
}
throw new WrappingError(
`Trying to wrap ${key}: ${this.constructor.name}.wrappers does not exist!`,
);
}
constructor(obj: WrappedInterface) { constructor(obj: WrappedInterface) {
super(); const keys = (Object.keys(obj) as (keyof typeof obj)[]).filter(
Object.assign(this, obj); (key) => typeof key === "string",
);
keys
.filter((key) => obj[key] === undefined)
.forEach((key) => (obj[key] = null as any));
const toWrap = new Set<string & keyof typeof obj>();
const toConstruct = Object.entries(obj).reduce(
(acc, [key, value]) => {
if (typeof key !== "string") return acc;
if (
["string", "number", "boolean"].some(
(type) => typeof value === type,
) ||
value === null ||
value instanceof GObject.Object
) {
acc[key] = value;
} else if (value === undefined) {
acc[key] = null;
} else {
toWrap.add(key as string & keyof typeof obj);
}
return acc;
},
{} as Record<string, string | number | boolean | null | VSWrapper<any>>,
);
// @ts-ignore
super(toConstruct);
toWrap.forEach((key) => {
const constructor = this.getWrapperConstructor(key);
if (obj[key] instanceof Array) {
// @ts-ignore
this[key] = obj[key].map((val) => new constructor(val));
} else {
// @ts-ignore
this[key] = new constructor(obj[key]);
}
});
Object.defineProperty(this, "keys", {
value: keys,
writable: false,
enumerable: false,
configurable: false,
});
}
/**
* Reconstruct the native JS object that doesn't have any GObject add-ons
* This is necessary over the spread operator because GJS defines GObject
* properties as non-enumerable for some reason.
*/
public simplify(): WrappedInterface {
return this.keys.reduce((acc, key) => {
const val = this[key as keyof this];
if (val instanceof VSWrapper) {
return {
...acc,
[key]: val.simplify(),
};
}
return {
...acc,
[key]: val,
};
}, {} as WrappedInterface);
}
/**
* Value-based deep equality check
* NOTE: this may be expensive depending on the data structure!
* NOTE: only deep-checks VSWrapper instances!
*/
public equals(other: WrappedInterface | null | undefined): boolean {
if (other === null || other === undefined) {
return false;
}
return this.keys.every((key) => {
// @ts-ignore
const thisValue = this[key];
// @ts-ignore
const otherValue = other[key];
return this.checkItemEquality(thisValue, otherValue);
});
}
private checkItemEquality(thisValue: any, otherValue: any): boolean {
if (thisValue === otherValue) return true;
if (thisValue instanceof Array && otherValue instanceof Array) {
return thisValue.every((val, idx) =>
this.checkItemEquality(val, otherValue[idx]),
);
}
if (thisValue instanceof VSWrapper && typeof otherValue === "object") {
return thisValue.equals(otherValue);
}
console.log(`items differ: ${thisValue} !== ${otherValue}`);
return false;
} }
/** /**
@ -45,10 +181,14 @@ export class VSWrapper<WrappedInterface extends Object> extends GObject.Object {
public static wrap< public static wrap<
WrappedInterface extends Object, WrappedInterface extends Object,
Wrapper extends VSWrapper<WrappedInterface>, Wrapper extends VSWrapper<WrappedInterface>,
>(this: Constructor<Wrapper>, obj: WrappedInterface): Wrapper { >(
this: Constructor<Wrapper>,
obj: WrappedInterface,
): Wrapper & WrappedInterface {
if (obj instanceof this) { if (obj instanceof this) {
return obj; return obj;
} }
// @ts-ignore The values of WrappedInterface exist on the object, as guaranteed by the Object.assign() call.
return new this(obj); return new this(obj);
} }
} }
@ -112,6 +252,39 @@ export class VSWorkspaceAdapter
declare readonly urgent: boolean; declare readonly urgent: boolean;
} }
/**
* Common interface for compositor backends that represents an output mode.
*/
export interface OutputMode {
/**
* The width of the output in pixels
*/
readonly width: number;
/**
* The height of the output in pixels
*/
readonly height: number;
/**
* The refresh rate of the mode in integer millihertz
*/
readonly refresh: number;
readonly aspect_ratio: string;
}
@register({
GTypeName: "VSOutputMode",
})
export class VSOutputMode extends VSWrapper<OutputMode> implements OutputMode {
@property(Number)
declare readonly width: number;
@property(Number)
declare readonly height: number;
@property(Number)
declare readonly refresh: number;
@property(String)
declare readonly aspect_ratio: string;
}
/** /**
* Common interface for compositor backends that represents the results of an output query. * Common interface for compositor backends that represents the results of an output query.
* Contains only information that VoidShell needs to know. * Contains only information that VoidShell needs to know.
@ -119,14 +292,16 @@ export class VSWorkspaceAdapter
export interface OutputAdapter { export interface OutputAdapter {
readonly name: string; readonly name: string;
readonly active: boolean; readonly active: boolean;
readonly powered: boolean;
readonly rect: Rect; readonly rect: Rect;
readonly scale: number; readonly scale: number;
readonly transform: OutputTransform; readonly transform: OutputTransform;
readonly focused: boolean;
readonly current_workspace: string; readonly current_workspace: string;
readonly make: string; readonly make: string;
readonly model: string; readonly model: string;
readonly serial: string; readonly serial: string;
readonly availableModes: OutputMode[];
readonly mode: OutputMode;
} }
@register({ @register({
@ -136,18 +311,24 @@ export class VSOutputAdapter
extends VSWrapper<OutputAdapter> extends VSWrapper<OutputAdapter>
implements OutputAdapter implements OutputAdapter
{ {
static readonly wrappers = {
rect: VSRect,
availableModes: VSOutputMode,
mode: VSOutputMode,
};
@property(String) @property(String)
declare readonly name: string; declare readonly name: string;
@property(Boolean) @property(Boolean)
declare readonly active: boolean; declare readonly active: boolean;
@property(Boolean)
declare readonly powered: boolean;
@property(VSRect) @property(VSRect)
declare readonly rect: VSRect; declare readonly rect: VSRect;
@property(Number) @property(Number)
declare readonly scale: number; declare readonly scale: number;
@property(String) @property(String)
declare readonly transform: OutputTransform; declare readonly transform: OutputTransform;
@property(Boolean)
declare readonly focused: boolean;
@property(String) @property(String)
declare readonly current_workspace: string; declare readonly current_workspace: string;
@property(String) @property(String)
@ -156,11 +337,10 @@ export class VSOutputAdapter
declare readonly model: string; declare readonly model: string;
@property(String) @property(String)
declare readonly serial: string; declare readonly serial: string;
@property(Object)
constructor(adapter: OutputAdapter) { declare readonly availableModes: VSOutputMode[];
super(adapter); @property(VSOutputMode)
this.rect = VSRect.wrap(adapter.rect); declare readonly mode: VSOutputMode;
}
} }
/** /**
@ -191,19 +371,16 @@ export class VSWorkspaceEvent
extends VSWrapper<WorkspaceEvent> extends VSWrapper<WorkspaceEvent>
implements WorkspaceEvent implements WorkspaceEvent
{ {
@property(String) static readonly wrappers = {
adapter: VSWorkspaceAdapter,
};
@property(Number)
declare readonly type: EventType; declare readonly type: EventType;
@property(String) @property(String)
declare readonly workspace: string; declare readonly workspace: string;
@property(VSWorkspaceAdapter) @property(VSWorkspaceAdapter)
declare readonly adapter: VSWorkspaceAdapter | null; declare readonly adapter: VSWorkspaceAdapter | null;
constructor(event: WorkspaceEvent) {
super(event);
this.adapter = event.adapter
? VSWorkspaceAdapter.wrap(event.adapter)
: null;
}
} }
/** /**
@ -234,27 +411,24 @@ export class VSOutputEvent
extends VSWrapper<OutputEvent> extends VSWrapper<OutputEvent>
implements OutputEvent implements OutputEvent
{ {
@property(String) static readonly wrappers = {
adapter: VSOutputAdapter,
};
@property(Number)
declare readonly type: EventType; declare readonly type: EventType;
@property(String) @property(String)
declare readonly output: string; declare readonly output: string;
@property(VSOutputAdapter) @property(VSOutputAdapter)
declare readonly adapter: VSOutputAdapter | null; declare readonly adapter: VSOutputAdapter | null;
constructor(event: OutputEvent) {
super(event);
this.adapter = event.adapter ? VSOutputAdapter.wrap(event.adapter) : null;
}
} }
export type OutputConfig = { export type OutputConfig = {
modeline: string; modeline: `${number}x${number}${`@${number}Hz` | `@${string}.${string}Hz` | ""}`;
scale?: number; scale?: number;
position: [x: number, y: number]; position: [x: number, y: number];
} & { active?: boolean;
/** colorProfile?: string;
* Extra options added to the configuration line. Support for these is compositor-dependent, and currently powered?: boolean;
* they will only be honored under Sway. transform: OutputTransform;
*/
[extra_option: string]: string | number | boolean;
}; };

View File

@ -1,6 +1,7 @@
import Gio from "gi://Gio"; import Gio from "gi://Gio";
import GObject, { register } from "astal/gobject"; import GObject, { register } from "astal/gobject";
import { getDbusXml } from "@/utils"; import { getDbusXml } from "@/utils";
import GLib from "gi://GLib";
let dbusConnectionResolve: ((connection: any) => void) | null = null; let dbusConnectionResolve: ((connection: any) => void) | null = null;
export const Connection: Promise<any> = new Promise<any>( export const Connection: Promise<any> = new Promise<any>(
@ -28,7 +29,7 @@ export class DBusObject extends GObject.Object {
this.#objectPath = objectPath; this.#objectPath = objectPath;
getDbusXml(iface).then(async (xml) => { getDbusXml(iface).then(async (xml) => {
try { try {
this.dbusObj = Gio.DBusExportedObject.wrapJSObject(xml, this); this.dbusObj = Gio.DBusExportedObject.wrapJSObject(xml, this.proxify());
this.dbusObj.export(await Connection, objectPath); this.dbusObj.export(await Connection, objectPath);
} catch (e) { } catch (e) {
console.error(`Error exporting to D-Bus: ${e}`); console.error(`Error exporting to D-Bus: ${e}`);
@ -36,6 +37,114 @@ export class DBusObject extends GObject.Object {
}); });
} }
/**
* Creates a proxy of the dbus object that returns object paths of DBusObjects
* rather than the objects themselves.
* This proxy should be used with the DBus export, and should not be used in any other context.
*
* Due to the way this works, setting DBus object paths is NOT SUPPORTED! Use a method if you really need to do that.
*/
private proxify() {
return new Proxy(this, {
get(target, property: keyof DBusObject) {
const marshall_func = `marshall_${String(property)}` as string &
keyof typeof target;
if (
marshall_func in target &&
typeof target[marshall_func] === "function"
) {
const variant: GLib.Variant = target[marshall_func]();
return variant.deepUnpack();
}
if (!(property in target)) {
return undefined;
}
if (target[property] instanceof DBusObject) {
return target[property].objectPath;
}
if (target[property] instanceof Array) {
return target[property].map((prop) =>
prop instanceof DBusObject ? prop.objectPath : prop,
);
}
return target[property];
},
});
}
private marshallValue(
property: string & keyof this,
value: any,
): GLib.Variant | null {
const marshall_func_name = `marshall_${property}` as string & keyof this;
if (
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);
case "number":
return GLib.Variant.new_double(value);
case "bigint":
return GLib.Variant.new_int64(Number(value));
case "boolean":
return GLib.Variant.new_boolean(value);
case "symbol":
return null;
case "object":
if (value instanceof Array) {
if (value.every((val) => val instanceof DBusObject)) {
return new GLib.Variant(
"ao",
value.map((val) => val.objectPath),
);
}
return new GLib.Variant("av", value);
} else {
return new GLib.Variant("a{sv}", value);
}
case "undefined":
return null;
case "function":
return null;
}
}
// private deepMarshallObject(obj: Object): GLib.Variant {
// if (obj instanceof DBusObject) {
// return GLib.Variant.new_object_path(obj.objectPath);
// }
// if (obj instanceof Array) {
// return new GLib.Variant(
// "av",
// obj.map(this.marshallValue.bind(this)).filter((val) => val !== null),
// );
// }
// const newObj = Object.entries(obj).reduce((obj, [key, value]) => {
// const toEmit = this.marshallValue(, value);
// if (toEmit !== null) {
// obj[key] = toEmit;
// }
// return obj;
// }, {} as any);
// return new GLib.Variant("a{sv}", newObj);
// }
protected emitDBusProperty<Prop extends keyof this & string>(property: Prop) {
const toEmit = this.marshallValue(property, this[property]);
if (!toEmit) {
return;
}
this.dbusObj?.emit_property_changed(property, toEmit);
}
get objectPath(): string { get objectPath(): string {
return this.#objectPath; return this.#objectPath;
} }

318
services/outputs/monitor.ts Normal file
View File

@ -0,0 +1,318 @@
import { property, register, signal } from "astal/gobject";
import GLib from "gi://GLib";
import { DBusObject } from "@services/dbus";
import {
OutputMode,
OutputTransform,
Rect,
VSOutputAdapter,
VSOutputMode,
VSRect,
VSWrapper,
} from "@services/compositor/types";
import { CompositorConnection } from "@services/compositor";
@register({
GTypeName: "VSMonitor",
})
export class VSMonitor extends DBusObject {
static _nextId: number = 1;
/**
* The simplified name of the monitor, such as HDMI-A-1 or DP-3
*/
@property(String)
public readonly name: string;
/**
* The list of available modes reported by the monitor's EDID
*/
@property(Object)
public readonly availableModes: VSOutputMode[];
/**
* The make of the monitor reported by EDID
*/
@property(String)
public readonly make: string;
/**
* The model of the monitor reported by EDID
*/
@property(String)
public readonly model: string;
/**
* The serial of the monitor reported by EDID
*/
@property(String)
public readonly serial: string;
_mode: OutputMode;
_active: boolean;
_powered: boolean;
_rect: Rect;
_scale: number;
_transform: OutputTransform;
constructor(adapter: VSOutputAdapter) {
super(
"dev.ezri.voidshell.Monitor",
`/dev/ezri/VoidShell/Monitor/_${VSMonitor._nextId++}`,
);
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._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) {
// EDID data shouldn't ever change, so we'll ignore it.
const toEmit = new Set<string & keyof this>();
if (!adapter.mode.equals(this._mode)) {
toEmit.add("mode");
}
if (this._active !== adapter.active) {
toEmit.add("active");
}
if (this._powered !== adapter.powered) {
toEmit.add("powered");
}
if (!adapter.rect.equals(this._rect)) {
toEmit.add("rect");
if (adapter.rect.x !== this._rect.x || adapter.rect.y !== this._rect.y) {
toEmit.add("position");
}
}
if (this._scale !== adapter.scale) {
toEmit.add("scale");
}
if (this._transform !== adapter.transform) {
toEmit.add("transform");
}
this._mode = adapter.mode.simplify();
this._active = adapter.active;
this._powered = adapter.powered;
this._rect = adapter.rect.simplify();
this._scale = adapter.scale;
this._transform = adapter.transform;
toEmit.forEach((prop) => {
let value = this[prop];
if (value instanceof VSWrapper) {
value = value.simplify();
}
this.notify(prop);
this.emitDBusProperty(prop);
});
}
protected marshall_rect(): GLib.Variant {
return new GLib.Variant("(iiii)", [
this._rect.x,
this._rect.y,
this._rect.width,
this._rect.height,
]);
}
protected marshall_mode(): GLib.Variant {
return new GLib.Variant("(iiis)", [
this._mode.width,
this._mode.height,
this._mode.refresh,
this._mode.aspect_ratio,
]);
}
protected marshall_availableModes(): GLib.Variant {
return new GLib.Variant(
"a(iiis)",
this.availableModes.map((mode) => [
mode.width,
mode.height,
mode.refresh,
mode.aspect_ratio,
]),
);
}
/**
* The current "mode" of the monitor, consisting of width, height, and refresh rate.
* Setting this will attempt to apply the change to the monitor asynchronously. Changes
* will not be immediately reflected.
*/
@property(VSOutputMode)
public get mode(): VSOutputMode {
return VSOutputMode.wrap(this._mode);
}
public set mode(value: OutputMode | [number, number, number, string]) {
if (value instanceof Array) {
const [width, height, refresh, aspect_ratio] = value;
value = {
width,
height,
refresh,
aspect_ratio,
};
}
// Wrap it to get the features of VSWrapper
const wrapped = VSOutputMode.wrap(value);
if (wrapped.equals(this._mode)) {
// Already in this mode
return;
}
console.log("setting mode");
const refreshString = wrapped.refresh.toString();
const modeline =
`${wrapped.width}x${wrapped.height}@${refreshString.substring(0, refreshString.length - 3)}.${refreshString.substring(refreshString.length - 3)}Hz` as const;
console.log(this.availableModes.map((mode) => mode.simplify()));
// Check to ensure that at least one mode matches this
if (!this.availableModes.some((mode) => wrapped.equals(mode))) {
// Invalid mode, log and return
console.warn(`Invalid mode ${modeline} for output ${this.name}`);
return;
}
// Apply the mode
CompositorConnection.instance.setOutputProperty(
this.name,
"modeline",
modeline,
);
// Don't notify anyone yet, the stored value will be updated asynchronously when we get the output change event
}
/**
* Whether the monitor is active (can have windows, workspaces, etc. on it).
* Setting this will attempt to apply the change to the monitor asynchronously. Changes
* will not be immediately reflected.
*/
@property(Boolean)
public get active() {
return this._active;
}
public set active(value) {
if (value === this._active) {
// Do nothing if nothing changed
return;
}
// Set the active state
CompositorConnection.instance.setOutputProperty(this.name, "active", value);
}
/**
* Whether the monitor is powered. An unpowered monitor can still be active, but will be placed into
* a power-saving mode with the panel turned off.
* Setting this will attempt to apply the change to the monitor asynchronously. Changes
* will not be immediately reflected.
*/
@property(Boolean)
public get powered() {
return this._powered;
}
public set powered(value) {
if (value === this._powered) {
// Do nothing if nothing changed
return;
}
// Set the powered state
CompositorConnection.instance
.setOutputProperty(this.name, "powered", value)
.catch(console.error);
}
/**
* The position in the framebuffer the upper-left corner of the monitor is placed at.
* Setting this will attempt to apply the change to the monitor asynchronously. Changes
* will not be immediately reflected.
*/
@property(Object)
public get position(): [x: number, y: number] {
return [this._rect.x, this._rect.y];
}
public set position(value) {
if (value[0] === this._rect.x && value[1] === this._rect.y) {
// Do nothing if nothing changed
return;
}
// Set the position
CompositorConnection.instance.setOutputProperty(
this.name,
"position",
value,
);
}
/**
* The scaling factor of the monitor, changing the effective size of the viewport into
* the framebuffer.
* Setting this will attempt to apply the change to the monitor asynchronously. Changes
* will not be immediately reflected.
*/
@property(Number)
public get scale(): number {
return this._scale;
}
public set scale(value) {
if (value === this._scale) {
return;
}
CompositorConnection.instance.setOutputProperty(this.name, "scale", value);
}
/**
* The rectangle of the viewport into the framebuffer. The width and height here represent
* the _effective_ size of the monitor after scaling.
* This property is read-only, and is determined by a combination of mode, position, and scale.
*/
@property(VSRect)
public get rect(): VSRect {
return VSRect.wrap(this._rect);
}
/**
* The transform string of the monitor, indicating if and how it is rotated and flipped
* Setting this will attempt to apply the change to the monitor asynchronously. Changes
* will not be immediately reflected.
*/
@property(String)
public get transform(): OutputTransform {
return this._transform;
}
public set transform(value) {
if (this._transform === value) {
return;
}
CompositorConnection.instance.setOutputProperty(
this.name,
"transform",
value,
);
}
/**
* Fired when the monitor is "destroyed" (disconnected). Invalidates the object.
*/
@signal(VSMonitor)
declare destroyed: () => void;
/**
* Fired when any element of the monitor's configuration changes.
*/
@signal(VSMonitor)
declare changed: (monitor: this) => void;
}

View File

@ -0,0 +1 @@
import GObject, { property, register } from "astal/gobject";

View File

@ -0,0 +1,67 @@
import GObject, { register, property, signal } from "astal/gobject";
import { VSMonitor } from "./monitor";
import { VSOutput } from "./output";
import { CompositorConnection } from "@services/compositor";
import { EventType, VSOutputEvent } from "@services/compositor/types";
@register({
GTypeName: "OutputService",
})
export class OutputService extends GObject.Object {
#monitors: VSMonitor[] = [];
#outputs: VSOutput[] = [];
public get monitors(): readonly VSMonitor[] {
return this.#monitors;
}
public get outputs(): readonly VSOutput[] {
return this.#outputs;
}
@signal(VSMonitor)
declare monitorConnected: (monitor: VSMonitor) => void;
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) => {
compositorOutputs.forEach((output) =>
this.#monitors.push(new VSMonitor(output)),
);
})
.catch(console.error);
}
}