summaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/apps.ts3
-rw-r--r--src/services/math.ts151
-rw-r--r--src/services/monitors.ts127
-rw-r--r--src/services/players.ts154
-rw-r--r--src/services/updates.ts148
5 files changed, 583 insertions, 0 deletions
diff --git a/src/services/apps.ts b/src/services/apps.ts
new file mode 100644
index 0000000..5396ac7
--- /dev/null
+++ b/src/services/apps.ts
@@ -0,0 +1,3 @@
+import AstalApps from "gi://AstalApps";
+
+export const Apps = new AstalApps.Apps();
diff --git a/src/services/math.ts b/src/services/math.ts
new file mode 100644
index 0000000..c66798c
--- /dev/null
+++ b/src/services/math.ts
@@ -0,0 +1,151 @@
+import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
+import { derivative, evaluate, rationalize, simplify } from "mathjs/number";
+
+export interface HistoryItem {
+ equation: string;
+ result: string;
+ icon: string;
+}
+
+@register({ GTypeName: "Math" })
+export default class Math extends GObject.Object {
+ static instance: Math;
+ static get_default() {
+ if (!this.instance) this.instance = new Math();
+
+ return this.instance;
+ }
+
+ readonly #maxHistory = 20;
+ readonly #path = `${CACHE}/math-history.json`;
+ readonly #history: HistoryItem[] = [];
+
+ #variables: Record<string, string> = {};
+ #lastExpression: HistoryItem | null = null;
+
+ @property(Object)
+ get history() {
+ return this.#history;
+ }
+
+ #save() {
+ writeFileAsync(this.#path, JSON.stringify(this.#history)).catch(console.error);
+ }
+
+ /**
+ * Commits the last evaluated expression to the history
+ */
+ commit() {
+ if (!this.#lastExpression) return;
+
+ // Try select first to prevent duplicates, if it fails, add it
+ if (!this.select(this.#lastExpression)) {
+ this.#history.unshift(this.#lastExpression);
+ if (this.#history.length > this.#maxHistory) this.#history.pop();
+ this.notify("history");
+ this.#save();
+ }
+ this.#lastExpression = null;
+ }
+
+ /**
+ * Moves an item in the history to the top
+ * @param item The item to select
+ * @returns If the item was successfully selected
+ */
+ select(item: HistoryItem) {
+ const idx = this.#history.findIndex(i => i.equation === item.equation && i.result === item.result);
+ if (idx >= 0) {
+ this.#history.splice(idx, 1);
+ this.#history.unshift(item);
+ this.notify("history");
+ this.#save();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Clears the history and variables
+ */
+ clear() {
+ if (this.#history.length > 0) {
+ this.#history.length = 0;
+ this.notify("history");
+ this.#save();
+ }
+ this.#lastExpression = null;
+ this.#variables = {};
+ }
+
+ /**
+ * Evaluates an equation and adds it to the history
+ * @param equation The equation to evaluate
+ * @returns A {@link HistoryItem} representing the result of the equation
+ */
+ evaluate(equation: string): HistoryItem {
+ if (equation.startsWith("clear"))
+ return {
+ equation: "Clear history",
+ result: "Delete history and previously set variables",
+ icon: "delete_forever",
+ };
+
+ let result: string, icon: string;
+ try {
+ if (equation.startsWith("help")) {
+ equation = "Help";
+ result =
+ "This is a calculator powered by Math.js.\nAvailable functions:\n\thelp: show help\n\tclear: clear history\n\t<x> = <equation>: sets <x> to <equation>\n\tsimplify <equation>: simplifies <equation>\n\tderive <x> <equation>: derives <equation> with respect to <x>\n\tdd<x> <equation>: short form of derive\n\trationalize <equation>: rationalizes <equation>\n\t<equation>: evaluates <equation>\nSee the documentation for syntax and inbuilt functions.";
+ icon = "help";
+ } else if (equation.includes("=")) {
+ const [left, right] = equation.split("=");
+ try {
+ this.#variables[left.trim()] = simplify(right).toString();
+ } catch {
+ this.#variables[left.trim()] = right.trim();
+ }
+ result = this.#variables[left.trim()];
+ icon = "equal";
+ } else if (equation.startsWith("simplify")) {
+ result = simplify(equation.slice(8), this.#variables).toString();
+ icon = "function";
+ } else if (equation.startsWith("derive") || equation.startsWith("dd")) {
+ const isShortForm = equation.startsWith("dd");
+ const respectTo = isShortForm ? equation.split(" ")[0].slice(2) : equation.split(" ")[1];
+ if (!respectTo) throw new Error(`Format: ${isShortForm ? "dd" : "derive "}<respect-to> <equation>`);
+ result = derivative(equation.slice((isShortForm ? 2 : 7) + respectTo.length), respectTo).toString();
+ icon = "function";
+ } else if (equation.startsWith("rationalize")) {
+ result = rationalize(equation.slice(11), this.#variables).toString();
+ icon = "function";
+ } else {
+ result = evaluate(equation, this.#variables).toString();
+ icon = "calculate";
+ }
+ } catch (e) {
+ equation = "Invalid equation: " + equation;
+ result = String(e);
+ icon = "error";
+ }
+
+ return (this.#lastExpression = { equation, result, icon });
+ }
+
+ constructor() {
+ super();
+
+ // Load history
+ if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) {
+ try {
+ this.#history = JSON.parse(readFile(this.#path));
+ // Init eval to create variables and last expression
+ for (const item of this.#history) this.evaluate(item.equation);
+ } catch (e) {
+ console.error("Math - Unable to load history", e);
+ }
+ }
+ }
+}
diff --git a/src/services/monitors.ts b/src/services/monitors.ts
new file mode 100644
index 0000000..78a0161
--- /dev/null
+++ b/src/services/monitors.ts
@@ -0,0 +1,127 @@
+import { GObject, execAsync, property, register } from "astal";
+import AstalHyprland from "gi://AstalHyprland";
+
+@register({ GTypeName: "Monitor" })
+export class Monitor extends GObject.Object {
+ readonly monitor: AstalHyprland.Monitor;
+ readonly width: number;
+ readonly height: number;
+ readonly id: number;
+ readonly serial: string;
+ readonly name: string;
+ readonly description: string;
+
+ @property(AstalHyprland.Workspace)
+ get activeWorkspace() {
+ return this.monitor.activeWorkspace;
+ }
+
+ isDdc: boolean = false;
+ busNum?: string;
+
+ #brightness: number = 0;
+
+ @property(Number)
+ get brightness() {
+ return this.#brightness;
+ }
+
+ set brightness(value) {
+ value = Math.min(1, Math.max(0, value));
+
+ this.#brightness = value;
+ this.notify("brightness");
+ execAsync(
+ this.isDdc
+ ? `ddcutil -b ${this.busNum} setvcp 10 ${Math.round(value * 100)}`
+ : `brightnessctl set ${Math.floor(value * 100)}% -q`
+ ).catch(console.error);
+ }
+
+ constructor(monitor: AstalHyprland.Monitor) {
+ super();
+
+ this.monitor = monitor;
+ this.width = monitor.width;
+ this.height = monitor.height;
+ this.id = monitor.id;
+ this.serial = monitor.serial;
+ this.name = monitor.name;
+ this.description = monitor.description;
+
+ monitor.connect("notify::active-workspace", () => this.notify("active-workspace"));
+
+ execAsync("ddcutil detect --brief")
+ .then(out => {
+ this.isDdc = out.split("\n\n").some(display => {
+ if (!/^Display \d+/.test(display)) return false;
+ const lines = display.split("\n");
+ if (lines[3].split(":")[3] !== monitor.serial) return false;
+ this.busNum = lines[1].split("/dev/i2c-")[1];
+ return true;
+ });
+ })
+ .catch(() => (this.isDdc = false))
+ .finally(async () => {
+ if (this.isDdc) {
+ const info = (await execAsync(`ddcutil -b ${this.busNum} getvcp 10 --brief`)).split(" ");
+ this.#brightness = Number(info[3]) / Number(info[4]);
+ } else
+ this.#brightness =
+ Number(await execAsync("brightnessctl get")) / Number(await execAsync("brightnessctl max"));
+ });
+ }
+}
+
+@register({ GTypeName: "Monitors" })
+export default class Monitors extends GObject.Object {
+ static instance: Monitors;
+ static get_default() {
+ if (!this.instance) this.instance = new Monitors();
+
+ return this.instance;
+ }
+
+ readonly #map: Map<number, Monitor> = new Map();
+
+ @property(Object)
+ get map() {
+ return this.#map;
+ }
+
+ @property(Object)
+ get list() {
+ return Array.from(this.#map.values());
+ }
+
+ @property(Monitor)
+ get active() {
+ return this.#map.get(AstalHyprland.get_default().focusedMonitor.id)!;
+ }
+
+ #notify() {
+ this.notify("map");
+ this.notify("list");
+ }
+
+ forEach(fn: (monitor: Monitor) => void) {
+ for (const monitor of this.#map.values()) fn(monitor);
+ }
+
+ constructor() {
+ super();
+
+ const hyprland = AstalHyprland.get_default();
+
+ for (const monitor of hyprland.monitors) this.#map.set(monitor.id, new Monitor(monitor));
+ if (this.#map.size > 0) this.#notify();
+
+ hyprland.connect("monitor-added", (_, monitor) => {
+ this.#map.set(monitor.id, new Monitor(monitor));
+ this.#notify();
+ });
+ hyprland.connect("monitor-removed", (_, id) => this.#map.delete(id) && this.#notify());
+
+ hyprland.connect("notify::focused-monitor", () => this.notify("active"));
+ }
+}
diff --git a/src/services/players.ts b/src/services/players.ts
new file mode 100644
index 0000000..b81d4b5
--- /dev/null
+++ b/src/services/players.ts
@@ -0,0 +1,154 @@
+import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
+import AstalMpris from "gi://AstalMpris";
+import { isRealPlayer } from "../utils/mpris";
+
+@register({ GTypeName: "Players" })
+export default class Players extends GObject.Object {
+ static instance: Players;
+ static get_default() {
+ if (!this.instance) this.instance = new Players();
+
+ return this.instance;
+ }
+
+ readonly #path = `${CACHE}/players.txt`;
+ readonly #players: AstalMpris.Player[] = [];
+ readonly #subs = new Map<
+ JSX.Element,
+ { signals: string[]; callback: () => void; ids: number[]; player: AstalMpris.Player | null }
+ >();
+
+ @property(AstalMpris.Player)
+ get lastPlayer(): AstalMpris.Player | null {
+ return this.#players.length > 0 && this.#players[0].identity !== null ? this.#players[0] : null;
+ }
+
+ /**
+ * List of real players.
+ */
+ @property(Object)
+ get list() {
+ return this.#players;
+ }
+
+ hookLastPlayer(widget: JSX.Element, signal: string, callback: () => void): this;
+ hookLastPlayer(widget: JSX.Element, signals: string[], callback: () => void): this;
+ hookLastPlayer(widget: JSX.Element, signals: string | string[], callback: () => void) {
+ if (!Array.isArray(signals)) signals = [signals];
+ // Add subscription
+ if (this.lastPlayer)
+ this.#subs.set(widget, {
+ signals,
+ callback,
+ ids: signals.map(s => this.lastPlayer!.connect(s, callback)),
+ player: this.lastPlayer,
+ });
+ else this.#subs.set(widget, { signals, callback, ids: [], player: null });
+
+ // Remove subscription on widget destroyed
+ widget.connect("destroy", () => {
+ const sub = this.#subs.get(widget);
+ if (sub?.player) sub.ids.forEach(id => sub.player!.disconnect(id));
+ this.#subs.delete(widget);
+ });
+
+ // Initial run of callback
+ callback();
+
+ // For chaining
+ return this;
+ }
+
+ makeCurrent(player: AstalMpris.Player) {
+ const index = this.#players.indexOf(player);
+ // Ignore if already current
+ if (index === 0) return;
+ // Remove if present
+ else if (index > 0) this.#players.splice(index, 1);
+ // Connect signals if not already in list (i.e. new player)
+ else this.#connectPlayerSignals(player);
+
+ // Add to front
+ this.#players.unshift(player);
+ this.#updatePlayer();
+
+ // Save to file
+ this.#save();
+ }
+
+ #updatePlayer() {
+ this.notify("last-player");
+
+ for (const sub of this.#subs.values()) {
+ sub.callback();
+ if (sub.player) sub.ids.forEach(id => sub.player!.disconnect(id));
+ sub.ids = this.lastPlayer ? sub.signals.map(s => this.lastPlayer!.connect(s, sub.callback)) : [];
+ sub.player = this.lastPlayer;
+ }
+ }
+
+ #save() {
+ writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error);
+ }
+
+ #connectPlayerSignals(player: AstalMpris.Player) {
+ // Change order on attribute change
+ for (const signal of [
+ "notify::playback-status",
+ "notify::shuffle-status",
+ "notify::loop-status",
+ "notify::volume",
+ "notify::rate",
+ ])
+ player.connect(signal, () => this.makeCurrent(player));
+ }
+
+ constructor() {
+ super();
+
+ const mpris = AstalMpris.get_default();
+
+ // Load players
+ if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) {
+ this.#players = readFile(this.#path)
+ .split("\n")
+ .map(p => mpris.players.find(p2 => p2.busName === p))
+ .filter(isRealPlayer) as AstalMpris.Player[];
+ // Add new players from in between sessions
+ for (const player of mpris.players)
+ if (!this.#players.includes(player) && isRealPlayer(player)) this.#players.push(player);
+ } else {
+ const sortOrder = [
+ AstalMpris.PlaybackStatus.PLAYING,
+ AstalMpris.PlaybackStatus.PAUSED,
+ AstalMpris.PlaybackStatus.STOPPED,
+ ];
+ this.#players = mpris.players
+ .filter(isRealPlayer)
+ .sort((a, b) => sortOrder.indexOf(a.playbackStatus) - sortOrder.indexOf(b.playbackStatus));
+ }
+ this.#updatePlayer();
+ this.#save();
+ // Connect signals to loaded players
+ for (const player of this.#players) this.#connectPlayerSignals(player);
+
+ // Add and connect signals when added
+ mpris.connect("player-added", (_, player) => {
+ if (isRealPlayer(player)) {
+ this.makeCurrent(player);
+ this.notify("list");
+ }
+ });
+
+ // Remove when closed
+ mpris.connect("player-closed", (_, player) => {
+ const index = this.#players.indexOf(player);
+ if (index >= 0) {
+ this.#players.splice(index, 1);
+ this.notify("list");
+ if (index === 0) this.#updatePlayer();
+ this.#save();
+ }
+ });
+ }
+}
diff --git a/src/services/updates.ts b/src/services/updates.ts
new file mode 100644
index 0000000..5bb6bd1
--- /dev/null
+++ b/src/services/updates.ts
@@ -0,0 +1,148 @@
+import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
+import { updates as config } from "../../config";
+
+interface Update {
+ name: string;
+ full: string;
+}
+
+interface Repo {
+ repo?: string[];
+ updates: Update[];
+ icon: string;
+ name: string;
+}
+
+interface Data {
+ cached?: boolean;
+ repos: Repo[];
+ errors: string[];
+}
+
+@register({ GTypeName: "Updates" })
+export default class Updates extends GObject.Object {
+ static instance: Updates;
+ static get_default() {
+ if (!this.instance) this.instance = new Updates();
+
+ return this.instance;
+ }
+
+ readonly #cachePath = `${CACHE}/updates.txt`;
+
+ #timeout?: GLib.Source;
+ #loading = false;
+ #data: Data = { cached: true, repos: [], errors: [] };
+
+ @property(Boolean)
+ get loading() {
+ return this.#loading;
+ }
+
+ @property(Object)
+ get data() {
+ return this.#data;
+ }
+
+ @property(Object)
+ get list() {
+ return this.#data.repos.map(r => r.updates).flat();
+ }
+
+ @property(Number)
+ get numUpdates() {
+ return this.#data.repos.reduce((acc, repo) => acc + repo.updates.length, 0);
+ }
+
+ async #updateFromCache() {
+ this.#data = JSON.parse(await readFileAsync(this.#cachePath));
+ this.notify("data");
+ this.notify("list");
+ this.notify("num-updates");
+ }
+
+ async #getRepo(repo: string) {
+ return (await execAsync(`bash -c "comm -12 <(pacman -Qq | sort) <(pacman -Slq '${repo}' | sort)"`)).split("\n");
+ }
+
+ getUpdates() {
+ // Return if already getting updates
+ if (this.#loading) return;
+
+ this.#loading = true;
+ this.notify("loading");
+
+ // Get new updates
+ Promise.allSettled([execAsync("checkupdates"), execAsync("yay -Qua")])
+ .then(async ([pacman, yay]) => {
+ const data: Data = { repos: [], errors: [] };
+
+ // Pacman updates (checkupdates)
+ if (pacman.status === "fulfilled") {
+ const repos: Repo[] = [
+ { repo: await this.#getRepo("core"), updates: [], icon: "hub", name: "Core repository" },
+ {
+ repo: await this.#getRepo("extra"),
+ updates: [],
+ icon: "add_circle",
+ name: "Extra repository",
+ },
+ {
+ repo: await this.#getRepo("multilib"),
+ updates: [],
+ icon: "account_tree",
+ name: "Multilib repository",
+ },
+ ];
+
+ for (const update of pacman.value.split("\n")) {
+ const pkg = update.split(" ")[0];
+ for (const repo of repos)
+ if (repo.repo?.includes(pkg)) repo.updates.push({ name: pkg, full: update });
+ }
+
+ for (const repo of repos) if (repo.updates.length > 0) data.repos.push(repo);
+ }
+
+ // AUR and devel updates (yay -Qua)
+ if (yay.status === "fulfilled") {
+ const aur: Repo = { updates: [], icon: "deployed_code_account", name: "AUR" };
+
+ for (const update of yay.value.split("\n")) {
+ if (/^\s*->/.test(update)) data.errors.push(update); // Error
+ else aur.updates.push({ name: update.split(" ")[0], full: update });
+ }
+
+ if (aur.updates.length > 0) data.repos.push(aur);
+ }
+
+ if (data.errors.length > 0 && data.repos.length === 0) {
+ this.#updateFromCache().catch(console.error);
+ } else {
+ // Cache and set
+ writeFileAsync(this.#cachePath, JSON.stringify({ cached: true, ...data })).catch(console.error);
+ this.#data = data;
+ this.notify("data");
+ this.notify("list");
+ this.notify("num-updates");
+ }
+
+ this.#loading = false;
+ this.notify("loading");
+
+ this.#timeout?.destroy();
+ this.#timeout = setTimeout(() => this.getUpdates(), config.interval);
+ })
+ .catch(console.error);
+ }
+
+ constructor() {
+ super();
+
+ // Initial update from cache, if fail then write valid data to cache so future reads don't fail
+ this.#updateFromCache().catch(() =>
+ writeFileAsync(this.#cachePath, JSON.stringify(this.#data)).catch(console.error)
+ );
+ this.getUpdates();
+ }
+}