First update in a while lol

This commit is contained in:
Ezri Brimhall 2024-11-19 14:42:30 -07:00
parent cc75318666
commit 23b63fa58d
Signed by: ezri
GPG Key ID: 058A78E5680C6F24
26 changed files with 2949 additions and 786 deletions

4
.eslintrc Normal file
View File

@ -0,0 +1,4 @@
{
"plugins": ["gjs"],
"extends": ["plugin:gjs/application"]
}

4
ambient.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
import "@girs/gjs";
import "@girs/gdk-3.0";
import "@girs/gjs/dom";
import "@girs/gtk-3.0";

23
app.ts
View File

@ -1,11 +1,16 @@
import { App } from "astal/gtk3"
import style from "./style.css"
import Bar from "./widget/Bar"
import { App } from "astal/gtk3";
import style from "./style.css";
import Bar from "./widget/Bar";
import "@/globals";
import { Sway } from "@services/sway/ipc";
import { Tree } from "@services/sway/workspaces";
new Tree("/home/ezri/.config/sway/workspaces.json");
App.start({
css: style,
main() {
Bar(0)
// Bar(1) // initialize other monitors
},
})
css: style,
main() {
Bar(0);
// Bar(1) // initialize other monitors
},
});

0
dbus/dbus.ts Normal file
View File

View File

@ -0,0 +1,3 @@
<!DOCTYPE
interface PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">

View File

@ -0,0 +1,12 @@
<!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.workspaces.Group">
<property name="name" type="s" access="read" />
<property name="outputName" type="s" access="read" />
<property name="focused" type="b" access="read" />
<property name="focusedWorkspacePath" type="o" access="read" />
<property name="workspacePaths" type="ao" access="read" />
</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.workspaces.Workspace">
<property name="active" type="b" access="read" />
<property name="focused" type="b" access="read" />
<property name="visible" type="b" access="read" />
<property name="urgent" type="b" access="read" />
<property name="name" type="s" access="read" />
<method name="focus" />
<method name="relocate">
<arg name="to" type="o" direction="in"/>
</method>
</interface>
</node>

1406
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "VoidShell",
"version": "0.0.0",
"description": "Custom GTK3 desktop shell meant for Sway sessions",
"type": "module",
"private": true,
"repository": {
"type": "git",
"url": "git+https://git.ezri.dev/ezri/VoidShell.git"
},
"author": "Ezri Brimhall <ezri@ezri.dev>",
"license": "All right reserved",
"devDependencies": {
"eslint": "^9.14.0",
"eslint-plugin-jsdoc": "^50.4.3",
"typescript": "^5.6.3"
},
"dependencies": {
"@girs/gdk-3.0": "^3.24.43-4.0.0-beta.16",
"@girs/gjs": "^4.0.0-beta.16"
}
}

View File

@ -12,7 +12,7 @@ export class System {
.split("\n")
.map((line) => line.trim())
.map((line) => /^PRETTY_HOSTNAME="?([^"]*)"?$/.exec(line))
.filter((line) => line)
.filter((line) => line !== null)
.map((line) => line[1]);
if (lines.length > 0) {
this.#name.set(lines[0]);
@ -38,7 +38,7 @@ export class System {
return System._instance;
}
private #name: Variable<string> = Variable("");
#name: Variable<string> = Variable("");
/**
* The "pretty" hostname of this computer, as a bound value.

42
services/dbus.ts Normal file
View File

@ -0,0 +1,42 @@
import Gio from "gi://Gio";
import GObject, { register } from "astal/gobject";
import { getDbusXml } from "@/utils";
let dbusConnectionResolve: ((connection: any) => void) | null = null;
export const Connection: Promise<any> = new Promise<any>(
(resolve) => (dbusConnectionResolve = resolve),
);
const ownerId = Gio.bus_own_name(
Gio.BusType.SESSION,
"dev.ezri.VoidShell",
Gio.BusNameOwnerFlags.NONE,
(connection) => {
dbusConnectionResolve?.(connection);
},
() => {},
() => {},
);
@register()
export class DBusObject extends GObject.Object {
protected dbusObj: Gio.DBusExportedObject | null = null;
#objectPath: string;
constructor(iface: string, objectPath: string) {
super();
this.#objectPath = objectPath;
getDbusXml(iface).then(async (xml) => {
try {
this.dbusObj = Gio.DBusExportedObject.wrapJSObject(xml, this);
this.dbusObj.export(await Connection, objectPath);
} catch (e) {
console.error(`Error exporting to D-Bus: ${e}`);
}
});
}
get objectPath(): string {
return this.#objectPath;
}
}

View File

@ -163,7 +163,8 @@ export interface Context {
* As such, this value can be used to give a context a slight advantage in the case of a tie, boost a more general
* context over a more specific one, or remove it from consideration entirely.
*
* This value is not considered when requesting a context by name.
* This value is not considered when requesting a context by name, allowing for contexts which cannot be automatically
* selected to be manually selected.
*/
priority?: number;
}

View File

@ -0,0 +1,10 @@
import type { Context } from "./workspaces";
export class InvalidContextError extends Error {
constructor(
public readonly context: Context,
reason: string,
) {
super(reason);
}
}

349
services/sway/ipc.ts Normal file
View File

@ -0,0 +1,349 @@
import GObject, { register, property, signal } from "astal/gobject";
import {
WorkspaceResponse,
OutputResponse,
SwayMessage,
SwayEvent,
WorkspaceEvent,
OutputEvent,
TreeNode,
CommandResponse,
} from "./types";
import Gio from "gi://Gio";
import GLib from "gi://GLib";
import * as utils from "@/utils";
import { Mutex } from "@/synchronization";
@register({
GTypeName: "Sway",
})
export class Sway extends GObject.Object {
#commandSocket: Gio.SocketConnection | null = null;
#subscriptionSocket: Gio.SocketConnection | null = null;
#mode: string = "";
@signal()
/**
* Emitted when the connection to sway is established
*/
declare connected: () => void;
@signal()
/**
* Emitted when the connection to sway is lost
*/
declare disconnected: () => void;
// TODO: figure out how to give these signals a proper type. For now, Object is /fine/.
@signal(Object)
/**
* Emitted when we receive a subscription event
*/
declare subscribed: (events: SwayEvent[]) => void;
@signal(Object)
/**
* Emitted when we receive a workspace event
*/
declare workspace: (event: WorkspaceEvent) => void;
@signal(Object)
/**
* Emitted when we receive an output event
*/
declare output: (event: OutputEvent) => void;
@property(String)
/**
* The current binding mode of sway
*/
get mode(): string {
return this.#mode;
}
@property(Boolean)
get isConnected(): boolean {
return this.#connected;
}
#connected = false;
/**
* Run a command on sway
*/
public async command(command: string): Promise<CommandResponse[]> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.RUN_COMMAND,
command,
)) as string,
);
}
/**
* Get the current sway workspaces
*/
public async getWorkspaces(): Promise<WorkspaceResponse[]> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_WORKSPACES,
)) as string,
);
}
/**
* Get the current sway outputs
*/
public async getOutputs(): Promise<OutputResponse[]> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_OUTPUTS,
)) as string,
);
}
/**
* Get the current sway tree
*/
public async getTree(): Promise<TreeNode> {
return JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_TREE,
)) as string,
);
}
constructor() {
super();
this.#connect();
}
static _instance: Sway;
/**
* Get the default instance of Sway
*/
static get instance() {
if (!Sway._instance) {
Sway._instance = new Sway();
}
return Sway._instance;
}
async #createConnection(address: string): Promise<Gio.SocketConnection> {
console.log(`Connecting to sway socket ${address}`);
const client = new Gio.SocketClient();
return new Promise<Gio.SocketConnection>((resolve, reject) => {
const socket_addr = new Gio.UnixSocketAddress({ path: address });
client.connect_async(socket_addr, null, (client, result) => {
try {
// Type assertion is safe, if it fails we reject the promise in the catch block
const connection = client!.connect_finish(result);
resolve(connection);
} catch (e) {
console.error(`Failed to connect to socket.`, e);
reject(e);
}
});
});
}
async #getMode(): Promise<string> {
const result = JSON.parse(
(await this.#sendRaw(
this.#commandSocket,
SwayMessage.GET_BINDING_STATE,
)) as string,
);
return result.name;
}
async #connect() {
console.log("Connecting to sway");
const address = GLib.getenv("SWAYSOCK");
if (!address) {
console.error("SWAYSOCK not set");
throw new Error("SWAYSOCK not set");
}
this.#commandSocket = await this.#createConnection(address);
this.#subscriptionSocket = await this.#createConnection(address);
console.log("Connected to sway");
// Start listening for subscriptions
this.#startSubscriberListen();
// Get the current mode
this.#mode = await this.#getMode();
this.notify("mode");
this.connected();
this.#connected = true;
this.notify("is-connected");
}
static readonly MAGIC = "i3-ipc";
static readonly HEADER_SIZE = Sway.MAGIC.length + 8;
static readonly CHUNK_SIZE = 4096;
async #startSubscriberListen() {
if (!this.#subscriptionSocket) {
console.error("Not connected");
throw new Error("Not connected");
}
await this.#subscribe(
SwayEvent.OUTPUT,
SwayEvent.WORKSPACE,
SwayEvent.MODE,
SwayEvent.SHUTDOWN,
);
const read = async () => {
while (true) {
try {
const [event, payload] = await this.#readRaw(
this.#subscriptionSocket!,
);
switch (event) {
case SwayEvent.OUTPUT:
this.output(JSON.parse(payload) as OutputEvent);
break;
case SwayEvent.WORKSPACE:
this.workspace(JSON.parse(payload) as WorkspaceEvent);
break;
case SwayEvent.MODE:
this.#mode = JSON.parse(payload).change;
this.notify("mode");
break;
case SwayEvent.SHUTDOWN:
this.disconnected();
break;
default:
console.warn("Unhandled event", event);
break;
}
} catch (e) {
console.error("Failed to read event", e);
}
}
};
read();
}
async #readHeader(
stream: Gio.InputStream,
): Promise<{ length: number; type: SwayMessage | SwayEvent }> {
// We read one byte at a time until we have constructed a full magic string, then we read the rest of the header.
// This allows us to handle partial reads and corrupted data gracefully.
let idx = 0;
while (idx < Sway.MAGIC.length) {
const buffer = await utils.readFromStreamRaw(stream, 1);
const byte = buffer[0];
if (byte !== Sway.MAGIC.charCodeAt(idx)) {
// Reset if we don't match the magic string
idx = 0;
} else {
// Otherwise, keep going
idx++;
}
}
const header = new DataView((await utils.readFromStream(stream, 8)).buffer);
const length = header.getUint32(0, true);
const type = header.getUint32(4, true);
return { length, type };
}
async #readRaw(
socket: Gio.SocketConnection,
): Promise<[SwayMessage | SwayEvent, string]> {
const inputStream = socket.input_stream;
const { length, type } = await this.#readHeader(inputStream);
const payloadBuf = await utils.readFromStream(inputStream, length);
const payload = new TextDecoder().decode(payloadBuf);
return [type as SwayMessage | SwayEvent, payload];
}
private messageMutex = new Mutex();
async #sendRaw(
socket: Gio.SocketConnection | null,
type: SwayMessage,
payload: string = "",
waitForResponse: boolean = true,
): Promise<string | null> {
return this.messageMutex.with(async () => {
if (!socket || !socket.is_connected()) {
console.error("Not connected");
throw new Error("Not connected");
}
if (socket === this.#commandSocket) {
if (type === SwayMessage.SUBSCRIBE) {
console.error("Cannot subscribe on command socket");
throw new Error("Cannot subscribe on command socket");
}
}
// Construct the message
const buffer = new ArrayBuffer(Sway.HEADER_SIZE + payload.length);
const magicView = new Uint8Array(buffer, 0, Sway.MAGIC.length);
const lengthView = new DataView(buffer, Sway.MAGIC.length, 4);
const typeView = new DataView(buffer, Sway.MAGIC.length + 4, 4);
const payloadView = new Uint8Array(buffer, Sway.HEADER_SIZE);
magicView.set(Sway.MAGIC.split("").map((c) => c.charCodeAt(0)));
lengthView.setUint32(0, payload.length, true);
typeView.setUint32(0, type, true);
payloadView.set(payload.split("").map((c) => c.charCodeAt(0)));
const outputStream = socket.output_stream;
// Send the message
try {
await utils.writeToStream(outputStream, buffer);
} catch (e) {
console.error("Failed to write to stream...", e);
throw e;
}
if (!waitForResponse) {
return null;
}
// Read the response
const [resultType, result] = await this.#readRaw(socket);
if (resultType !== type) {
throw new Error(`Unexpected response type: ${resultType}`);
}
return result;
});
}
async #subscribe(...events: SwayEvent[]) {
if (!this.#subscriptionSocket) {
console.error("Not connected");
throw new Error("Not connected");
}
const eventNames = events.map((e) => SwayEvent[e].toLowerCase());
const payload = JSON.stringify(eventNames);
console.log("Subscribing to events:", payload);
try {
await this.#sendRaw(
this.#subscriptionSocket,
SwayMessage.SUBSCRIBE,
payload,
);
console.log(`Subscribed to events: ${payload}`);
this.subscribed(events);
} catch (e) {
console.error(`Failed to subscribe:`, e);
throw e;
}
}
}

26
services/sway/service.ts Normal file
View File

@ -0,0 +1,26 @@
import * as Astal from "astal";
import GObject, { register, property, signal } from "astal/gobject";
import { Sway } from "./ipc";
import * as types from "./types";
@register({
GTypeName: "SwayService",
})
export class SwayService extends GObject.Object {
static _instance: SwayService;
constructor() {
super();
// Initialize the IPC and register event listeners
Sway.instance;
}
public static get instance(): SwayService {
if (!SwayService._instance) {
SwayService._instance = new SwayService();
}
return SwayService._instance;
}
#onWorkspaceEvent(event: types.WorkspaceEvent) {}
}

View File

@ -252,7 +252,7 @@ export interface XWaylandWindowProperties {
*/
export interface TreeNode {
id: number;
name: number;
name: string;
type: NodeType;
border: NodeBorder;
current_border_width: number;
@ -351,8 +351,8 @@ export enum WorkspaceEventChange {
export interface WorkspaceEvent {
change: WorkspaceEventChange;
current: WorkspaceResponse;
old: WorkspaceResponse | null;
current: TreeNode;
old: TreeNode | null;
}
export interface OutputEvent {

807
services/sway/workspaces.ts Normal file
View File

@ -0,0 +1,807 @@
import GObject, { register, property, signal } from "astal/gobject";
import { readFileAsync, monitorFile } from "astal/file";
import * as types from "./types";
import * as defs from "./definitions";
import Gdk from "gi://Gdk";
import Gio from "gi://Gio";
import GLib from "gi://GLib";
import { Sway } from "./ipc";
import { InvalidContextError } from "./exceptions";
import { dereferenceSymbolicLink, getDbusXml } from "@/utils";
import { Connection, DBusObject } from "@services/dbus";
@register({
GTypeName: "SwayWorkspace",
})
export class Workspace extends DBusObject {
#active: boolean = false;
#focused: boolean = false;
#visible: boolean = false;
#urgent: boolean = false;
groups: Set<Group> = new Set();
#currentGroup: Group | null = null;
#currentOutput: string | null = null;
constructor(public readonly definition: defs.Workspace) {
super(
"dev.ezri.voidshell.workspaces.Workspace",
`/dev/ezri/VoidShell/workspaces/workspace/${definition.index}`,
);
}
/**
* Create an ephemeral workspace from a sync operation that is not defined in the system.
*/
public static fromSync(update: types.WorkspaceResponse) {
const [name, index] = (() => {
if (parseInt(update.name).toString() === update.name) {
return [`ephemeral: ${update.name}`, parseInt(update.name)];
} else {
return [update.name, update.num];
}
})();
const definition: defs.Workspace = {
index,
name,
exec: "console",
program_name: "console",
};
const workspace = new this(definition);
workspace.sync(update);
return workspace;
}
@property(String)
get objectPath() {
return `/dev/ezri/VoidShell/workspaces/workspace/${this.definition.index}`;
}
@property(Boolean)
get active() {
return this.#active;
}
set active(value: boolean) {
if (this.#active === value) {
return;
}
this.#active = value;
this.notify("active");
this.dbusObj?.emit_property_changed(
"active",
GLib.Variant.new_boolean(value),
);
if (!value) {
// Whenever a workspace is deactivated, all other flags should be unset.
this.focused = false;
this.visible = false;
this.urgent = false;
}
}
@property(Boolean)
get focused() {
return this.#focused;
}
set focused(value: boolean) {
if (this.#focused === value) {
return;
}
this.#focused = value;
this.notify("focused");
this.dbusObj?.emit_property_changed(
"focused",
GLib.Variant.new_boolean(value),
);
}
@property(Boolean)
get visible() {
return this.#visible;
}
set visible(value: boolean) {
if (this.#visible === value) {
return;
}
this.#visible = value;
this.notify("visible");
this.dbusObj?.emit_property_changed(
"visible",
GLib.Variant.new_boolean(value),
);
}
@property(Boolean)
get urgent() {
return this.#urgent;
}
set urgent(value: boolean) {
if (this.#urgent === value) {
return;
}
this.#urgent = value;
this.notify("urgent");
this.dbusObj?.emit_property_changed(
"urgent",
GLib.Variant.new_boolean(value),
);
}
@property(String)
get name() {
return this.definition.name;
}
@property(Group)
/**
* The group that this workspace is currently assigned to.
*
* While a workspace can be in multiple groups, including multiple groups
* in the same context, it can only be assigned to one group at a time. If
* activated in a different group in the same context, it will be reassigned
* to that group.
*
* An inactive workspace will not have a current group, even if it only belongs
* to a single group in the active context.
*/
get currentGroup() {
return this.#currentGroup;
}
set currentGroup(group: Group | null) {
if (group !== this.#currentGroup) {
this.#currentGroup = group;
this.notify("current-group");
}
}
@property(String)
get currentOutput() {
return this.#currentGroup?.outputName ?? null;
}
set currentOutput(output) {
if (output !== this.#currentOutput) {
this.#currentOutput = output;
this.notify("current-output");
}
}
deactivate() {
console.log(`Deactivating workspace ${this.definition.index}`);
this.active = false;
}
sync(update: types.WorkspaceResponse) {
this.active = true;
this.visible = update.visible;
this.focused = update.focused;
this.urgent = update.urgent;
}
async relocate(to: Group, retainFocus: boolean = false) {
if (this.#currentGroup === to) {
return;
}
await Sway.instance.command(
`workspace ${this.definition.index}; move workspace to output ${to.outputName}`,
);
}
async focus(to?: Group) {
if (this.#currentGroup === to || to === undefined) {
await Sway.instance.command(`workspace ${this.definition.index}`);
} else {
await this.relocate(to);
}
}
async moveContainerTo() {
await Sway.instance.command(
`move container to workspace ${this.definition.index}`,
);
}
}
@register({
GTypeName: "SwayWorkspaceGroup",
})
export class Group extends DBusObject {
#workspaces: Workspace[] = [];
#context: Context;
#outputName: string | null = null;
#monitor: Gdk.Monitor | null = null;
@property(Context)
get context() {
return this.#context;
}
@property(Workspace)
get focusedWorkspace() {
return this.#workspaces.find((workspace) => workspace.visible);
}
@property(Workspace)
get focused() {
return this.focusedWorkspace?.focused ?? false;
}
@property(Gdk.Monitor)
get monitor() {
return this.#monitor;
}
@property(String)
get outputName() {
return this.#outputName ?? "";
}
@property(Object)
get workspacePaths() {
return this.#workspaces.map((workspace) => workspace.objectPath);
}
@property(String)
get focusedWorkspacePath() {
return this.focusedWorkspace?.objectPath ?? "";
}
@signal()
declare gainedFocus: () => void;
@signal()
declare lostFocus: () => void;
@signal(Gdk.Monitor)
declare activated: (monitor: Gdk.Monitor) => void;
@signal()
declare deactivated: () => void;
constructor(
public readonly definition: defs.Group,
public readonly name: string,
public readonly outputDefinition: defs.Output,
context: Context,
) {
super(
"dev.ezri.voidshell.workspaces.Group",
`/dev/ezri/VoidShell/workspaces/context/${context.name}/group/${name}`,
);
this.#context = context;
this.#workspaces = definition.workspaces
.map((index) => Tree.instance.workspaces.get(index.toString()))
.filter((workspace) => workspace !== undefined);
this.#workspaces.forEach((workspace) => {
workspace.connect(
"notify::visible",
// Bind the function, just to be safe.
(() => {
if (workspace.visible) {
this.notify("focused-workspace");
}
}).bind(this),
);
workspace.connect(
"notify::focused",
(() => {
if (workspace === this.focusedWorkspace) {
this.notify("focused");
}
}).bind(this),
);
});
}
/**
* Compute the score of this group based on the given Sway output.
*/
score(output: types.OutputResponse): [number, number] {
if (
this.outputDefinition.make === output.make &&
this.outputDefinition.model === output.model &&
this.outputDefinition.serial === output.serial
) {
// Perfect match scores 3.
return [3, 0];
}
const nameMatch =
this.outputDefinition.names?.findIndex((name) => name === output.name) ??
-1;
if (nameMatch !== -1) {
// Name match scores 2.
return [2, nameMatch];
}
if (this.outputDefinition.names?.includes("*")) {
// Wildcard match scores 1.
return [1, 0];
}
return [0, 0];
}
getOutputPreferences(outputs: types.OutputResponse[]) {
// Highest preference goes to a perfect match
const scores = outputs
.map((output) => [output, ...this.score(output)] as const)
.sort((a, b) => {
if (a[1] === b[1]) {
return a[2] - b[2];
}
return b[1] - a[1];
})
.filter(([_, score]) => score > 0);
return scores;
}
async activate(output: types.OutputResponse) {
// Ensure that this output is compatible with the group.
const [score, _] = this.score(output);
if (score === 0) {
throw new Error(
`Output ${output.name} is not compatible with this group.`,
);
}
// Configure monitor
await Sway.instance.command(
`output ${output.name} pos ${this.outputDefinition.position[0]} ${this.outputDefinition.position[1]} ${this.outputDefinition.mode}`,
);
// Enable monitor.
await Sway.instance.command(`output ${output.name} enable`);
// Store monitor name.
this.#outputName = output.name;
// Retrieve GDK monitor.
const monitor =
Gdk.Display.get_default()?.get_monitor_at_point(
this.outputDefinition.position[0],
this.outputDefinition.position[1],
) ?? null;
// Notify listeners.
this.#monitor = monitor;
if (monitor !== null) {
this.activated(monitor);
}
this.notify("monitor");
// If the output's focused workspace is not in this group, focus the first workspace.
if (!this.#workspaces.some((workspace) => workspace.visible)) {
await this.#workspaces[0].focus(this);
}
// Add ourselves to each workspace
this.#workspaces.forEach((workspace) => workspace.groups.add(this));
}
async focus() {
if (this.#outputName === null) {
throw new Error("Group is not activated.");
}
await Sway.instance.command(`focus output ${this.#outputName}`);
}
getWorkspace(index: number): Workspace | undefined {
if (index >= this.#workspaces.length) {
return undefined;
}
return this.#workspaces[index];
}
async focusWorkspace(index: number): Promise<boolean> {
const workspace = this.getWorkspace(index);
if (workspace === undefined) return false;
try {
await workspace.focus(this);
} catch {
return false;
}
return true;
}
deactivate() {
// Remove ourselves from every workspace
this.#workspaces.forEach((workspace) => workspace.groups.delete(this));
}
}
@register({
GTypeName: "SwayWorkspaceContext",
})
export class Context extends DBusObject {
#groups: Map<string, Group> = new Map();
#focusedGroup: Group | null = null;
#groupOrder: string[] = [];
#active: boolean = false;
constructor(
public readonly definition: defs.Context,
public readonly name: string,
) {
super(
"dev.ezri.voidshell.workspaces.Context",
`/dev/ezri/VoidShell/workspaces/context/${name}`,
);
definition.outputs.forEach((output) => {
if (output.group === undefined || !definition.groups[output.group]) {
throw new Error(`Group ${output.group} not found in context`);
}
const group = new Group(
definition.groups[output.group],
output.group,
output,
this,
);
this.#groups.set(output.group, group);
});
// Group order is defined by the order in which the groups are defined in the context, not the order in which they are defined in the outputs.
this.#groupOrder = Object.keys(definition.groups);
}
@property(Group)
get focusedGroup() {
return this.#focusedGroup;
}
@property(Boolean)
get active() {
return this.#active;
}
getGroupOnOutput(output: string): Group | undefined {
return Array.from(this.#groups.values()).find(
(group) => group.outputName === output,
);
}
/**
* This function implements the output matching algorithm.
* @param outputSet The set of outputs to match against. Usually the currently connected outputs, as retrieved from Sway.
* @returns A map of group names to outputs and scores.
*/
#matchGroupsWithOutputs(outputSet: types.OutputResponse[]) {
// First get preferences
const scores = Array.from(this.#groups.entries()).map(([name, group]) => {
return [[name, group], group.getOutputPreferences(outputSet)] as const;
});
// Create set of available outputs
const availableOutputs = new Set(outputSet);
// Create set of used groups
const usedGroups = new Set<Group>();
let i = 0;
let score = 3;
const result = new Map<
string,
[output: types.OutputResponse, score: number]
>();
while (availableOutputs.size > 0 && score > 0) {
const [[name, group], preferences] = scores[i];
if (usedGroups.has(group)) {
i++;
if (i >= scores.length) {
i = 0;
score--;
}
continue;
}
const [output, groupScore] = preferences.find(
([output, s]) => s === score && availableOutputs.has(output),
) ?? [undefined];
if (output !== undefined) {
availableOutputs.delete(output);
usedGroups.add(group);
result.set(name, [output, groupScore]);
if (score === 1) {
// Only one output can be matched with a wildcard
break;
}
}
}
// Verify that all groups have been matched
if (usedGroups.size !== this.#groups.size) {
return null;
}
return result;
}
/**
* Compute the score of this context based on the given Sway output set.
*/
score(outputSet: types.OutputResponse[], includePriority = true) {
const match = this.#matchGroupsWithOutputs(outputSet);
if (match === null) {
return 0;
}
return Array.from(match.values()).reduce(
(acc, [_, score]) => acc + score,
includePriority ? (this.definition.priority ?? 0) : 0,
);
}
/**
* Activate this context.
*/
async activate() {
const outputs = await Sway.instance.getOutputs();
const match = this.#matchGroupsWithOutputs(outputs);
if (match === null) {
throw new InvalidContextError(
this,
"Could not activate context: context is incompatible with current outputs.",
);
}
// Construct a set of output names that this context will use.
const usedOutputs = new Set(
Array.from(match.values()).map(([output]) => output.name),
);
if (Tree.instance.currentContext !== null) {
await Tree.instance.currentContext.deactivate(usedOutputs);
}
// Activate groups.
await Promise.all(
Array.from(match.entries()).map(async ([name, [output, _]]) => {
const group = this.#groups.get(name);
if (group === undefined) {
throw new InvalidContextError(
this,
`Could not activate context: group ${name} is not defined.`,
);
}
await group.activate(output);
}),
);
// Notify listeners.
this.#active = true;
this.notify("active");
Tree.instance.notify("current-context");
}
/**
* Deactivate this context.
* @param outputsToLeave A set of outputs that should not be disabled, selected by output name
*/
async deactivate(outputsToLeave: Set<string>) {
// First, notify for deactivation and move ourselves to the lowest priority in the event list
this.#active = false;
Tree.instance.emit("deactivating");
await Promise.resolve(); // This should allow the event loop to run and any event listeners to run before we continue
// Then, deactivate the outputs and groups.
const command = Array.from(this.#groups.values())
.map((group) => {
group.deactivate();
return group;
})
.filter((group) => !outputsToLeave.has(group.outputName ?? ""))
.map((group) => `output ${group.outputName} disable`)
.join("; ");
await Sway.instance.command(command);
}
getWorkspace(
index: number,
): [workspace: Workspace, group: Group] | [null, null] {
const currentGroupIndex = Math.max(
this.#groupOrder.indexOf(this.focusedGroup?.name ?? ""),
0,
);
const groupName = this.#groupOrder
.rotate(currentGroupIndex)
.find((name) => {
const group = this.#groups.get(name);
if (group === undefined) {
return false;
}
const workspace = group.getWorkspace(index);
if (workspace === this.focusedGroup?.focusedWorkspace) {
// If the workspace is the focused workspace of the focused group, don't return it; we want to search other outputs.
return false;
}
// If the workspace at this index exists on this output, return it. Otherwise, return the accumulator.
return !!workspace;
});
if (groupName === undefined) {
return [null, null];
}
const group = this.#groups.get(groupName) ?? null;
const workspace = group?.getWorkspace(index) ?? null;
if (workspace === null || group === null) {
return [null, null];
}
return [workspace, group];
}
}
@register({
GTypeName: "SwayWorkspaceTree",
})
export class Tree extends GObject.Object {
#workspaces: Map<string, Workspace> = new Map();
#contexts: Map<string, Context> = new Map();
static _instance: Tree | null = null;
public get workspaces() {
return this.#workspaces;
}
async #parseFile(path: string) {
console.log(`reading file ${path}`);
const contents = await readFileAsync(path);
const data = JSON.parse(contents) as defs.Config;
this.#workspaces = new Map();
this.#contexts = new Map();
// Create workspaces
data.workspaces.forEach((workspace) => {
this.#workspaces.set(
workspace.index.toString(),
new Workspace(workspace),
);
});
// Create contexts
Object.entries(data.contexts).forEach(([name, context]) => {
this.#contexts.set(name, new Context(context, name));
});
// Notify for tree refresh
this.emit("tree-refresh");
// If we're connected to Sway, immediately do a full tree sync. Otherwise,
// wait to be connected and then do a full tree sync.
if (Sway.instance.isConnected) {
this.sync();
} else {
const connId = Sway.instance.connect("connected", () => {
Sway.instance.disconnect(connId);
this.sync();
});
}
}
/**
* Synchronize the state of VoidShell's workspace tree with Sway
*/
async sync() {
const touched = new Set<Workspace>();
try {
const workspaces = await Sway.instance.getWorkspaces();
workspaces.forEach((workspace) => {
const ws = this.#workspaces.get(workspace.name);
if (ws === undefined) {
this.#workspaces.set(workspace.name, Workspace.fromSync(workspace));
} else {
ws.sync(workspace);
touched.add(ws);
}
});
this.#workspaces.forEach((workspace) => {
if (!touched.has(workspace)) {
workspace.active = false;
}
});
} catch (e) {
console.error("Failed to sync", e);
}
}
static get instance() {
if (!this._instance) {
throw new Error("Tree instance not initialized");
}
return Tree._instance as Tree;
}
constructor(filepath: string) {
super();
console.log(`Parsing file ${filepath}`);
if (Tree._instance !== null) {
console.error("Tree instance already initialized...");
throw new Error("Tree instance already initialized");
}
Tree._instance = this;
console.log("Dereferencing symlink");
dereferenceSymbolicLink(filepath)
.then(async (resolvedPath) => {
console.log(`Resolved ${filepath} to ${resolvedPath}`);
try {
await this.#parseFile(resolvedPath);
} catch (e) {
console.error("Error parsing config file:", e);
}
monitorFile(filepath, async (_, event) => {
if (event === Gio.FileMonitorEvent.CHANGES_DONE_HINT) {
const oldWorkspaces = this.#workspaces;
const oldContexts = this.#contexts;
try {
await this.#parseFile(filepath);
} catch (e) {
console.error("Error parsing config file:", e);
this.#workspaces = oldWorkspaces;
this.#contexts = oldContexts;
}
}
});
})
.catch((reason) => {
console.error("Error dereferencing symlink:", reason);
});
Sway.instance.connect("workspace", (_, event: types.WorkspaceEvent) => {
this.sync();
});
}
@property(Context)
get currentContext(): Context | null {
return (
Array.from(this.#contexts.values()).find((context) => context.active) ??
null
);
}
@property(String)
get currentContextName(): string {
return (
Array.from(this.#contexts.entries()).find(
([, context]) => context.active,
)?.[0] ?? ""
);
}
@signal()
/**
* Emitted when a context begins deactivating.
*/
declare deactivating: () => void;
@signal()
/**
* Emitted after a full tree refresh which invalidates all context, group, and workspace objects.
*/
declare treeRefresh: () => void;
async findNewContext() {
const outputs = await Sway.instance.getOutputs();
const topScore = Array.from(this.#contexts.values())
.map((context) => [context, context.score(outputs)] as const)
.sort(([, a], [, b]) => b - a)
.find(([_, score]) => score > 0)?.[0];
if (topScore === undefined) {
// No available context
return;
}
await topScore.activate();
}
async requestContext(name: string) {
const context = this.#contexts.get(name);
if (context === undefined) {
throw new Error(`Context ${name} not found`);
}
// Get current outputs
const outputs = await Sway.instance.getOutputs();
// Score the context
const score = context.score(outputs, false);
if (score > 0) {
await context.activate();
}
throw new Error(`Context ${name} is incompatible with current outputs`);
}
getWorkspace(
groupIndex: number,
): [workspace: Workspace, group: Group] | [null, null] {
return this.currentContext?.getWorkspace(groupIndex) ?? [null, null];
}
}

59
synchronization.ts Normal file
View File

@ -0,0 +1,59 @@
// This module contains async synchronization primitives
//
// Synchronization primitives are still needed in async code because any "await" or ".then()" call will
// return to the event loop, potentially allowing another coroutine to
/**
* Basic synchronization primitive that only allows a single coroutine to access a resource
* at a time.
*/
export class Mutex {
private locking = Promise.resolve();
private isLocked = false;
/**
* Get the current state of the mutex.
* Unlike multithreaded synchronization primitives, this can be queried safely without having to acquire
* the mutex, as asyncio multitasking is cooperative, meaning you don't need to worry about another task
* interrupting your function unless your function returns to the event loop via an 'await' statement.
*/
get locked() {
return this.isLocked;
}
/**
* Acquire the mutex. This returns a function to release the mutex, which must be called
* or the application will deadlock.
*
* It is preferred to use the with() method instead of calling acquire() directly, which provides
* safeguards to ensure that the lock will always be released, however some logic and code flows
* will require the flexibility that calling acquire() directly gives.
*/
async acquire() {
this.isLocked = true;
let unlockNext: () => void;
const willLock = new Promise<void>((resolve) => (unlockNext = resolve));
willLock.then(() => (this.isLocked = false));
const willUnlock = this.locking.then(() => unlockNext);
this.locking = this.locking.then(() => willLock);
return willUnlock;
}
/**
* Executes the given function with the mutex acquired, and releases the mutex when the function exits.
*
* This is the preferred way to utilize a Mutex, as it ensures that it will not be left in a locked state.
*/
async with<T>(func: () => T): Promise<T> {
const release = await this.acquire();
try {
let result = func();
while (result instanceof Promise) {
result = await result;
}
return result;
} finally {
release();
}
}
}

View File

@ -1,21 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"experimentalDecorators": true,
"strict": true,
"target": "ES2023",
"moduleResolution": "Bundler",
// "checkJs": true,
// "allowJs": true,
"jsx": "react-jsx",
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
"paths": {
"astal": [
"/usr/share/astal/gjs"
],
"astal/*": [
"/usr/share/astal/gjs/*"
]
},
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"experimentalDecorators": true,
"strict": true,
"target": "ES2023",
"moduleResolution": "Bundler",
// "checkJs": true,
// "allowJs": true,
"jsx": "react-jsx",
"jsxImportSource": "/usr/share/astal/gjs/gtk3",
"include": ["ambient.d.ts"],
"paths": {
"astal": ["/usr/share/astal/gjs"],
"astal/*": ["/usr/share/astal/gjs/*"],
"@/*": ["./*"],
"@widgets/*": ["./widget/*"],
"@services/*": ["./services/*"]
}
}
}

144
utils.ts Normal file
View File

@ -0,0 +1,144 @@
import Gio from "gi://Gio";
import GLib from "gi://GLib";
import { readFileAsync } from "astal/file";
export async function readFromStreamRaw(
stream: Gio.InputStream,
bytes: number,
): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
stream.read_bytes_async(
bytes,
GLib.PRIORITY_DEFAULT,
null,
(stream, result) => {
try {
const data = stream!.read_bytes_finish(result);
const buffer = data.get_data();
if (buffer === null) {
reject(new Error("Failed to read from stream"));
return;
}
resolve(buffer);
} catch (e) {
reject(e);
}
},
);
});
}
export async function readFromStream(
stream: Gio.InputStream,
bytes: number,
): Promise<Uint8Array> {
const chunkCount = Math.ceil(bytes / 4096);
const buffer = await Array.from({ length: chunkCount }, (_, i) => i).reduce(
async (acc, i) => {
const buffer = await acc;
const chunkSize = Math.min(4096, bytes - i * 4096);
const chunk = await readFromStreamRaw(stream, chunkSize);
buffer.set(chunk, i * 4096);
return buffer;
},
Promise.resolve(new Uint8Array(bytes)),
);
return buffer;
}
export async function writeToStream(
stream: Gio.OutputStream,
data: ArrayBuffer | Uint8Array,
): Promise<void> {
if (data instanceof ArrayBuffer) {
data = new Uint8Array(data);
}
return new Promise<void>((resolve, reject) => {
stream.write_all_async(
data as Uint8Array,
GLib.PRIORITY_DEFAULT,
null,
(stream, result) => {
try {
stream?.write_all_finish(result) ??
reject(new Error("Failed to write to stream"));
resolve();
} catch (e) {
reject(e);
}
},
);
});
}
export async function dereferenceSymbolicLink(
filename: string,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
console.log(`Dereferencing symbolic link for ${filename}`);
const file = Gio.File.new_for_path(filename);
file.query_info_async(
"standard::is-symlink,standard::symlink-target",
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
null,
(file, result) => {
try {
const info = file!.query_info_finish(result);
if (info.get_is_symlink()) {
console.log("File is a symlink");
const target = info.get_symlink_target() as string;
console.log(target);
if (target.startsWith("./") || target.startsWith("../")) {
resolve(file!.resolve_relative_path(target).get_path()!);
} else if (target.startsWith("/")) {
resolve(target);
} else {
resolve(file!.get_parent()!.get_child(target).get_path()!);
}
resolve(file!.resolve_relative_path(target).get_path()!);
} else {
resolve(filename);
}
} catch (e) {
console.error(e);
reject(e);
}
},
);
});
}
const dbusXml = new Map<string, string>();
export async function getDbusXml(ifaceName: string) {
if (dbusXml.has(ifaceName)) {
return dbusXml.get(ifaceName) as string;
}
const contents = await readFileAsync(`dbus/${ifaceName}.xml`);
dbusXml.set(ifaceName, contents);
return contents;
}
declare global {
// Add new "rotate" method to Array
interface Array<T> {
/**
* Rotates the array by `n` positions.
*
* @param n The number of positions to rotate the array by.
* @returns The rotated array.
*/
rotate(n: number): T[];
}
}
Array.prototype.rotate = function <T>(this: T[], n: number): T[] {
const array = this;
const length = array.length;
if (length === 0) {
return [];
}
n = ((n % length) + length) % length;
return array.slice(n).concat(array.slice(0, n));
};

View File

@ -1,10 +1,13 @@
import { App, Astal, Gtk } from "astal/gtk3";
import { Variable } from "astal";
import { bind, Variable } from "astal";
import { SystemName } from "./system/Name";
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"
@ -18,9 +21,7 @@ export default function Bar(monitor: number) {
application={App}
>
<centerbox>
<button onClicked="echo hello" halign={Gtk.Align.CENTER}>
Welcome to AGS!
</button>
<label label={currentContext} />
<box>
<SystemName />
</box>

View File

@ -1,312 +0,0 @@
import GObject, { register, property, signal } from "astal/gobject";
import { App } from "astal";
import {
WorkspaceResponse,
OutputResponse,
SwayMessage,
SwayEvent,
ModeEvent,
WorkspaceEvent,
OutputEvent,
} from "./types";
import Gdk from "gi://Gdk";
import Gio from "gi://Gio";
import GLib from "gi://GLib";
import * as utils from "../../utils";
@register({
GTypeName: "Sway",
})
/**
* Sway connection singleton that implements the sway IPC protocol at a low level.
*/
export class Sway extends GObject.Object {
private #commandSocket: Gio.SocketConnection | null = null;
private #subscriptionSocket: Gio.SocketConnection | null = null;
private #mode: string = "";
@signal()
/**
* Emitted when the connection to sway is established
*/
declare connected: () => void;
@signal()
/**
* Emitted when the connection to sway is lost
*/
declare disconnected: () => void;
@signal(Array)
/**
* Emitted when we receive a subscription event
*/
declare subscribed: (events: SwayEvent[]) => void;
@signal(Object)
/**
* Emitted when we receive a workspace event
*/
declare workspace: (event: WorkspaceEvent) => void;
@signal(Object)
/**
* Emitted when we receive an output event
*/
declare output: (event: OutputEvent) => void;
@property(String)
/**
* The current binding mode of sway
*/
get mode(): string {
return this.#mode;
}
/**
* Run a command on sway
*/
public async command(command: string): Promise<CommandResponse[]> {
const result = JSON.parse(
await this.#sendRaw(this.#commandSocket, SwayMessage.RUN_COMMAND, command)
);
}
/**
* Get the current sway workspaces
*/
public async getWorkspaces(): Promise<WorkspaceResponse[]> {
const result = JSON.parse(
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_WORKSPACES)
);
}
/**
* Get the current sway outputs
*/
public async getOutputs(): Promise<OutputResponse[]> {
const result = JSON.parse(
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_OUTPUTS)
);
}
/**
* Get the current sway tree
*/
public async getTree(): Promise<TreeNode> {
const result = JSON.parse(
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_TREE)
);
}
private constructor() {
super();
this.#connect();
}
private static #instance: Sway;
/**
* Get the default instance of Sway
*/
static get_default() {
if (!Sway.#instance) {
Sway.#instance = new Sway();
}
return Sway.#instance;
}
private async #createConnection(
address: string
): Promise<Gio.SocketConnection> {
const client = new Gio.SocketClient();
return new Promise<Gio.SocketConnection>((resolve, reject) => {
client.connect_to_uri_async(
`unix:${address}`,
0,
null,
(client, result) => {
try {
const connection = client.connect_to_uri_finish(result);
resolve(connection);
} catch (e) {
reject(e);
}
}
);
});
}
private async #getMode(): Promise<string> {
const result = JSON.parse(
await this.#sendRaw(this.#commandSocket, SwayMessage.GET_BINDING_STATE)
);
return result.name;
}
private async #connect() {
const address = GLib.getenv("SWAYSOCK");
if (!address) {
throw new Error("SWAYSOCK not set");
}
const socket = new Gio.SocketClient();
// Create two connections, one for commands and one for subscriptions. This is so we
// can easily distinguish between responses to commands and subscription events.
const command = await this.#createConnection(address);
const subscription = await this.#createConnection(address);
this.#commandSocket = command;
this.#subscriptionSocket = subscription;
this.connected();
// Get the current mode
this.#mode = await this.#getMode();
this.notify("mode");
this.#startSubscriberListen();
}
static readonly MAGIC = "i3-ipc";
static readonly HEADER_SIZE = Sway.MAGIC.length + 8;
static readonly CHUNK_SIZE = 4096;
private #startSubscriberListen() {
if (!this.#subscriptionSocket) {
throw new Error("Not connected");
}
this.#subscribe(
SwayEvent.OUTPUT,
SwayEvent.WORKSPACE,
SwayEvent.MODE,
SwayEvent.SHUTDOWN
);
const inputStream = this.#subscriptionSocket.input_stream;
const reader = new Gio.DataInputStream({ base_stream: inputStream });
const read = async () => {
while (true) {
try {
const [event, payload] = await this.#readRaw(
this.#subscriptionSocket
);
switch (event) {
case SwayEvent.OUTPUT:
const event = JSON.parse(payload) as OutputResponse;
this.output(event);
break;
case SwayEvent.WORKSPACE:
const event = JSON.parse(payload) as WorkspaceResponse;
this.workspace(event);
break;
case SwayEvent.MODE:
const event = JSON.parse(payload) as ModeResponse;
this.mode(event);
break;
case SwayEvent.SHUTDOWN:
this.disconnected();
break;
default:
console.warn("Unhandled event", event);
break;
}
} catch (e) {
console.error("Failed to read event", e);
break;
}
}
};
read();
}
private async #readHeader(
stream: Gio.InputStream
): Promise<{ length: number; type: SwayMessage | SwayEvent }> {
// We read one byte at a time until we have constructed a full magic string, then we read the rest of the header
// This allows us to handle partial reads and corrupted data gracefully
let idx = 0;
const magic = new Uint8Array(Sway.MAGIC.length);
while (idx < Sway.MAGIC.length) {
const buffer = await utils.readFromStreamRaw(stream, 1);
const byte = buffer[0];
if (byte !== Sway.MAGIC.charCodeAt(idx)) {
// Reset if we don't match the magic string
idx = 0;
} else {
// Otherwise, keep going
idx++;
}
}
const header = new DataView(await readFromStream(stream, 8).buffer);
const length = header.getUint32(0, true);
const type = header.getUint32(4, true);
return { length, type };
}
private async #readRaw(
socket: Gio.SocketConnection
): Promise<[SwayMessage | SwayEvent, string]> {
const inputStream = socket.input_stream;
const { length, type } = await this.#readHeader(inputStream);
const payloadBuf = utils.readFromStream(inputStream, length);
const payload = new TextDecoder().decode(payloadBuf);
return [type as SwayMessage | SwayEvent, payload];
}
private async #sendRaw(
socket: Gio.SocketConnection | null,
type: SwayMessage,
payload: string = ""
): Promise<string> {
if (!socket || !socket.is_connected()) {
throw new Error("Not connected");
}
if (socket === this.#commandSocket && type === SwayMessage.SUBSCRIBE) {
throw new Error("Cannot subscribe on command socket");
}
// Construct the message
const buffer = new ArrayBuffer(Sway.HEADER_SIZE + payload.length);
const magicView = new Uint8Array(buffer, 0, Sway.MAGIC.length);
const lengthView = new DataView(buffer, Sway.MAGIC.length, 4);
const typeView = new DataView(buffer, Sway.MAGIC.length + 4, 4);
const payloadView = new Uint8Array(buffer, Sway.HEADER_SIZE);
magicView.set(Sway.MAGIC.split("").map((c) => c.charCodeAt(0)));
lengthView.setUint32(0, payload.length, true);
typeView.setUint32(0, type, true);
payloadView.set(payload.split("").map((c) => c.charCodeAt(0)));
const outputStream = this.#commandSocket.output_stream;
// Send the message
await utils.writeToStream(outputStream, buffer);
// Read the response
const [resultType, result] = await this.#readRaw(socket);
if (resultType !== type) {
throw new Error(`Unexpected response type: ${resultType}`);
}
return result;
}
private async #subscribe(...events: SwayEvent[]) {
if (!this.#subscriptionSocket) {
throw new Error("Not connected");
}
const eventNames = events.map((e) => SwayEvent[e].toLowerCase());
const payload = JSON.stringify(eventNames);
const result = JSON.parse(
await this.#sendRaw(
this.#subscriptionSocket,
SwayMessage.SUBSCRIBE,
payload
)
);
if (result.success) {
this.subscribed(events);
} else {
throw new Error(`Failed to subscribe: ${result}`);
}
}
}

View File

@ -1,25 +0,0 @@
import { App } from "astal";
import GObject, { register, property, signal } from "astal/gobject";
import { Sway } from "./ipc";
import * as types from "./types";
@register({
GTypeName: "SwayService",
})
export class SwayService extends GObject.Object {
private static #instance: SwayService;
private constructor() {
// Initialize the IPC and register event listeners
Sway.get_default();
}
public static get_default(): SwayService {
if (!SwayService.#instance) {
SwayService.#instance = new SwayService();
}
return SwayService.#instance;
}
private #onWorkspaceEvent(event: types.WorkspaceEvent) {}
}

View File

@ -1,343 +0,0 @@
import GObject, { register, property, signal } from "astal/gobject";
import { App } from "astal";
import { readFileAsync, monitorFile } from "astal/file";
import * as types from "./types";
import * as defs from "./definitions";
import Gdk from "gi://Gdk";
import Gio from "gi://Gio";
import GLib from "gi://GLib";
import { Sway } from "./ipc";
@register({
GTypeName: "SwayWorkspace",
})
export class Workspace extends GObject.Object {
private #active: boolean = false;
private #focused: boolean = false;
private #visible: boolean = false;
private #urgent: boolean = false;
private #groups: Group[] = [];
private #currentGroup: Group | null = null;
private #currentOutput: string | null = null;
constructor(public readonly definition: defs.Workspace) {
super();
}
@property(Boolean)
get active() {
return this.#active;
}
@property(Boolean)
get focused() {
return this.#focused;
}
@property(Boolean)
get visible() {
return this.#visible;
}
@property(Boolean)
get urgent() {
return this.#urgent;
}
@property({ $gtype: "SwayWorkspaceGroup" })
/**
* The group that this workspace is currently assigned to.
*
* While a workspace can be in multiple groups, including multiple groups
* in the same context, it can only be assigned to one group at a time. If
* activated in a different group in the same context, it will be reassigned
* to that group.
*
* An inactive workspace will not have a current group, even if it only belongs
* to a single group in the active context.
*/
get currentGroup() {
return this.#currentGroup;
}
@property(String)
get currentOutput() {
return this.#currentGroup?.outputName ?? null;
}
handleEvent(event: types.WorkspaceEvent) {
if (
event.change === types.WorkspaceEventChange.EMPTY &&
event.current?.name == this.definition.index.toString()
) {
// If the workspace was emptied, deactivate it.
this.#active = false;
this.#focused = false;
this.#visible = false;
this.#urgent = false;
this.#currentGroup = null;
this.#currentOutput = null;
this.notify("active");
this.notify("focused");
this.notify("visible");
this.notify("urgent");
this.notify("current-group");
this.notify("current-output");
return;
}
const update =
event.current?.name === this.definition.index.toString()
? event.current
: event.old;
if (update !== undefined) {
this.parseUpdate(update);
}
}
parseUpdate(update: types.WorkspaceResponse) {
if (!this.#active) {
this.#active = true;
this.notify("active");
}
if (this.#focused !== update.focused) {
this.#focused = update.focused;
this.notify("focused");
}
if (this.#visible !== update.visible) {
this.#visible = update.visible;
this.notify("visible");
}
if (this.#urgent !== update.urgent) {
this.#urgent = update.urgent;
this.notify("urgent");
}
if (this.#currentOutput !== update.output) {
this.#currentGroup =
this.#groups.find((group) => group.outputName === update.output) ??
// Default to null, rather than undefined.
null;
this.notify("current-group");
this.notify("current-output");
}
}
async relocate(to: Group) {
if (this.#currentGroup === to) {
return;
}
if (this.#active) {
// If the workspace is active, we need to move it to the new group.
await Sway.command(
`workspace ${this.#index}; move workspace to output ${to.outputName}`
);
} else {
// If the workspace is inactive, we can simply assign it to the new group.
await Sway.command(`workspace ${this.#index} output ${to.outputName}`);
}
}
}
@register({
GTypeName: "SwayWorkspaceGroup",
})
export class Group extends GObject.Object {
private #workspaces: Workspace[] = [];
private #context: Context;
private #outputName: string | null = null;
private #monitor: Gdk.Monitor | null = null;
@property({ $gtype: "SwayWorkspaceContext" })
get context() {
return this.#context;
}
@property(Workspace)
get focusedWorkspace() {
return this.#workspaces.find((workspace) => workspace.visible);
}
@property(Workspace)
get focused() {
return this.focusedWorkspace?.focused ?? false;
}
@property(Gdk.Monitor)
get monitor() {
return this.#monitor;
}
@signal()
declare focused: () => void;
@signal()
declare unfocused: () => void;
@signal(Gdk.Monitor)
declare activated: (monitor: Gdk.Monitor) => void;
@signal()
declare deactivated: () => void;
constructor(
public readonly definition: defs.Group,
public readonly outputDefinition: defs.Output,
context: Context
) {
super();
this.#context = context;
this.#workspaces = definition.workspaces.map((index) =>
Tree.instance.workspaces.get(index.toString())
);
this.#workspaces.forEach((workspace) => {
workspace.connect(
"notify::visible",
// Bind the function, just to be safe.
(() => {
if (workspace.visible) {
this.notify("focused-workspace");
}
}).bind(this)
);
workspace.connect(
"notify::focused",
(() => {
if (workspace === this.focusedWorkspace) {
this.notify("focused");
}
}).bind(this)
);
});
}
/**
* Compute the score of this group based on the given Sway output.
*/
score(output: types.OutputResponse): [number, number] {
if (
this.outputDefinition.make === output.make &&
this.outputDefinition.model === output.model &&
this.outputDefinition.serial === output.serial
) {
// Perfect match scores 3.
return [3, 0];
}
const nameMatch = this.outputDefinition.names.findIndex(
(name) => name === output.name
);
if (nameMatch !== -1) {
// Name match scores 2.
return [2, nameMatch];
}
if (this.outputDefinition.names.includes("*")) {
// Wildcard match scores 1.
return [1, 0];
}
return [0, 0];
}
getOutputPreferences(outputs: types.OutputResponse[]) {
// Highest preference goes to a perfect match
const scores = outputs
.map((output) => [output, ...this.score(output)] as const)
.sort((a, b) => {
if (a[1] === b[1]) {
return a[2] - b[2];
}
return b[1] - a[1];
})
.filter(([_, score]) => score > 0)
.map(([output]) => output);
return scores;
}
async activate(output: types.OutputResponse) {
// Ensure that this output is compatible with the group.
const [score, _] = this.score(output);
if (score === 0) {
throw new Error(
`Output ${output.name} is not compatible with this group.`
);
}
// Configure monitor
await Sway.get_default().command(
`output ${output.name} pos ${this.outputDefinition.x} ${this.outputDefinition.y} ${this.outputDefinition.mode}`
);
// Enable monitor.
await Sway.get_default().command(`output ${output.name} enable`);
// Store monitor name.
this.#outputName = output.name;
// Retrieve GDK monitor.
const monitor = Gdk.Display.get_default().get_monitor_at_point(
this.outputDefinition.x,
this.outputDefinition.y
);
// Notify listeners.
this.#monitor = monitor;
this.activated(monitor);
this.notify("monitor");
}
async focus() {
if (this.#outputName === null) {
throw new Error("Group is not activated.");
}
await Sway.get_default().command(`focus output ${this.#outputName}`);
}
}
@register({
GTypeName: "SwayWorkspaceContext",
})
class Context extends GObject.Object {
private #groups: Map<string, Group> = new Map();
private #focusedGroup: Group | null = null;
constructor(public readonly definition: defs.Context) {
super();
}
@property(Group)
get focusedGroup() {
return this.#focusedGroup;
}
}
@register({
GTypeName: "SwayWorkspaceTree",
})
class Tree extends GObject.Object {
private #workspaces: Map<string, Workspace> = new Map();
private #contexts: Map<string, Context> = new Map();
private static #instance: Tree | null = null;
public get workspaces() {
return this.#workspaces;
}
private constructor() {}
private async #parseFile(path: string) {
const contents = await readFileAsync(path);
const data = JSON.parse(contents) as defs.Config;
}
static get instance() {
if (!Tree.#instance) {
Tree.#instance = new Tree();
}
return Tree.#instance;
}
}

View File

@ -1,4 +1,4 @@
import { System } from "../services/System";
import { System } from "@services/System";
export function SystemName() {
const system = System.get_default();

View File

@ -1,68 +0,0 @@
import Gio from "gi://Gio";
import GLib from "gi://GLib";
export async function readFromStreamRaw(
stream: Gio.InputStream,
bytes: number
): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
const buffer = new Uint8Array(bytes);
stream.read_bytes_async(
bytes,
GLib.PRIORITY_DEFAULT,
null,
(stream, result) => {
try {
const data = stream.read_bytes_finish(result);
data.get_data(buffer);
resolve(buffer);
} catch (e) {
reject(e);
}
}
);
});
}
export async function readFromStream(
stream: Gio.InputStream,
bytes: number
): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
const chunkCount = Math.ceil(bytes / 4096);
const buffer = await Array.from({ length: chunkCount }, (_, i) => i).reduce(
(acc, i) => {
const buffer = await acc;
const chunkSize = Math.min(4096, bytes - i * 4096);
const chunk = await readFromStreamRaw(stream, chunkSize);
buffer.set(chunk, i * 4096);
return buffer;
},
Promise.resolve(new Uint8Array(bytes))
);
});
}
export async function writeToStream(
stream: Gio.OutputStream,
data: ArrayBuffer | Uint8Array
): Promise<void> {
if (data instanceof ArrayBuffer) {
data = new Uint8Array(data);
}
return new Promise<void>((resolve, reject) => {
stream.write_all_async(
data,
GLib.PRIORITY_DEFAULT,
null,
(stream, result) => {
try {
stream.write_all_finish(result);
resolve();
} catch (e) {
reject(e);
}
}
);
});
}